diff options
Diffstat (limited to 'src/lib.rs')
-rw-r--r-- | src/lib.rs | 302 |
1 files changed, 302 insertions, 0 deletions
diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..718886c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,302 @@ +use adw::prelude::*; +use gtk::GridLayoutChild; +use relm4::{gtk, ComponentParts, ComponentSender, RelmWidgetExt, Component}; + +mod widgets; + +#[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<gtk::gio::ApplicationBusyGuard>, + + #[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: ComponentSender<Self>) -> 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)] +impl Component 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. + fn init( + init: Self::Init, + window: Self::Root, + sender: ComponentSender<Self>, + ) -> relm4::ComponentParts<Self> { + 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::<gtk::Widget>()), + (&widgets.summary_label, widgets.summary_field.upcast_ref::<gtk::Widget>()), + (&widgets.tag_label, widgets.tag_holder.upcast_ref::<gtk::Widget>()), + (&widgets.content_label, widgets.content_textarea.upcast_ref::<gtk::Widget>()) + ].into_iter().enumerate() { + 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); + } + + widgets.content.set_layout_manager(Some(model.narrow_layout.clone())); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>, _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."); + }, + } + } + + fn update_cmd(&mut self, msg: Self::CommandOutput, _sender: ComponentSender<Self>, _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); + }; + }, + } + } +} |