feat: unify the detail view with QR sharing and default pane layout

This commit is contained in:
randogoth 2026-10-03 09:55:00 +03:00
parent 84650e50bd
commit 22c20427f0
4 changed files with 256 additions and 164 deletions

View file

@ -22,7 +22,6 @@ pub enum Page {
Onboard,
Signup,
Mail,
Contacts,
Settings,
}
@ -159,13 +158,15 @@ pub enum Message {
// Contacts
/// A sidebar contact was clicked: the article pane shows its details.
ContactSelected(String),
/// The own identity was clicked: the same detail view, this time with
/// our own key lines in it.
OwnSelected,
ContactImportChanged(String),
ContactImportPressed,
ContactWrite(String),
ContactAccept(String, String),
ContactBlock(String),
ContactUnblock(String),
CopyOwnAddress,
CopyExport,
// Settings
@ -272,6 +273,8 @@ pub struct Carrier {
/// The contact whose details the article pane shows, selected from
/// the sidebar's bottom panel; a message selection clears it.
pub selected_contact: Option<String>,
/// True when the article pane shows our own identity's details.
pub selected_own: bool,
pub compose: Option<Compose>,
/// Composes set aside by a sidebar trip instead of Cancel, kept in
/// the drafts sidecar; the folder only shows once it holds one.
@ -333,6 +336,7 @@ impl Carrier {
lists: vec![Vec::new(), Vec::new(), Vec::new()],
open: None,
selected_contact: None,
selected_own: false,
compose: None,
drafts,
snapshot: None,
@ -519,10 +523,18 @@ impl Carrier {
}
Message::ContactSelected(address) => {
self.stash_draft();
self.selected_own = false;
self.selected_contact = Some(address);
self.open = None;
self.page = Page::Mail;
}
Message::OwnSelected => {
self.stash_draft();
self.selected_own = true;
self.selected_contact = None;
self.open = None;
self.page = Page::Mail;
}
Message::FetchCancelled => {
if self.busy.is_some() {
self.cancel.store(true, Ordering::Relaxed);
@ -661,6 +673,7 @@ impl Carrier {
self.page = Page::Mail;
self.folder = folder;
self.open = None;
self.selected_own = false;
self.selected_contact = None;
return self.refresh().into_iter().collect();
}
@ -668,12 +681,14 @@ impl Carrier {
self.stash_draft();
self.page = page;
self.open = None;
self.selected_own = false;
self.selected_contact = None;
if page == Page::Mail || page == Page::Contacts || page == Page::Settings {
if page == Page::Mail || page == Page::Settings {
return self.refresh().into_iter().collect();
}
}
Message::MessageSelected(folder, id) => {
self.selected_own = false;
self.selected_contact = None;
if folder == Folder::Drafts {
self.open_draft(&id);
@ -866,14 +881,6 @@ impl Carrier {
};
return vec![PendingOp::Accept { address, identity }];
}
Message::CopyOwnAddress => {
if let Some(snap) = &self.snapshot {
let uri = format!("smol://{}/{}", snap.who.address, snap.who.key);
if let Some(display) = gtk::gdk::Display::default() {
display.clipboard().set_text(&uri);
}
}
}
Message::CopyExport => {
if let Some(text) = &self.backup_text {
if let Some(display) = gtk::gdk::Display::default() {
@ -1036,6 +1043,27 @@ mod tests {
assert_eq!(app.busy.as_deref(), Some("fetching"));
}
#[test]
fn own_and_contact_selection_supersede_each_other() {
let mut app = app();
app.handle(Message::OwnSelected);
assert!(app.selected_own);
assert_eq!(app.page, Page::Mail);
assert!(app.open.is_none());
app.handle(Message::ContactSelected("bob@localhost".into()));
assert!(!app.selected_own);
assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost"));
app.handle(Message::OwnSelected);
assert!(app.selected_own);
assert!(app.selected_contact.is_none());
// A folder trip closes the detail, own included.
app.handle(Message::GotoMail(Folder::Sent));
assert!(!app.selected_own);
}
#[test]
fn text_edits_pass_the_busy_guard() {
let mut app = app();

View file

@ -43,6 +43,8 @@ pub struct OpenedMsg {
pub struct Contact {
pub address: String,
pub key: String,
/// The key's short grouped form, the line the detail view shows.
pub fingerprint: String,
pub verified: bool,
/// Some(true) accepted, Some(false) blocked, None never decided.
pub accepted: Option<bool>,
@ -472,6 +474,7 @@ pub fn contacts(store: &Store) -> Result<Vec<Contact>, String> {
.map(|(address, identity, verified, accepted)| Contact {
address,
key: b32(&identity),
fingerprint: fingerprint(&identity),
verified,
accepted: accepted.map(|state| state == 1),
})

View file

@ -213,10 +213,10 @@ pub struct CarrierWidgets {
sidebar_own: gtk::Label,
sidebar_contacts: gtk::ListBox,
// contacts page
own_label: gtk::Label,
qr: gtk::Image,
contacts_list: 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>,
@ -268,7 +268,9 @@ impl CarrierWidgets {
// the actions and the destinations; the bottom panel leads with
// our own identity, carded apart from the contact list.
let sidebar = gtk::Paned::new(Orientation::Vertical);
sidebar.set_position(250);
// 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
@ -337,11 +339,8 @@ impl CarrierWidgets {
sidebar_top.append(&settings_nav);
// Our own identity heads the bottom panel, carded so it reads
// apart from the contacts below, with the copy the sidebar never
// used to offer.
let own_row = gtk::Box::new(Orientation::Horizontal, 6);
own_row.add_css_class("card");
own_row.set_margin_top(6);
// 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);
@ -349,16 +348,51 @@ impl CarrierWidgets {
own_text.set_margin_start(12);
let own_caption = label("you", "caption");
let sidebar_own = label("", "mono heading");
sidebar_own.set_selectable(true);
// 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_copy_small = icon_button("edit-copy-symbolic", "Copy your address");
own_copy_small.set_margin_end(6);
own_row.append(&own_text);
own_row.append(&own_copy_small);
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();
@ -373,7 +407,7 @@ impl CarrierWidgets {
// mail: the split — list above, article below
let paned = gtk::Paned::new(Orientation::Vertical);
paned.set_position(250);
paned.set_position(1);
let list_scroll = gtk::ScrolledWindow::new();
let mail_list = gtk::ListBox::new();
mail_list.set_selection_mode(gtk::SelectionMode::Single);
@ -401,9 +435,44 @@ impl CarrierWidgets {
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(&article_subject);
article_head.append(&subject_row);
article_head.append(&article_meta);
article.append(&article_head);
article.append(&gtk::Separator::new(Orientation::Horizontal));
@ -493,54 +562,6 @@ impl CarrierWidgets {
compose.append(&compose_actions);
stack.add_named(&compose, Some("compose"));
// contacts
let contacts = gtk::Box::new(Orientation::Vertical, 12);
contacts.set_margin_top(12);
contacts.set_margin_bottom(12);
contacts.set_margin_start(12);
contacts.set_margin_end(12);
let own_card = gtk::Box::new(Orientation::Horizontal, 12);
own_card.add_css_class("card");
let own_column = gtk::Box::new(Orientation::Vertical, 6);
own_column.set_hexpand(true);
own_column.set_valign(Align::Center);
own_column.set_margin_top(12);
own_column.set_margin_bottom(12);
own_column.set_margin_start(12);
own_column.append(&label("you", "caption"));
let own_label = label("", "mono");
own_label.set_wrap(true);
own_label.set_wrap_mode(gtk::pango::WrapMode::Char);
own_label.set_selectable(true);
own_column.append(&own_label);
let own_copy = icon_label_button("edit-copy-symbolic", "Copy", "");
own_copy.set_halign(Align::Start);
own_column.append(&own_copy);
own_card.append(&own_column);
let qr = gtk::Image::new();
qr.set_pixel_size(144);
qr.set_margin_top(12);
qr.set_margin_bottom(12);
qr.set_margin_end(12);
own_card.append(&qr);
contacts.append(&own_card);
let import_list = boxed_list();
let contact_import = adw::EntryRow::builder()
.title("Import a smol:// address")
.build();
let import_button = icon_label_button("document-open-symbolic", "Import", "accent");
import_button.set_valign(Align::Center);
contact_import.add_suffix(&import_button);
import_list.append(&contact_import);
contacts.append(&import_list);
let contacts_page_scroll = gtk::ScrolledWindow::new();
contacts_page_scroll.set_vexpand(true);
let contacts_page_list = boxed_list();
contacts_page_list.set_valign(Align::Start);
contacts_page_scroll.set_child(Some(&contacts_page_list));
contacts.append(&contacts_page_scroll);
stack.add_named(&contacts, Some("contacts"));
// settings: the preferences page every GNOME app shows, one group
// per concern, so scrolling, width and margins come for free.
let settings = adw::PreferencesPage::new();
@ -778,7 +799,50 @@ impl CarrierWidgets {
columns.set_start_child(Some(&sidebar));
columns.set_end_child(Some(&toolbar));
columns.set_resize_start_child(false);
columns.set_position(225);
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
});
let toasts = adw::ToastOverlay::new();
toasts.set_child(Some(&columns));
root.set_child(Some(&toasts));
@ -842,8 +906,7 @@ impl CarrierWidgets {
body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer)))
});
own_copy.connect_clicked(input(Message::CopyOwnAddress));
own_copy_small.connect_clicked(input(Message::CopyOwnAddress));
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();
@ -936,9 +999,9 @@ impl CarrierWidgets {
compose_send,
sidebar_own,
sidebar_contacts: contacts_list,
own_label,
qr,
contacts_list: contacts_page_list,
detail_share,
detail_copy,
detail_qr,
identity_values,
offer_group,
offer_row,
@ -1210,7 +1273,6 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
},
Page::Signup => "signup",
Page::Mail => "mail",
Page::Contacts => "contacts",
Page::Settings => "settings",
}
};
@ -1267,6 +1329,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
);
// 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
@ -1275,10 +1341,12 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
.iter()
.find(|c| &c.address == address)
});
match (&model.open, contact_detail) {
(Some((folder, msg)), _) => {
match (&model.open, own_detail, contact_detail) {
(Some((folder, msg)), _, _) => {
w.article_stack.set_visible_child_name("detail");
// A message body is prose; only the contact detail below is a
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() {
@ -1326,15 +1394,35 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
delete.connect_clicked(input(Message::DeletePressed));
w.actions.append(&delete);
}
(None, Some(contact)) => {
(None, 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);
}
(None, _, Some(contact)) => {
w.article_stack.set_visible_child_name("detail");
w.article_view.add_css_class("monospace");
w.article_subject.set_text(&contact.address);
w.article_meta.set_text(contact_state(contact));
w.article_body.set_text(&format!(
"key {}\nfingerprint {}\nsmol://{}/{}",
contact.key, contact.key, contact.address, contact.key
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", "");
@ -1369,8 +1457,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
}
}
}
(None, None) => {
(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);
}
}
@ -1415,26 +1505,6 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender<Carr
.map(|row| row.upcast_ref::<gtk::ListBoxRow>()),
);
}
if let Some(snap) = &model.snapshot {
let uri = format!("smol://{}/{}", snap.who.address, snap.who.key);
w.own_label.set_text(&uri);
if let Some(texture) = qr_texture(&uri) {
w.qr.set_paintable(Some(&texture));
}
}
clear_list(&w.contacts_list);
if let Some(snap) = &model.snapshot {
if snap.contacts.is_empty() {
let empty = adw::ActionRow::builder()
.title("No contacts yet")
.subtitle("Import a smol:// address above to add one.")
.build();
w.contacts_list.append(&empty);
}
for contact in &snap.contacts {
w.contacts_list.append(&contact_row(contact, sender));
}
}
// Settings: the identity rows are fixed, so only their values change.
if let Some(snap) = &model.snapshot {
@ -1653,68 +1723,59 @@ fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow {
item
}
/// One contact on the contacts page: the address over its key, the trust
/// state and the actions that state allows.
fn contact_row(contact: &Contact, sender: &ComponentSender<Carrier>) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(escape(&contact.address))
.subtitle(escape(&contact.key))
.build();
row.set_title_lines(1);
row.set_subtitle_lines(1);
let state = label(contact_state(contact), "caption");
state.set_valign(Align::Center);
row.add_suffix(&state);
let write = icon_button("mail-message-new-symbolic", "Write to this contact");
let write_msg = Message::ContactWrite(contact.address.clone());
let write_sender = sender.clone();
write.connect_clicked(move |_| write_sender.input(write_msg.clone()));
row.add_suffix(&write);
match contact.accepted {
Some(true) => {
let block = icon_button("action-unavailable-symbolic", "Block this contact");
let msg = Message::ContactBlock(contact.address.clone());
let block_sender = sender.clone();
block.connect_clicked(move |_| block_sender.input(msg.clone()));
row.add_suffix(&block);
}
Some(false) => {
let unblock = icon_button("object-select-symbolic", "Unblock this contact");
let msg = Message::ContactUnblock(contact.address.clone());
let unblock_sender = sender.clone();
unblock.connect_clicked(move |_| unblock_sender.input(msg.clone()));
row.add_suffix(&unblock);
}
None => {
let accept = icon_button("object-select-symbolic", "Accept this contact");
let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone());
let accept_sender = sender.clone();
accept.connect_clicked(move |_| accept_sender.input(msg.clone()));
row.add_suffix(&accept);
}
}
row
/// The 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}")
}
/// The own-address QR as an RGBA texture; black on white, the form every
/// scanner expects, taking no cues from the theme.
/// 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 stride = size as usize * 4;
let mut pixels = Vec::with_capacity(size as usize * size as usize * 4);
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 {
let dark = code.get_module(x, y);
let (r, g, b) = if dark { (0, 0, 0) } else { (0xff, 0xff, 0xff) };
pixels.extend_from_slice(&[r, g, b, 0xff]);
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(
size,
size,
dim,
dim,
gtk::gdk::MemoryFormat::R8g8b8a8,
&gtk::glib::Bytes::from_owned(pixels),
stride,