//! 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::address::trust_label; 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, DialogKind, Message, OnboardMode, OpResult, Page, PendingOp, SignupMode, SignupStep, }; use crate::i18n; 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) -> String { let id = match contact.accepted { Some(true) => "contact-state-accepted", Some(false) => "contact-state-blocked", None if contact.verified => "contact-state-verified", None => "contact-state-tofu", }; i18n::t(id) } #[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, list_empty_hint: gtk::Label, 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, compose_pin_card: gtk::Box, compose_pin_title: gtk::Label, compose_pin_body: gtk::Label, compose_pin_key: gtk::Label, compose_pin_fingerprint: gtk::Label, compose_pin_hint: gtk::Label, compose_pin_button: gtk::Button, // 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_rows: Vec, secret_row: adw::ActionRow, secret_reveal: gtk::Button, host_row: adw::ActionRow, public_key_row: adw::ActionRow, rotations_value: gtk::Label, keep_mail_check: gtk::CheckButton, strict_check: gtk::CheckButton, timeout_row: adw::SpinRow, // onboarding onboard_seed_label: gtk::Label, onboard_seed_entry: adw::EntryRow, // signup signup_key_button: gtk::Button, signup_key_card: gtk::Box, signup_key_value: gtk::Label, signup_key_fingerprint: gtk::Label, signup_key_hint: gtk::Label, signup_start_over: gtk::Button, signup_step_back: gtk::Button, signup_mode_new: gtk::ToggleButton, signup_mode_existing: gtk::ToggleButton, signup_host: adw::EntryRow, signup_user: adw::EntryRow, signup_invite: adw::EntryRow, register_button: gtk::Button, recall_button: gtk::Button, // 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", &i18n::t("mail-fetch-button"), "accent", ); let write_button = icon_label_button( "mail-message-new-symbolic", &i18n::t("mail-write-button"), "", ); 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", &i18n::t("folder-inbox")), nav_row("contact-new-symbolic", &i18n::t("folder-requests")), nav_row("mail-send-symbolic", &i18n::t("folder-sent")), nav_row("document-edit-symbolic", &i18n::t("folder-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", &i18n::t("settings-nav-label"), ); 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(&i18n::t("identity-you-caption"), "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(&i18n::t("contacts-caption"), "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(i18n::t("contacts-import-placeholder")) .build(); let import_button = icon_label_button( "document-open-symbolic", &i18n::t("contacts-import-button"), "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(&i18n::t("contacts-import-placeholder"))); 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", &i18n::t("mail-fetch-button"), "pill", ); list_empty_fetch.set_halign(Align::Center); let list_empty_hint = label(&i18n::t("mail-empty-hint"), "dim caption"); list_empty_hint.set_wrap(true); list_empty_hint.set_justify(gtk::Justification::Center); list_empty_hint.set_halign(Align::Center); let list_empty_box = gtk::Box::new(Orientation::Vertical, 8); list_empty_box.append(&list_empty_fetch); list_empty_box.append(&list_empty_hint); list_empty.set_child(Some(&list_empty_box)); 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(&i18n::t("detail-qr-tooltip"))); 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", &i18n::t("detail-copy-tooltip")); 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(i18n::t("mail-empty-title")) .description(i18n::t("mail-empty-description")) .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(i18n::t("compose-to-label")) .build(); let compose_subject = adw::EntryRow::builder() .title(i18n::t("compose-subject-label")) .build(); compose_fields.append(&compose_to); compose_fields.append(&compose_subject); compose.append(&compose_fields); // Shown when a send was refused because the recipient's key would // have come from a server with no pin: the refusal and its fix in // one place, since nowhere else in the app pins a correspondent's // server. let compose_pin_title = label("", "heading"); compose_pin_title.set_wrap(true); let compose_pin_body = label("", "dim"); compose_pin_body.set_wrap(true); let compose_pin_key = label("", "mono"); compose_pin_key.set_selectable(true); compose_pin_key.set_wrap(true); compose_pin_key.set_wrap_mode(gtk::pango::WrapMode::Char); let compose_pin_fingerprint = label("", "mono dim"); compose_pin_fingerprint.set_selectable(true); compose_pin_fingerprint.set_wrap(true); let compose_pin_hint = label(&i18n::t("trust-fingerprint-hint"), "dim caption"); compose_pin_hint.set_wrap(true); let compose_pin_button = button("", "accent"); compose_pin_button.set_halign(Align::Start); compose_pin_button.set_margin_top(6); let compose_pin_card = gtk::Box::new(Orientation::Vertical, 6); compose_pin_card.add_css_class("card"); compose_pin_card.set_visible(false); for child in [&compose_pin_title, &compose_pin_body] { child.set_margin_start(12); child.set_margin_end(12); } compose_pin_title.set_margin_top(12); for child in [ &compose_pin_key, &compose_pin_fingerprint, &compose_pin_hint, ] { child.set_margin_start(12); child.set_margin_end(12); } compose_pin_button.set_margin_start(12); compose_pin_button.set_margin_bottom(12); compose_pin_card.append(&compose_pin_title); compose_pin_card.append(&compose_pin_body); compose_pin_card.append(&compose_pin_key); compose_pin_card.append(&compose_pin_fingerprint); compose_pin_card.append(&compose_pin_hint); compose_pin_card.append(&compose_pin_button); compose.append(&compose_pin_card); // 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(&i18n::t("compose-add-header-button"), "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(i18n::t("compose-send-button")) .build(); compose_send_button.set_child(Some(&compose_send)); let compose_cancel = button(&i18n::t("action-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: a tabbed preferences page — Identity, Server, Backup, // Advanced — switched by an adw::ViewSwitcher over an // adw::ViewStack, each tab its own PreferencesPage. let settings_stack = adw::ViewStack::new(); // Read-only values use Adwaita's "property" row: the field name // sits small above the value it labels, which leaves the value // itself room to wrap instead of being ellipsized into a suffix. // Markup is off so the copy button can hand back exactly what is // on screen. // `id` is a message id (e.g. "settings-field-host"), not literal // text, so both the title and its "Copy the …" tooltip come from // the catalogue rather than one being built from the other. let property_row = |id: &str| -> adw::ActionRow { let title = i18n::t(id); let row = adw::ActionRow::builder() .title(&title) .use_markup(false) .subtitle_lines(0) .subtitle_selectable(true) .build(); row.add_css_class("property"); row.add_css_class("monospace"); let copy = icon_button( "edit-copy-symbolic", &i18n::t1("property-row-copy-tooltip", "field", title.to_lowercase()), ); let copy_source = row.clone(); copy.connect_clicked(move |_| { if let Some(display) = gtk::gdk::Display::default() { display .clipboard() .set_text(©_source.subtitle().unwrap_or_default()); } }); row.add_suffix(©); row }; // -- Identity tab -------------------------------------------------- // The switcher already names each page, so the first group on a // page carries no title of its own. let identity_page = adw::PreferencesPage::new(); let identity_group = adw::PreferencesGroup::new(); let mut identity_rows = Vec::new(); for field in [ "settings-field-address", "settings-field-key", "settings-field-fingerprint", "settings-field-uri", ] { let row = property_row(field); identity_group.add(&row); identity_rows.push(row); } identity_page.add(&identity_group); // The group header names this one, so the row carries only the // secret — masked until the reveal button is pressed. let secret_group = adw::PreferencesGroup::builder() .title(i18n::t("settings-recovery-secret-title")) .build(); let secret_row = adw::ActionRow::builder() .use_markup(false) .title_lines(0) .title_selectable(true) .build(); secret_row.add_css_class("monospace"); let secret_reveal = icon_button( "view-reveal-symbolic", &i18n::t("settings-secret-reveal-tooltip"), ); let secret_copy = icon_button( "edit-copy-symbolic", &i18n::t("settings-secret-copy-tooltip"), ); secret_row.add_suffix(&secret_reveal); secret_row.add_suffix(&secret_copy); secret_group.add(&secret_row); identity_page.add(&secret_group); settings_stack.add_titled_with_icon( &identity_page, Some("identity"), &i18n::t("settings-tab-identity"), "avatar-default-symbolic", ); // -- Server tab ---------------------------------------------------- let server_page = adw::PreferencesPage::new(); let server_group = adw::PreferencesGroup::builder() .title(i18n::t("server-connection-title")) .build(); let host_row = property_row("settings-field-host"); let public_key_row = property_row("settings-field-public-key"); server_group.add(&host_row); server_group.add(&public_key_row); let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); timeout_row.set_title(&i18n::t("server-timeout-title")); timeout_row.set_subtitle(&i18n::t("server-timeout-subtitle")); server_group.add(&timeout_row); server_page.add(&server_group); let network_group = adw::PreferencesGroup::builder() .title(i18n::t("server-fetching-title")) .build(); let keep_mail_row = adw::ActionRow::builder() .title(i18n::t("server-leave-mail-title")) .subtitle(i18n::t("server-leave-mail-subtitle")) .subtitle_lines(0) .activatable(true) .build(); let keep_mail_check = gtk::CheckButton::builder().valign(Align::Center).build(); keep_mail_row.add_suffix(&keep_mail_check); keep_mail_row.set_activatable_widget(Some(&keep_mail_check)); network_group.add(&keep_mail_row); server_page.add(&network_group); settings_stack.add_titled_with_icon( &server_page, Some("server"), &i18n::t("settings-tab-server"), "network-server-symbolic", ); // -- Backup tab ---------------------------------------------------- let backup_page = adw::PreferencesPage::new(); let backup_group = adw::PreferencesGroup::builder() .description(i18n::t("backup-page-description")) .build(); let export_row = adw::ActionRow::builder() .title(i18n::t("backup-export-title")) .subtitle(i18n::t("backup-export-subtitle")) .activatable(true) .build(); export_row.add_prefix(>k::Image::from_icon_name("document-save-symbolic")); export_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let import_row = adw::ActionRow::builder() .title(i18n::t("backup-import-title")) .subtitle(i18n::t("backup-import-subtitle")) .activatable(true) .build(); import_row.add_prefix(>k::Image::from_icon_name("document-open-symbolic")); import_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); backup_group.add(&export_row); backup_group.add(&import_row); backup_page.add(&backup_group); settings_stack.add_titled_with_icon( &backup_page, Some("backup"), &i18n::t("settings-tab-backup"), "document-save-symbolic", ); // -- Advanced tab -------------------------------------------------- let advanced_page = adw::PreferencesPage::new(); let rotations_group = adw::PreferencesGroup::builder() .title(i18n::t("advanced-rotations-title")) .description(i18n::t("advanced-rotations-description")) .build(); // Both actions sit in their group's body, centred under the // explanation: a long description wraps under the group title and // would run straight into a header-suffix button. let rotations_row = adw::ActionRow::builder() .title(i18n::t("advanced-rotations-completed")) .build(); let rotations_value = label("", "dim numeric"); rotations_value.set_valign(Align::Center); rotations_row.add_suffix(&rotations_value); rotations_group.add(&rotations_row); let rotate_button = icon_label_button( "view-refresh-symbolic", &i18n::t("advanced-rotate-button"), "", ); rotate_button.set_halign(Align::Center); rotate_button.set_margin_top(12); rotations_group.add(&rotate_button); advanced_page.add(&rotations_group); let trust_group = adw::PreferencesGroup::builder() .title(i18n::t("advanced-first-contact-title")) .description(i18n::t("advanced-first-contact-description")) .build(); let strict_row = adw::ActionRow::builder() .title(i18n::t("advanced-strict-pinning-title")) .subtitle(i18n::t("advanced-strict-pinning-subtitle")) .subtitle_lines(0) .activatable(true) .build(); let strict_check = gtk::CheckButton::builder().valign(Align::Center).build(); strict_row.add_suffix(&strict_check); strict_row.set_activatable_widget(Some(&strict_check)); trust_group.add(&strict_row); advanced_page.add(&trust_group); // The log-out button is wide and destructive, so it sits in the // group's body under the warning rather than squeezed beside the // header where its label would crowd the title. let logout_group = adw::PreferencesGroup::builder() .title(i18n::t("advanced-logout-title")) .description(i18n::t("advanced-logout-description")) .build(); let reset_button = icon_label_button( "system-log-out-symbolic", &i18n::t("advanced-logout-button"), "danger", ); reset_button.set_halign(Align::Center); reset_button.set_margin_top(12); logout_group.add(&reset_button); advanced_page.add(&logout_group); settings_stack.add_titled_with_icon( &advanced_page, Some("advanced"), &i18n::t("settings-tab-advanced"), "emblem-system-symbolic", ); // The switcher is a centred bar of its own above the pages, since // the window's headerbar already carries the app name and address. let settings_switcher = adw::ViewSwitcher::builder() .stack(&settings_stack) .policy(adw::ViewSwitcherPolicy::Wide) .halign(Align::Center) .build(); let settings_switcher_bar = gtk::Box::new(Orientation::Vertical, 0); settings_switcher_bar.add_css_class("toolbar"); settings_switcher_bar.append(&settings_switcher); let settings = gtk::Box::new(Orientation::Vertical, 0); settings.append(&settings_switcher_bar); settings.append(&settings_stack); 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(&i18n::t("onboard-tagline"), "dim"); tagline.set_halign(Align::Center); tagline.set_margin_bottom(12); onboard_start.append(&tagline); let create_button = button(&i18n::t("onboard-create-button"), "pill"); let restore_button = button(&i18n::t("onboard-restore-button"), "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(&i18n::t("onboard-created-title"), "subtitle"); onboard_created.append(&created_title); onboard_created.append(&label(&i18n::t("onboard-created-description"), "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(&i18n::t("onboard-saved-button"), "pill"); let created_back = button(&i18n::t("action-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(&i18n::t("onboard-restore-title"), "subtitle")); let restore_list = boxed_list(); let onboard_seed_entry = adw::EntryRow::builder() .title(i18n::t("onboard-seed-entry-title")) .build(); restore_list.append(&onboard_seed_entry); onboard_restore.append(&restore_list); let restore_go = button(&i18n::t("onboard-restore-go-button"), "pill"); let restore_back = button(&i18n::t("action-back"), "pill-quiet"); onboard_restore.append(&restore_go); onboard_restore.append(&restore_back); stack.add_named(¢er(&onboard_restore), Some("onboard-restore")); // signup: step 1 — the server this address lives on let signup_server = gtk::Box::new(Orientation::Vertical, 12); signup_server.append(&label(&i18n::t("signup-server-title"), "subtitle")); let signup_server_group = adw::PreferencesGroup::new(); let signup_host = adw::EntryRow::builder() .title(i18n::t("settings-field-host")) .build(); signup_server_group.add(&signup_host); signup_server.append(&signup_server_group); // Shown once a key has been fetched: the key and its fingerprint, // prominent, plus the reminder to check it against what the // operator published. Hidden otherwise. let signup_key_value = label("", "mono"); signup_key_value.set_selectable(true); signup_key_value.set_wrap(true); signup_key_value.set_margin_top(12); signup_key_value.set_margin_start(12); signup_key_value.set_margin_end(12); let signup_key_fingerprint = label("", "mono dim"); signup_key_fingerprint.set_selectable(true); signup_key_fingerprint.set_wrap(true); signup_key_fingerprint.set_margin_bottom(12); signup_key_fingerprint.set_margin_start(12); signup_key_fingerprint.set_margin_end(12); let signup_key_card = gtk::Box::new(Orientation::Vertical, 4); signup_key_card.add_css_class("card"); signup_key_card.set_visible(false); signup_key_card.append(&signup_key_value); signup_key_card.append(&signup_key_fingerprint); signup_server.append(&signup_key_card); let signup_key_hint = label(&i18n::t("trust-fingerprint-hint"), "dim"); signup_key_hint.set_wrap(true); signup_key_hint.set_visible(false); signup_server.append(&signup_key_hint); let signup_key_button = button(&i18n::t("signup-fetch-key-button"), "pill"); let signup_start_over = button(&i18n::t("signup-start-over-button"), "pill-quiet"); signup_server.append(&signup_key_button); signup_server.append(&signup_start_over); stack.add_named(¢er(&signup_server), Some("signup-server")); // signup: step 2 — bind a username, or recall an existing address let signup_account = gtk::Box::new(Orientation::Vertical, 12); signup_account.append(&label(&i18n::t("signup-account-title"), "subtitle")); let signup_mode_new = gtk::ToggleButton::builder() .label(i18n::t("signup-mode-new-label")) .active(true) .hexpand(true) .build(); let signup_mode_existing = gtk::ToggleButton::builder() .label(i18n::t("signup-mode-existing-label")) .hexpand(true) .build(); signup_mode_existing.set_group(Some(&signup_mode_new)); let signup_mode_box = gtk::Box::new(Orientation::Horizontal, 0); signup_mode_box.add_css_class("linked"); signup_mode_box.set_homogeneous(true); signup_mode_box.append(&signup_mode_new); signup_mode_box.append(&signup_mode_existing); signup_account.append(&signup_mode_box); let signup_account_group = adw::PreferencesGroup::new(); let signup_user = adw::EntryRow::builder() .title(i18n::t("signup-user-label")) .build(); let signup_invite = adw::EntryRow::builder() .title(i18n::t("signup-invite-label")) .build(); signup_account_group.add(&signup_user); signup_account_group.add(&signup_invite); signup_account.append(&signup_account_group); let register_button = button(&i18n::t("signup-register-button"), "pill"); let recall_button = button(&i18n::t("signup-recall-button"), "pill"); signup_account.append(®ister_button); signup_account.append(&recall_button); let signup_step_back = button(&i18n::t("action-back"), "quiet"); signup_account.append(&signup_step_back); stack.add_named(¢er(&signup_account), Some("signup-account")); // --- 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(&i18n::t("action-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_pin_button.connect_clicked(input(Message::PinServerAction)); 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)); 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)); reset_button.connect_clicked(input(Message::ResetPressed)); secret_reveal.connect_clicked(input(Message::SecretRevealToggled)); secret_copy.connect_clicked(input(Message::CopySecret)); let timeout_sender = sender.clone(); timeout_row.connect_value_notify(move |row| { timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) }); let keep_mail_sender = sender.clone(); keep_mail_check.connect_toggled(move |check| { keep_mail_sender.input(Message::KeepMailChanged(check.is_active())) }); let strict_sender = sender.clone(); strict_check.connect_toggled(move |check| { strict_sender.input(Message::StrictPinningChanged(check.is_active())) }); // Export/Import go straight to a native file dialog rather than a // Message — the chosen path only matters for this one call, and // round-tripping it through the model would gain nothing. let export_sender = sender.clone(); let export_window = root.clone(); export_row.connect_activated(move |_| { let dialog = gtk::FileDialog::builder() .title(i18n::t("backup-export-dialog-title")) .initial_name("carrier-backup.gsmol") .build(); let sender = export_sender.clone(); dialog.save( Some(&export_window), gtk::gio::Cancellable::NONE, move |result| { if let Ok(file) = result { if let Some(path) = file.path() { sender.input(Message::ExportBackupTo(path)); } } }, ); }); let backup_import_sender = sender.clone(); let backup_import_window = root.clone(); import_row.connect_activated(move |_| { let dialog = gtk::FileDialog::builder() .title(i18n::t("backup-import-dialog-title")) .build(); let sender = backup_import_sender.clone(); dialog.open( Some(&backup_import_window), gtk::gio::Cancellable::NONE, move |result| { if let Ok(file) = result { if let Some(path) = file.path() { if let Ok(text) = std::fs::read_to_string(&path) { sender.input(Message::ImportBackupFrom(text)); } } } }, ); }); 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())) }); signup_key_button.connect_clicked(input(Message::SignupKeyAction)); signup_start_over.connect_clicked(input(Message::SignupStartOver)); signup_step_back.connect_clicked(input(Message::SignupStepBack)); register_button.connect_clicked(input(Message::SignupRegister)); recall_button.connect_clicked(input(Message::SignupRecall)); let mode_new_sender = sender.clone(); signup_mode_new.connect_toggled(move |btn| { if btn.is_active() { mode_new_sender.input(Message::SignupModeChanged(SignupMode::New)); } }); let mode_existing_sender = sender.clone(); signup_mode_existing.connect_toggled(move |btn| { if btn.is_active() { mode_existing_sender.input(Message::SignupModeChanged(SignupMode::Existing)); } }); 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, list_empty_hint, article_stack, article_subject, article_meta, article_headers, article_view, article_body, actions, compose_to, compose_headers, compose_subject, compose_body, compose_send, compose_pin_card, compose_pin_title, compose_pin_body, compose_pin_key, compose_pin_fingerprint, compose_pin_hint, compose_pin_button, sidebar_own, sidebar_contacts: contacts_list, detail_share, detail_copy, detail_qr, identity_rows, secret_row, secret_reveal, host_row, public_key_row, rotations_value, keep_mail_check, strict_check, timeout_row, onboard_seed_label, onboard_seed_entry, signup_key_button, signup_key_card, signup_key_value, signup_key_fingerprint, signup_key_hint, signup_start_over, signup_step_back, signup_mode_new, signup_mode_existing, signup_host, signup_user, signup_invite, register_button, recall_button, 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 mut widgets = CarrierWidgets::new(&root, &sender); if let Some(op) = op { spawn_op(&model, op, &sender); } // `update_view` runs only after a message, and a clean install // boots with none queued, so the first paint happens here — // otherwise the stack sits on its default page until the first // click flips it to the page the model opened with. render(&model, &mut widgets, &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; let keep_mail = model.keep_mail; match op { PendingOp::Snapshot { read } => sender.spawn_command(move |out| { let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot( &store, &master, &read, ))); }), PendingOp::Register { addr, invite } => sender.spawn_command(move |out| { let res = ops::register(&store, &master, &addr, invite.as_deref(), timeout); let _ = out.send(OpResult::Generic( res.map(|_| i18n::t("status-registered-note")), )); }), PendingOp::Restore { addr } => sender.spawn_command(move |out| { let res = ops::restore(&store, &master, &addr, timeout) .map(|index| i18n::t1("status-restored-note", "index", index as i64)); let _ = out.send(OpResult::Generic(res)); }), PendingOp::LearnKey { addr } => sender.spawn_command(move |out| { let offered = ops::learn_key(&store, &addr, timeout) .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, keep_mail, 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(|_| i18n::t("status-deleted-note")); 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(|_| i18n::t1("status-accepted-note", "address", address.clone())); 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 { i18n::t1("status-blocked-note", "address", address.clone()) } else { i18n::t1("status-not-blocked-note", "address", address.clone()) } }); let _ = out.send(OpResult::Generic(res)); }), PendingOp::ImportContact { uri } => sender.spawn_command(move |out| { let res = ops::import_contact(&store, &uri) .map(|(address, _)| i18n::t1("status-imported-note", "address", address)); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Rotate => sender.spawn_command(move |out| { let res = ops::rotate(&store, &master, timeout).map(|(index, key)| { let mut args = fluent_bundle::FluentArgs::new(); args.set("index", index as i64); args.set("key_prefix", key[..12].to_string()); i18n::t_args("status-rotated-note", Some(&args)) }); let _ = out.send(OpResult::Generic(res)); }), PendingOp::Export { path } => sender.spawn_command(move |out| { let _ = out.send(OpResult::Generic(ops::export_backup( &store, &master, &path, ))); }), 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(); } } } } // The pin card follows the recipient: a prompt armed for a different // address simply isn't shown, so editing To retires it with no // explicit clearing. let compose_prompt = model.compose.as_ref().and_then(|compose| { let typed = ops::parse_address(compose.to.trim()).ok()?.short(); model .pin_prompt .as_ref() .filter(|prompt| prompt.addr.short() == typed) }); w.compose_pin_card.set_visible(compose_prompt.is_some()); if let Some(prompt) = compose_prompt { w.compose_pin_title.set_text(&i18n::t1( "compose-pin-title", "host", trust_label(&prompt.addr.host, prompt.addr.port), )); w.compose_pin_body.set_text(&i18n::t1( "compose-pin-body", "address", prompt.addr.short(), )); match prompt.offered { Some(key) => { w.compose_pin_key.set_text(&b32(&key)); w.compose_pin_fingerprint.set_text(&fingerprint(&key)); w.compose_pin_button .set_label(&i18n::t("compose-pin-button-send")); } None => w .compose_pin_button .set_label(&i18n::t("compose-pin-button-fetch")), } let fetched = prompt.offered.is_some(); for line in [ &w.compose_pin_key, &w.compose_pin_fingerprint, &w.compose_pin_hint, ] { line.set_visible(fetched); } } // 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 => match model.signup.step { SignupStep::Server => "signup-server", SignupStep::Account => "signup-account", }, 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_id, description_id) = match model.folder { Folder::Inbox => ( None, "mail-inbox-empty-title", "mail-inbox-empty-description", ), Folder::Requests => ( Some("contact-new-symbolic"), "mail-requests-empty-title", "mail-requests-empty-description", ), Folder::Sent => ( Some("mail-send-symbolic"), "mail-sent-empty-title", "mail-sent-empty-description", ), Folder::Drafts => ( Some("document-edit-symbolic"), "mail-drafts-empty-title", "mail-drafts-empty-description", ), }; w.list_empty.set_icon_name(icon); w.list_empty.set_title(&i18n::t(title_id)); w.list_empty.set_description(Some(&i18n::t(description_id))); let inbox_empty = model.folder == Folder::Inbox; w.list_empty_fetch.set_visible(inbox_empty); w.list_empty_hint .set_visible(inbox_empty && !model.keep_mail); 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(&i18n::t1( "own-identity-meta", "rotations", own.rotations as i64, )); 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); // `contact_state` collapses to accepted/blocked once a token // exists, which says nothing about where the key came from — // so the server's pin state is spelled out beside it. let contact_meta_id = if contact.pinned { "contact-meta-line-yes" } else { "contact-meta-line-no" }; w.article_meta .set_text(&i18n::t1(contact_meta_id, "state", contact_state(contact))); let mut body = detail_body(&contact.address, &contact.key, &contact.fingerprint); if !contact.server.is_empty() { body.push_str(&format!( "\n{:<14}{}", i18n::t("detail-server-label"), contact.server )); } // A key fetched for this contact's server renders here rather // than in a second card: the pane is already a key column. if let Some(key) = model .pin_prompt .as_ref() .filter(|prompt| prompt.addr.short() == contact.address) .and_then(|prompt| prompt.offered) { body.push_str(&format!( "\n\n{}\n{:<14}{}\n{:<14}{}\n\n{}", i18n::t("contact-server-key-offered"), i18n::t("detail-key-label"), b32(&key), i18n::t("detail-fingerprint-label"), fingerprint(&key), i18n::t("trust-fingerprint-hint"), )); } w.article_body.set_text(&body); 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", &i18n::t("mail-write-button"), "", ); 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); if !contact.pinned && !contact.server.is_empty() { let fetched = model.pin_prompt.as_ref().is_some_and(|prompt| { prompt.offered.is_some() && prompt.addr.short() == contact.address }); let pin = icon_label_button( "channel-secure-symbolic", &i18n::t(if fetched { "contacts-pin-button-fetched" } else { "contacts-pin-button-unfetched" }), if fetched { "accent" } else { "" }, ); pin.connect_clicked(input(Message::PinServerAction)); w.actions.append(&pin); } match contact.accepted { Some(true) => { let block = icon_label_button( "action-unavailable-symbolic", &i18n::t("action-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", &i18n::t("action-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", &i18n::t("action-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() { i18n::t("mail-no-subject") } else { msg.subject.clone() }; w.article_subject.set_text(&subject); let label = i18n::t(if *folder == Folder::Sent { "mail-meta-to" } else { "mail-meta-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 from = if known { format!("{address}") } else { address }; let mail_meta_id = if msg.kept { "mail-meta-line-yes" } else { "mail-meta-line-no" }; let mut meta_args = fluent_bundle::FluentArgs::new(); meta_args.set("label", label); meta_args.set("from", from); meta_args.set("time", escape(&ops::when(msg.time))); meta_args.set("id", msg.id[..msg.id.len().min(8)].to_string()); let meta = i18n::t_args(mail_meta_id, Some(&meta_args)); 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", &i18n::t("action-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", &i18n::t("action-accept"), "accent", ); accept.connect_clicked(input(Message::AcceptPressed)); let block = icon_label_button( "action-unavailable-symbolic", &i18n::t("action-block"), "danger", ); block.connect_clicked(input(Message::BlockPressed)); w.actions.append(&accept); w.actions.append(&block); } else { w.actions.append(&crate::view::label( &i18n::t("mail-no-reply-address"), "caption", )); } } let spacer = gtk::Label::new(None); spacer.set_hexpand(true); w.actions.append(&spacer); let delete = icon_label_button("user-trash-symbolic", &i18n::t("action-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(&i18n::t(if replying { "action-reply" } else { "compose-send-button" })); 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(&i18n::t("contacts-empty-hint"), "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/server 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.uri.clone(), ]; for (row, text) in w.identity_rows.iter().zip(values) { row.set_subtitle(&text); } w.host_row.set_subtitle(&snap.who.host); // Pins are ordered by host and hold correspondents' servers too, // so the account's own is found by its label rather than taken // off the front of the list. let own = trust_label(&snap.who.host, snap.who.port); let public_key = snap .pins .iter() .find(|(label, _)| *label == own) .map(|(_, key)| b32(key)) .unwrap_or_else(|| i18n::t("settings-public-key-not-pinned")); w.public_key_row.set_subtitle(&public_key); w.rotations_value.set_text(&snap.who.rotations.to_string()); } // The recovery secret is masked until revealed; the copy button // always copies the real value (see Message::CopySecret), not // whatever's currently on screen. let secret = ops::seed_text(&model.master.unwrap_or([0u8; KEY_LEN])); if model.secret_revealed { w.secret_row.set_title(&secret); w.secret_reveal.set_icon_name("view-conceal-symbolic"); w.secret_reveal .set_tooltip_text(Some(&i18n::t("settings-secret-hide-tooltip"))); } else { w.secret_row.set_title(&"•".repeat(secret.chars().count())); w.secret_reveal.set_icon_name("view-reveal-symbolic"); w.secret_reveal .set_tooltip_text(Some(&i18n::t("settings-secret-reveal-tooltip"))); } if w.keep_mail_check.is_active() != model.keep_mail { w.keep_mail_check.set_active(model.keep_mail); } if w.strict_check.is_active() != model.strict_pinning { w.strict_check.set_active(model.strict_pinning); } if w.timeout_row.value() as u64 != model.timeout { w.timeout_row.set_value(model.timeout as f64); } // A programmatic reset (factory reset, discarding a cancelled // identity) clears these in the model; push that once into the // entries too, the same way a compose prefill does, so typing from // a previous attempt cannot linger on screen. if model.form_fresh.take() { w.onboard_seed_entry.set_text(&model.onboard.seed); w.signup_host.set_text(&model.signup.host); w.signup_user.set_text(&model.signup.user); w.signup_invite.set_text(&model.signup.invite); } // Onboarding. if model.onboard.mode == OnboardMode::Created { w.onboard_seed_label.set_text(&model.onboard.seed_shown); } // Signup. let signup_busy = model.busy.is_none(); for button in [ &w.signup_key_button, &w.signup_start_over, &w.signup_step_back, &w.register_button, &w.recall_button, ] { button.set_sensitive(signup_busy); } // The server step's one button: a fetched or already-trusted key // reads "Continue" and shows the key prominently; otherwise "Fetch". if let Some((_, key)) = &model.pin_offer { w.signup_key_value.set_text(&b32(key)); w.signup_key_fingerprint.set_text(&fingerprint(key)); w.signup_key_card.set_visible(true); w.signup_key_hint.set_visible(true); w.signup_key_button .set_label(&i18n::t("signup-continue-button")); } else if model.signup.key_trusted { w.signup_key_card.set_visible(false); w.signup_key_hint.set_visible(false); w.signup_key_button .set_label(&i18n::t("signup-continue-button")); } else { w.signup_key_card.set_visible(false); w.signup_key_hint.set_visible(false); w.signup_key_button .set_label(&i18n::t("signup-fetch-key-button")); } let new_address = model.signup.mode == SignupMode::New; w.signup_invite.set_visible(new_address); w.register_button.set_visible(new_address); w.recall_button.set_visible(!new_address); if w.signup_mode_new.is_active() != new_address { w.signup_mode_new.set_active(new_address); } if w.signup_mode_existing.is_active() == new_address { w.signup_mode_existing.set_active(!new_address); } // 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 kind = dialog.kind(); let alert = match dialog { Dialog::Delete { kept, .. } => { let alert = adw::AlertDialog::new( Some(&i18n::t("delete-dialog-title")), Some(&i18n::t(if *kept { "delete-dialog-body-kept" } else { "delete-dialog-body-gone" })), ); alert.add_response("cancel", &i18n::t("action-cancel")); alert.add_response("here", &i18n::t("action-delete")); alert.set_response_appearance("here", adw::ResponseAppearance::Destructive); if *kept { alert.add_response( "everywhere", &i18n::t("delete-dialog-everywhere-button"), ); alert.set_response_appearance( "everywhere", adw::ResponseAppearance::Destructive, ); } alert } Dialog::Rotate => { let alert = adw::AlertDialog::new( Some(&i18n::t("rotate-dialog-title")), Some(&i18n::t("advanced-rotations-description")), ); alert.add_response("cancel", &i18n::t("action-cancel")); alert.add_response("rotate", &i18n::t("advanced-rotate-button")); alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); alert } Dialog::Reset => { let alert = adw::AlertDialog::new( Some(&i18n::t("reset-dialog-title")), Some(&i18n::t("advanced-logout-description")), ); alert.add_response("cancel", &i18n::t("action-cancel")); alert.add_response("reset", &i18n::t("advanced-logout-button")); alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); alert } Dialog::Reply { rows, .. } => { let list = rows .iter() .map(String::as_str) .collect::>() .join("\n"); let alert = adw::AlertDialog::new( Some(&i18n::t("reply-dialog-title")), Some(&i18n::t1("reply-dialog-body", "list", list)), ); alert.add_response("cancel", &i18n::t("reply-dialog-cancel-button")); alert.add_response("without", &i18n::t("reply-dialog-without-button")); alert.add_response("copy", &i18n::t("reply-dialog-copy-button")); alert.set_response_appearance("copy", adw::ResponseAppearance::Suggested); alert } Dialog::KeyChange { address, known_fingerprint, offered_fingerprint, .. } => { let mut args = fluent_bundle::FluentArgs::new(); args.set("address", address.clone()); args.set("known_fingerprint", known_fingerprint.clone()); args.set("offered_fingerprint", offered_fingerprint.clone()); let alert = adw::AlertDialog::new( Some(&i18n::t("key-change-dialog-title")), Some(&i18n::t_args("key-change-dialog-body", Some(&args))), ); alert.add_response("cancel", &i18n::t("action-cancel")); alert.add_response("replace", &i18n::t("key-change-dialog-replace-button")); alert.set_response_appearance("replace", adw::ResponseAppearance::Destructive); 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 kind == DialogKind::Reply { "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| { // Anything that is not an explicit confirmation — a // Cancel, Escape, or the close response — cancels. response_sender.input(match (kind, response) { (DialogKind::Delete, "here") => Message::DeleteConfirmed(false), (DialogKind::Delete, "everywhere") => Message::DeleteConfirmed(true), (DialogKind::Delete, _) => Message::DeleteCancelled, (DialogKind::Rotate, "rotate") => Message::RotateConfirmed, (DialogKind::Rotate, _) => Message::RotateCancelled, (DialogKind::Reset, "reset") => Message::ResetConfirmed, (DialogKind::Reset, _) => Message::ResetCancelled, (DialogKind::Reply, "copy") => Message::ReplyConfirmed(true), (DialogKind::Reply, "without") => Message::ReplyConfirmed(false), (DialogKind::Reply, _) => Message::ReplyCancelled, (DialogKind::KeyChange, "replace") => Message::KeyChangeConfirmed, (DialogKind::KeyChange, _) => Message::KeyChangeCancelled, }); }); 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); i18n::t1("status-fetching-progress", "count", inbox as i64) } (Some(busy), false) => match busy.as_str() { "fetching" => i18n::t("status-busy-fetching"), "refreshing" => i18n::t("status-busy-refreshing"), "registering" => i18n::t("status-busy-registering"), "restoring" => i18n::t("status-busy-restoring"), other => format!("{other}…"), }, (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() { i18n::t("mail-no-subject") } 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(&i18n::t("compose-header-placeholder"))); 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", &i18n::t("compose-header-remove-tooltip"), ); 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!( "{:<14}{key}\n{:<14}{fingerprint}\nsmol://{address}/{key}", i18n::t("detail-key-label"), i18n::t("detail-fingerprint-label"), ) } /// 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, )) }