From 7dee9f44241f8f9f26590205485f9f8ab701b807 Mon Sep 17 00:00:00 2001 From: Vika Date: Thu, 22 Aug 2024 22:05:42 +0300 Subject: 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). --- src/lib.rs | 413 +++++++------------------------------------------------------ 1 file changed, 46 insertions(+), 367 deletions(-) (limited to 'src/lib.rs') diff --git a/src/lib.rs b/src/lib.rs index 87eed10..a9335bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,16 +1,22 @@ use std::sync::Arc; use adw::prelude::*; -use gtk::GridLayoutChild; -use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; +use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; pub mod components { pub(crate) mod smart_summary; pub(crate) use smart_summary::{ SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput }; + + pub(crate) mod post_editor; + pub(crate) use post_editor::{ + PostEditor, Input as PostEditorInput + }; } -mod widgets; + +use components::post_editor::{Post, Visibility}; + pub mod secrets; pub mod micropub; pub mod util; @@ -18,56 +24,31 @@ pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; pub const VISIBILITY: [&str; 2] = ["public", "private"]; -#[tracker::track] #[derive(Debug)] -pub struct PostComposerModel { - /// Busy guard for generating the summary using an LLM. - /// Makes the summary field read-only and blocks the Smart Summary button. - #[no_eq] smart_summary_busy_guard: Option, - #[no_eq] submit_busy_guard: Option, - - #[do_not_track] name_buffer: gtk::EntryBuffer, - #[do_not_track] summary_buffer: gtk::EntryBuffer, - #[do_not_track] content_buffer: gtk::TextBuffer, - - #[do_not_track] wide_layout: gtk::GridLayout, - #[do_not_track] narrow_layout: gtk::BoxLayout, - - #[do_not_track] micropub: Arc, - - #[do_not_track] smart_summary: Controller, -} +pub struct App { + micropub: Arc, + submit_busy_guard: Option, -impl PostComposerModel { - fn busy_changed(&self) -> bool { - self.changed(Self::submit_busy_guard() | Self::smart_summary_busy_guard()) - } - fn busy(&self) -> bool { - self.submit_busy_guard.is_some() || self.smart_summary_busy_guard.is_some() - } -} - -#[derive(Debug)] -#[allow(private_interfaces)] -pub enum PostComposerInput { - #[doc(hidden)] SmartSummary(components::smart_summary::Output), - Submit, + post_editor: Controller> } #[derive(Debug)] -pub enum PostComposerCommandOutput { +#[doc(hidden)] +pub enum Input { + SubmitButtonPressed, + PostEditor(Option) } #[relm4::component(pub async)] -impl AsyncComponent for PostComposerModel { +impl AsyncComponent for App { /// The type of the messages that this component can receive. - type Input = PostComposerInput; + type Input = Input; /// The type of the messages that this component can send. type Output = (); /// The type of data with which this component will be initialized. type Init = micropub::Client; /// The type of the command outputs that this component can receive. - type CommandOutput = PostComposerCommandOutput; + type CommandOutput = (); view! { #[root] @@ -83,9 +64,9 @@ impl AsyncComponent for PostComposerModel { set_icon_name: "document-send-symbolic", set_tooltip: "Send post", - connect_clicked => Self::Input::Submit, - #[track = "model.busy_changed()"] - set_sensitive: !model.busy(), + connect_clicked => Self::Input::SubmitButtonPressed, + #[watch] + set_sensitive: model.submit_busy_guard.is_none(), }, bar = adw::HeaderBar::new() { @@ -95,163 +76,8 @@ impl AsyncComponent for PostComposerModel { bar }, - #[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::submit_busy_guard())"] - set_sensitive: model.submit_busy_guard.is_none(), - }, - - #[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::submit_busy_guard())"] - set_sensitive: model.submit_busy_guard.is_none(), - } - }, - - #[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 = >k::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(>k::StringList::new(&VISIBILITY)), - set_hexpand: false, - - #[track = "model.changed(Self::submit_busy_guard())"] - set_sensitive: model.submit_busy_guard.is_none(), - }, - }, - }, - }, - }, - - 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(>k::Align::End.to_value())), - add_setter: (&summary_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&tag_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&content_label, "halign", Some(>k::Align::End.to_value())), - }, - - } - } + model.post_editor.widget(), } } @@ -262,24 +88,12 @@ impl AsyncComponent for PostComposerModel { window: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { - let content_buffer = gtk::TextBuffer::default(); - let model = PostComposerModel { - smart_summary_busy_guard: None, + let model = App { submit_busy_guard: None, - - name_buffer: gtk::EntryBuffer::default(), - summary_buffer: gtk::EntryBuffer::default(), - content_buffer: content_buffer.clone(), - - wide_layout: gtk::GridLayout::new(), - narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical), - micropub: Arc::new(init), - smart_summary: components::SmartSummaryButton::builder() - .launch(()) - .forward(sender.input_sender(), PostComposerInput::SmartSummary), - - tracker: Default::default() + post_editor: components::PostEditor::builder() + .launch(None) + .forward(sender.input_sender(), Self::Input::PostEditor), }; let widgets = view_output!(); @@ -287,176 +101,41 @@ impl AsyncComponent for PostComposerModel { #[cfg(debug_assertions)] window.add_css_class("devel"); - 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::()), - Row::TwoColumn(&widgets.summary_label, widgets.summary_field.upcast_ref::()), - Row::TwoColumn(&widgets.tag_label, widgets.tag_holder.upcast_ref::()), - Row::TwoColumn(&widgets.content_label, widgets.content_textarea.upcast_ref::()), - Row::Span(widgets.misc_prop_wrapper.upcast_ref::()), - ].into_iter().enumerate() { - match content { - Row::TwoColumn(label, field) => { - let label_layout = layout.layout_child(label) - .downcast::() - .unwrap(); - label_layout.set_row(row as i32); - label_layout.set_column(0); - - let field_layout = layout.layout_child(field) - .downcast::() - .unwrap(); - field_layout.set_row(row as i32); - field_layout.set_column(1); - }, - Row::Span(widget) => { - let widget_layout = layout.layout_child(widget) - .downcast::() - .unwrap(); - widget_layout.set_row(row as i32); - widget_layout.set_column_span(2); - } - } - } - - widgets.content.set_layout_manager(Some(model.narrow_layout.clone())); - AsyncComponentParts { model, widgets } } - async fn update_with_view( + async fn update( &mut self, - widgets: &mut Self::Widgets, message: Self::Input, - sender: AsyncComponentSender, - root: &Self::Root + _sender: AsyncComponentSender, + _root: &Self::Root ) { - self.reset(); // Reset the tracker - match message { - PostComposerInput::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); - }, - PostComposerInput::SmartSummary(components::SmartSummaryOutput::Chunk(text)) => { - self.summary_buffer.insert_text(self.summary_buffer.length(), text); + Input::SubmitButtonPressed => { + self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); + self.post_editor.sender().send(components::PostEditorInput::Submit).unwrap(); }, - PostComposerInput::SmartSummary(components::SmartSummaryOutput::Done) => { - self.set_smart_summary_busy_guard(None); + Input::PostEditor(None) => { + self.submit_busy_guard = None; } - PostComposerInput::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); - widgets.toast_overlay.add_toast(toast); - - }, - PostComposerInput::Submit => { - if self.content_buffer.char_count() == 0 { - self.update_view(widgets, sender); - return - } - self.set_submit_busy_guard( - Some(relm4::main_adw_application().mark_busy()) - ); - // Update view to lock the interface up - self.update_view(widgets, sender.clone()); - self.reset(); - - use microformats::types::{Item, Class, KnownClass, PropertyValue}; - let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]); - if self.name_buffer.length() > 0 { - let proplist = mf2.properties.entry("name".to_owned()).or_default(); - proplist.push(PropertyValue::Plain(self.name_buffer.text().into())); - } - if self.summary_buffer.length() > 0 { - let proplist = mf2.properties.entry("summary".to_owned()).or_default(); - proplist.push(PropertyValue::Plain(self.summary_buffer.text().into())); - } - - // TODO: tags - - { - let proplist = mf2.properties.entry("content".to_owned()).or_default(); - proplist.push(PropertyValue::Plain(self.content_buffer.text( - &self.content_buffer.start_iter(), - &self.content_buffer.end_iter(), - false - ).into())); - } - - { - let proplist = mf2.properties.entry("visibility".to_owned()).or_default(); - let selected = VISIBILITY[widgets.visibility_selector.selected() as usize]; - proplist.push(PropertyValue::Plain(selected.to_owned())); - } - + Input::PostEditor(Some(post)) => { + let mf2 = post.into(); + log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap()); match self.micropub.send_post(mf2).await { Ok(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(glib::clone!(#[strong] root, move |toast| { - gtk::UriLauncher::new(&location.to_string()).launch( - Some(root.upcast_ref::()), - None::<&gio::Cancellable>, - glib::clone!(#[weak] toast, move |result| { - if let Err(err) = result { - log::warn!("Error opening post URI: {}", err); - } else { - toast.dismiss() - } - }) - ); - })); - widgets.toast_overlay.add_toast(toast); + self.post_editor.sender() + .send(components::PostEditorInput::SubmitDone(location)) + .unwrap(); }, Err(err) => { log::warn!("Error sending post: {}", err); - let toast = adw::Toast::new(&format!("Error sending post: {}", err)); - toast.set_timeout(0); - toast.set_priority(adw::ToastPriority::High); - widgets.toast_overlay.add_toast(toast); + self.post_editor.sender() + .send(components::PostEditorInput::SubmitError(err)) + .unwrap(); } } - self.set_submit_busy_guard(None); + self.submit_busy_guard = None; }, } - - self.update_view(widgets, sender); } } -- cgit 1.4.1