//! The Relm4 view: one component whose widgets mirror the model. The //! `Component` impl is a thin adapter — `handle` and `handle_result` in //! `app.rs` own the state machine; background work runs through //! `spawn_command` on the blocking pool and reports back as `OpResult`. //! //! Every visual decision is delegated to libadwaita: the widgets below are //! the stylesheet's own rows, groups, dialogs and status pages, and the //! only styling is its documented class names. Nothing here picks a colour, //! a font or a size, so carrier follows the user's theme and accent. use std::sync::atomic::Ordering; use std::time::Duration; use fumi_core::crypto::{b32, fingerprint, KEY_LEN}; use gtk::prelude::*; use gtk::{Align, Orientation, WrapMode}; use relm4::adw; use relm4::adw::prelude::*; use relm4::ComponentSender; use relm4::{Component, ComponentParts}; use crate::app::{Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp}; use crate::ops::{self, Contact, Folder, Row}; use relm4::RelmApp; /// A label, the unit every line is built from. The class argument is a /// space-separated set of Adwaita's own typography classes, so weight and /// size come from the stylesheet rather than from markup or a hardcoded /// font: `heading` and `title` for hierarchy, `dim` and `caption` for /// metadata, `mono` wherever a key or address must stay column-aligned. fn label(text: &str, classes: &str) -> gtk::Label { let label = gtk::Label::new(Some(text)); for class in classes.split_whitespace() { match class { "dim" => label.add_css_class("dim-label"), "caption" => { label.add_css_class("caption"); label.add_css_class("dim-label"); } "heading" => label.add_css_class("heading"), "title" => label.add_css_class("title-1"), "subtitle" => label.add_css_class("title-4"), "mono" => label.add_css_class("monospace"), "numeric" => label.add_css_class("numeric"), other => label.add_css_class(other), } } label.set_xalign(0.0); label } fn escape(text: &str) -> String { gtk::glib::markup_escape_text(text).to_string() } /// Buttons map onto the stylesheet's built-in classes: "accent" is the /// suggested action, "danger" the destructive one, "pill" the wide call to /// action a welcome screen wants, everything else flat. fn button(text: &str, class: &str) -> gtk::Button { let button = gtk::Button::with_label(text); match class { "accent" => button.add_css_class("suggested-action"), "danger" => button.add_css_class("destructive-action"), "quiet" => button.add_css_class("flat"), "pill" => { button.add_css_class("pill"); button.add_css_class("suggested-action"); } "pill-quiet" => button.add_css_class("pill"), _ => {} } button } /// The same, with a symbolic icon beside the label. `ButtonContent` does /// the icon-to-label spacing the stylesheet expects. fn icon_label_button(icon: &str, text: &str, class: &str) -> gtk::Button { let button = button("", class); button.set_child(Some( &adw::ButtonContent::builder() .icon_name(icon) .label(text) .build(), )); button } /// A flat round icon button: the Adwaita form for a secondary affordance /// that sits inside a row rather than in a toolbar. fn icon_button(icon: &str, tooltip: &str) -> gtk::Button { let button = gtk::Button::from_icon_name(icon); button.add_css_class("flat"); button.add_css_class("circular"); button.set_tooltip_text(Some(tooltip)); button.set_valign(Align::Center); button } /// One sidebar destination: icon, name, and a count that stays hidden /// until it is non-zero. The row carries no class of its own — the list's /// `navigation-sidebar` paints hover and selection. fn nav_row(icon: &str, text: &str) -> (gtk::ListBoxRow, gtk::Label) { let row = gtk::ListBoxRow::new(); let line = gtk::Box::new(Orientation::Horizontal, 12); line.set_margin_top(6); line.set_margin_bottom(6); line.set_margin_start(6); line.set_margin_end(6); line.append(>k::Image::from_icon_name(icon)); let name = gtk::Label::new(Some(text)); name.set_xalign(0.0); name.set_hexpand(true); line.append(&name); let count = label("", "caption numeric"); count.set_visible(false); line.append(&count); row.set_child(Some(&line)); (row, count) } /// A list that paints itself as one rounded group: the Adwaita form for a /// short, fixed set of rows, as opposed to a long scrolling one. fn boxed_list() -> gtk::ListBox { let list = gtk::ListBox::new(); list.add_css_class("boxed-list"); list.set_selection_mode(gtk::SelectionMode::None); list } /// Removes every child of a box, the coarse re-render primitive. fn clear(boxed: >k::Box) { while let Some(child) = boxed.first_child() { boxed.remove(&child); } } /// The same for lists. fn clear_list(list: >k::ListBox) { while let Some(child) = list.first_child() { list.remove(&child); } } pub fn run(dir: std::path::PathBuf) { let app: RelmApp<_> = RelmApp::new("com.randogoth.carrier"); app.run::(dir); } fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { text.to_string() } else { let cut: String = text.chars().take(max - 1).collect(); format!("{cut}…") } } fn buffer_text(buffer: >k::TextBuffer) -> String { buffer .text(&buffer.start_iter(), &buffer.end_iter(), false) .to_string() } /// The contact's trust state as one word, the same vocabulary the sidebar, /// the detail pane and the contacts page all label it with. fn contact_state(contact: &Contact) -> &'static str { match contact.accepted { Some(true) => "accepted", Some(false) => "blocked", None if contact.verified => "verified", None => "trust on first use", } } #[derive(Debug, Clone)] pub struct CarrierWidgets { /// `AlertDialog::present` needs a parent, and only the window is one. window: gtk::Window, window_title: adw::WindowTitle, // sidebar top: actions and destinations folders: gtk::ListBox, folder_counts: Vec, /// The Drafts row, hidden until the folder holds one. drafts_nav: gtk::ListBoxRow, settings_nav: gtk::ListBox, fetch_button: gtk::Button, write_button: gtk::Button, stack: gtk::Stack, // mail page list_stack: gtk::Stack, mail_list: gtk::ListBox, list_empty: adw::StatusPage, list_empty_fetch: gtk::Button, article_stack: gtk::Stack, article_subject: gtk::Label, article_meta: gtk::Label, article_headers: gtk::Label, article_view: gtk::TextView, article_body: gtk::TextBuffer, actions: gtk::Box, // compose page compose_to: adw::EntryRow, compose_headers: gtk::ListBox, compose_subject: adw::EntryRow, compose_body: gtk::TextBuffer, compose_send: adw::ButtonContent, // sidebar bottom: own identity and the contact list sidebar_own: gtk::Label, sidebar_contacts: gtk::ListBox, // the QR-only share popover on the detail view's header detail_share: gtk::MenuButton, detail_copy: gtk::Button, detail_qr: gtk::Image, // settings page identity_values: Vec, offer_group: adw::PreferencesGroup, offer_row: adw::ActionRow, pins_group: adw::PreferencesGroup, pin_rows: Vec, timeout_row: adw::SpinRow, backup_label: gtk::Label, // onboarding onboard_seed_label: gtk::Label, // signup signup_back: gtk::Button, signup_offer_list: gtk::ListBox, signup_offer_row: adw::ActionRow, // status bar and dialogs spinner: gtk::Spinner, status_label: gtk::Label, cancel_button: gtk::Button, /// The dialog currently on screen, so `render` presents it once on the /// None -> Some edge instead of stacking a copy on every message. dialog: Option, } impl CarrierWidgets { fn new(root: >k::Window, sender: &ComponentSender) -> Self { // One helper per widget parameter type: a single closure would // have its signature unified across every call site. let input = |msg: Message| { let sender = sender.clone(); move |_: >k::Button| sender.input(msg.clone()) }; // An Adwaita headerbar carrying the app name over the account // address, so the window reads like every other GNOME app and the // identity in use is never more than a glance away. let window_title = adw::WindowTitle::new("carrier", ""); let header = adw::HeaderBar::new(); header.set_title_widget(Some(&window_title)); root.set_titlebar(Some(&header)); // The sidebar column, left of the main box, split on the same // line as the main box's list/article split. The top panel stacks // the actions and the destinations; the bottom panel leads with // our own identity, carded apart from the contact list. let sidebar = gtk::Paned::new(Orientation::Vertical); // Position 1 is below any child's minimum, so both columns open // with the top pane at its smallest height. sidebar.set_position(1); // Refuse to shrink the top panel past its own minimum: otherwise // dragging the divider up clips it from the top and the actions // are the first thing to go. The filler below the folders has no // minimum of its own, so that space collapses first instead. sidebar.set_shrink_start_child(false); let sidebar_top = gtk::Box::new(Orientation::Vertical, 12); sidebar_top.set_margin_top(6); sidebar_top.set_margin_start(6); sidebar_top.set_margin_end(6); let sidebar_bottom = gtk::Box::new(Orientation::Vertical, 6); sidebar_bottom.set_margin_start(6); sidebar_bottom.set_margin_end(6); sidebar_bottom.set_margin_bottom(6); sidebar.set_start_child(Some(&sidebar_top)); sidebar.set_end_child(Some(&sidebar_bottom)); // Fetch and Write lead the top panel, each an equal half. Only // Fetch is suggested: two suggested actions side by side would // leave neither of them reading as the primary one. let sidebar_actions = gtk::Box::new(Orientation::Horizontal, 6); sidebar_actions.set_homogeneous(true); let fetch_button = icon_label_button("mail-send-receive-symbolic", "Fetch", "accent"); let write_button = icon_label_button("mail-message-new-symbolic", "Write", ""); sidebar_actions.append(&fetch_button); sidebar_actions.append(&write_button); sidebar_top.append(&sidebar_actions); // The destinations follow, as the sidebar list every GNOME app // uses: icon, name, and the unread count on the right. let folders = gtk::ListBox::new(); folders.add_css_class("navigation-sidebar"); folders.set_selection_mode(gtk::SelectionMode::Single); let folder_rows = [ nav_row("folder-download-symbolic", "Inbox"), nav_row("contact-new-symbolic", "Requests"), nav_row("mail-send-symbolic", "Sent"), nav_row("document-edit-symbolic", "Drafts"), ]; let mut folder_counts = Vec::new(); let mut drafts_nav = None; for (index, (row, count)) in folder_rows.into_iter().enumerate() { // The drafts folder is carrier's own: it stays out of the // sidebar until a compose has been set aside into it. if index == 3 { row.set_visible(false); drafts_nav = Some(row.clone()); } folders.append(&row); folder_counts.push(count); } let drafts_nav = drafts_nav.expect("four folder rows"); sidebar_top.append(&folders); // Settings closes the top panel, pinned to its bottom. It is its // own list so that it highlights like a destination without // joining the folders' selection, the way GNOME Settings splits // its own sidebar. let filler = gtk::Label::new(None); filler.set_vexpand(true); sidebar_top.append(&filler); let settings_nav = gtk::ListBox::new(); settings_nav.add_css_class("navigation-sidebar"); settings_nav.set_selection_mode(gtk::SelectionMode::Single); let (settings_row, _) = nav_row("preferences-system-symbolic", "Settings"); settings_nav.append(&settings_row); sidebar_top.append(&settings_nav); // Our own identity heads the bottom panel, carded so it reads // apart from the contacts below. Tapping it opens the detail // view the contacts get, with our own key lines in it. let own_text = gtk::Box::new(Orientation::Vertical, 0); own_text.set_hexpand(true); own_text.set_margin_top(8); own_text.set_margin_bottom(8); own_text.set_margin_start(12); let own_caption = label("you", "caption"); let sidebar_own = label("", "mono heading"); // Not selectable: a selectable label eats clicks on the text with // its selection gesture, and the address is already copyable from // the detail view's URI line. sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End); own_text.append(&own_caption); own_text.append(&sidebar_own); let own_row = gtk::Button::new(); own_row.add_css_class("card"); own_row.set_halign(Align::Fill); own_row.set_margin_top(6); own_row.set_child(Some(&own_text)); sidebar_bottom.append(&own_row); // The list's header carries its one action: importing a // smol:// address, in a popover off a plain add button. let contacts_head = gtk::Box::new(Orientation::Horizontal, 6); let contacts_caption = label("contacts", "caption"); contacts_caption.set_hexpand(true); contacts_caption.set_valign(Align::Center); contacts_caption.set_margin_start(12); let contact_import = adw::EntryRow::builder() .title("Import a smol:// address") .build(); let import_button = icon_label_button("document-open-symbolic", "Import", "accent"); import_button.set_valign(Align::Center); contact_import.add_suffix(&import_button); let import_list = boxed_list(); import_list.set_width_request(320); import_list.set_margin_top(6); import_list.set_margin_bottom(6); import_list.set_margin_start(6); import_list.set_margin_end(6); import_list.append(&contact_import); let import_popover = gtk::Popover::new(); import_popover.set_child(Some(&import_list)); let add_contact = gtk::MenuButton::new(); add_contact.set_icon_name("list-add-symbolic"); add_contact.add_css_class("flat"); add_contact.add_css_class("circular"); add_contact.set_tooltip_text(Some("Import a smol:// address")); add_contact.set_popover(Some(&import_popover)); contacts_head.append(&contacts_caption); contacts_head.append(&add_contact); sidebar_bottom.append(&contacts_head); let contacts_scroll = gtk::ScrolledWindow::new(); contacts_scroll.set_vexpand(true); let contacts_list = gtk::ListBox::new(); contacts_list.add_css_class("navigation-sidebar"); contacts_list.set_selection_mode(gtk::SelectionMode::Single); contacts_scroll.set_child(Some(&contacts_list)); sidebar_bottom.append(&contacts_scroll); // --- pages ------------------------------------------------------ let stack = gtk::Stack::new(); stack.set_vexpand(true); // mail: the split — list above, article below let paned = gtk::Paned::new(Orientation::Vertical); paned.set_position(1); let list_scroll = gtk::ScrolledWindow::new(); let mail_list = gtk::ListBox::new(); mail_list.set_selection_mode(gtk::SelectionMode::Single); list_scroll.set_child(Some(&mail_list)); // An empty folder gets a status page rather than a stray line of // dim text, so the pane says what to do about it. let list_empty = adw::StatusPage::new(); let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill"); list_empty_fetch.set_halign(Align::Center); list_empty.set_child(Some(&list_empty_fetch)); let list_stack = gtk::Stack::new(); list_stack.set_hhomogeneous(false); list_stack.set_vhomogeneous(false); list_stack.add_named(&list_scroll, Some("list")); list_stack.add_named(&list_empty, Some("empty")); paned.set_start_child(Some(&list_stack)); paned.set_resize_start_child(true); paned.set_shrink_start_child(false); let article = gtk::Box::new(Orientation::Vertical, 0); let article_head = gtk::Box::new(Orientation::Vertical, 10); article_head.set_margin_top(12); article_head.set_margin_bottom(12); article_head.set_margin_start(12); article_head.set_margin_end(12); let article_subject = label("", "subtitle"); article_subject.set_wrap(true); article_subject.set_hexpand(true); // The detail view's header bar: the address on the left, the QR // share on the right. The popover holds the code and nothing // else — the URI sits in the body below. let detail_qr = gtk::Image::new(); detail_qr.set_margin_top(12); detail_qr.set_margin_bottom(12); detail_qr.set_margin_start(12); detail_qr.set_margin_end(12); let detail_popover = gtk::Popover::new(); detail_popover.set_child(Some(&detail_qr)); let detail_share = gtk::MenuButton::new(); detail_share.set_icon_name("send-to-symbolic"); detail_share.add_css_class("flat"); detail_share.add_css_class("circular"); detail_share.set_tooltip_text(Some("Show the address as a QR code")); detail_share.set_popover(Some(&detail_popover)); detail_share.set_valign(Align::Start); detail_share.set_visible(false); // Copies the address the header shows, so it serves the contact // and the own-identity detail alike. let detail_copy = icon_button("edit-copy-symbolic", "Copy the address"); detail_copy.set_valign(Align::Start); detail_copy.set_visible(false); let copy_source = article_subject.clone(); detail_copy.connect_clicked(move |_| { let address = copy_source.text().to_string(); if let Some(display) = gtk::gdk::Display::default() { display.clipboard().set_text(&address); } }); let subject_row = gtk::Box::new(Orientation::Horizontal, 6); subject_row.append(&article_subject); subject_row.append(&detail_copy); subject_row.append(&detail_share); let article_meta = label("", "caption"); article_meta.set_wrap(true); article_head.append(&subject_row); article_head.append(&article_meta); // The sender's own frontmatter fields, when a mail carries any: // envelope data, so it reads like the contact detail's key block // rather than like body prose. let article_headers = label("", "caption mono"); article_headers.set_wrap(true); article_headers.set_wrap_mode(gtk::pango::WrapMode::WordChar); article_headers.set_selectable(true); article_headers.set_visible(false); article_head.append(&article_headers); article.append(&article_head); article.append(>k::Separator::new(Orientation::Horizontal)); let article_view = gtk::TextView::new(); article_view.set_wrap_mode(WrapMode::WordChar); article_view.set_editable(false); article_view.set_cursor_visible(false); article_view.set_top_margin(12); article_view.set_bottom_margin(12); article_view.set_left_margin(12); article_view.set_right_margin(12); let article_body = article_view.buffer(); let article_scroll = gtk::ScrolledWindow::new(); article_scroll.set_vexpand(true); article_scroll.set_child(Some(&article_view)); article.append(&article_scroll); // The action bar carries the stylesheet's `toolbar` class, which // supplies its own padding and ground, so no separator is needed // to tell it apart from the body above. let actions = gtk::Box::new(Orientation::Horizontal, 6); actions.add_css_class("toolbar"); article.append(&actions); // No icon: the pane sits directly under the list's own empty page, // and two envelopes stacked up the window say nothing twice. let article_empty = adw::StatusPage::builder() .title("No message selected") .description("Pick a message from the list above to read it.") .build(); let article_stack = gtk::Stack::new(); article_stack.set_hhomogeneous(false); article_stack.set_vhomogeneous(false); article_stack.add_named(&article, Some("detail")); article_stack.add_named(&article_empty, Some("empty")); paned.set_end_child(Some(&article_stack)); // The sidebar's divider follows this one and vice versa, so both // columns always split on the same line. let sidebar_sync = sidebar.clone(); paned.connect_position_notify(move |p| { if sidebar_sync.position() != p.position() { sidebar_sync.set_position(p.position()); } }); let main_sync = paned.clone(); sidebar.connect_position_notify(move |p| { if main_sync.position() != p.position() { main_sync.set_position(p.position()); } }); stack.add_named(&paned, Some("mail")); // compose let compose = gtk::Box::new(Orientation::Vertical, 12); compose.set_margin_top(12); compose.set_margin_bottom(12); compose.set_margin_start(12); compose.set_margin_end(12); let compose_fields = boxed_list(); let compose_to = adw::EntryRow::builder().title("To").build(); let compose_subject = adw::EntryRow::builder().title("Subject").build(); compose_fields.append(&compose_to); compose_fields.append(&compose_subject); compose.append(&compose_fields); // Frontmatter rows: one `Key: value` entry each, plus a flat // add button below. Rows are rebuilt in render on structural // changes only, so the entries never lose focus while typing. let compose_headers = boxed_list(); compose.append(&compose_headers); let add_header = button("+ header", "quiet"); add_header.set_halign(Align::Start); compose.append(&add_header); let compose_view = gtk::TextView::new(); compose_view.set_wrap_mode(WrapMode::WordChar); compose_view.set_top_margin(8); compose_view.set_bottom_margin(8); compose_view.set_left_margin(8); compose_view.set_right_margin(8); let compose_body = compose_view.buffer(); let compose_scroll = gtk::ScrolledWindow::new(); compose_scroll.add_css_class("card"); compose_scroll.set_vexpand(true); compose_scroll.set_child(Some(&compose_view)); compose.append(&compose_scroll); let compose_send_button = button("", "accent"); let compose_send = adw::ButtonContent::builder() .icon_name("mail-send-symbolic") .label("Send") .build(); compose_send_button.set_child(Some(&compose_send)); let compose_cancel = button("Cancel", "quiet"); let compose_actions = gtk::Box::new(Orientation::Horizontal, 6); compose_actions.set_halign(Align::End); compose_actions.append(&compose_cancel); compose_actions.append(&compose_send_button); compose.append(&compose_actions); stack.add_named(&compose, Some("compose")); // settings: the preferences page every GNOME app shows, one group // per concern, so scrolling, width and margins come for free. let settings = adw::PreferencesPage::new(); let identity_group = adw::PreferencesGroup::builder().title("Identity").build(); let identity_actions = gtk::Box::new(Orientation::Horizontal, 6); let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate", ""); let learn_button = button("Learn key", ""); identity_actions.append(&learn_button); identity_actions.append(&rotate_button); identity_group.set_header_suffix(Some(&identity_actions)); let mut identity_values = Vec::new(); for title in ["Address", "Key", "Fingerprint", "Rotations"] { let row = adw::ActionRow::builder().title(title).build(); let value = label("", "mono dim"); value.set_selectable(true); value.set_ellipsize(gtk::pango::EllipsizeMode::End); value.set_max_width_chars(48); value.set_valign(Align::Center); row.add_suffix(&value); identity_group.add(&row); identity_values.push(value); } settings.add(&identity_group); // The pin offer is a group that appears in place rather than a // dialog: pinning is a decision about this host, taken next to the // identity it belongs to. let offer_group = adw::PreferencesGroup::builder() .title("Server key offered") .visible(false) .build(); let offer_row = adw::ActionRow::builder().title("").build(); offer_row.add_css_class("monospace"); offer_row.set_subtitle_lines(2); let offer_pin = button("Pin", "accent"); offer_pin.set_valign(Align::Center); let offer_discard = button("Discard", ""); offer_discard.set_valign(Align::Center); offer_row.add_suffix(&offer_discard); offer_row.add_suffix(&offer_pin); offer_group.add(&offer_row); settings.add(&offer_group); let pins_group = adw::PreferencesGroup::builder() .title("Server keys") .build(); settings.add(&pins_group); let network_group = adw::PreferencesGroup::builder().title("Network").build(); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); timeout_row.set_title("Timeout"); timeout_row.set_subtitle("Seconds before a network call gives up"); network_group.add(&timeout_row); settings.add(&network_group); let backup_group = adw::PreferencesGroup::builder() .title("Backup") .description( "Sealed to a key derived from the seed, so it restores only where this\n identity already lives. The seed itself is never in the file.", ) .build(); let backup_actions = gtk::Box::new(Orientation::Horizontal, 6); let export_button = icon_label_button("document-save-symbolic", "Export", ""); let copy_export = icon_button("edit-copy-symbolic", "Copy the backup"); backup_actions.append(©_export); backup_actions.append(&export_button); backup_group.set_header_suffix(Some(&backup_actions)); let backup_label = label("", "mono caption"); backup_label.set_wrap(true); backup_label.set_wrap_mode(gtk::pango::WrapMode::Char); backup_label.set_selectable(true); let backup_scroll = gtk::ScrolledWindow::new(); backup_scroll.add_css_class("card"); backup_scroll.set_height_request(72); backup_scroll.set_child(Some(&backup_label)); backup_group.add(&backup_scroll); settings.add(&backup_group); let import_group = adw::PreferencesGroup::builder() .title("Restore") .description("Paste a gsmol backup to restore the contacts, pins and mail it carries.") .build(); let import_view = gtk::TextView::new(); import_view.set_wrap_mode(WrapMode::WordChar); import_view.set_top_margin(8); import_view.set_bottom_margin(8); import_view.set_left_margin(8); import_view.set_right_margin(8); import_view.add_css_class("monospace"); let import_body = import_view.buffer(); let import_scroll = gtk::ScrolledWindow::new(); import_scroll.add_css_class("card"); import_scroll.set_height_request(96); import_scroll.set_child(Some(&import_view)); import_group.add(&import_scroll); let import_backup = icon_label_button("document-open-symbolic", "Restore", ""); import_backup.set_halign(Align::End); import_backup.set_margin_top(6); import_group.add(&import_backup); settings.add(&import_group); stack.add_named(&settings, Some("settings")); // onboarding: three static modes, each a centred column let center = |child: >k::Box| { let outer = gtk::Box::new(Orientation::Vertical, 0); outer.set_hexpand(true); outer.set_vexpand(true); child.set_halign(Align::Center); child.set_valign(Align::Center); child.set_width_request(520); outer.append(child); outer }; let onboard_start = gtk::Box::new(Orientation::Vertical, 12); let title = label("carrier", "title"); title.set_halign(Align::Center); onboard_start.append(&title); let tagline = label("smolmail: one seed, sealed mail, five operations", "dim"); tagline.set_halign(Align::Center); tagline.set_margin_bottom(12); onboard_start.append(&tagline); let create_button = button("Create an identity", "pill"); let restore_button = button("Restore from a seed", "pill-quiet"); onboard_start.append(&create_button); onboard_start.append(&restore_button); stack.add_named(¢er(&onboard_start), Some("onboard")); let onboard_created = gtk::Box::new(Orientation::Vertical, 12); let created_title = label("Write this seed down", "subtitle"); onboard_created.append(&created_title); onboard_created.append(&label( "It is the only secret, and it is shown once.", "dim", )); let onboard_seed_label = label("", "mono subtitle"); onboard_seed_label.set_selectable(true); onboard_seed_label.set_wrap(true); onboard_seed_label.set_wrap_mode(gtk::pango::WrapMode::Char); onboard_seed_label.set_hexpand(true); onboard_seed_label.set_margin_top(12); onboard_seed_label.set_margin_bottom(12); onboard_seed_label.set_margin_start(12); onboard_seed_label.set_margin_end(12); let seed_card = gtk::Box::new(Orientation::Horizontal, 0); seed_card.add_css_class("card"); seed_card.append(&onboard_seed_label); onboard_created.append(&seed_card); let saved_button = button("I saved it", "pill"); let created_back = button("Back", "pill-quiet"); onboard_created.append(&saved_button); onboard_created.append(&created_back); stack.add_named(¢er(&onboard_created), Some("onboard-created")); let onboard_restore = gtk::Box::new(Orientation::Vertical, 12); onboard_restore.append(&label("Restore from a seed", "subtitle")); let restore_list = boxed_list(); let onboard_seed_entry = adw::EntryRow::builder() .title("Seed — 32 bytes, base32 or hex") .build(); restore_list.append(&onboard_seed_entry); onboard_restore.append(&restore_list); let restore_go = button("Restore", "pill"); let restore_back = button("Back", "pill-quiet"); onboard_restore.append(&restore_go); onboard_restore.append(&restore_back); stack.add_named(¢er(&onboard_restore), Some("onboard-restore")); // signup let signup = gtk::Box::new(Orientation::Vertical, 12); signup.append(&label("Set up the address", "subtitle")); let signup_group = adw::PreferencesGroup::builder() .description( "The invite token and server key are only needed when the operator published them.", ) .build(); let signup_user = adw::EntryRow::builder().title("User").build(); let signup_host = adw::EntryRow::builder().title("Host").build(); let signup_invite = adw::EntryRow::builder().title("Invite token").build(); let signup_key = adw::EntryRow::builder().title("Server key").build(); for row in [&signup_user, &signup_host, &signup_invite, &signup_key] { signup_group.add(row); } signup.append(&signup_group); let signup_learn = button("Learn the server key and pin what it presents", ""); signup.append(&signup_learn); let signup_offer_list = boxed_list(); signup_offer_list.set_visible(false); let signup_offer_row = adw::ActionRow::builder() .title("Server key offered") .build(); signup_offer_row.set_subtitle_lines(2); let signup_offer_pin = button("Pin", "accent"); signup_offer_pin.set_valign(Align::Center); let signup_offer_discard = button("Discard", ""); signup_offer_discard.set_valign(Align::Center); signup_offer_row.add_suffix(&signup_offer_discard); signup_offer_row.add_suffix(&signup_offer_pin); signup_offer_list.append(&signup_offer_row); signup.append(&signup_offer_list); let register_button = button("Register", "pill"); let recall_button = button("I already have this address", "pill-quiet"); signup.append(®ister_button); signup.append(&recall_button); let signup_back = button("Back", "quiet"); signup_back.set_visible(false); signup.append(&signup_back); stack.add_named(¢er(&signup), Some("signup")); // --- status bar ------------------------------------------------- // One fixed line at the bottom: the spinner and the running // operation while something is busy, the last finished note // afterwards — no transient chrome, nothing moving. let status_bar = gtk::Box::new(Orientation::Horizontal, 10); status_bar.add_css_class("toolbar"); let spinner = gtk::Spinner::new(); spinner.set_visible(false); let status_label = label("", "caption"); status_label.set_hexpand(true); let cancel_button = button("Cancel", ""); cancel_button.set_visible(false); status_bar.append(&spinner); status_bar.append(&status_label); status_bar.append(&cancel_button); let toolbar = adw::ToolbarView::new(); toolbar.set_content(Some(&stack)); toolbar.add_bottom_bar(&status_bar); // The columns split is a paned too: the divider between sidebar // and main box drags, extra space goes to the main box. let columns = gtk::Paned::new(Orientation::Horizontal); columns.set_start_child(Some(&sidebar)); columns.set_end_child(Some(&toolbar)); columns.set_resize_start_child(false); columns.set_position(1); // The sidebar opens as wide as the top-left pane is tall — a // value only the first layout knows, so the divider moves once // measurement lands. let list_default = list_stack.clone(); let columns_default = columns.clone(); gtk::glib::timeout_add_local(Duration::from_millis(50), move || { let height = list_default.height(); if height == 0 { gtk::glib::ControlFlow::Continue } else { columns_default.set_position(height); gtk::glib::ControlFlow::Break } }); // A window resize (fullscreen, maximize, tiling) makes the paned // re-derive its dividers, and the position sync between the two // columns then feeds those transient values to each other. Every // height change re-asserts the default layout: the top panes back // at their smallest, the sidebar back at the top pane's height. let watch_root = root.clone(); let watch_paned = paned.clone(); let watch_sidebar = sidebar.clone(); let watch_list = list_stack.clone(); let watch_columns = columns.clone(); let last_height = std::cell::Cell::new(0); gtk::glib::timeout_add_local(Duration::from_millis(250), move || { let height = watch_root.height(); let was = last_height.replace(height); if was != 0 && was != height { watch_paned.set_position(1); watch_sidebar.set_position(1); let relist = watch_list.clone(); let recolumns = watch_columns.clone(); gtk::glib::timeout_add_local(Duration::from_millis(200), move || { let measured = relist.height(); if measured > 0 { recolumns.set_position(measured); } gtk::glib::ControlFlow::Break }); } gtk::glib::ControlFlow::Continue }); root.set_child(Some(&columns)); // --- signal wiring --------------------------------------------- // Both sidebar lists route through row-activated, which fires only // on a click or Enter: the programmatic `select_row` in `render` // would otherwise feed its own selection straight back as input. let folder_sender = sender.clone(); folders.connect_row_activated(move |_, row| { let folder = match row.index() { 0 => Folder::Inbox, 1 => Folder::Requests, 2 => Folder::Sent, 3 => Folder::Drafts, _ => return, }; folder_sender.input(Message::GotoMail(folder)); }); let settings_sender = sender.clone(); settings_nav.connect_row_activated(move |_, _| { settings_sender.input(Message::Goto(Page::Settings)); }); fetch_button.connect_clicked(input(Message::FetchPressed)); list_empty_fetch.connect_clicked(input(Message::FetchPressed)); write_button.connect_clicked(input(Message::ComposePressed)); cancel_button.connect_clicked(input(Message::FetchCancelled)); // The mail and contact lists carry their target in the row's // widget name, which row-activated reads back. let mail_sender = sender.clone(); mail_list.connect_row_activated(move |_, row| { if let Some((folder, id)) = row.widget_name().split_once('|') { let folder = match folder { "0" => Folder::Inbox, "1" => Folder::Requests, "2" => Folder::Sent, "3" => Folder::Drafts, _ => return, }; mail_sender.input(Message::MessageSelected(folder, id.to_string())); } }); let contact_sender = sender.clone(); contacts_list.connect_row_activated(move |_, row| { contact_sender.input(Message::ContactSelected(row.widget_name().to_string())); }); let meta_link = sender.clone(); article_meta.connect_activate_link(move |_, uri| { meta_link.input(Message::ContactSelected(uri.to_string())); gtk::glib::Propagation::Stop }); add_header.connect_clicked(input(Message::ComposeHeaderAdded)); compose_send_button.connect_clicked(input(Message::SendPressed)); compose_cancel.connect_clicked(input(Message::ComposeClosed)); let to_sender = sender.clone(); compose_to.connect_changed(move |entry| { to_sender.input(Message::ComposeToChanged(entry.text().to_string())) }); let subject_sender = sender.clone(); compose_subject.connect_changed(move |entry| { subject_sender.input(Message::ComposeSubjectChanged(entry.text().to_string())) }); let body_sender = sender.clone(); compose_body.connect_changed(move |buffer| { body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer))) }); own_row.connect_clicked(input(Message::OwnSelected)); copy_export.connect_clicked(input(Message::CopyExport)); import_button.connect_clicked(input(Message::ContactImportPressed)); let import_sender = sender.clone(); contact_import.connect_changed(move |entry| { import_sender.input(Message::ContactImportChanged(entry.text().to_string())) }); let import_entry_sender = sender.clone(); contact_import.connect_entry_activated(move |_| { import_entry_sender.input(Message::ContactImportPressed) }); rotate_button.connect_clicked(input(Message::RotatePressed)); learn_button.connect_clicked(input(Message::SettingsLearnKey)); offer_pin.connect_clicked(input(Message::PinAccept)); signup_offer_pin.connect_clicked(input(Message::PinAccept)); offer_discard.connect_clicked(input(Message::PinDiscard)); signup_offer_discard.connect_clicked(input(Message::PinDiscard)); export_button.connect_clicked(input(Message::ExportPressed)); import_backup.connect_clicked(input(Message::ImportPressed)); let timeout_sender = sender.clone(); timeout_row.connect_value_notify(move |row| { timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) }); let import_body_sender = sender.clone(); import_body.connect_changed(move |buffer| { import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) }); create_button.connect_clicked(input(Message::OnboardCreate)); restore_button.connect_clicked(input(Message::OnboardRestore)); restore_back.connect_clicked(input(Message::OnboardBack)); created_back.connect_clicked(input(Message::OnboardBack)); saved_button.connect_clicked(input(Message::OnboardSeedSaved)); let seed_sender = sender.clone(); onboard_seed_entry.connect_changed(move |entry| { seed_sender.input(Message::OnboardSeedChanged(entry.text().to_string())) }); let seed_go_sender = sender.clone(); onboard_seed_entry.connect_entry_activated(move |_| { seed_go_sender.input(Message::OnboardRestoreFromSeed) }); restore_go.connect_clicked(input(Message::OnboardRestoreFromSeed)); let user_sender = sender.clone(); signup_user.connect_changed(move |entry| { user_sender.input(Message::SignupUserChanged(entry.text().to_string())) }); let host_sender = sender.clone(); signup_host.connect_changed(move |entry| { host_sender.input(Message::SignupHostChanged(entry.text().to_string())) }); let invite_sender = sender.clone(); signup_invite.connect_changed(move |entry| { invite_sender.input(Message::SignupInviteChanged(entry.text().to_string())) }); let key_sender = sender.clone(); signup_key.connect_changed(move |entry| { key_sender.input(Message::SignupKeyChanged(entry.text().to_string())) }); signup_learn.connect_clicked(input(Message::SignupLearnKey)); register_button.connect_clicked(input(Message::SignupRegister)); recall_button.connect_clicked(input(Message::SignupRecall)); signup_back.connect_clicked(input(Message::SignupBack)); CarrierWidgets { window: root.clone(), window_title, folders, folder_counts, drafts_nav, settings_nav, fetch_button, write_button, stack, list_stack, mail_list, list_empty, list_empty_fetch, article_stack, article_subject, article_meta, article_headers, article_view, article_body, actions, compose_to, compose_headers, compose_subject, compose_body, compose_send, sidebar_own, sidebar_contacts: contacts_list, detail_share, detail_copy, detail_qr, identity_values, offer_group, offer_row, pins_group, pin_rows: Vec::new(), timeout_row, backup_label, onboard_seed_label, signup_back, signup_offer_list, signup_offer_row, spinner, status_label, cancel_button, dialog: None, } } } impl Component for Carrier { type CommandOutput = OpResult; type Input = Message; type Output = (); type Init = std::path::PathBuf; type Root = gtk::Window; type Widgets = CarrierWidgets; fn init_root() -> Self::Root { let window = gtk::Window::new(); window.set_title(Some("carrier")); window.set_default_size(1080, 640); window } fn init( init: Self::Init, root: Self::Root, sender: ComponentSender, ) -> ComponentParts { let (model, op) = Carrier::boot(init); let widgets = CarrierWidgets::new(&root, &sender); if let Some(op) = op { spawn_op(&model, op, &sender); } ComponentParts { model, widgets } } fn update(&mut self, message: Self::Input, sender: ComponentSender, _root: &Self::Root) { for op in self.handle(message) { spawn_op(self, op, &sender); } } fn update_cmd( &mut self, message: Self::CommandOutput, sender: ComponentSender, _root: &Self::Root, ) { if let Some(op) = self.handle_result(message) { spawn_op(self, op, &sender); } } fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender) { render(self, widgets, &sender); } } /// Runs one `PendingOp` on the blocking pool and reports back. The fetch /// also gets a ticker: a thread that re-lists the store every 250 ms until /// the model raises the cancel flag, giving the list live progress. fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { let store = model.store.clone(); let master = model.master.unwrap_or([0u8; KEY_LEN]); let timeout = model.timeout; match op { PendingOp::Snapshot { read } => sender.spawn_command(move |out| { let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot( &store, &master, &read, ))); }), PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| { let res = if key.is_empty() { ops::register(&store, &master, &addr, invite.as_deref(), timeout) } else { ops::parse_key(&key).and_then(|key| { ops::pin(&store, &addr, &key)?; ops::register(&store, &master, &addr, invite.as_deref(), timeout) }) }; let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string()))); }), PendingOp::Restore { addr } => sender.spawn_command(move |out| { let res = ops::restore(&store, &master, &addr, timeout) .map(|index| format!("restored at rotation {index}")); let _ = out.send(OpResult::Generic(res)); }), PendingOp::LearnKey { addr } => sender.spawn_command(move |out| { let offered = ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key))); let _ = out.send(OpResult::PinOffered(offered)); }), PendingOp::Pin { addr, key } => sender.spawn_command(move |out| { let _ = out.send(OpResult::Pinned(ops::pin(&store, &addr, &key))); }), PendingOp::Fetch { cancel } => { let ticker_cancel = cancel.clone(); let input = sender.input_sender().clone(); std::thread::spawn(move || { while !ticker_cancel.load(Ordering::Relaxed) { std::thread::sleep(Duration::from_millis(250)); if ticker_cancel.load(Ordering::Relaxed) { break; } let _ = input.send(Message::FetchTick); } }); sender.spawn_command(move |out| { let _ = out.send(OpResult::Generic(ops::fetch( &store, &master, cancel, timeout, ))); }); } PendingOp::Send { to, subject, body, reply_to, headers, } => sender.spawn_command(move |out| { let res = ops::send( &store, &master, &to, if subject.is_empty() { None } else { Some(subject.as_str()) }, &body, reply_to.as_deref(), &headers, timeout, ); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Delete { id, folder, everywhere, } => sender.spawn_command(move |out| { let res = ops::delete(&store, &master, &id, folder, everywhere, timeout) .map(|_| "deleted".to_string()); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Accept { address, identity } => sender.spawn_command(move |out| { let res = ops::accept_contact(&store, &master, &address, &identity, timeout) .map(|_| format!("accepted {address}")); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Block { address } => sender.spawn_command(move |out| { let res = ops::block_contact(&store, &master, &address, timeout).map(|blocked| { if blocked { format!("blocked {address}") } else { format!("{address} was not blocked") } }); let _ = out.send(OpResult::Generic(res)); }), PendingOp::ImportContact { uri } => sender.spawn_command(move |out| { let res = ops::import_contact(&store, &uri) .map(|(address, _)| format!("imported {address} (verified)")); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Rotate => sender.spawn_command(move |out| { let res = ops::rotate(&store, &master, timeout) .map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12])); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Unpin { label } => sender.spawn_command(move |out| { let res = ops::unpin(&store, &label).map(|_| format!("unpinned {label}")); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Export { dir } => sender.spawn_command(move |out| { let exported = ops::export_backup(&store, &master, &dir) .map(|(path, text)| (format!("backup written to {}", path.display()), text)); let _ = out.send(OpResult::Exported(exported)); }), PendingOp::ImportBackup { text } => sender.spawn_command(move |out| { let _ = out.send(OpResult::Generic(ops::import_backup( &store, &master, &text, ))); }), } } /// The coarse re-render: called after every input and result. Plain state /// reads map onto widget updates; lists are rebuilt from scratch, which /// a modest mailbox makes the honest option. fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender) { let input = |msg: Message| { let sender = sender.clone(); move |_: >k::Button| sender.input(msg.clone()) }; // Prefill entries once after a reply, write or draft reopen; // afterwards typing flows entry -> model only, so the view never // fights the user. The fresh flag also rebuilds the header rows, // which otherwise sync on count changes alone. let mut compose_fresh = false; if model.compose_fresh.take() { compose_fresh = true; if let Some(compose) = &model.compose { w.compose_to.set_text(&compose.to); w.compose_subject.set_text(&compose.subject); w.compose_body.set_text(&compose.body); } } let rows = model.compose.as_ref().map_or(0, |c| c.headers.len()); let mut current = 0; let mut child = w.compose_headers.first_child(); while let Some(item) = child { current += 1; child = item.next_sibling(); } if compose_fresh || current != rows { let added = rows > current; clear_list(&w.compose_headers); if let Some(compose) = &model.compose { let mut last_entry = None; for (index, text) in compose.headers.iter().enumerate() { let (row, entry) = compose_header_row(index, text, sender); last_entry = Some(entry); w.compose_headers.append(&row); } // A freshly appended row is the one being typed into. if added { if let Some(entry) = last_entry { entry.grab_focus(); } } } } // Header and sidebar identity. let address = model .snapshot .as_ref() .map(|s| s.who.address.clone()) .unwrap_or_default(); w.window_title.set_subtitle(&address); w.sidebar_own.set_text(&address); // Sidebar destinations: the active one carries the list selection, and // the list that does not own the current page clears its own. let unread = |folder: usize| { model .lists .get(folder) .map(|rows| rows.iter().filter(|r| r.unread).count()) .unwrap_or(0) }; let draft_count = model.drafts.len(); w.drafts_nav.set_visible(draft_count > 0); for (count, value) in w .folder_counts .iter() .zip([unread(0), unread(1), 0, draft_count]) { count.set_text(&value.to_string()); count.set_visible(value > 0); } if model.page == Page::Mail { w.folders .select_row(w.folders.row_at_index(model.folder as i32).as_ref()); w.settings_nav.unselect_all(); } else if model.page == Page::Settings { w.folders.unselect_all(); w.settings_nav .select_row(w.settings_nav.row_at_index(0).as_ref()); } else { w.folders.unselect_all(); w.settings_nav.unselect_all(); } let fetching = model.busy.as_deref() == Some("fetching"); w.fetch_button.set_sensitive(model.busy.is_none()); w.write_button.set_sensitive(model.busy.is_none()); // Page stack. let page = if model.compose.is_some() { "compose" } else { match model.page { Page::Onboard => match model.onboard.mode { OnboardMode::Start => "onboard", OnboardMode::Created => "onboard-created", OnboardMode::Restore => "onboard-restore", }, Page::Signup => "signup", Page::Mail => "mail", Page::Settings => "settings", } }; w.stack.set_visible_child_name(page); // Mail list. clear_list(&w.mail_list); let rows = model.rows(); let (icon, title, description) = match model.folder { Folder::Inbox => ( "mail-read-symbolic", "Inbox is empty", "Fetch to check the mailbox.", ), Folder::Requests => ( "contact-new-symbolic", "No requests", "Mail from senders you have not accepted yet arrives here.", ), Folder::Sent => ( "mail-send-symbolic", "Nothing sent yet", "Messages you send keep a sealed copy here.", ), Folder::Drafts => ( "document-edit-symbolic", "No drafts", "A compose closed from the sidebar is kept here.", ), }; w.list_empty.set_icon_name(Some(icon)); w.list_empty.set_title(title); w.list_empty.set_description(Some(description)); w.list_empty_fetch .set_visible(model.folder == Folder::Inbox); w.list_stack .set_visible_child_name(if rows.is_empty() { "empty" } else { "list" }); let mut selected_row = None; for row in &rows { let item = mail_row(model.folder, row); let is_selected = model .open .as_ref() .is_some_and(|(folder, msg)| *folder == model.folder && msg.id == row.id); if is_selected { selected_row = Some(item.clone()); } w.mail_list.append(&item); } w.mail_list.select_row( selected_row .as_ref() .map(|row| row.upcast_ref::()), ); // Article. let own_detail = model .selected_own .then(|| model.snapshot.as_ref().map(|s| &s.who)) .flatten(); let contact_detail = model.selected_contact.as_ref().and_then(|address| { model .snapshot .as_ref()? .contacts .iter() .find(|c| &c.address == address) }); // The detail selections show over an open message, but the message // stays selected underneath: its list highlight holds, and picking // it again returns to it. match (&model.open, own_detail, contact_detail) { (_, Some(own), _) => { w.article_stack.set_visible_child_name("detail"); w.article_view.add_css_class("monospace"); w.article_subject.set_text(&own.address); w.article_meta .set_text(&format!("you · rotation {}", own.rotations)); w.article_body .set_text(&detail_body(&own.address, &own.key, &own.fingerprint)); set_qr(&w.detail_qr, &format!("smol://{}/{}", own.address, own.key)); w.detail_share.set_visible(true); w.detail_copy.set_visible(true); clear(&w.actions); } (_, _, Some(contact)) => { w.article_stack.set_visible_child_name("detail"); w.article_view.add_css_class("monospace"); w.article_subject.set_text(&contact.address); w.article_meta.set_text(contact_state(contact)); w.article_body.set_text(&detail_body( &contact.address, &contact.key, &contact.fingerprint, )); set_qr( &w.detail_qr, &format!("smol://{}/{}", contact.address, contact.key), ); w.detail_share.set_visible(true); w.detail_copy.set_visible(true); clear(&w.actions); let write = icon_label_button("mail-message-new-symbolic", "Write", ""); let write_msg = Message::ContactWrite(contact.address.clone()); let write_sender = sender.clone(); write.connect_clicked(move |_| write_sender.input(write_msg.clone())); w.actions.append(&write); let spacer = gtk::Label::new(None); spacer.set_hexpand(true); w.actions.append(&spacer); match contact.accepted { Some(true) => { let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); let msg = Message::ContactBlock(contact.address.clone()); let block_sender = sender.clone(); block.connect_clicked(move |_| block_sender.input(msg.clone())); w.actions.append(&block); } Some(false) => { let unblock = icon_label_button("object-select-symbolic", "Unblock", "accent"); let msg = Message::ContactUnblock(contact.address.clone()); let unblock_sender = sender.clone(); unblock.connect_clicked(move |_| unblock_sender.input(msg.clone())); w.actions.append(&unblock); } None => { let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone()); let accept_sender = sender.clone(); accept.connect_clicked(move |_| accept_sender.input(msg.clone())); w.actions.append(&accept); } } } (Some((folder, msg)), _, _) => { w.article_stack.set_visible_child_name("detail"); w.detail_share.set_visible(false); w.detail_copy.set_visible(false); // A message body is prose; only the detail below is a // column of keys that has to stay aligned. w.article_view.remove_css_class("monospace"); let subject = if msg.subject.is_empty() { "(no subject)".to_string() } else { msg.subject.clone() }; w.article_subject.set_text(&subject); let label = if *folder == Folder::Sent { "to" } else { "from" }; // The address opens the sender's contact details when they // are a contact; an unknown or keyless sender stays text. let known = model .snapshot .as_ref() .is_some_and(|snap| snap.contacts.iter().any(|c| c.address == msg.from)); let address = escape(&msg.from); let mut meta = if known { format!("{label} {address}") } else { format!("{label} {address}") }; meta.push_str(&format!(" · {} UTC", escape(&ops::when(msg.time)))); meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)])); if msg.kept { meta.push_str(" · a server copy still exists"); } w.article_meta.set_markup(&meta); let headers: Vec = msg .headers .iter() .map(|(key, value)| format!("{key}: {value}")) .collect(); w.article_headers.set_visible(!headers.is_empty()); w.article_headers.set_text(&headers.join("\n")); w.article_body.set_text(&msg.body); clear(&w.actions); let reply = icon_label_button("mail-reply-sender-symbolic", "Reply", ""); reply.connect_clicked(input(Message::ReplyPressed)); w.actions.append(&reply); if *folder == Folder::Requests { if msg.reply_to.is_some() { let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); accept.connect_clicked(input(Message::AcceptPressed)); let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); block.connect_clicked(input(Message::BlockPressed)); w.actions.append(&accept); w.actions.append(&block); } else { w.actions.append(&crate::view::label( "no reply address: cannot accept or block", "caption", )); } } let spacer = gtk::Label::new(None); spacer.set_hexpand(true); w.actions.append(&spacer); let delete = icon_label_button("user-trash-symbolic", "Delete", "danger"); delete.connect_clicked(input(Message::DeletePressed)); w.actions.append(&delete); } (None, None, None) => { w.article_stack.set_visible_child_name("empty"); w.detail_share.set_visible(false); w.detail_copy.set_visible(false); clear(&w.actions); } } // Compose: a reply says so, on the button and in its icon. if let Some(compose) = &model.compose { let replying = compose.reply_to.is_some(); w.compose_send .set_label(if replying { "Reply" } else { "Send" }); w.compose_send.set_icon_name(if replying { "mail-reply-sender-symbolic" } else { "mail-send-symbolic" }); } // Contacts: the sidebar's bottom panel lists them; the selected one // carries the list's selection so the detail view has a visible anchor. clear_list(&w.sidebar_contacts); if let Some(snap) = &model.snapshot { if snap.contacts.is_empty() { let empty = gtk::ListBoxRow::new(); empty.set_activatable(false); let hint = label("no contacts", "caption"); hint.set_margin_top(6); hint.set_margin_bottom(6); hint.set_margin_start(12); empty.set_child(Some(&hint)); w.sidebar_contacts.append(&empty); } let mut selected_contact_row = None; for contact in &snap.contacts { let item = sidebar_contact_row(contact); if model.selected_contact.as_deref() == Some(contact.address.as_str()) { selected_contact_row = Some(item.clone()); } w.sidebar_contacts.append(&item); } w.sidebar_contacts.select_row( selected_contact_row .as_ref() .map(|row| row.upcast_ref::()), ); } // Settings: the identity rows are fixed, so only their values change. if let Some(snap) = &model.snapshot { let values = [ snap.who.address.clone(), snap.who.key.clone(), snap.who.fingerprint.clone(), snap.who.rotations.to_string(), ]; for (value, text) in w.identity_values.iter().zip(values) { value.set_text(&text); } } if let Some((_, key)) = &model.pin_offer { w.offer_row.set_title(&escape(&fingerprint(key))); w.offer_row.set_subtitle(&escape(&b32(key))); w.offer_group.set_visible(true); w.signup_offer_row.set_subtitle(&escape(&fingerprint(key))); w.signup_offer_list.set_visible(true); } else { w.offer_group.set_visible(false); w.signup_offer_list.set_visible(false); } if w.timeout_row.value() as u64 != model.timeout { w.timeout_row.set_value(model.timeout as f64); } for row in std::mem::take(&mut w.pin_rows) { w.pins_group.remove(&row); } if let Some(snap) = &model.snapshot { if snap.pins.is_empty() { let empty = adw::ActionRow::builder() .title("No server keys pinned") .build(); w.pins_group.add(&empty); w.pin_rows.push(empty); } for (pin_label, key) in &snap.pins { let row = adw::ActionRow::builder() .title(escape(pin_label)) .subtitle(escape(&b32(key))) .build(); row.set_subtitle_lines(1); let unpin = icon_button("user-trash-symbolic", "Unpin this server key"); unpin.add_css_class("destructive-action"); unpin.connect_clicked(input(Message::UnpinPressed(pin_label.clone()))); row.add_suffix(&unpin); w.pins_group.add(&row); w.pin_rows.push(row); } } let backup_text = model .backup_text .as_ref() .map(|text| text.chars().take(2000).collect::()) .unwrap_or_default(); w.backup_label.set_text(&backup_text); // Onboarding. if model.onboard.mode == OnboardMode::Created { w.onboard_seed_label.set_text(&model.onboard.seed_shown); } // Signup. w.signup_back.set_visible(model.signup.fresh); // Confirmations are presented once, on the None -> Some edge: `render` // runs after every message and an alert dialog is modal, so presenting // again would stack a second copy over the first. match (&model.dialog, w.dialog.is_some()) { (Some(dialog), false) => { let rotating = matches!(dialog, Dialog::Rotate); let replying = matches!(dialog, Dialog::Reply { .. }); let alert = match dialog { Dialog::Delete { kept, .. } => { let alert = adw::AlertDialog::new( Some("Delete this message?"), Some(if *kept { "Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too." } else { "The sealed copy in the local store is removed." }), ); alert.add_response("cancel", "Cancel"); alert.add_response("here", "Delete"); alert.set_response_appearance("here", adw::ResponseAppearance::Destructive); if *kept { alert.add_response("everywhere", "Delete Everywhere"); alert.set_response_appearance( "everywhere", adw::ResponseAppearance::Destructive, ); } alert } Dialog::Rotate => { let alert = adw::AlertDialog::new( Some("Rotate the identity?"), Some("The old key stays derivable from the seed, but the rotation index never moves back."), ); alert.add_response("cancel", "Cancel"); alert.add_response("rotate", "Rotate"); alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); alert } Dialog::Reply { rows, .. } => { let list = rows .iter() .map(String::as_str) .collect::>() .join("\n"); let alert = adw::AlertDialog::new( Some("Carry the sender's fields into the reply?"), Some(&format!( "This message carries its own frontmatter:\n\n{list}\n\nReply with copies of these fields, or start with none. The fields stay editable either way." )), ); alert.add_response("cancel", "Cancel Reply"); alert.add_response("without", "Reply Without"); alert.add_response("copy", "Copy Fields"); alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested); alert } }; // The reply dialog carries its own default: Enter answers the // question instead of cancelling the reply the user asked for. let close_default = if replying { "without" } else { "cancel" }; alert.set_close_response("cancel"); alert.set_default_response(Some(close_default)); let response_sender = sender.clone(); alert.connect_response(None, move |_, response| { response_sender.input(match (rotating, replying, response) { (true, _, "rotate") => Message::RotateConfirmed, (true, _, _) => Message::RotateCancelled, (_, true, "copy") => Message::ReplyConfirmed(true), (_, true, "without") => Message::ReplyConfirmed(false), (_, true, _) => Message::ReplyCancelled, (false, _, "here") => Message::DeleteConfirmed(false), (false, _, "everywhere") => Message::DeleteConfirmed(true), (false, _, _) => Message::DeleteCancelled, }); }); alert.present(Some(&w.window)); w.dialog = Some(alert); } (None, true) => { // Drop the handle before closing: `close` emits the close // response, which would otherwise arrive as a second cancel // and bring us straight back here. if let Some(alert) = w.dialog.take() { alert.close(); } } _ => {} } // The fixed bottom line: work in progress while something runs, the // last note otherwise, errors tinted until the next note replaces them. w.spinner.set_visible(model.busy.is_some()); if model.busy.is_some() { w.spinner.start(); } else { w.spinner.stop(); } w.status_label.set_text(&match (&model.busy, fetching) { (Some(_), true) => { let inbox = model.lists.first().map(|rows| rows.len()).unwrap_or(0); format!("fetching… {inbox} in inbox") } (Some(busy), false) => format!("{busy}…"), (None, _) => model.status.clone().unwrap_or_default(), }); let errored = model.status_error && model.busy.is_none(); if errored { w.status_label.add_css_class("error"); } else { w.status_label.remove_css_class("error"); } w.cancel_button.set_visible(fetching); } /// One list row, like a note title: the read flag and subject on the left, /// sender and date muted on the right. An action row supplies the metrics /// and the selection paint the stylesheet expects of a list. fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow { let item = adw::ActionRow::new(); item.add_prefix(>k::Image::from_icon_name(if row.unread { "mail-unread-symbolic" } else { "mail-read-symbolic" })); let subject = if row.subject.is_empty() { "(no subject)".to_string() } else { row.subject.clone() }; // Action row titles are markup, so unread weight goes in as a span // rather than a class, which would take the suffixes with it. item.set_title(&if row.unread { format!("{}", escape(&subject)) } else { escape(&subject) }); item.set_title_lines(1); if !row.unread { item.add_css_class("dim-label"); } let from = label(&truncate(&row.from, 24), "caption"); from.set_valign(Align::Center); let date = label(&ops::when(row.at), "caption numeric"); date.set_valign(Align::Center); item.add_suffix(&from); item.add_suffix(&date); item.set_activatable(true); // The list's row-activated signal routes the click; the row carries // its folder and id in the widget name for the handler to read. item.set_widget_name(&format!("{}|{}", folder as usize, row.id)); item } /// One compose header row: a `Key: value` entry plus a remove button. /// The row carries its own validation state — anything the send would /// reject is tinted and explained right where it is typed, and the /// empty rows the send skips are left quiet. fn compose_header_row<'a>( index: usize, text: &str, sender: &ComponentSender, ) -> (gtk::ListBoxRow, gtk::Entry) { let entry = gtk::Entry::new(); entry.set_hexpand(true); entry.set_placeholder_text(Some("Key: value")); entry.set_text(text); let style = |entry: >k::Entry, text: &str| match crate::ops::header_row(text) { Err(reason) => { entry.add_css_class("error"); entry.set_tooltip_text(Some(&reason)); } Ok(_) => { entry.remove_css_class("error"); entry.set_tooltip_text(None); } }; style(&entry, text); let change_sender = sender.clone(); let style_entry = entry.clone(); entry.connect_changed(move |changed| { let text = changed.text().to_string(); style(&style_entry, &text); change_sender.input(Message::ComposeHeaderChanged(index, text)); }); let remove = icon_button("user-trash-symbolic", "Remove this header"); let remove_sender = sender.clone(); remove.connect_clicked(move |_| { remove_sender.input(Message::ComposeHeaderRemoved(index)); }); let line = gtk::Box::new(Orientation::Horizontal, 6); line.set_margin_top(6); line.set_margin_bottom(6); line.set_margin_start(12); line.set_margin_end(6); line.append(&entry); line.append(&remove); let row = gtk::ListBoxRow::new(); row.set_child(Some(&line)); (row, entry) } /// One contact in the sidebar's bottom panel: the address over its trust /// state; activating it opens the detail pane. fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow { let item = adw::ActionRow::builder() .title(escape(&contact.address)) .subtitle(contact_state(contact)) .activatable(true) .build(); item.set_title_lines(1); item.set_subtitle_lines(1); item.set_widget_name(&contact.address); item } /// The detail view's body: the three lines an address is made of, in /// the column-aligned form a key block wants. fn detail_body(address: &str, key: &str, fingerprint: &str) -> String { format!("key {key}\nfingerprint {fingerprint}\nsmol://{address}/{key}") } /// Puts one URI's QR code into the detail header's share popover. A /// GtkImage renders its paintable at the default icon size — a 16px /// square, centered — unless pixel-size overrides it, and its widget /// sizes like an icon slot unless a request overrides that. Both are /// set to the texture's own dimensions, so the code paints 1:1. fn set_qr(image: >k::Image, uri: &str) { let Some(texture) = qr_texture(uri) else { return; }; let dim = texture.intrinsic_width(); image.set_size_request(dim, dim); image.set_pixel_size(dim); image.set_paintable(Some(&texture)); } /// The QR as an RGBA texture, black on white, taking no cues from the /// theme. Each module is a whole number of pixels and the four quiet /// modules the spec asks for are included, so nothing scales the texture /// and no softening ever lands on an edge. The scale targets ~210px: a /// popover that does not fit below its anchor is not placed at all. fn qr_texture(uri: &str) -> Option { const QUIET: i32 = 4; let code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?; let size = code.size(); let scale = (210 / (size + 2 * QUIET)).clamp(3, 6); let dim = (size + 2 * QUIET) * scale; let stride = dim as usize * 4; let mut pixels = vec![0xffu8; (dim * dim) as usize * 4]; for y in 0..size { for x in 0..size { if code.get_module(x, y) { for dy in 0..scale { for dx in 0..scale { let px = (x + QUIET) * scale + dx; let py = (y + QUIET) * scale + dy; let i = (py * dim + px) as usize * 4; pixels[i] = 0; pixels[i + 1] = 0; pixels[i + 2] = 0; } } } } } Some(gtk::gdk::MemoryTexture::new( dim, dim, gtk::gdk::MemoryFormat::R8g8b8a8, >k::glib::Bytes::from_owned(pixels), stride, )) }