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.
440 lines
17 KiB
Rust
440 lines
17 KiB
Rust
//! TRACES: FR-MRG-6 | FR-CAT-4
|
|
//! Where each photograph sits in the grid: the one place rows are computed.
|
|
//!
|
|
//! # Slots
|
|
//!
|
|
//! The grid is a lattice of `columns` slots per row, and until panoramas had
|
|
//! 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, 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,
|
|
//! a shift-click's run and a scrub all still mean what they meant.
|
|
//!
|
|
//! Everything that turns an ordinal into a place on screen, or a place on
|
|
//! screen into an ordinal, asks [`Layout`]; the markup draws each cell at
|
|
//! the slot Rust gives it and sizes the scrollbar from [`Layout::total_slots`].
|
|
//!
|
|
//! # What it costs
|
|
//!
|
|
//! Where a wide photograph sits depends on every wide photograph before it,
|
|
//! so the layout is built from the ordinals of all of them in the current
|
|
//! view, not from the loaded window. Those are few — a library has a handful
|
|
//! of panoramas among thousands of frames — and they are read in one query,
|
|
//! and only when what the grid lists changes (`LibraryFacts`), or when the
|
|
//! window finds them out of date. The window itself says how wide each of
|
|
//! its own photographs is: the aspect comes with the cells' own read.
|
|
//!
|
|
//! # On the tablet, and with few columns
|
|
//!
|
|
//! 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 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: 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 {
|
|
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
|
|
/// where `full_width` says so or the columns are too few to put it beside
|
|
/// anything.
|
|
pub fn span_in(natural: usize, columns: usize, full_width: bool) -> usize {
|
|
let columns = columns.max(1);
|
|
if natural <= 1 {
|
|
1
|
|
} else if full_width || columns <= natural {
|
|
columns
|
|
} else {
|
|
natural
|
|
}
|
|
}
|
|
|
|
/// The grid's placement of the current view.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Layout {
|
|
columns: usize,
|
|
total: 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 {
|
|
/// Every photograph one slot: the grid as it was.
|
|
pub fn uniform(columns: usize, total: usize) -> Self {
|
|
Layout {
|
|
columns: columns.max(1),
|
|
total,
|
|
anchors: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Pack a view of `total` photographs whose wide ones are `wide` —
|
|
/// `(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, usize)> = wide
|
|
.iter()
|
|
.filter(|(ordinal, _)| *ordinal < total)
|
|
.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);
|
|
|
|
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, 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, natural));
|
|
next_slot = slot + span;
|
|
next_ordinal = ordinal + 1;
|
|
}
|
|
Layout {
|
|
columns,
|
|
total,
|
|
anchors,
|
|
}
|
|
}
|
|
|
|
/// Whether any photograph takes more than one slot.
|
|
#[cfg(test)]
|
|
pub fn is_uniform(&self) -> bool {
|
|
self.anchors.is_empty()
|
|
}
|
|
|
|
/// The last wide cell at or before `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])
|
|
}
|
|
|
|
/// The slot `ordinal` starts at.
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
_ => 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,
|
|
}
|
|
}
|
|
|
|
/// The slot just past the last photograph: what the scrollbar spans.
|
|
pub fn total_slots(&self) -> usize {
|
|
self.slot_of(self.total)
|
|
}
|
|
|
|
/// The first photograph whose cell ends after `slot` — the one a view
|
|
/// whose first row begins at `slot` shows first. `total` past the end.
|
|
pub fn ordinal_at(&self, slot: usize) -> usize {
|
|
// `slot_of(n) + span_of(n)` rises with `n`, so the answer is where
|
|
// it first passes `slot`.
|
|
let (mut lo, mut hi) = (0usize, self.total);
|
|
while lo < hi {
|
|
let mid = lo + (hi - lo) / 2;
|
|
if self.slot_of(mid) + self.span_of(mid) > slot {
|
|
hi = mid;
|
|
} else {
|
|
lo = mid + 1;
|
|
}
|
|
}
|
|
lo
|
|
}
|
|
|
|
/// The photograph a step of `rows` rows from `ordinal` lands on: the
|
|
/// one under the same column, or where that falls in the gap a wide cell
|
|
/// left at the end of a row, the last one in that row. Clamped to the
|
|
/// view.
|
|
pub fn step_rows(&self, ordinal: usize, rows: isize) -> usize {
|
|
if self.total == 0 {
|
|
return 0;
|
|
}
|
|
let rows_total = self.total_slots().div_ceil(self.columns) as isize;
|
|
let from = self.slot_of(ordinal.min(self.total - 1));
|
|
let row = (from / self.columns) as isize + rows;
|
|
if row < 0 {
|
|
return 0;
|
|
}
|
|
if row >= rows_total {
|
|
return self.total - 1;
|
|
}
|
|
let target = row as usize * self.columns + from % self.columns;
|
|
// The last photograph starting at or before the target slot.
|
|
let after = self.ordinal_starting_after(target);
|
|
after.saturating_sub(1).min(self.total - 1)
|
|
}
|
|
|
|
/// The first photograph that starts after `slot`.
|
|
fn ordinal_starting_after(&self, slot: usize) -> usize {
|
|
let (mut lo, mut hi) = (0usize, self.total);
|
|
while lo < hi {
|
|
let mid = lo + (hi - lo) / 2;
|
|
if self.slot_of(mid) > slot {
|
|
hi = mid;
|
|
} else {
|
|
lo = mid + 1;
|
|
}
|
|
}
|
|
lo
|
|
}
|
|
|
|
/// Whether the window starting at `offset` agrees with this layout
|
|
/// about which of its photographs are wide — `natural[i]` being the
|
|
/// natural span of the window's row `i`, as its own read found it. A
|
|
/// layout built before a reorder, a merge or a change of aspect does
|
|
/// not, and is read again.
|
|
pub fn agrees_with(&self, offset: usize, natural: &[usize], full_width: bool) -> bool {
|
|
natural
|
|
.iter()
|
|
.enumerate()
|
|
.all(|(i, &n)| self.span_of(offset + i) == span_in(n, self.columns, full_width))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The grid drawn as text, one row per line: `.` a photograph, a letter
|
|
/// for each slot of a wide one, `_` an empty slot.
|
|
fn draw(layout: &Layout) -> Vec<String> {
|
|
let mut slots = vec!['_'; layout.total_slots()];
|
|
let mut wide = b'A';
|
|
for n in 0..layout.total {
|
|
let (s, span) = (layout.slot_of(n), layout.span_of(n));
|
|
for slot in &mut slots[s..s + span] {
|
|
assert_eq!(*slot, '_', "photograph {n} overlaps another at slot {s}");
|
|
*slot = if span > 1 { wide as char } else { '.' };
|
|
}
|
|
if span > 1 {
|
|
wide += 1;
|
|
}
|
|
}
|
|
slots
|
|
.chunks(layout.columns)
|
|
.map(|r| r.iter().collect())
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_grid_of_ordinary_photographs_is_the_grid_it_always_was() {
|
|
let l = Layout::pack(4, 10, false, &[]);
|
|
assert!(l.is_uniform());
|
|
assert_eq!(l, Layout::uniform(4, 10));
|
|
assert_eq!(draw(&l), ["....", "....", ".."]);
|
|
for n in 0..10 {
|
|
assert_eq!(l.slot_of(n), n);
|
|
}
|
|
assert_eq!(l.ordinal_at(8), 8);
|
|
}
|
|
|
|
#[test]
|
|
fn a_wide_photograph_takes_two_slots_and_the_rest_move_along() {
|
|
// Photograph 2 is a 2:1 panorama, 5 a 4:1.
|
|
let l = Layout::pack(5, 10, false, &[(2, 2), (5, 3)]);
|
|
assert_eq!(draw(&l), ["..AA.", ".BBB.", "..."]);
|
|
assert_eq!(l.slot_of(3), 4);
|
|
assert_eq!(l.slot_of(5), 6);
|
|
assert_eq!(l.span_of(5), 3);
|
|
assert_eq!(l.total_slots(), 13);
|
|
}
|
|
|
|
#[test]
|
|
fn a_wide_photograph_that_does_not_fit_starts_the_next_row() {
|
|
// Three ordinary frames fill three of four columns; the panorama
|
|
// after them needs two, so it opens the next row and the fourth
|
|
// column of the first stays empty. Nothing later is moved up into
|
|
// it: the grid still reads in capture order.
|
|
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
|
assert_eq!(draw(&l), ["..._", "AAA.", "..."]);
|
|
assert_eq!(l.slot_of(3), 4);
|
|
assert_eq!(l.slot_of(4), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn with_too_few_columns_a_wide_photograph_takes_the_whole_row() {
|
|
let l = Layout::pack(3, 5, false, &[(1, 3)]);
|
|
assert_eq!(draw(&l), [".__", "AAA", "..."]);
|
|
let l = Layout::pack(2, 4, false, &[(1, 2)]);
|
|
assert_eq!(draw(&l), ["._", "AA", ".."]);
|
|
// One column: nothing to span.
|
|
let l = Layout::pack(1, 3, false, &[(1, 3)]);
|
|
assert!(l.is_uniform());
|
|
}
|
|
|
|
#[test]
|
|
fn on_the_tablet_a_wide_photograph_takes_the_whole_row() {
|
|
let l = Layout::pack(5, 8, true, &[(2, 2)]);
|
|
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)]);
|
|
assert_eq!(draw(&l), ["AABB_", "CCDDD", "..."]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_visible_slot_names_the_photograph_there() {
|
|
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
|
// Row 1 begins at slot 4, the panorama.
|
|
assert_eq!(l.ordinal_at(4), 3);
|
|
// The empty slot at the end of row 0 belongs to nothing: the next
|
|
// photograph is the panorama.
|
|
assert_eq!(l.ordinal_at(3), 3);
|
|
// Slot 5 is inside the panorama.
|
|
assert_eq!(l.ordinal_at(5), 3);
|
|
assert_eq!(l.ordinal_at(8), 5);
|
|
// Past the end.
|
|
assert_eq!(l.ordinal_at(40), 8);
|
|
// And every ordinal is found again at its own slot.
|
|
for n in 0..8 {
|
|
assert_eq!(l.ordinal_at(l.slot_of(n)), n);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn up_and_down_move_by_rows_not_by_a_row_of_ordinals() {
|
|
// ..._
|
|
// AAA.
|
|
// ....
|
|
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
|
// Down from the second frame lands in the panorama under it.
|
|
assert_eq!(l.step_rows(1, 1), 3);
|
|
// Down from the panorama, to the frame under its first column.
|
|
assert_eq!(l.step_rows(3, 1), 5);
|
|
// Up from the frame beside the panorama: the gap above it is empty,
|
|
// so the last frame of that row.
|
|
assert_eq!(l.step_rows(4, -1), 2);
|
|
// Up from under the panorama's middle, into the panorama.
|
|
assert_eq!(l.step_rows(6, -1), 3);
|
|
// Clamped at both ends.
|
|
assert_eq!(l.step_rows(1, -3), 0);
|
|
assert_eq!(l.step_rows(1, 9), 7);
|
|
// A page is several rows at once.
|
|
assert_eq!(l.step_rows(0, 2), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn a_window_that_disagrees_with_the_layout_is_caught() {
|
|
let l = Layout::pack(4, 8, false, &[(3, 3)]);
|
|
assert!(l.agrees_with(2, &[1, 3, 1], false));
|
|
// The panorama has moved to ordinal 4: the window sees it there.
|
|
assert!(!l.agrees_with(2, &[1, 1, 3], false));
|
|
// A photograph that became wide.
|
|
assert!(!l.agrees_with(0, &[2], false));
|
|
}
|
|
|
|
#[test]
|
|
fn spans_by_aspect() {
|
|
assert_eq!(natural_span(None), 1);
|
|
assert_eq!(natural_span(Some(1.5)), 1);
|
|
// 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);
|
|
assert_eq!(span_in(2, 6, true), 6);
|
|
assert_eq!(span_in(1, 6, true), 1);
|
|
}
|
|
}
|