use std::sync::Arc; use adw::prelude::*; use gtk::GridLayoutChild; use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts}, AsyncComponentSender, RelmWidgetExt}; mod widgets; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; #[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, #[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, } 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 = (); /// The type of the command outputs that this component can receive. type CommandOutput = PostComposerCommandOutput; view! { #[root] adw::ApplicationWindow { set_title: Some("Create post"), adw::ToolbarView { add_top_bar: &{ relm4::view! { send_button = gtk::Button { set_label: "Post", connect_clicked => Self::Input::Submit, }, bar = adw::HeaderBar::new() { pack_end: &send_button, }, } bar }, #[name = "content_wrapper"] adw::BreakpointBin { set_width_request: 320, 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, }, #[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, set_css_classes: &["linked"], gtk::Entry { set_hexpand: true, set_buffer: &model.summary_buffer, #[track = "model.changed(Self::ai_summary_busy_guard())"] set_sensitive: model.ai_summary_busy_guard.is_none(), }, #[name = "ai_summary_button"] gtk::Button { connect_clicked => Self::Input::AiGenSummaryBegin, #[track = "model.changed(Self::ai_summary_busy_guard())"] set_sensitive: model.ai_summary_busy_guard.is_none(), set_tooltip: "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 { set_css_classes: &["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::TextView { set_buffer: Some(&model.content_buffer), set_hexpand: true, set_vexpand: true, set_css_classes: &["frame", "view"], set_monospace: true, set_left_margin: 8, set_right_margin: 8, set_top_margin: 8, set_bottom_margin: 8, }, }, add_breakpoint = adw::Breakpoint::new( adw::BreakpointCondition::new_length( adw::BreakpointConditionLengthType::MinWidth, 480.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, 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), tracker: Default::default() }; let widgets = view_output!(); let layout = &model.wide_layout; widgets.content.set_layout_manager(Some(layout.clone())); layout.set_column_homogeneous(false); layout.set_row_spacing(10); for (row, (label, field)) in [ (&widgets.name_label, widgets.name_field.upcast_ref::()), (&widgets.summary_label, widgets.summary_field.upcast_ref::()), (&widgets.tag_label, widgets.tag_holder.upcast_ref::()), (&widgets.content_label, widgets.content_textarea.upcast_ref::()) ].into_iter().enumerate() { 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); } 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 => { log::warn!("Submitting posts is not yet implemented."); }, } 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); }; }, } } }