use gettextrs::*; use crate::components::tag_pill::*; use adw::prelude::*; use glib::translate::IntoGlib; use gtk::GridLayoutChild; use relm4::{factory::FactoryVecDeque, gtk, prelude::{Controller, DynamicIndex}, Component, ComponentParts, ComponentSender, RelmWidgetExt}; #[cfg(feature = "smart-summary")] use relm4::prelude::ComponentController; #[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, #[do_not_track] pending_tag_buffer: gtk::EntryBuffer, #[do_not_track] tags: relm4::factory::FactoryVecDeque, visibility: Visibility, #[do_not_track] wide_layout: gtk::GridLayout, #[cfg(feature = "smart-summary")] #[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)] // intentional #[allow(clippy::manual_non_exhaustive)] // false positive pub enum Input { #[cfg(feature = "smart-summary")] #[doc(hidden)] SmartSummary(crate::components::smart_summary::Output), #[doc(hidden)] VisibilitySelected(Visibility), #[doc(hidden)] AddTagFromBuffer, #[doc(hidden)] RemoveTag(DynamicIndex), Submit, SubmitDone(glib::Uri), SubmitError(E) } #[relm4::component(pub)] impl Component for PostEditor { #[cfg(feature = "smart-summary")] type Init = (soup::Session, Option); #[cfg(not(feature = "smart-summary"))] 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: 200, gtk::ScrolledWindow { #[name = "content"] gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 5, set_margin_all: 5, #[name = "name_label"] gtk::Label { set_markup: &gettext("Name"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[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: &gettext("Summary"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[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(), }, }, #[name = "tag_label"] gtk::Label { set_markup: &gettext("Tags"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "tag_holder"] gtk::Box { set_hexpand: true, set_orientation: gtk::Orientation::Vertical, set_spacing: 5, gtk::Box { add_css_class: "linked", set_orientation: gtk::Orientation::Horizontal, #[name = "pending_tag_entry"] gtk::Entry { set_hexpand: true, set_width_request: 200, set_buffer: &model.pending_tag_buffer, connect_activate => Self::Input::AddTagFromBuffer, #[track = "model.changed(Self::sending())"] set_sensitive: !model.sending, }, gtk::Button { set_icon_name: "list-add-symbolic", add_css_class: "suggested-action", connect_clicked => Self::Input::AddTagFromBuffer, } }, }, #[name = "tag_viewport"] gtk::ScrolledWindow { set_height_request: 32, set_valign: gtk::Align::Center, gtk::Viewport { set_scroll_to_focus: true, set_valign: gtk::Align::Center, #[wrap(Some)] set_child = model.tags.widget(), } }, #[name = "content_label"] gtk::Label { set_markup: &gettext("Content"), set_halign: gtk::Align::Start, set_valign: gtk::Align::Start, set_margin_vertical: 10, set_margin_horizontal: 10, }, #[name = "content_textarea_wrapper"] gtk::ScrolledWindow { set_vexpand: true, set_height_request: 200, #[name = "content_textarea"] 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::FlowBoxChild { set_halign: gtk::Align::Start, gtk::Box { set_spacing: 5, #[name = "visibility_label"] gtk::Label { set_markup: &gettext("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())), add_setter: (&pending_tag_entry, "hexpand", Some(&false.to_value())), }, } } } fn init( init: Self::Init, root: Self::Root, sender: ComponentSender ) -> ComponentParts { #[cfg(feature = "smart-summary")] let (http, init) = init; 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(), pending_tag_buffer: gtk::EntryBuffer::default(), tags: FactoryVecDeque::builder() .launch({ let listbox = gtk::Box::default(); listbox.set_orientation(gtk::Orientation::Horizontal); listbox.set_spacing(5); listbox }) .forward( sender.input_sender(), |del: TagPillDelete| Input::RemoveTag(del.0) ), visibility: Visibility::Public, wide_layout: gtk::GridLayout::new(), #[cfg(feature = "smart-summary")] smart_summary: crate::components::SmartSummaryButton::builder() .launch(http) .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!(); #[cfg(feature = "smart-summary")] widgets.summary_field.append(model.smart_summary.widget()); 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(gettext(list_item.name().as_str()).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)); } let mut tags = model.tags.guard(); post.tags.into_iter().for_each(|t| { tags.push_back(t.into_boxed_str()); }); 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 prev_layout = widgets.content.layout_manager().unwrap(); 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), SecondColumn(&'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::SecondColumn(widgets.tag_viewport.upcast_ref::()), Row::TwoColumn(&widgets.content_label, widgets.content_textarea_wrapper.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); }, Row::SecondColumn(widget) => { let widget_layout = layout.layout_child(widget) .downcast::() .unwrap(); widget_layout.set_row(row as i32); widget_layout.set_column(1); } } } widgets.content.set_layout_manager(Some(prev_layout)); ComponentParts { model, widgets } } fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender, root: &Self::Root) { self.reset(); match msg { #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Start) => { widgets.content_textarea.set_sensitive(false); if self.content_buffer.char_count() == 0 { let _ = self.smart_summary.sender().send( crate::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( crate::components::SmartSummaryInput::Text(text.into()) ).is_ok() { self.summary_buffer.set_text(""); } } widgets.content_textarea.set_sensitive(true); }, #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Chunk(text)) => { self.summary_buffer.insert_text(self.summary_buffer.length(), text); }, #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Done) => { self.set_smart_summary_busy_guard(None); }, #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Error(err)) => { self.set_smart_summary_busy_guard(None); let toast = adw::Toast::new(&gettext!("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::AddTagFromBuffer => { let tag = String::from(self.pending_tag_buffer.text()); if !tag.is_empty() { self.tags.guard().push_back( tag.into_boxed_str() ); self.pending_tag_buffer.set_text(""); } }, Input::RemoveTag(idx) => { self.tags.guard().remove(idx.current_index()); }, 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: self.tags.iter().map(|t| t.0.clone().into()).collect(), 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(""); self.tags.guard().clear(); self.content_buffer.set_text(""); let toast = adw::Toast::new(&gettext("Post submitted")); toast.set_button_label(Some(&gettext("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(&gettext!("Error sending post: {}", err)); toast.set_timeout(0); toast.set_priority(adw::ToastPriority::High); root.add_toast(toast); } } self.update_view(widgets, sender); } }