carrier/src/view.rs

1922 lines
79 KiB
Rust

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