From 6050a8e703ad8d7e62edb1bf9f4c6ee1dedbc2ea Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 28 Sep 2026 19:33:43 -0400 Subject: [PATCH] Size a panorama's cell and thumbnail by class: two, three or four columns One lookup, natural_span, maps a photograph's aspect to the columns its cell spans, and the same number names its thumbnail class, Wide2, Wide3 or Wide4, 512 pixels of long edge per column, so a 4:1 panorama is as sharp across four columns as a frame is in one. The boundaries are where the two neighbouring cells would leave the same share of themselves undrawn, sqrt(s(s+1)): 2.45 and 3.46, with the first at 1.9 so a 3:2 frame stays a frame. A grid too narrow for the class falls back to the widest that fits, the tablet gives the whole row, and a cell asks for the class it is actually drawn at: its span, but never more than its own class. The merge renders each wide class up to the composite's own, which covers every fallback. --- core/dr-thumbs/src/lib.rs | 50 +++++++---- ui/dr-ui/src/library_ui/layout.rs | 133 +++++++++++++++++++++++------- ui/dr-ui/src/library_ui/window.rs | 4 +- ui/dr-ui/src/merge_thumbs.rs | 22 +++-- ui/dr-ui/ui/library.slint | 2 +- 5 files changed, 155 insertions(+), 56 deletions(-) diff --git a/core/dr-thumbs/src/lib.rs b/core/dr-thumbs/src/lib.rs index e04e62e..7c9f9c3 100644 --- a/core/dr-thumbs/src/lib.rs +++ b/core/dr-thumbs/src/lib.rs @@ -73,33 +73,51 @@ pub enum ThumbSize { /// where something actually asks for that detail. Large = 1, /// TRACES: FR-MRG-6 - /// A panorama's cell, which spans two or three columns — or the whole - /// row on the tablet — at the height of one. Its long edge is sized for - /// that width rather than for a square: the grid class of a 4:1 - /// panorama is 256×64, a smear across three cells. Made only for - /// photographs that wide, so it costs a library nothing else. - Wide = 2, + /// A panorama's cell two columns wide, at the height of one: long edge + /// sized for the width rather than for a square, since the grid class + /// of a 4:1 panorama is 256×64 — a smear across the cells. The wide + /// classes are made only for photographs that wide, so they cost a + /// library nothing else. + Wide2 = 2, + /// Three columns. + Wide3 = 3, + /// Four columns: the widest class. + Wide4 = 4, } +/// The most columns a wide class spans. +pub const WIDEST_SPAN: usize = 4; + impl ThumbSize { - /// Long edge in pixels. + /// Long edge in pixels. A wide class is 512 per column it spans, which + /// keeps its short edge near the large class's for the aspect that + /// class is chosen for — sharp at the largest cells on a 2x display. pub fn edge(self) -> u32 { match self { ThumbSize::Grid => 256, ThumbSize::Large => 1024, - ThumbSize::Wide => 2048, + ThumbSize::Wide2 => 1024, + ThumbSize::Wide3 => 1536, + ThumbSize::Wide4 => 2048, + } + } + + /// The wide class for a cell `span` columns wide: `None` for one + /// column, and the widest class for anything past it. + pub fn wide(span: usize) -> Option { + match span { + 0 | 1 => None, + 2 => Some(ThumbSize::Wide2), + 3 => Some(ThumbSize::Wide3), + _ => Some(ThumbSize::Wide4), } } /// The class for a cell `span` columns wide whose columns are drawn at - /// `pixels`: the wide class for any cell wider than one, whatever the + /// `pixels`: a wide class for any cell wider than one, whatever the /// zoom, since its height is a column's and its width is not. pub fn for_span(span: usize, pixels: u32) -> Self { - if span > 1 { - ThumbSize::Wide - } else { - Self::for_cell(pixels) - } + Self::wide(span).unwrap_or_else(|| Self::for_cell(pixels)) } /// The smallest class that can fill a cell of this size without visibly @@ -121,7 +139,9 @@ impl ThumbSize { match v { 0 => Some(ThumbSize::Grid), 1 => Some(ThumbSize::Large), - 2 => Some(ThumbSize::Wide), + 2 => Some(ThumbSize::Wide2), + 3 => Some(ThumbSize::Wide3), + 4 => Some(ThumbSize::Wide4), _ => None, } } diff --git a/ui/dr-ui/src/library_ui/layout.rs b/ui/dr-ui/src/library_ui/layout.rs index 0a84fd9..cde0420 100644 --- a/ui/dr-ui/src/library_ui/layout.rs +++ b/ui/dr-ui/src/library_ui/layout.rs @@ -7,8 +7,9 @@ //! cells of their own a photograph's slot was its ordinal — row //! `ordinal / columns`, column `ordinal % columns`, spelled out in half a //! dozen places in the markup and in Rust. A photograph at least about twice -//! as wide as it is tall now takes two slots side by side, three for the -//! widest, and a slot is no longer an ordinal: every cell after a wide one +//! as wide as it is tall now takes two, three or four slots side by side — +//! the class [`natural_span`] picks from its aspect, which also picks its +//! thumbnail's size class — and a slot is no longer an ordinal: every cell after a wide one //! is pushed along, and a wide cell that would not fit in what is left of a //! row starts the next one and leaves the rest of its row empty. Reading //! order is kept — nothing later is moved up into the gap — so the arrows, @@ -33,20 +34,29 @@ //! A wide photograph takes the whole row there: on a touch-first device //! always, and anywhere the columns are too few to put it beside anything. -/// The aspect from which a photograph takes two slots: about 2:1, a little -/// under so a 2:1 crop that came out a pixel short still counts. -pub const WIDE_ASPECT: f32 = 1.9; -/// From which it takes three. -pub const EXTRA_WIDE_ASPECT: f32 = 2.9; +/// The aspect from which a photograph takes more than one slot, and the +/// classes above it: `SPAN_FROM[k]` is where a span of `k + 2` begins. +/// +/// # Why these +/// +/// A cell `s` slots wide is about `s:1`, and the thumbnail is fitted inside +/// it, so a photograph of aspect `a` between `s` and `s + 1` either fills +/// the width of an `s` cell and leaves `s / a` of it drawn, or the height of +/// an `s + 1` cell and leaves `a / (s + 1)`. The two are equal at +/// `a = √(s(s+1))`: 2.45 between two and three, 3.46 between three and four. +/// The first step is the exception, at 1.9 rather than √2: a 3:2 frame is a +/// photograph, not a panorama, and a 2:1 crop a pixel short of 2 still is. +pub const SPAN_FROM: [f32; 3] = [1.9, 2.45, 3.46]; +/// TRACES: FR-MRG-6 /// How many slots a photograph of this aspect would like, before the columns -/// have their say. +/// have their say: 1, or 2, 3 or 4 — the one lookup both the packing and +/// the thumbnail's size class (`ThumbSize::wide`) are chosen from. pub fn natural_span(aspect: Option) -> usize { - match aspect { - Some(a) if a >= EXTRA_WIDE_ASPECT => 3, - Some(a) if a >= WIDE_ASPECT => 2, - _ => 1, - } + let Some(a) = aspect else { + return 1; + }; + 1 + SPAN_FROM.iter().take_while(|from| a >= **from).count() } /// How many slots it gets in a grid of `columns`: its own, or the whole row @@ -68,9 +78,9 @@ pub fn span_in(natural: usize, columns: usize, full_width: bool) -> usize { pub struct Layout { columns: usize, total: usize, - /// Each wide photograph: its ordinal, the slot it starts at and how many - /// it takes. Ascending in both. - anchors: Vec<(usize, usize, usize)>, + /// Each wide photograph: its ordinal, the slot it starts at, how many + /// it takes, and how many it would like. Ascending in the first two. + anchors: Vec<(usize, usize, usize, usize)>, } impl Layout { @@ -87,24 +97,24 @@ impl Layout { /// `(ordinal, natural span)`, in any order — into rows of `columns`. pub fn pack(columns: usize, total: usize, full_width: bool, wide: &[(usize, usize)]) -> Self { let columns = columns.max(1); - let mut wide: Vec<(usize, usize)> = wide + let mut wide: Vec<(usize, usize, usize)> = wide .iter() .filter(|(ordinal, _)| *ordinal < total) - .map(|&(ordinal, natural)| (ordinal, span_in(natural, columns, full_width))) - .filter(|(_, span)| *span > 1) + .map(|&(ordinal, natural)| (ordinal, span_in(natural, columns, full_width), natural)) + .filter(|(_, span, _)| *span > 1) .collect(); wide.sort_unstable(); - wide.dedup_by_key(|(ordinal, _)| *ordinal); + wide.dedup_by_key(|(ordinal, _, _)| *ordinal); let mut anchors = Vec::with_capacity(wide.len()); // The slot and ordinal just past the last wide cell placed. let (mut next_slot, mut next_ordinal) = (0usize, 0usize); - for (ordinal, span) in wide { + for (ordinal, span, natural) in wide { let mut slot = next_slot + (ordinal - next_ordinal); if slot % columns + span > columns { slot = slot.div_ceil(columns) * columns; } - anchors.push((ordinal, slot, span)); + anchors.push((ordinal, slot, span, natural)); next_slot = slot + span; next_ordinal = ordinal + 1; } @@ -122,8 +132,8 @@ impl Layout { } /// The last wide cell at or before `ordinal`. - fn anchor_before(&self, ordinal: usize) -> Option<&(usize, usize, usize)> { - let at = self.anchors.partition_point(|(o, _, _)| *o <= ordinal); + fn anchor_before(&self, ordinal: usize) -> Option<&(usize, usize, usize, usize)> { + let at = self.anchors.partition_point(|(o, _, _, _)| *o <= ordinal); at.checked_sub(1).map(|i| &self.anchors[i]) } @@ -131,15 +141,26 @@ impl Layout { pub fn slot_of(&self, ordinal: usize) -> usize { match self.anchor_before(ordinal) { None => ordinal, - Some(&(o, slot, _)) if o == ordinal => slot, - Some(&(o, slot, span)) => slot + span + (ordinal - o - 1), + Some(&(o, slot, _, _)) if o == ordinal => slot, + Some(&(o, slot, span, _)) => slot + span + (ordinal - o - 1), } } /// How many slots `ordinal` takes. pub fn span_of(&self, ordinal: usize) -> usize { match self.anchor_before(ordinal) { - Some(&(o, _, span)) if o == ordinal => span, + Some(&(o, _, span, _)) if o == ordinal => span, + _ => 1, + } + } + + /// The columns' worth of thumbnail `ordinal`'s cell draws: its span, + /// but no more than its own class — a 2:1 panorama given the whole row + /// on the tablet is fitted to the row's height, and is drawn two + /// columns wide in it. + pub fn class_span(&self, ordinal: usize) -> usize { + match self.anchor_before(ordinal) { + Some(&(o, _, span, natural)) if o == ordinal => span.min(natural), _ => 1, } } @@ -293,6 +314,53 @@ mod tests { assert_eq!(draw(&l), ["..___", "AAAAA", "....."]); } + #[test] + fn at_each_class_boundary_the_cell_left_empty_is_the_smaller() { + // At `a = √(s(s+1))` an `s` cell and an `s + 1` one leave the same + // share undrawn; either side, the class chosen leaves less. + let drawn = |a: f32, s: usize| (s as f32 / a).min(a / s as f32); + for (k, from) in SPAN_FROM.iter().enumerate().skip(1) { + let s = k + 1; + for a in [from - 0.05, from + 0.05] { + let chosen = natural_span(Some(a)); + let other = if chosen == s { s + 1 } else { s }; + assert!( + drawn(a, chosen) >= drawn(a, other), + "{a}: {chosen} draws {} and {other} {}", + drawn(a, chosen), + drawn(a, other) + ); + } + } + } + + #[test] + fn a_row_of_every_class_packs_in_reading_order() { + // Six columns: a 2:1, a frame, a 3:1, then a 4:1 that does not fit + // beside them, a frame, a 5:1 that is still four, and a 2:1 beside + // it. + let aspects = [2.0, 1.5, 3.0, 4.0, 1.5, 5.0, 2.0, 1.5]; + let wide: Vec<(usize, usize)> = aspects + .iter() + .enumerate() + .map(|(n, a)| (n, natural_span(Some(*a)))) + .filter(|(_, s)| *s > 1) + .collect(); + let l = Layout::pack(6, aspects.len(), false, &wide); + assert_eq!(draw(&l), ["AA.BBB", "CCCC._", "DDDDEE", "."]); + assert_eq!(l.class_span(3), 4); + assert_eq!(l.class_span(5), 4); + // With four columns the 4:1s take whole rows and nothing else moves + // out of order. + let l = Layout::pack(4, aspects.len(), false, &wide); + assert_eq!(draw(&l), ["AA._", "BBB_", "CCCC", ".___", "DDDD", "EE."]); + // On the tablet every wide one takes the row, and its thumbnail is + // still its own class. + let l = Layout::pack(6, aspects.len(), true, &wide); + assert_eq!(l.span_of(0), 6); + assert_eq!(l.class_span(0), 2); + } + #[test] fn wide_photographs_side_by_side_and_back_to_back() { let l = Layout::pack(5, 7, false, &[(0, 2), (1, 2), (2, 2), (3, 3)]); @@ -354,9 +422,14 @@ mod tests { fn spans_by_aspect() { assert_eq!(natural_span(None), 1); assert_eq!(natural_span(Some(1.5)), 1); - assert_eq!(natural_span(Some(1.95)), 2); - assert_eq!(natural_span(Some(2.5)), 2); - assert_eq!(natural_span(Some(3.8)), 3); + // The class boundaries, each side. + assert_eq!(natural_span(Some(1.89)), 1); + assert_eq!(natural_span(Some(1.9)), 2); + assert_eq!(natural_span(Some(2.44)), 2); + assert_eq!(natural_span(Some(2.45)), 3); + assert_eq!(natural_span(Some(3.45)), 3); + assert_eq!(natural_span(Some(3.46)), 4); + assert_eq!(natural_span(Some(9.0)), 4, "four is the widest"); assert_eq!(span_in(2, 6, false), 2); assert_eq!(span_in(3, 3, false), 3); assert_eq!(span_in(3, 2, false), 2); diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs index 72a7e4e..5471d4d 100644 --- a/ui/dr-ui/src/library_ui/window.rs +++ b/ui/dr-ui/src/library_ui/window.rs @@ -526,7 +526,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { let Some(made) = merged.get(&c.image_id) else { continue; }; - let wanted = dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels); + let wanted = dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels); if held .get(&c.image_id) .is_some_and(|h| h.class == Some(wanted)) @@ -755,7 +755,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { let classes: Vec = { let layout = ctl.layout.borrow(); (0..ctl.paths.borrow().len()) - .map(|i| dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels)) + .map(|i| dr_thumbs::ThumbSize::for_span(layout.class_span(offset + i), cell_pixels)) .collect() }; diff --git a/ui/dr-ui/src/merge_thumbs.rs b/ui/dr-ui/src/merge_thumbs.rs index 52ed330..9d05d31 100644 --- a/ui/dr-ui/src/merge_thumbs.rs +++ b/ui/dr-ui/src/merge_thumbs.rs @@ -116,14 +116,14 @@ impl Reduced { } /// Which size classes a composite of this shape is thumbnailed at: the two -/// every photograph has, and the wide one where the grid will give it a -/// wide cell. +/// every photograph has, and the wide classes its cell can be drawn at — +/// its own, from the one lookup the grid packs by, and each narrower one a +/// grid with too few columns falls back to. pub fn classes(width: u32, height: u32) -> Vec { let aspect = width as f32 / height.max(1) as f32; + let natural = crate::library_ui::layout::natural_span(Some(aspect)); let mut out = vec![ThumbSize::Grid, ThumbSize::Large]; - if crate::library_ui::layout::natural_span(Some(aspect)) > 1 { - out.push(ThumbSize::Wide); - } + out.extend((2..=natural).filter_map(ThumbSize::wide)); out } @@ -198,8 +198,14 @@ mod tests { #[test] fn a_panorama_is_thumbnailed_for_its_wide_cell_and_a_squat_merge_is_not() { - assert_eq!(classes(22_000, 5_600).last(), Some(&ThumbSize::Wide)); - assert!(!classes(9_000, 6_000).contains(&ThumbSize::Wide)); + use ThumbSize::*; + assert_eq!( + classes(22_000, 5_600), + [Grid, Large, Wide2, Wide3, Wide4], + "a 3.9:1 panorama spans four" + ); + assert_eq!(classes(8_000, 4_000), [Grid, Large, Wide2]); + assert_eq!(classes(9_000, 6_000), [Grid, Large]); } #[test] @@ -359,7 +365,7 @@ mod tests { &header, Some(crop), decoder, - &[ThumbSize::Grid, ThumbSize::Large, ThumbSize::Wide], + &classes(crop.width, crop.height), ) .unwrap(); diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index 8093ed6..63fbb09 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -486,7 +486,7 @@ export struct LibraryCell { // TRACES: FR-MRG-6 // Where the cell sits in the grid's lattice of slots — row // `slot / columns`, column `slot % columns` — and how many slots it - // takes: one, or two or three for a panorama, or the whole row. Rust's + // takes: one, or two, three or four for a panorama, or the whole row. Rust's // layout (`library_ui::layout`) is the one place these are computed. slot: int, span: int,