//! 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, toasts: adw::ToastOverlay, toolbar: adw::ToolbarView, // sidebar top: actions and destinations folders: gtk::ListBox, folder_counts: Vec, 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_view: gtk::TextView, article_body: gtk::TextBuffer, actions: gtk::Box, // compose page compose_to: adw::EntryRow, 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, // contacts page own_label: gtk::Label, qr: gtk::Image, contacts_list: gtk::ListBox, // 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, /// The note already spoken as a toast, for the same reason. last_status: String, } 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); sidebar.set_position(250); // 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"), ]; let mut folder_counts = Vec::new(); for (row, count) in folder_rows { folders.append(&row); folder_counts.push(count); } 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, with the copy the sidebar never // used to offer. let own_row = gtk::Box::new(Orientation::Horizontal, 6); own_row.add_css_class("card"); own_row.set_margin_top(6); 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"); sidebar_own.set_selectable(true); sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End); own_text.append(&own_caption); own_text.append(&sidebar_own); let own_copy_small = icon_button("edit-copy-symbolic", "Copy your address"); own_copy_small.set_margin_end(6); own_row.append(&own_text); own_row.append(&own_copy_small); sidebar_bottom.append(&own_row); 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(250); 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); let article_meta = label("", "caption"); article_meta.set_wrap(true); article_head.append(&article_subject); article_head.append(&article_meta); 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); let article_empty = adw::StatusPage::builder() .icon_name("mail-read-symbolic") .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); 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")); // contacts let contacts = gtk::Box::new(Orientation::Vertical, 12); contacts.set_margin_top(12); contacts.set_margin_bottom(12); contacts.set_margin_start(12); contacts.set_margin_end(12); let own_card = gtk::Box::new(Orientation::Horizontal, 12); own_card.add_css_class("card"); let own_column = gtk::Box::new(Orientation::Vertical, 6); own_column.set_hexpand(true); own_column.set_valign(Align::Center); own_column.set_margin_top(12); own_column.set_margin_bottom(12); own_column.set_margin_start(12); own_column.append(&label("you", "caption")); let own_label = label("", "mono"); own_label.set_wrap(true); own_label.set_wrap_mode(gtk::pango::WrapMode::Char); own_label.set_selectable(true); own_column.append(&own_label); let own_copy = icon_label_button("edit-copy-symbolic", "Copy", ""); own_copy.set_halign(Align::Start); own_column.append(&own_copy); own_card.append(&own_column); let qr = gtk::Image::new(); qr.set_pixel_size(144); qr.set_margin_top(12); qr.set_margin_bottom(12); qr.set_margin_end(12); own_card.append(&qr); contacts.append(&own_card); let import_list = boxed_list(); 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); import_list.append(&contact_import); contacts.append(&import_list); let contacts_page_scroll = gtk::ScrolledWindow::new(); contacts_page_scroll.set_vexpand(true); let contacts_page_list = boxed_list(); contacts_page_list.set_valign(Align::Start); contacts_page_scroll.set_child(Some(&contacts_page_list)); contacts.append(&contacts_page_scroll); stack.add_named(&contacts, Some("contacts")); // 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 ------------------------------------------------- // Work in progress gets its own bottom bar, revealed only while // something runs; finished notes speak through a toast instead, so // the chrome is present exactly as long as the work is. let status_bar = gtk::Box::new(Orientation::Horizontal, 10); status_bar.add_css_class("toolbar"); let spinner = gtk::Spinner::new(); 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); toolbar.set_reveal_bottom_bars(false); // 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(225); let toasts = adw::ToastOverlay::new(); toasts.set_child(Some(&columns)); root.set_child(Some(&toasts)); // --- 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, _ => 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, _ => 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())); }); 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_copy.connect_clicked(input(Message::CopyOwnAddress)); own_copy_small.connect_clicked(input(Message::CopyOwnAddress)); 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, toasts, toolbar, folders, folder_counts, settings_nav, fetch_button, write_button, stack, list_stack, mail_list, list_empty, list_empty_fetch, article_stack, article_subject, article_meta, article_view, article_body, actions, compose_to, compose_subject, compose_body, compose_send, sidebar_own, sidebar_contacts: contacts_list, own_label, qr, contacts_list: contacts_page_list, 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, last_status: String::new(), } } } 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, } => 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(), 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 or write-to-contact; afterwards // typing flows entry -> model only, so the view never fights the user. if model.compose_fresh.take() { 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); } } // 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) }; for (count, value) in w.folder_counts.iter().zip([unread(0), unread(1), 0]) { 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::Contacts => "contacts", Page::Settings => "settings", } }; w.stack.set_visible_child_name(page); // Mail list. clear_list(&w.mail_list); let rows = model .lists .get(model.folder as usize) .cloned() .unwrap_or_default(); 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.", ), }; 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 contact_detail = model.selected_contact.as_ref().and_then(|address| { model .snapshot .as_ref()? .contacts .iter() .find(|c| &c.address == address) }); match (&model.open, contact_detail) { (Some((folder, msg)), _) => { w.article_stack.set_visible_child_name("detail"); // A message body is prose; only the contact 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" }; let mut meta = format!("{label} {} · {} UTC", msg.from, 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_text(&meta); 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, 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(&format!( "key {}\nfingerprint {}\nsmol://{}/{}", contact.key, contact.key, contact.address, contact.key )); 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); } } } (None, None) => { w.article_stack.set_visible_child_name("empty"); 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::()), ); } if let Some(snap) = &model.snapshot { let uri = format!("smol://{}/{}", snap.who.address, snap.who.key); w.own_label.set_text(&uri); if let Some(texture) = qr_texture(&uri) { w.qr.set_paintable(Some(&texture)); } } clear_list(&w.contacts_list); if let Some(snap) = &model.snapshot { if snap.contacts.is_empty() { let empty = adw::ActionRow::builder() .title("No contacts yet") .subtitle("Import a smol:// address above to add one.") .build(); w.contacts_list.append(&empty); } for contact in &snap.contacts { w.contacts_list.append(&contact_row(contact, sender)); } } // 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 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 } }; alert.set_close_response("cancel"); alert.set_default_response(Some("cancel")); let response_sender = sender.clone(); alert.connect_response(None, move |_, response| { response_sender.input(match (rotating, response) { (true, "rotate") => Message::RotateConfirmed, (true, _) => Message::RotateCancelled, (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 bottom bar carries work in progress and nothing else, so it is // revealed exactly as long as something runs. w.toolbar.set_reveal_bottom_bars(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, _) => String::new(), }); w.cancel_button.set_visible(fetching); // A finished result speaks once, as a toast. Errors hold until they are // dismissed; everything else fades on its own. let note = match (&model.busy, &model.status) { (None, Some(status)) => status.clone(), _ => String::new(), }; if !note.is_empty() && note != w.last_status { let toast = adw::Toast::new(¬e); if model.status_error { toast.set_timeout(0); } w.toasts.add_toast(toast); } w.last_status = note; } /// 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 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 } /// One contact on the contacts page: the address over its key, the trust /// state and the actions that state allows. fn contact_row(contact: &Contact, sender: &ComponentSender) -> adw::ActionRow { let row = adw::ActionRow::builder() .title(escape(&contact.address)) .subtitle(escape(&contact.key)) .build(); row.set_title_lines(1); row.set_subtitle_lines(1); let state = label(contact_state(contact), "caption"); state.set_valign(Align::Center); row.add_suffix(&state); let write = icon_button("mail-message-new-symbolic", "Write to this contact"); let write_msg = Message::ContactWrite(contact.address.clone()); let write_sender = sender.clone(); write.connect_clicked(move |_| write_sender.input(write_msg.clone())); row.add_suffix(&write); match contact.accepted { Some(true) => { let block = icon_button("action-unavailable-symbolic", "Block this contact"); let msg = Message::ContactBlock(contact.address.clone()); let block_sender = sender.clone(); block.connect_clicked(move |_| block_sender.input(msg.clone())); row.add_suffix(&block); } Some(false) => { let unblock = icon_button("object-select-symbolic", "Unblock this contact"); let msg = Message::ContactUnblock(contact.address.clone()); let unblock_sender = sender.clone(); unblock.connect_clicked(move |_| unblock_sender.input(msg.clone())); row.add_suffix(&unblock); } None => { let accept = icon_button("object-select-symbolic", "Accept this contact"); let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone()); let accept_sender = sender.clone(); accept.connect_clicked(move |_| accept_sender.input(msg.clone())); row.add_suffix(&accept); } } row } /// The own-address QR as an RGBA texture; black on white, the form every /// scanner expects, taking no cues from the theme. fn qr_texture(uri: &str) -> Option { let code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?; let size = code.size(); let stride = size as usize * 4; let mut pixels = Vec::with_capacity(size as usize * size as usize * 4); for y in 0..size { for x in 0..size { let dark = code.get_module(x, y); let (r, g, b) = if dark { (0, 0, 0) } else { (0xff, 0xff, 0xff) }; pixels.extend_from_slice(&[r, g, b, 0xff]); } } Some(gtk::gdk::MemoryTexture::new( size, size, gtk::gdk::MemoryFormat::R8g8b8a8, >k::glib::Bytes::from_owned(pixels), stride, )) }