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/components/post_editor.rs | 489 ++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 413 ++++------------------------------- src/main.rs | 4 +- 3 files changed, 537 insertions(+), 369 deletions(-) create mode 100644 src/components/post_editor.rs 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, + pub summary: Option, + pub tags: Vec, + pub content: String, + pub visibility: Visibility +} + +impl From 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 { + #[no_eq] smart_summary_busy_guard: Option, + 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, + _err: std::marker::PhantomData +} + +impl PostEditor { + 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 { + #[doc(hidden)] SmartSummary(components::smart_summary::Output), + #[doc(hidden)] VisibilitySelected(Visibility), + Submit, + SubmitDone(glib::Uri), + SubmitError(E) +} + +#[relm4::component(pub)] +impl Component for PostEditor { + type Init = Option; + type Output = Option; + type Input = Input; + 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 = >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(&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::() + .unwrap() + .value(); + let v = glib::EnumClass::new::() + .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(>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())), + }, + + } + } + } + + fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { + 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::( + [] as [gtk::Expression; 0], + glib::closure::RustClosure::new(|v| { + let list_item = v[0].get::().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::()), + 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())); + + ComponentParts { model, widgets } + } + + fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender, 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); + } +} 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); } } diff --git a/src/main.rs b/src/main.rs index acb9f66..31d7d24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use adw::prelude::GtkWindowExt; use relm4::{ComponentParts, ComponentSender, RelmApp, Component, ComponentController}; -use bowl::PostComposerModel; +use bowl::App; use bowl::APPLICATION_ID; @@ -15,7 +15,7 @@ fn main() { log::set_max_level(log::LevelFilter::Debug); let app = RelmApp::new(APPLICATION_ID); - app.run_async::( + app.run_async::( bowl::micropub::Client::new( glib::Uri::parse(&std::env::var("MICROPUB_URI").unwrap(), glib::UriFlags::NONE).unwrap(), std::env::var("MICROPUB_TOKEN").unwrap(), -- cgit 1.4.1