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

@ -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,