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.
This commit is contained in:
2026-09-28 19:57:37 -04:00
parent ae4e1a0f07
commit 6050a8e703
5 changed files with 155 additions and 56 deletions
+35 -15
View File
@@ -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<Self> {
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,
}
}
+103 -30
View File
@@ -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<f32>) -> 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);
+2 -2
View File
@@ -526,7 +526,7 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc<LibraryController>) {
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<LibraryController>) {
let classes: Vec<dr_thumbs::ThumbSize> = {
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()
};
+14 -8
View File
@@ -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<ThumbSize> {
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();
+1 -1
View File
@@ -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,