//! 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) -> 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 { 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); } }