summary refs log tree commit diff
path: root/src/components/post_editor.rs
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2024-08-22 22:05:42 +0300
committerVika <vika@fireburn.ru>2024-08-22 23:13:03 +0300
commit7dee9f44241f8f9f26590205485f9f8ab701b807 (patch)
treeb13f3aac34edb33815fb5d5050526336b9e1bbb4 /src/components/post_editor.rs
parentf164ee83342d025204897018b15141de6e1ca93d (diff)
downloadbowl-7dee9f44241f8f9f26590205485f9f8ab701b807.tar.zst
Factor out the post editor UI into a separate component
Now it's easy to use the same UI for sending a new post or editing an
existing one (by loading it with `?q=source` and then comparing).
Diffstat (limited to 'src/components/post_editor.rs')
-rw-r--r--src/components/post_editor.rs489
1 files changed, 489 insertions, 0 deletions
diff --git a/src/components/post_editor.rs b/src/components/post_editor.rs
new file mode 100644
index 0000000..be17251
--- /dev/null
+++ b/src/components/post_editor.rs
@@ -0,0 +1,489 @@
+use crate::components;
+use adw::prelude::*;
+
+use glib::translate::IntoGlib;
+use gtk::GridLayoutChild;
+use relm4::{gtk, prelude::{ComponentController, Controller}, Component, ComponentParts, ComponentSender, RelmWidgetExt};
+
+#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)]
+#[enum_type(name = "MicropubVisibility")]
+pub enum Visibility {
+    #[default]
+    Public = 0,
+    Private = 1,
+}
+impl std::fmt::Display for Visibility {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Self::Public => "public",
+            Self::Private => "private"
+        })
+    }
+}
+
+#[derive(Default, Debug, PartialEq, Eq)]
+pub struct Post {
+    pub name: Option<String>,
+    pub summary: Option<String>,
+    pub tags: Vec<String>,
+    pub content: String,
+    pub visibility: Visibility
+}
+
+impl From<Post> for microformats::types::Item {
+    fn from(post: Post) -> Self {
+        use microformats::types::{Item, Class, KnownClass, PropertyValue};
+        let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]);
+
+        if let Some(name) = post.name {
+            mf2.properties.insert(
+                "name".to_owned(), vec![PropertyValue::Plain(name)]
+            );
+        }
+
+        if let Some(summary) = post.summary {
+            mf2.properties.insert(
+                "summary".to_owned(),
+                vec![PropertyValue::Plain(summary)]
+            );
+        }
+
+        if !post.tags.is_empty() {
+            mf2.properties.insert(
+                "category".to_string(),
+                post.tags.into_iter().map(PropertyValue::Plain).collect()
+            );
+        }
+
+        mf2.properties.insert(
+            "visibility".to_string(),
+            vec![PropertyValue::Plain(post.visibility.to_string())]
+        );
+
+        mf2.properties.insert(
+            "content".to_string(),
+            vec![PropertyValue::Plain(post.content)]
+        );
+
+        mf2
+    }
+}
+
+#[tracker::track]
+#[derive(Debug)]
+pub(crate) struct PostEditor<E> {
+    #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
+    sending: bool,
+
+    #[do_not_track] name_buffer: gtk::EntryBuffer,
+    #[do_not_track] summary_buffer: gtk::EntryBuffer,
+    #[do_not_track] content_buffer: gtk::TextBuffer,
+    visibility: Visibility,
+
+    #[do_not_track] wide_layout: gtk::GridLayout,
+    #[do_not_track] narrow_layout: gtk::BoxLayout,
+
+    #[do_not_track] smart_summary: Controller<components::SmartSummaryButton>,
+    _err: std::marker::PhantomData<E>
+}
+
+impl<E> PostEditor<E> {
+    fn busy_changed(&self) -> bool {
+        self.changed(Self::sending() | Self::smart_summary_busy_guard())
+    }
+    fn busy(&self) -> bool {
+        self.sending || self.smart_summary_busy_guard.is_some()
+    }
+}
+
+#[derive(Debug)]
+#[allow(private_interfaces)]
+pub enum Input<E: std::error::Error + std::fmt::Debug + 'static> {
+    #[doc(hidden)] SmartSummary(components::smart_summary::Output),
+    #[doc(hidden)] VisibilitySelected(Visibility),
+    Submit,
+    SubmitDone(glib::Uri),
+    SubmitError(E)
+}
+
+#[relm4::component(pub)]
+impl<E: std::error::Error + std::fmt::Debug + 'static> Component for PostEditor<E> {
+    type Init = Option<Post>;
+    type Output = Option<Post>;
+    type Input = Input<E>;
+    type CommandOutput = ();
+
+    view! {
+        #[root]
+        #[name = "toast_overlay"]
+        adw::ToastOverlay {
+            #[name = "content_wrapper"]
+            adw::BreakpointBin {
+                set_width_request: 360,
+                set_height_request: 480,
+
+                #[name = "content"]
+                gtk::Box {
+                    set_orientation: gtk::Orientation::Vertical,
+                    set_spacing: 5,
+                    set_margin_all: 5,
+
+                    #[name = "name_label"]
+                    gtk::Label {
+                        set_markup: "Name",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "name_field"]
+                    gtk::Entry {
+                        set_hexpand: true,
+                        set_buffer: &model.name_buffer,
+                        #[track = "model.changed(Self::sending())"]
+                        set_sensitive: !model.sending,
+                    },
+
+                    #[name = "summary_label"]
+                    gtk::Label {
+                        set_markup: "Summary",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "summary_field"]
+                    gtk::Box {
+                        set_orientation: gtk::Orientation::Horizontal,
+                        add_css_class: "linked",
+
+                        gtk::Entry {
+                            set_hexpand: true,
+                            set_buffer: &model.summary_buffer,
+                            #[track = "model.busy_changed()"]
+                            set_sensitive: !model.busy(),
+                        },
+
+                        model.smart_summary.widget(),
+                    },
+
+                    #[name = "tag_label"]
+                    gtk::Label {
+                        set_markup: "Tags",
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                    },
+                    #[name = "tag_holder"]
+                    // TODO: tag component (because of complex logic)
+                    gtk::Box {
+                        add_css_class: "frame",
+                        set_hexpand: true,
+                        set_orientation: gtk::Orientation::Horizontal,
+                        set_spacing: 5,
+                        set_height_request: 36,
+                    },
+
+
+                    #[name = "content_label"]
+                    gtk::Label {
+                        set_markup: "Content",
+                        set_halign: gtk::Align::Start,
+                        set_valign: gtk::Align::Start,
+                        set_margin_vertical: 10,
+                        set_margin_horizontal: 10,
+                    },
+
+                    #[name = "content_textarea"]
+                    gtk::ScrolledWindow {
+                        set_vexpand: true,
+
+                        gtk::TextView {
+                            set_buffer: Some(&model.content_buffer),
+                            set_hexpand: true,
+                            #[iterate]
+                            add_css_class: &["frame", "view"],
+
+                            set_monospace: true,
+                            set_wrap_mode: gtk::WrapMode::Word,
+                            set_vscroll_policy: gtk::ScrollablePolicy::Natural,
+
+                            set_left_margin: 8,
+                            set_right_margin: 8,
+                            set_top_margin: 8,
+                            set_bottom_margin: 8,
+
+                            #[track = "model.changed(Self::sending())"]
+                            set_sensitive: !model.sending
+                        }
+                    },
+
+                    #[name = "misc_prop_wrapper"]
+                    gtk::Box {
+                        set_hexpand: true,
+
+                        gtk::FlowBox {
+                            set_hexpand: false,
+                            set_orientation: gtk::Orientation::Horizontal,
+                            set_homogeneous: false,
+                            set_column_spacing: 0,
+                            set_min_children_per_line: 2,
+                            set_max_children_per_line: 6,
+                            set_selection_mode: gtk::SelectionMode::None,
+
+                            append = &gtk::Box {
+                                set_spacing: 5,
+
+                                #[name = "visibility_label"]
+                                gtk::Label {
+                                    set_markup: "Visibility",
+                                    set_halign: gtk::Align::Start,
+                                    set_valign: gtk::Align::Start,
+                                    set_margin_vertical: 10,
+                                    set_margin_horizontal: 10,
+                                },
+                                #[name = "visibility_selector"]
+                                gtk::DropDown {
+                                    set_model: Some(&visibility_model),
+                                    set_hexpand: false,
+
+                                    #[track = "model.changed(Self::sending())"]
+                                    set_sensitive: !model.sending,
+
+                                    connect_selected_item_notify[sender] => move |w| {
+                                        if let Some(obj) = w.selected_item() {
+                                            let v = obj.downcast::<adw::EnumListItem>()
+                                                .unwrap()
+                                                .value();
+                                            let v = glib::EnumClass::new::<Visibility>()
+                                                .to_value(v)
+                                                .unwrap()
+                                                .get()
+                                                .unwrap();
+                                            sender.input(Self::Input::VisibilitySelected(v));
+                                        }
+                                    },
+                                },
+                            },
+                        },
+                    },
+                },
+
+                add_breakpoint = adw::Breakpoint::new(
+                    adw::BreakpointCondition::new_length(
+                        adw::BreakpointConditionLengthType::MinWidth,
+                        512.0,
+                        adw::LengthUnit::Px
+                    )
+                ) {
+                    add_setter: (&content, "layout_manager", Some(&model.wide_layout.to_value())),
+                    add_setter: (&name_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&summary_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&tag_label, "halign", Some(&gtk::Align::End.to_value())),
+                    add_setter: (&content_label, "halign", Some(&gtk::Align::End.to_value())),
+                },
+
+            }
+        }
+    }
+
+    fn init(init: Self::Init, root: Self::Root, sender: ComponentSender<Self>) -> ComponentParts<Self> {
+        let mut model = Self {
+            smart_summary_busy_guard: None,
+            sending: false,
+
+            name_buffer: gtk::EntryBuffer::default(),
+            summary_buffer: gtk::EntryBuffer::default(),
+            content_buffer: gtk::TextBuffer::default(),
+            visibility: Visibility::Public,
+
+            wide_layout: gtk::GridLayout::new(),
+            narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical),
+
+            smart_summary: components::SmartSummaryButton::builder()
+                .launch(())
+                .forward(sender.input_sender(), Input::SmartSummary),
+
+            tracker: Default::default(),
+            _err: std::marker::PhantomData,
+        };
+
+        let visibility_model = adw::EnumListModel::new(Visibility::static_type());
+
+        let widgets = view_output!();
+
+        widgets.visibility_selector.set_expression(Some(
+            gtk::ClosureExpression::new::<String>(
+                [] as [gtk::Expression; 0],
+                glib::closure::RustClosure::new(|v| {
+                    let list_item = v[0].get::<adw::EnumListItem>().unwrap();
+                    Some(list_item.name().into())
+                })
+            )
+        ));
+
+        if let Some(post) = init {
+            if let Some(name) = post.name {
+                model.name_buffer.set_text(glib::GString::from(name));
+            }
+            if let Some(summary) = post.summary {
+                model.summary_buffer.set_text(glib::GString::from(summary));
+            }
+
+            // TODO: tags
+
+            model.content_buffer.set_text(&post.content);
+
+            widgets.visibility_selector.set_selected(
+                visibility_model.find_position(post.visibility.into_glib())
+            );
+            model.visibility = post.visibility;
+        }
+
+        let layout = &model.wide_layout;
+        widgets.content.set_layout_manager(Some(layout.clone()));
+        layout.set_column_homogeneous(false);
+        layout.set_row_spacing(10);
+
+        enum Row<'a> {
+            TwoColumn(&'a gtk::Label, &'a gtk::Widget),
+            Span(&'a gtk::Widget)
+        }
+
+        for (row, content) in [
+            Row::TwoColumn(&widgets.name_label, widgets.name_field.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.summary_label, widgets.summary_field.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.tag_label, widgets.tag_holder.upcast_ref::<gtk::Widget>()),
+            Row::TwoColumn(&widgets.content_label, widgets.content_textarea.upcast_ref::<gtk::Widget>()),
+            Row::Span(widgets.misc_prop_wrapper.upcast_ref::<gtk::Widget>()),
+        ].into_iter().enumerate() {
+            match content {
+                Row::TwoColumn(label, field) => {
+                    let label_layout = layout.layout_child(label)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    label_layout.set_row(row as i32);
+                    label_layout.set_column(0);
+
+                    let field_layout = layout.layout_child(field)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    field_layout.set_row(row as i32);
+                    field_layout.set_column(1);
+                },
+                Row::Span(widget) => {
+                    let widget_layout = layout.layout_child(widget)
+                        .downcast::<GridLayoutChild>()
+                        .unwrap();
+                    widget_layout.set_row(row as i32);
+                    widget_layout.set_column_span(2);
+                }
+            }
+        }
+
+        widgets.content.set_layout_manager(Some(model.narrow_layout.clone()));
+
+        ComponentParts { model, widgets }
+    }
+
+    fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) {
+        match msg {
+            Input::SmartSummary(components::SmartSummaryOutput::Start) => {
+                widgets.content_textarea.set_sensitive(false);
+                if self.content_buffer.char_count() == 0 {
+                    let _ = self.smart_summary.sender().send(
+                        components::SmartSummaryInput::Cancel
+                    );
+                } else {
+                    let text = self.content_buffer.text(
+                        &self.content_buffer.start_iter(),
+                        &self.content_buffer.end_iter(),
+                        false
+                    );
+
+                    self.set_smart_summary_busy_guard(
+                        Some(relm4::main_adw_application().mark_busy())
+                    );
+                    if self.smart_summary.sender().send(
+                        components::SmartSummaryInput::Text(text.into())
+                    ).is_ok() {
+                        self.summary_buffer.set_text("");
+                    }
+                }
+                widgets.content_textarea.set_sensitive(true);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Chunk(text)) => {
+                self.summary_buffer.insert_text(self.summary_buffer.length(), text);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Done) => {
+                self.set_smart_summary_busy_guard(None);
+            },
+            Input::SmartSummary(components::SmartSummaryOutput::Error(err)) => {
+                self.set_smart_summary_busy_guard(None);
+
+                let toast = adw::Toast::new(&format!("Smart Summary error: {}", err));
+                toast.set_timeout(0);
+                toast.set_priority(adw::ToastPriority::High);
+                root.add_toast(toast);
+            },
+            Input::VisibilitySelected(vis) => {
+                log::debug!("Changed visibility: {}", vis);
+                self.visibility = vis;
+            },
+            Input::Submit => {
+                self.sending = true;
+                let post = if self.content_buffer.char_count() > 0 {
+                    Some(Post {
+                        name: if self.name_buffer.length() > 0 {
+                            Some(self.name_buffer.text().into())
+                        } else { None },
+                        summary: if self.summary_buffer.length() > 0 {
+                            Some(self.summary_buffer.text().into())
+                        } else { None },
+                        tags: vec![],
+                        content: self.content_buffer.text(
+                            &self.content_buffer.start_iter(),
+                            &self.content_buffer.end_iter(),
+                            false
+                        ).into(),
+                        visibility: self.visibility,
+                    })
+                } else { None };
+                let _ = sender.output(post);
+            },
+            Input::SubmitDone(location) => {
+                self.name_buffer.set_text("");
+                self.summary_buffer.set_text("");
+                // TODO: tags
+                self.content_buffer.set_text("");
+                let toast = adw::Toast::new("Post submitted");
+                toast.set_button_label(Some("Open"));
+                toast.connect_button_clicked(move |toast| {
+                    gtk::UriLauncher::new(&location.to_string()).launch(
+                        None::<&adw::ApplicationWindow>,
+                        None::<&gio::Cancellable>,
+                        glib::clone!(#[weak] toast, move |result| {
+                            if let Err(err) = result {
+                                log::warn!("Error opening post URI: {}", err);
+                            } else {
+                                toast.dismiss()
+                            }
+                        })
+                    );
+                });
+
+                root.add_toast(toast);
+            },
+            Input::SubmitError(err) => {
+                let toast = adw::Toast::new(&format!("Error sending post: {}", err));
+                toast.set_timeout(0);
+                toast.set_priority(adw::ToastPriority::High);
+
+                root.add_toast(toast);
+            }
+        }
+
+        self.update_view(widgets, sender);
+    }
+}