From 22c20427f0fb2b78ed81d7ea0dd4cfd82a9ffdad Mon Sep 17 00:00:00 2001 From: randogoth Date: Sat, 3 Oct 2026 09:55:00 +0300 Subject: [PATCH] feat: unify the detail view with QR sharing and default pane layout --- README.md | 2 +- src/app.rs | 50 +++++-- src/ops.rs | 3 + src/view.rs | 365 ++++++++++++++++++++++++++++++---------------------- 4 files changed, 256 insertions(+), 164 deletions(-) diff --git a/README.md b/README.md index d127757..6aa852f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window — - Fetch (with cancel), read, reply and send sealed mail; the inbox, the requests tier and sent copies each keep their own folder. - Composes closed by any sidebar trip are stashed as drafts — kept beside the store, reopened from a folder that appears only once one exists; Cancel is the only way to throw one away. - Accept or block a requester when their mail carried a self-certifying reply address; accept tokens attach to accepted contacts automatically. -- Contacts with verified/tofu marks and smol:// import; your own address as a URI, a QR code and a clipboard line. +- Contacts with verified/tofu marks and smol:// import; your own identity opens in the same detail view, its address in the header bar with a QR share beside it, key, fingerprint and URI below. - Key rotation with a confirm-first step, server pin management, a network timeout, and gsmol backup export/import that never contains the seed. ## Layout diff --git a/src/app.rs b/src/app.rs index 638eb87..260c940 100644 --- a/src/app.rs +++ b/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, + /// True when the article pane shows our own identity's details. + pub selected_own: bool, pub compose: Option, /// 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(); diff --git a/src/ops.rs b/src/ops.rs index 6507c4e..2a3a577 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -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, @@ -472,6 +474,7 @@ pub fn contacts(store: &Store) -> Result, String> { .map(|(address, identity, verified, accepted)| Contact { address, key: b32(&identity), + fingerprint: fingerprint(&identity), verified, accepted: accepted.map(|state| state == 1), }) diff --git a/src/view.rs b/src/view.rs index 7bff2a9..b1248f8 100644 --- a/src/view.rs +++ b/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, @@ -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 "signup", Page::Mail => "mail", - Page::Contacts => "contacts", Page::Settings => "settings", } }; @@ -1267,6 +1329,10 @@ fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender { + 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 { + (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 { + (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()), ); } - 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) -> 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 { + 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,