use std::sync::Arc; use adw::prelude::*; use gtk::GridLayoutChild; use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts}, AsyncComponentSender, RelmWidgetExt}; mod widgets; pub mod micropub; pub mod util; 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] ai_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, } impl PostComposerModel { async fn ai_generate_summary(_content: glib::GString, _sender: AsyncComponentSender) -> PostComposerCommandOutput { // This is just a UI mock-up. In real-life conditions, this // would send a request to a summarizer API of some sort. // // Perhaps this API could be created using Ollama. // // Alternatively, one could probably launch a small local LLM // to do this, without network access. for i in ["I'", "m ", "sorry,", " I", " am ", "unable", " to", " ", "write", " you ", "a summary.", " I", " am", " not ", "really ", "an ", "LLM."] { tokio::time::sleep(std::time::Duration::from_millis(100)).await; _sender.input(PostComposerInput::AiGenSummaryProgress(i.to_string())); } PostComposerCommandOutput::AiSummaryDone(Ok(())) } } #[derive(Debug)] pub enum PostComposerInput { AiGenSummaryBegin, AiGenSummaryProgress(String), Submit, } #[derive(Debug)] pub enum PostComposerCommandOutput { AiSummaryDone(Result<(), ()>) } #[relm4::component(pub async)] impl AsyncComponent for PostComposerModel { /// The type of the messages that this component can receive. type Input = PostComposerInput; /// 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; view! { #[root] adw::ApplicationWindow { set_title: Some("Create post"), set_width_request: 360, set_height_request: 480, adw::ToolbarView { add_top_bar: &{ relm4::view! { send_button = gtk::Button { set_label: "Post", connect_clicked => Self::Input::Submit, #[track = "model.changed(Self::submit_busy_guard())"] set_sensitive: model.submit_busy_guard.is_none(), }, bar = adw::HeaderBar::new() { pack_end: &send_button, }, } 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.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), }, #[name = "ai_summary_button"] gtk::Button { connect_clicked => Self::Input::AiGenSummaryBegin, #[track = "model.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), set_tooltip_markup: Some("Smart Summary\nAsk a language model to summarize the content of your post in a single sentence."), gtk::Stack { gtk::Label { #[track = "model.changed(Self::ai_summary_busy_guard())"] set_visible: model.ai_summary_busy_guard.is_none(), set_markup: "✨" }, gtk::Spinner { #[track = "model.changed(Self::ai_summary_busy_guard())"] set_visible: model.ai_summary_busy_guard.is_some(), set_spinning: true, } } }, }, #[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())), }, } } } } } /// Initialize the UI and model. async fn init( init: Self::Init, window: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { let model = PostComposerModel { ai_summary_busy_guard: None, submit_busy_guard: None, name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), content_buffer: gtk::TextBuffer::default(), wide_layout: gtk::GridLayout::new(), narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical), micropub: Arc::new(init), tracker: Default::default() }; let widgets = view_output!(); #[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( &mut self, widgets: &mut Self::Widgets, message: Self::Input, sender: AsyncComponentSender, root: &Self::Root ) { self.reset(); // Reset the tracker match message { PostComposerInput::AiGenSummaryBegin => { self.set_ai_summary_busy_guard( Some(relm4::main_adw_application().mark_busy()) ); self.summary_buffer.set_text(""); sender.oneshot_command( Self::ai_generate_summary( // TODO: apparently this thing may keep inline images. Investigate. self.content_buffer.text( &self.content_buffer.start_iter(), &self.content_buffer.end_iter(), false ), sender.clone() ) ) }, PostComposerInput::AiGenSummaryProgress(text) => { self.summary_buffer.insert_text(self.summary_buffer.length(), text); }, 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())); } 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); }, 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.set_submit_busy_guard(None); }, } self.update_view(widgets, sender); } async fn update_cmd(&mut self, msg: Self::CommandOutput, _sender: AsyncComponentSender, _root: &Self::Root) { match msg { Self::CommandOutput::AiSummaryDone(res) => { self.set_ai_summary_busy_guard(None); if let Err(err) = res { log::warn!("AI summary generation failed: {:?}", err); }; }, } } }