feat: unify the detail view with QR sharing and default pane layout
This commit is contained in:
parent
84650e50bd
commit
22c20427f0
4 changed files with 256 additions and 164 deletions
50
src/app.rs
50
src/app.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
365
src/view.rs
365
src/view.rs
|
|
@ -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(>k::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: >k::Image, uri: &str) {
|
||||
let Some(texture) = qr_texture(uri) else {
|
||||
return;
|
||||
};
|
||||
let dim = texture.intrinsic_width();
|
||||
image.set_size_request(dim, dim);
|
||||
image.set_pixel_size(dim);
|
||||
image.set_paintable(Some(&texture));
|
||||
}
|
||||
|
||||
/// The QR as an RGBA texture, black on white, taking no cues from the
|
||||
/// theme. Each module is a whole number of pixels and the four quiet
|
||||
/// modules the spec asks for are included, so nothing scales the texture
|
||||
/// and no softening ever lands on an edge. The scale targets ~210px: a
|
||||
/// popover that does not fit below its anchor is not placed at all.
|
||||
fn qr_texture(uri: &str) -> Option<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,
|
||||
>k::glib::Bytes::from_owned(pixels),
|
||||
stride,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue