diff options
author | Vika <vika@fireburn.ru> | 2024-08-22 22:05:42 +0300 |
---|---|---|
committer | Vika <vika@fireburn.ru> | 2024-08-22 23:13:03 +0300 |
commit | 7dee9f44241f8f9f26590205485f9f8ab701b807 (patch) | |
tree | b13f3aac34edb33815fb5d5050526336b9e1bbb4 /src/components | |
parent | f164ee83342d025204897018b15141de6e1ca93d (diff) | |
download | bowl-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')
-rw-r--r-- | src/components/post_editor.rs | 489 |
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 = >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::<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(>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<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); + } +} |