use crate::components::tag_pill::*; use adw::prelude::*; use gettextrs::*; use glib::translate::IntoGlib; #[cfg(feature = "smart-summary")] use relm4::prelude::ComponentController; use relm4::{ factory::FactoryVecDeque, gtk, prelude::{Controller, DynamicIndex}, 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, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PostConversionSettings { pub send_html_directly: bool, } impl Post { pub fn into_mf2(self, settings: PostConversionSettings) -> microformats::types::Item { use microformats::types::{Class, Fragment, Item, KnownClass, PropertyValue}; let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]); if let Some(name) = self.name { mf2.properties .insert("name".to_owned(), vec![PropertyValue::Plain(name)]); } if let Some(summary) = self.summary { mf2.properties .insert("summary".to_owned(), vec![PropertyValue::Plain(summary)]); } if !self.tags.is_empty() { mf2.properties.insert( "category".to_string(), self.tags.into_iter().map(PropertyValue::Plain).collect(), ); } mf2.properties.insert( "visibility".to_string(), vec![PropertyValue::Plain(self.visibility.to_string())], ); let content = if settings.send_html_directly { PropertyValue::Fragment(Fragment { html: self.content.clone(), value: self.content, lang: None, }) } else { PropertyValue::Plain(self.content) }; mf2.properties.insert("content".to_string(), vec![content]); mf2 } } #[tracker::track] #[derive(Debug)] pub(crate) struct PostEditor { #[no_eq] smart_summary_busy_guard: Option, sending: bool, #[do_not_track] #[allow(dead_code)] spell_checker: spelling::Checker, #[do_not_track] spelling_adapter: spelling::TextBufferAdapter, #[do_not_track] name_buffer: gtk::EntryBuffer, #[do_not_track] summary_buffer: gtk::EntryBuffer, #[do_not_track] content_buffer: sourceview5::Buffer, #[do_not_track] pending_tag_buffer: gtk::EntryBuffer, #[do_not_track] tags: relm4::factory::FactoryVecDeque, visibility: Visibility, #[do_not_track] narrow_layout: gtk::BoxLayout, #[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::Grid { set_orientation: gtk::Orientation::Vertical, set_column_homogeneous: false, set_row_spacing: 10, set_margin_all: 5, #[name = "name_label"] attach[0, 0, 1, 1] = >k::Label { set_markup: &gettext("Name"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "name_field"] attach[1, 0, 1, 1] = >k::Entry { set_hexpand: true, set_buffer: &model.name_buffer, #[track = "model.changed(Self::sending())"] set_sensitive: !model.sending, }, #[name = "summary_label"] attach[0, 1, 1, 1] = >k::Label { set_markup: &gettext("Summary"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "summary_field"] attach[1, 1, 1, 1] = >k::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"] attach[0, 2, 1, 1] = >k::Label { set_markup: &gettext("Tags"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "tag_holder"] attach[1, 2, 1, 1] = >k::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, } }, }, attach[1, 3, 1, 1] = model.tags.widget(), #[name = "content_label"] attach[0, 4, 1, 1] = >k::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"] attach[1, 4, 1, 1] = >k::ScrolledWindow { set_vexpand: true, set_height_request: 200, #[name = "content_textarea"] gtk::TextView { set_buffer: Some(&model.content_buffer), set_extra_menu: Some(&model.spelling_adapter.menu_model()), insert_action_group: ("spelling", Some(&model.spelling_adapter)), 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"] attach[0, 5, 2, 1] = >k::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)); } }, }, }, }, }, }, }, }, // We could've used AdwMultiLayoutView, but a raw // breakpoint makes a bit more sense since we need to // change some properties along the way. add_breakpoint = adw::Breakpoint::new( adw::BreakpointCondition::new_length( adw::BreakpointConditionLengthType::MaxWidth, 512.0, adw::LengthUnit::Px ) ) { add_setter: (&content, "layout_manager", Some(&model.narrow_layout.to_value())), }, } } } fn init( init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { #[cfg(feature = "smart-summary")] let (http, init) = init; let spell_checker = spelling::Checker::default(); let content_buffer = Default::default(); let mut model = Self { smart_summary_busy_guard: None, sending: false, spelling_adapter: spelling::TextBufferAdapter::new(&content_buffer, &spell_checker), spell_checker, name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), content_buffer, pending_tag_buffer: gtk::EntryBuffer::default(), tags: FactoryVecDeque::builder() .launch({ let listbox = gtk::Box::default(); let layout = adw::WrapLayout::builder() .child_spacing(5) .line_spacing(5) .build(); listbox.set_layout_manager(Some(layout)); listbox }) .forward(sender.input_sender(), |del: TagPillDelete| { Input::RemoveTag(del.0) }), visibility: Visibility::Public, narrow_layout: gtk::BoxLayout::builder() .orientation(gtk::Orientation::Vertical) .spacing(5) .build(), #[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()); model.spelling_adapter.set_enabled(true); 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; } 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); } }