From d0353dbc6ac624f63240ec64b83d238499cb0c7c Mon Sep 17 00:00:00 2001 From: Vika Date: Mon, 19 Aug 2024 21:42:04 +0300 Subject: Post composer UI prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently the UI does precisely nothing, but the ✨ Smart Summary button prints a message stating what it's supposed to do. The Post button currently just logs to the console, although ultimately it should send a message to a parent component or something. Perhaps even the composer UI itself should be a separate part that can provide an MF2-JSON document on a command. --- src/lib.rs | 302 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 19 +++ src/widgets.rs | 30 ++++ src/widgets/field_with_label.rs | 0 4 files changed, 351 insertions(+) create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/widgets.rs create mode 100644 src/widgets/field_with_label.rs (limited to 'src') 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, + + #[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) -> 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, + ) -> relm4::ComponentParts { + 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())); + + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender, _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, _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); + }; + }, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..4f4688e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,19 @@ +use adw::prelude::GtkWindowExt; +use relm4::{ComponentParts, ComponentSender, RelmApp, Component, ComponentController}; + +use bowl::PostComposerModel; + +const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; + +static GLIB_LOGGER: glib::GlibLogger = glib::GlibLogger::new( + glib::GlibLoggerFormat::Plain, + glib::GlibLoggerDomain::CrateTarget, +); + +fn main() { + log::set_logger(&GLIB_LOGGER).unwrap(); + log::set_max_level(log::LevelFilter::Debug); + + let app = RelmApp::new(APPLICATION_ID); + app.run::( () ); +} diff --git a/src/widgets.rs b/src/widgets.rs new file mode 100644 index 0000000..eb5766b --- /dev/null +++ b/src/widgets.rs @@ -0,0 +1,30 @@ +use gtk::prelude::*; +use relm4::{ + gtk, RelmWidgetExt, WidgetTemplate, +}; + + +#[relm4::widget_template(pub)] +impl WidgetTemplate for FieldWithLabel { + view! { + #[name = "layout"] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + + #[name = "label"] + gtk::Label { + set_width_request: 150, + set_height_request: 36, + }, + + #[name = "input_wrapper"] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_css_classes: &["linked"], + + #[name = "input"] + gtk::Entry { set_hexpand: true }, + }, + } + } +} diff --git a/src/widgets/field_with_label.rs b/src/widgets/field_with_label.rs new file mode 100644 index 0000000..e69de29 -- cgit 1.4.1