Give a panorama a wide cell in the grid

A 4:1 composite drawn in one square cell is a strip a few pixels high.
A photograph about twice as wide as it is tall (1.9 and up) now spans
two columns, three from 2.9, with a thumbnail class of its own whose
long edge is sized for that width; on the tablet, or where the columns
are too few to put it beside anything, it takes the whole row.

Rows are computed in one place, library_ui::layout. The grid is a
lattice of slots: each cell is drawn at the slot Rust gives it, and a
wide cell that would not fit in what is left of a row starts the next
one, leaving the gap empty so the grid still reads in capture order.
The scrollbar spans the slots, a scroll reports a slot that the layout
turns back into an ordinal, and scrubs, restores and the cursor go
through the same conversion. Up and down step by rows through the
layout rather than by a row's worth of ordinals; left and right, a
shift-click's run, burst folding and the timeline are ordinal-based
and unchanged.

The window's own read carries each photograph's w and h, so the cells
know their shape with no query per cell. Where the wide ones sit in
the whole list is one query, run when what the grid lists changes or
when a window finds the layout out of date, and a library with no
panorama answers it from a partial index created on first use
(images_wide), not a schema bump. The merge makes the wide thumbnail
for a wide composite along with the others.
This commit is contained in:
2026-09-28 19:57:37 -04:00
parent ce5b7d72e3
commit ae4e1a0f07
13 changed files with 878 additions and 89 deletions
+366
View File
@@ -0,0 +1,366 @@
//! 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 slots side by side, three for the
//! widest, 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 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;
/// How many slots a photograph of this aspect would like, before the columns
/// have their say.
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,
}
}
/// 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 and how many
/// it takes. Ascending in both.
anchors: Vec<(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)> = wide
.iter()
.filter(|(ordinal, _)| *ordinal < total)
.map(|&(ordinal, natural)| (ordinal, span_in(natural, columns, full_width)))
.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) 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));
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)> {
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 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 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);
assert_eq!(natural_span(Some(1.95)), 2);
assert_eq!(natural_span(Some(2.5)), 2);
assert_eq!(natural_span(Some(3.8)), 3);
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);
}
}