summary refs log tree commit diff
path: root/src/components/post_editor.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/post_editor.rs')
-rw-r--r--src/components/post_editor.rs368
1 files changed, 186 insertions, 182 deletions
diff --git a/src/components/post_editor.rs b/src/components/post_editor.rs
index c42b06a..021ba91 100644
--- a/src/components/post_editor.rs
+++ b/src/components/post_editor.rs
@@ -1,12 +1,16 @@
-use gettextrs::*;
 use crate::components::tag_pill::*;
 use adw::prelude::*;
+use gettextrs::*;
 
 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;
+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")]
@@ -19,7 +23,7 @@ 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"
+            Self::Private => "private",
         })
     }
 }
@@ -30,43 +34,52 @@ pub struct Post {
     pub summary: Option<String>,
     pub tags: Vec<String>,
     pub content: String,
-    pub visibility: Visibility
+    pub visibility: Visibility,
+}
+
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct PostConversionSettings {
+    pub send_html_directly: bool,
 }
 
-impl From<Post> for microformats::types::Item {
-    fn from(post: Post) -> Self {
-        use microformats::types::{Item, Class, KnownClass, PropertyValue};
+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) = post.name {
-            mf2.properties.insert(
-                "name".to_owned(), vec![PropertyValue::Plain(name)]
-            );
+        if let Some(name) = self.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 let Some(summary) = self.summary {
+            mf2.properties
+                .insert("summary".to_owned(), vec![PropertyValue::Plain(summary)]);
         }
 
-        if !post.tags.is_empty() {
+        if !self.tags.is_empty() {
             mf2.properties.insert(
                 "category".to_string(),
-                post.tags.into_iter().map(PropertyValue::Plain).collect()
+                self.tags.into_iter().map(PropertyValue::Plain).collect(),
             );
         }
 
         mf2.properties.insert(
             "visibility".to_string(),
-            vec![PropertyValue::Plain(post.visibility.to_string())]
+            vec![PropertyValue::Plain(self.visibility.to_string())],
         );
 
-        mf2.properties.insert(
-            "content".to_string(),
-            vec![PropertyValue::Plain(post.content)]
-        );
+        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
     }
@@ -75,22 +88,35 @@ impl From<Post> for microformats::types::Item {
 #[tracker::track]
 #[derive(Debug)]
 pub(crate) struct PostEditor<E> {
-    #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
+    #[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,
-
-    #[do_not_track] pending_tag_buffer: gtk::EntryBuffer,
-    #[do_not_track] tags: relm4::factory::FactoryVecDeque<TagPill>,
+    #[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<TagPill>,
     visibility: Visibility,
 
-    #[do_not_track] wide_layout: gtk::GridLayout,
+    #[do_not_track]
+    narrow_layout: gtk::BoxLayout,
 
     #[cfg(feature = "smart-summary")]
-    #[do_not_track] smart_summary: Controller<crate::components::SmartSummaryButton>,
-    _err: std::marker::PhantomData<E>
+    #[do_not_track]
+    smart_summary: Controller<crate::components::SmartSummaryButton>,
+    _err: std::marker::PhantomData<E>,
 }
 
 impl<E> PostEditor<E> {
@@ -107,13 +133,17 @@ impl<E> PostEditor<E> {
 #[allow(clippy::manual_non_exhaustive)] // false positive
 pub enum Input<E: std::error::Error + std::fmt::Debug + Send + 'static> {
     #[cfg(feature = "smart-summary")]
-    #[doc(hidden)] SmartSummary(crate::components::smart_summary::Output),
-    #[doc(hidden)] VisibilitySelected(Visibility),
-    #[doc(hidden)] AddTagFromBuffer,
-    #[doc(hidden)] RemoveTag(DynamicIndex),
+    #[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)
+    SubmitError(E),
 }
 
 #[relm4::component(pub)]
@@ -137,20 +167,21 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
 
                 gtk::ScrolledWindow {
                     #[name = "content"]
-                    gtk::Box {
+                    gtk::Grid {
                         set_orientation: gtk::Orientation::Vertical,
-                        set_spacing: 5,
+                        set_column_homogeneous: false,
+                        set_row_spacing: 10,
                         set_margin_all: 5,
 
                         #[name = "name_label"]
-                        gtk::Label {
+                        attach[0, 0, 1, 1] = &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 {
+                        attach[1, 0, 1, 1] = &gtk::Entry {
                             set_hexpand: true,
                             set_buffer: &model.name_buffer,
                             #[track = "model.changed(Self::sending())"]
@@ -158,14 +189,14 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                         },
 
                         #[name = "summary_label"]
-                        gtk::Label {
+                        attach[0, 1, 1, 1] = &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 {
+                        attach[1, 1, 1, 1] = &gtk::Box {
                             set_orientation: gtk::Orientation::Horizontal,
                             add_css_class: "linked",
 
@@ -178,14 +209,14 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                         },
 
                         #[name = "tag_label"]
-                        gtk::Label {
+                        attach[0, 2, 1, 1] = &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 {
+                        attach[1, 2, 1, 1] = &gtk::Box {
                             set_hexpand: true,
                             set_orientation: gtk::Orientation::Vertical,
                             set_spacing: 5,
@@ -211,22 +242,10 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                             },
                         },
 
-                        #[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(),
-                            }
-                        },
+                        attach[1, 3, 1, 1] = model.tags.widget(),
 
                         #[name = "content_label"]
-                        gtk::Label {
+                        attach[0, 4, 1, 1] = &gtk::Label {
                             set_markup: &gettext("Content"),
                             set_halign: gtk::Align::Start,
                             set_valign: gtk::Align::Start,
@@ -235,12 +254,15 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                         },
 
                         #[name = "content_textarea_wrapper"]
-                        gtk::ScrolledWindow {
+                        attach[1, 4, 1, 1] = &gtk::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"],
@@ -260,7 +282,7 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                         },
 
                         #[name = "misc_prop_wrapper"]
-                        gtk::Box {
+                        attach[0, 5, 2, 1] = &gtk::Box {
                             set_hexpand: true,
 
                             gtk::FlowBox {
@@ -315,19 +337,17 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                     },
                 },
 
+                // 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::MinWidth,
+                        adw::BreakpointConditionLengthType::MaxWidth,
                         512.0,
                         adw::LengthUnit::Px
                     )
                 ) {
-                    add_setter: (&content, "layout_manager", Some(&model.wide_layout.to_value())),
-                    add_setter: (&name_label, "halign", Some(&gtk::Align::End.to_value())),
-                    add_setter: (&summary_label, "halign", Some(&gtk::Align::End.to_value())),
-                    add_setter: (&tag_label, "halign", Some(&gtk::Align::End.to_value())),
-                    add_setter: (&content_label, "halign", Some(&gtk::Align::End.to_value())),
-                    add_setter: (&pending_tag_entry, "hexpand", Some(&false.to_value())),
+                    add_setter: (&content, "layout_manager", Some(&model.narrow_layout.to_value())),
                 },
 
             }
@@ -337,34 +357,46 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
     fn init(
         init: Self::Init,
         root: Self::Root,
-        sender: ComponentSender<Self>
+        sender: ComponentSender<Self>,
     ) -> ComponentParts<Self> {
         #[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: gtk::TextBuffer::default(),
-            pending_tag_buffer: gtk::EntryBuffer::default(),
+            content_buffer,
 
+            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);
+                    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)
-                ),
+                .forward(sender.input_sender(), |del: TagPillDelete| {
+                    Input::RemoveTag(del.0)
+                }),
             visibility: Visibility::Public,
 
-            wide_layout: gtk::GridLayout::new(),
+            narrow_layout: gtk::BoxLayout::builder()
+                .orientation(gtk::Orientation::Vertical)
+                .spacing(5)
+                .build(),
 
             #[cfg(feature = "smart-summary")]
             smart_summary: crate::components::SmartSummaryButton::builder()
@@ -378,19 +410,20 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
         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::<String>(
+        model.spelling_adapter.set_enabled(true);
+
+        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(gettext(list_item.name().as_str()).into())
-                })
-            )
-        ));
+                }),
+            )));
 
         if let Some(post) = init {
             if let Some(name) = post.name {
@@ -401,108 +434,68 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
             }
 
             let mut tags = model.tags.guard();
-            post.tags.into_iter().for_each(|t| { tags.push_back(t.into_boxed_str()); });
+            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())
-            );
+            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::<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::SecondColumn(widgets.tag_viewport.upcast_ref::<gtk::Widget>()),
-            Row::TwoColumn(&widgets.content_label, widgets.content_textarea_wrapper.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);
-                },
-                Row::SecondColumn(widget) => {
-                    let widget_layout = layout.layout_child(widget)
-                        .downcast::<GridLayoutChild>()
-                        .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<Self>, root: &Self::Root) {
+    fn update_with_view(
+        &mut self,
+        widgets: &mut Self::Widgets,
+        msg: Self::Input,
+        sender: ComponentSender<Self>,
+        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
-                    );
+                    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
+                        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.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);
-            },
+                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);
@@ -511,44 +504,51 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                 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.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 },
+                        } else {
+                            None
+                        },
                         summary: if self.summary_buffer.length() > 0 {
                             Some(self.summary_buffer.text().into())
-                        } else { None },
+                        } 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(),
+                        content: self
+                            .content_buffer
+                            .text(
+                                &self.content_buffer.start_iter(),
+                                &self.content_buffer.end_iter(),
+                                false,
+                            )
+                            .into(),
                         visibility: self.visibility,
                     })
-                } else { None };
+                } else {
+                    None
+                };
                 let _ = sender.output(post);
-            },
+            }
             Input::SubmitDone(location) => {
                 self.name_buffer.set_text("");
                 self.summary_buffer.set_text("");
@@ -560,18 +560,22 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post
                     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()
+                        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);