summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorVika <vika@fireburn.ru>2024-08-19 21:42:04 +0300
committerVika <vika@fireburn.ru>2024-08-19 21:42:04 +0300
commitd0353dbc6ac624f63240ec64b83d238499cb0c7c (patch)
treedba8ed669d4f774217ee01c2fb4534835951161e /src
parent2b25e0454c60bb30b9a531b94836ca0d64e6c5e1 (diff)
downloadbowl-d0353dbc6ac624f63240ec64b83d238499cb0c7c.tar.zst
Post composer UI prototype
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.
Diffstat (limited to 'src')
-rw-r--r--src/lib.rs302
-rw-r--r--src/main.rs19
-rw-r--r--src/widgets.rs30
-rw-r--r--src/widgets/field_with_label.rs0
4 files changed, 351 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(&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())),
+                    },
+
+                }
+            }
+        }
+                
+    }
+    /// 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);
+                };
+            },
+        }
+    }
+}
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::<PostComposerModel>( () );
+}
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
--- /dev/null
+++ b/src/widgets/field_with_label.rs