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:
+35
-15
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user