diff --git a/core/dr-thumbs/src/lib.rs b/core/dr-thumbs/src/lib.rs index 351af9e..e04e62e 100644 --- a/core/dr-thumbs/src/lib.rs +++ b/core/dr-thumbs/src/lib.rs @@ -72,6 +72,13 @@ pub enum ThumbSize { /// Zoomed cells, the loupe, and the filmstrip. ~45 KB each, fetched only /// 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, } impl ThumbSize { @@ -80,6 +87,18 @@ impl ThumbSize { match self { ThumbSize::Grid => 256, ThumbSize::Large => 1024, + ThumbSize::Wide => 2048, + } + } + + /// 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 + /// 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) } } @@ -102,15 +121,13 @@ impl ThumbSize { match v { 0 => Some(ThumbSize::Grid), 1 => Some(ThumbSize::Large), + 2 => Some(ThumbSize::Wide), _ => None, } } fn from_i64(v: i64) -> Self { - match v { - 1 => ThumbSize::Large, - _ => ThumbSize::Grid, - } + Self::from_stored(v).unwrap_or(ThumbSize::Grid) } } diff --git a/ui/dr-ui/src/library/cells.rs b/ui/dr-ui/src/library/cells.rs index 783a91e..c4babd5 100644 --- a/ui/dr-ui/src/library/cells.rs +++ b/ui/dr-ui/src/library/cells.rs @@ -39,7 +39,7 @@ pub(super) fn uncollapsed(image: &str) -> String { } /// One grid cell's data, read from the catalog. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct LibraryCell { pub image_id: i64, pub name: String, @@ -53,6 +53,10 @@ pub struct LibraryCell { pub metadata_state: u8, /// UTC seconds, once EXIF has been read. pub captured_at: Option, + /// TRACES: FR-MRG-6 + /// Width over height, where the catalog knows both: what decides whether + /// the cell spans columns. Read with the window, never per cell. + pub aspect: Option, } /// Read a window of cells out of the catalog. @@ -346,6 +350,94 @@ pub fn ordinal_of_path( } } +/// TRACES: FR-MRG-6 +/// Which rows of the catalog are wide enough to span columns, spelled as +/// [`crate::library_ui`]'s layout counts them: `w / h >= 1.9`, in integers +/// so the index below can be matched term for term. Change one, change the +/// other — `the_wide_predicate_is_the_layouts` holds them together. +pub(super) const WIDE: &str = "w * 10 >= h * 19"; + +/// Make sure the partial index of wide photographs exists. +/// +/// Created on first use rather than by a migration: it holds only the +/// panoramas, it changes nothing an older build reads, and a schema bump +/// would make every older peer refuse this catalog's snapshot. Keyed on the +/// grid's ordering, so the planner can answer "is there any?" from it alone. +fn ensure_wide_index(catalog: &Catalog) -> Result<(), dr_catalog::CatalogError> { + catalog.connection().execute_batch(&format!( + "CREATE INDEX IF NOT EXISTS images_wide + ON images(captured_at, source_ref) WHERE {WIDE}" + ))?; + Ok(()) +} + +/// TRACES: FR-MRG-6 +/// The photographs in the grid's current list that are wide enough to span +/// columns: each one's ordinal and aspect, in order. +/// +/// What the layout places them from. Answered from the small side first: a +/// library with no panorama in it — the reference library, and every one +/// until a merge writes a composite — is told so by one probe of the +/// partial index, and nothing else runs. Otherwise one pass over the list, +/// numbering it through the ordering the cells are read with, exactly as +/// [`ordinal_of_path`] does and for its reason; it runs when what the grid +/// lists changes, not per scroll. +pub fn wide_ordinals( + catalog: &Catalog, + scope: Option, + filter: &RatingFilter, + trash: bool, +) -> Result, dr_catalog::CatalogError> { + ensure_wide_index(catalog)?; + let any: bool = catalog.connection().query_row( + &format!("SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})"), + [], + |r| r.get(0), + )?; + if !any { + return Ok(Vec::new()); + } + let (inner, params) = if trash { + ( + format!( + "SELECT i.w AS w, i.h AS h, + row_number() OVER ({TRASH_ORDER}) - 1 AS ord + FROM images i + WHERE {TRASHED}" + ), + Vec::new(), + ) + } else { + let (clause, scope_params) = scope_clause(catalog, scope)?; + let rated = filter.sql(); + let folded = uncollapsed("i"); + let (order, mut params) = grid_order_for(catalog, scope); + // The window's parameters first — see `ordinal_of_path`. + params.extend(scope_params); + ( + format!( + "SELECT i.w AS w, i.h AS h, + row_number() OVER ({order}) - 1 AS ord + FROM images i + WHERE {VISIBLE}{rated}{folded}{clause}" + ), + params, + ) + }; + let mut stmt = catalog.connection().prepare(&format!( + "SELECT ord, w, h FROM ({inner}) WHERE {WIDE} ORDER BY ord" + ))?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(0)?.max(0) as usize, + r.get::<_, i64>(1)? as f32 / r.get::<_, i64>(2)?.max(1) as f32, + )) + })? + .collect::, _>>()?; + Ok(rows) +} + /// The columns every windowed read selects, in the order [`row_to_cell`] reads /// them. /// @@ -353,7 +445,7 @@ pub fn ordinal_of_path( /// the one column that is *not* here stays conspicuous. See /// [`attach_file_ids`] for why the server's file id is fetched separately. pub(super) const CELL_COLUMNS: &str = - "i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at"; + "i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at, i.w, i.h"; /// Shared row mapping, so the scoped and unscoped queries cannot drift. /// @@ -368,6 +460,10 @@ pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result { size: r.get::<_, Option>(2)?.unwrap_or(0) as u64, metadata_state: r.get::<_, i64>(3)? as u8, captured_at: r.get(4)?, + aspect: match (r.get::<_, Option>(5)?, r.get::<_, Option>(6)?) { + (Some(w), Some(h)) if w > 0 && h > 0 => Some(w as f32 / h as f32), + _ => None, + }, }) } @@ -944,6 +1040,93 @@ mod tests { // --- the trash view (FR-CAT-15) --------------------------------------- /// Mark one image trashed, at a given instant. + fn shaped(catalog: &Catalog, path: &str, w: i64, h: i64) { + let n = catalog + .connection() + .execute( + "UPDATE images SET w = ?2, h = ?3 WHERE source_ref = ?1", + rusqlite::params![path, w, h], + ) + .unwrap(); + assert_eq!(n, 1, "fixture should have shaped exactly {path}"); + } + + #[test] + fn the_wide_photographs_are_found_at_their_ordinals() { + let catalog = with_images(20); + assert!( + wide_ordinals(&catalog, None, &RatingFilter::default(), false) + .unwrap() + .is_empty() + ); + shaped(&catalog, "PhotosRaw/img005.CR2", 4000, 1000); + shaped(&catalog, "PhotosRaw/img012.CR2", 3000, 1400); + shaped(&catalog, "PhotosRaw/img013.CR2", 3000, 2000); + let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap(); + assert_eq!( + wide.iter().map(|(o, _)| *o).collect::>(), + vec![5, 12], + "{wide:?}" + ); + assert!((wide[0].1 - 4.0).abs() < 1e-6); + + // The ordinals are the grid's: a frame trashed before them moves + // them up, and in the trash only the trash counts. + trash_at(&catalog, "PhotosRaw/img003.CR2", 1); + trash_at(&catalog, "PhotosRaw/img012.CR2", 2); + let wide = wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap(); + assert_eq!(wide.iter().map(|(o, _)| *o).collect::>(), vec![4]); + let trashed = wide_ordinals(&catalog, None, &RatingFilter::default(), true).unwrap(); + // Newest deletion first: img012 then img003. + assert_eq!(trashed.iter().map(|(o, _)| *o).collect::>(), vec![0]); + } + + #[test] + fn a_library_with_no_panorama_is_told_so_by_the_index() { + let catalog = with_images(20); + wide_ordinals(&catalog, None, &RatingFilter::default(), false).unwrap(); + let plan: Vec = catalog + .connection() + .prepare(&format!( + "EXPLAIN QUERY PLAN SELECT EXISTS(SELECT 1 FROM images WHERE {WIDE})" + )) + .unwrap() + .query_map([], |r| r.get::<_, String>(3)) + .unwrap() + .flatten() + .collect(); + let plan = plan.join(" | "); + assert!(plan.contains("images_wide"), "{plan}"); + } + + #[test] + fn the_wide_predicate_is_the_layouts() { + use crate::library_ui::layout::natural_span; + let catalog = with_images(1); + for (w, h) in [ + (19, 10), + (189, 100), + (190, 100), + (191, 100), + (6000, 4000), + (4000, 6000), + (22_993, 5_980), + (1_899, 1_000), + (1_900, 1_000), + ] { + let sql: bool = catalog + .connection() + .query_row( + &format!("SELECT {}", WIDE.replace('w', "?1").replace('h', "?2")), + rusqlite::params![w, h], + |r| r.get(0), + ) + .unwrap(); + let rust = natural_span(Some(w as f32 / h as f32)) > 1; + assert_eq!(sql, rust, "{w}×{h}"); + } + } + fn trash_at(catalog: &Catalog, path: &str, when: i64) { let n = catalog .connection() diff --git a/ui/dr-ui/src/library_ui/controller.rs b/ui/dr-ui/src/library_ui/controller.rs index d61b6b0..533dde3 100644 --- a/ui/dr-ui/src/library_ui/controller.rs +++ b/ui/dr-ui/src/library_ui/controller.rs @@ -123,6 +123,15 @@ pub struct LibraryController { /// blank in the meantime; after, the store holds the same ones. pub(super) merged_thumbs: RefCell>>, + /// TRACES: FR-MRG-6 + /// Where each photograph of the current list sits in the grid — see + /// [`super::layout`]. Rebuilt on every window load from `wide` and the + /// column count, which is arithmetic over a handful of panoramas. + pub(super) layout: RefCell, + /// The wide photographs of the current list, `(ordinal, natural span)`, + /// or `None` when they have to be read again: what the list is changed, + /// or a window found them out of date. + pub(super) wide: RefCell>>, /// Where in the catalog the current window starts. Scrubbing moves this. pub(super) offset: RefCell, /// The first visible ordinal, kept so returning from the develop view lands @@ -460,6 +469,8 @@ impl LibraryController { captured_at: RefCell::new(Vec::new()), thumb_class: RefCell::new(Vec::new()), merged_thumbs: RefCell::new(Default::default()), + layout: RefCell::new(super::layout::Layout::uniform(1, 0)), + wide: RefCell::new(None), offset: RefCell::new(0), resume_at: std::cell::Cell::new(0), roll_open: std::cell::Cell::new(None), diff --git a/ui/dr-ui/src/library_ui/filter_bar.rs b/ui/dr-ui/src/library_ui/filter_bar.rs index 0589796..6be5917 100644 --- a/ui/dr-ui/src/library_ui/filter_bar.rs +++ b/ui/dr-ui/src/library_ui/filter_bar.rs @@ -814,19 +814,20 @@ pub(super) fn refilter(window: &AppWindow, ctl: &Rc) { /// lands in: a scrolled window would otherwise show no date at all until /// the next month began. pub(super) fn period_headings( - months: impl Iterator>, - offset: usize, + months: impl Iterator, usize)>, columns: usize, ) -> Vec { let columns = columns.max(1); let mut announced: Option<(i64, i64)> = None; months .enumerate() - .map(|(i, month)| { + .map(|(i, (month, slot))| { let Some((y, m)) = month else { return String::new(); }; - let begins_row = (i + offset).is_multiple_of(columns); + // By slot, not ordinal: a panorama's cell pushes what follows it + // along (FR-MRG-6). + let begins_row = slot.is_multiple_of(columns); if i == 0 || (begins_row && announced != Some((y, m))) { announced = Some((y, m)); format!("{} {y}", month_name(m)) @@ -863,6 +864,29 @@ fn show_range(window: &AppWindow, ctl: &Rc) { mod tests { use super::*; + /// Every cell one slot, the window starting at `offset`. + fn at_slots( + months: [Option<(i64, i64)>; N], + offset: usize, + ) -> impl Iterator, usize)> { + months + .into_iter() + .enumerate() + .map(move |(i, m)| (m, offset + i)) + } + + #[test] + fn a_month_is_announced_on_the_row_a_panorama_pushed_it_to() { + // Four columns; a panorama spans the first three slots of row two, + // and the frame after it sits in the fourth. February's first frame + // is pushed to the start of row three, and is announced there. + let jan = Some((2026, 1)); + let feb = Some((2026, 2)); + let cells = [(jan, 0), (jan, 1), (jan, 2), (jan, 4), (jan, 7), (feb, 8)]; + let h = period_headings(cells.into_iter(), 4); + assert_eq!(h, ["January 2026", "", "", "", "", "February 2026"]); + } + #[test] fn a_month_that_begins_mid_row_is_still_announced() { // Four columns; August starts in the second column of the second @@ -877,7 +901,7 @@ mod tests { aug, aug, oct, oct, // oct, ]; - let h = period_headings(months.into_iter(), 0, 4); + let h = period_headings(at_slots(months, 0), 4); assert_eq!(h[0], "March 2024"); assert_eq!(h[4], "", "still March at the top of row two"); assert_eq!(h[5], "", "never mid-row"); @@ -892,12 +916,12 @@ mod tests { let feb = Some((2026, 2)); // Offset 2 into a 4-column grid: the window's first cell is in the // third column, and the row after it begins a new month. - let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4); + let h = period_headings(at_slots([jan, jan, feb, feb, feb], 2), 4); assert_eq!(h[0], "January 2026"); assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2"); assert_eq!(h[3], ""); // Undated cells carry nothing and announce nothing. - let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4); + let h = period_headings(at_slots([None, jan, None, None, jan], 0), 4); assert_eq!(h[0], ""); assert_eq!(h[4], "January 2026"); } diff --git a/ui/dr-ui/src/library_ui/grid.rs b/ui/dr-ui/src/library_ui/grid.rs index cd43adc..c530e9a 100644 --- a/ui/dr-ui/src/library_ui/grid.rs +++ b/ui/dr-ui/src/library_ui/grid.rs @@ -27,7 +27,7 @@ use super::timeline::{ capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place, zoomed_span, }; -use super::window::{bring_window_to, load_window, mark_open, window_move}; +use super::window::{bring_window_to, load_window, mark_open, show_cursor, window_move}; /// Walk the keyboard cursor through the library — the arrow keys. /// @@ -109,7 +109,7 @@ fn place_cursor( } crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend); - window.global::().set_library_cursor(at as i32); + show_cursor(window, ctl, at); } /// Where a step of `delta` along the roll lands, as a library ordinal, from @@ -419,6 +419,33 @@ fn wire_grid_cursor_and_zoom( }); } + // TRACES: FR-MRG-6 + // Up and down, by rows: with a panorama's cell in the grid a row is not + // a fixed number of photographs, so the layout says what is under the + // cursor that many rows on, and the step is the distance to it. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + window + .global::() + .on_library_move_cursor_rows(move |rows, extend| { + let Some(w) = weak.upgrade() else { return }; + let delta = match coll.cursor() { + Some(from) => { + let to = ctl.layout.borrow().step_rows(from, rows as isize); + to as i32 - from as i32 + } + // The first press takes hold of the grid, whichever key. + None => rows, + }; + if delta == 0 && coll.cursor().is_some() { + return; + } + move_cursor(&w, &ctl, &coll, delta, extend); + }); + } + { let weak = window.as_weak(); let ctl = ctl.clone(); @@ -635,9 +662,18 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc) { let ctl = ctl.clone(); window .global::() - .on_library_scrolled(move |first_visible| { + .on_library_scrolled(move |first_slot| { let Some(w) = weak.upgrade() else { return }; - let first_visible = first_visible.max(0) as usize; + // The grid reports the first slot of its first visible row; + // the photograph there is what everything below means + // (FR-MRG-6). + let first_slot = first_slot.max(0) as usize; + let total = w.global::().get_library_total().max(0) as usize; + let first_visible = ctl + .layout + .borrow() + .ordinal_at(first_slot) + .min(total.saturating_sub(1)); // **A report from a grid that is not on screen is not a scroll.** // @@ -693,7 +729,7 @@ fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc) { // hand. Without this they each rebuilt against whatever `scroll-to` // was last *set* to — a stale scrub, or zero — and landed there. w.global::() - .set_library_scroll_to(first_visible as i32); + .set_library_scroll_to(first_slot as i32); // Move the timeline marker with the view. Scrolling the grid is a // way of moving through time just as scrubbing is, and a marker diff --git a/ui/dr-ui/src/library_ui/layout.rs b/ui/dr-ui/src/library_ui/layout.rs new file mode 100644 index 0000000..0a84fd9 --- /dev/null +++ b/ui/dr-ui/src/library_ui/layout.rs @@ -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) -> 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 { + 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); + } +} diff --git a/ui/dr-ui/src/library_ui/mod.rs b/ui/dr-ui/src/library_ui/mod.rs index 2651023..9cb4082 100644 --- a/ui/dr-ui/src/library_ui/mod.rs +++ b/ui/dr-ui/src/library_ui/mod.rs @@ -39,6 +39,7 @@ mod controller; mod filter_bar; mod grid; +pub(crate) mod layout; mod offline; mod open; mod ratings_keywords; diff --git a/ui/dr-ui/src/library_ui/open.rs b/ui/dr-ui/src/library_ui/open.rs index 3f8d898..bb56ca5 100644 --- a/ui/dr-ui/src/library_ui/open.rs +++ b/ui/dr-ui/src/library_ui/open.rs @@ -26,7 +26,7 @@ use super::offline::refresh_offline; use super::ratings_keywords::refresh_xmp_conflicts; use super::sync::{start_derived_sync, start_sweep}; use super::timeline::{apply_place, note_place}; -use super::window::{load_window, window_start}; +use super::window::{load_window, seek_to, window_start}; /// Reload the grid for the current scope and offset. /// @@ -776,9 +776,7 @@ pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc) { let total = w.global::().get_library_total().max(0) as usize; *ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total); load_window(&w, &ctl_cb); - w.global::().set_library_scroll_to(anchor as i32); - w.global::() - .set_library_scroll_token(w.global::().get_library_scroll_token() + 1); + seek_to(&w, &ctl_cb, anchor); }); // Replacing the slot drops the previous timer, which is what makes this diff --git a/ui/dr-ui/src/library_ui/timeline.rs b/ui/dr-ui/src/library_ui/timeline.rs index ddcf001..b40dc58 100644 --- a/ui/dr-ui/src/library_ui/timeline.rs +++ b/ui/dr-ui/src/library_ui/timeline.rs @@ -18,7 +18,7 @@ use crate::{AppWindow, Library, TimelineBar, View}; use super::controller::LibraryController; use super::filter_bar::{push_people_chips, refilter}; -use super::window::{load_window, mark_open, window_start}; +use super::window::{load_window, mark_open, seek_to, show_cursor, window_start}; /// Move the timeline's zoom by whole levels. /// @@ -501,13 +501,10 @@ fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { // Move the viewport as well as the window. Cells are drawn at their // absolute place in the library, so loading rows around image 15,000 while // the viewport sits at row 0 shows an empty grid until the user scrolls. - window - .global::() - .set_library_scroll_to(position as i32); - window - .global::() - .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); load_window(window, ctl); + // After the load, which is what places the cells: the grid scrolls by + // slot, and a panorama before `position` moves its slot along. + seek_to(window, ctl, position); } /// TRACES: FR-CAT-7 @@ -543,12 +540,7 @@ pub fn restore_position(window: &AppWindow, ctl: &Rc) { // `resume_at` is what the grid last reported as its first visible image, // which is the thing the photographer is actually looking at. let anchor = ctl.resume_at.get().min(total - 1); - window - .global::() - .set_library_scroll_to(anchor as i32); - window - .global::() - .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); + seek_to(window, ctl, anchor); } /// Format a bucket start for the histogram's hover label. @@ -998,16 +990,11 @@ pub(super) fn resume_position( // Before the grid is shown, not after: the markup gates it on an `if`, and // the rebuilt Flickable reads `scroll-to` in its `init`. Setting these // afterwards would leave that init to run against the previous position. - window - .global::() - .set_library_scroll_to(anchor as i32); - window - .global::() - .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); + seek_to(window, ctl, anchor); if let Some(at) = focus { coll.set_cursor(Some(at)); - window.global::().set_library_cursor(at as i32); + show_cursor(window, ctl, at); } // The view has moved, so the capture-time marker has to move with it. diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs index efd331f..72a7e4e 100644 --- a/ui/dr-ui/src/library_ui/window.rs +++ b/ui/dr-ui/src/library_ui/window.rs @@ -230,6 +230,31 @@ pub(super) fn bring_window_to( .filter(|row| *row < ctl.paths.borrow().len()) } +/// TRACES: FR-MRG-6 +/// Ask the grid to show ordinal `at` in its first row. +/// +/// The grid scrolls by slots, which a panorama's cell makes different from +/// ordinals; this is the one conversion, so no caller spells `scroll-to` +/// in the wrong unit. +pub(super) fn seek_to(window: &AppWindow, ctl: &LibraryController, at: usize) { + let slot = ctl.layout.borrow().slot_of(at); + window + .global::() + .set_library_scroll_to(slot as i32); + window + .global::() + .set_library_scroll_token(window.global::().get_library_scroll_token() + 1); +} + +/// Put the keyboard cursor on ordinal `at`, telling the grid where its cell +/// is so its row can be kept in view. +pub(super) fn show_cursor(window: &AppWindow, ctl: &LibraryController, at: usize) { + window.global::().set_library_cursor(at as i32); + window + .global::() + .set_library_cursor_slot(ctl.layout.borrow().slot_of(at) as i32); +} + /// Record that the photograph at `row` of the loaded window is the one now /// open in develop, and mark it on the roll. pub(super) fn mark_open(window: &AppWindow, ctl: &LibraryController, row: usize) { @@ -327,6 +352,10 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { }; let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts); *ctl.library_facts.borrow_mut() = Some(facts); + // A different list has its panoramas in different places. + if describes_something_new { + *ctl.wide.borrow_mut() = None; + } // TRACES: FR-NC-6a // Whether the newly scoped collection is already pinned. Read here rather @@ -444,18 +473,37 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { .into(), ); + // TRACES: FR-MRG-6 + // Where each cell goes: the layout of the whole list, checked against + // what this window says about its own photographs' shapes and read again + // if they disagree — a reorder, a merge, a composite's size learned. + let columns = window.global::().get_library_columns().max(1) as usize; + let layout = place_cells( + catalog, ctl, &cells, offset, total, columns, scope, &filter, trash, + ); + window + .global::() + .set_library_total_slots(layout.total_slots() as i32); + let cursor = window.global::().get_library_cursor(); + if cursor >= 0 { + window + .global::() + .set_library_cursor_slot(layout.slot_of(cursor as usize) as i32); + } + // Month headings. The grid is ordered by capture time, so without these a // wall of thumbnails gives no sense of *when* you are looking — the // sidebar says it, but only if you consult it. - let columns = window.global::().get_library_columns().max(1) as usize; let headings = period_headings( - cells.iter().map(|c| { - c.captured_at.map(|t| { - let (y, m, _, _) = civil_from_unix(t); - (y, m) - }) + cells.iter().enumerate().map(|(i, c)| { + ( + c.captured_at.map(|t| { + let (y, m, _, _) = civil_from_unix(t); + (y, m) + }), + layout.slot_of(offset + i), + ) }), - offset, columns, ); @@ -470,16 +518,15 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { // A composite a merge has just catalogued is drawn from the merge's own // thumbnails until the store can serve it (FR-MRG-6), and counts as // served at that class so no fetch is spent on a file still uploading. - let wanted = dr_thumbs::ThumbSize::for_cell( - window.global::().get_library_cell_size().max(1.0) as u32, - ); + let cell_pixels = window.global::().get_library_cell_size().max(1.0) as u32; let mut held = held; { let merged = ctl.merged_thumbs.borrow(); - for c in &cells { + for (i, c) in cells.iter().enumerate() { let Some(made) = merged.get(&c.image_id) else { continue; }; + let wanted = dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels); if held .get(&c.image_id) .is_some_and(|h| h.class == Some(wanted)) @@ -507,9 +554,12 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { let rows: Vec = cells .iter() .zip(headings) - .map(|(c, heading)| { + .enumerate() + .map(|(i, (c, heading))| { let carried = held.get(&c.image_id); LibraryCell { + slot: layout.slot_of(offset + i) as i32, + span: layout.span_of(offset + i) as i32, period_heading: heading.into(), // A freshly loaded window has no drag in flight. lifted: false, @@ -611,6 +661,55 @@ pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { request_thumbnails(window, ctl); } +/// TRACES: FR-MRG-6 +/// The layout of the current list, made to agree with the window just read. +/// +/// The panoramas' ordinals are kept between loads — a scroll does not move +/// them — and read again only when they are unknown or when this window +/// finds a photograph wide that the layout does not, or the other way round. +/// That check is over the window's own rows, in memory; the read it can +/// trigger is one query ([`library::wide_ordinals`]). +#[allow(clippy::too_many_arguments)] +fn place_cells( + catalog: &dr_catalog::Catalog, + ctl: &LibraryController, + cells: &[library::LibraryCell], + offset: usize, + total: usize, + columns: usize, + scope: Option, + filter: &library::RatingFilter, + trash: bool, +) -> super::layout::Layout { + use super::layout::{natural_span, Layout}; + let full_width = dr_plat::is_touch_first(); + let natural: Vec = cells.iter().map(|c| natural_span(c.aspect)).collect(); + let read = || -> Vec<(usize, usize)> { + match library::wide_ordinals(catalog, scope, filter, trash) { + Ok(wide) => wide + .into_iter() + .map(|(ordinal, aspect)| (ordinal, natural_span(Some(aspect)))) + .collect(), + Err(e) => { + log::warn!("reading the grid's panoramas: {e}"); + Vec::new() + } + } + }; + if ctl.wide.borrow().is_none() { + *ctl.wide.borrow_mut() = Some(read()); + } + let pack = |wide: &[(usize, usize)]| Layout::pack(columns, total, full_width, wide); + let mut layout = pack(ctl.wide.borrow().as_deref().unwrap_or_default()); + if !layout.agrees_with(offset, &natural, full_width) { + let wide = read(); + layout = pack(&wide); + *ctl.wide.borrow_mut() = Some(wide); + } + *ctl.layout.borrow_mut() = layout.clone(); + layout +} + /// Where a row of the loaded window sits in the fetch queue. /// /// On screen first, top to bottom; then the rows below the view, nearest @@ -648,11 +747,17 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { return; }; - // The drawn cell size decides which class to ask for. Chosen once for the - // batch rather than per row, and carried through to the drain so a cell it - // fills can record what it is now showing. + // The drawn cell size decides which class to ask for, and a panorama's + // cell asks for the wide one (FR-MRG-6). Carried through to the drain per + // row, so a cell it fills can record what it is now showing. let cell_pixels = window.global::().get_library_cell_size().max(1.0) as u32; - let class = dr_thumbs::ThumbSize::for_cell(cell_pixels); + let offset = *ctl.offset.borrow(); + let classes: Vec = { + let layout = ctl.layout.borrow(); + (0..ctl.paths.borrow().len()) + .map(|i| dr_thumbs::ThumbSize::for_span(layout.span_of(offset + i), cell_pixels)) + .collect() + }; let mut wanted: Vec = { let paths = ctl.paths.borrow(); @@ -668,7 +773,10 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { let image_id = *image_ids.get(i)?; // A zoomed grid asks for detail a 256px thumbnail cannot give, // and a wall of small cells does not pay for it. - let thumb_size = class; + let thumb_size = classes + .get(i) + .copied() + .unwrap_or(dr_thumbs::ThumbSize::Grid); // Keyed on the photograph, so scrolling back over a cell that // has already been served does not ask for it again. if !requested.insert((image_id, thumb_size)) { @@ -697,7 +805,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { // the model they were built against; only the order they are asked for in // changes. { - let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow()); + let first_on_screen = ctl.resume_at.get().saturating_sub(offset); let on_screen = ctl.viewport_cells.get().max(1); wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen)); } @@ -709,7 +817,7 @@ fn request_thumbnails(window: &AppWindow, ctl: &Rc) { library::thumbs_dir(&conn.account), library::catalog_path(&conn.account), ); - drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); + drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, classes); } /// TRACES: FR-CAT-9 | FR-DEV-6 @@ -835,7 +943,7 @@ fn drain_thumbnails( ctl: Rc, rx: Receiver, requested: usize, - class: dr_thumbs::ThumbSize, + classes: Vec, ) { let timer = slint::Timer::default(); let ctl_cb = ctl.clone(); @@ -977,7 +1085,9 @@ fn drain_thumbnails( model.set_row_data(t.row, row); // What this cell is now showing, so the next reload // can carry it over and know not to ask again. - record_class(&ctl_cb, t.row, class); + if let Some(class) = classes.get(t.row) { + record_class(&ctl_cb, t.row, *class); + } } } ThumbnailMessage::Unavailable { row, reason } => { @@ -989,7 +1099,9 @@ fn drain_thumbnails( // A verdict is worth carrying too: "no preview" is // an answer about the file, and re-asking it on // every scroll is a fetch that will fail again. - record_class(&ctl_cb, row, class); + if let Some(class) = classes.get(row) { + record_class(&ctl_cb, row, *class); + } } } // TRACES: FR-CAT-9 diff --git a/ui/dr-ui/src/merge_thumbs.rs b/ui/dr-ui/src/merge_thumbs.rs index ceb6174..52ed330 100644 --- a/ui/dr-ui/src/merge_thumbs.rs +++ b/ui/dr-ui/src/merge_thumbs.rs @@ -115,10 +115,16 @@ impl Reduced { } } -/// Which size classes a composite of this shape is thumbnailed at. +/// 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. pub fn classes(width: u32, height: u32) -> Vec { - let _ = (width, height); - vec![ThumbSize::Grid, ThumbSize::Large] + let aspect = width as f32 / height.max(1) as f32; + let mut out = vec![ThumbSize::Grid, ThumbSize::Large]; + if crate::library_ui::layout::natural_span(Some(aspect)) > 1 { + out.push(ThumbSize::Wide); + } + out } /// Develop the reduced composite as develop would open the file, and render @@ -190,6 +196,12 @@ pub fn render( mod tests { use super::*; + #[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)); + } + #[test] fn a_reduction_averages_its_boxes_and_the_ragged_edge() { // 5 × 3 by 2: boxes of 2 × 2, then 1 × 2 at the right, 2 × 1 and @@ -286,7 +298,8 @@ mod tests { eprintln!("no GPU; skipping"); return; }; - let (width, height, white) = (3000u32, 760u32, 13_023u32); + // Past `SOURCE_EDGE`, so the thumbnails come from a reduced copy. + let (width, height, white) = (4600u32, 1160u32, 13_023u32); let pixels = composite(width, height, white); let profile = profile(white); let header = dr_export::SourceMetadata { @@ -298,8 +311,8 @@ mod tests { let crop = dr_export::Rect { x: 40, y: 30, - width: 2900, - height: 700, + width: 4500, + height: 1080, }; // Develop's side: the whole composite written as the merge writes @@ -328,7 +341,8 @@ mod tests { let mut developed = crate::open_session(&ctx, decoder, &bytes, &meta).unwrap(); // The merge's side: the same bands, reduced as they pass. - let mut reduced = Reduced::new(width, height, 1500); + let mut reduced = Reduced::new(width, height, SOURCE_EDGE); + assert_eq!(reduced.factor(), 2); for first in (0..height).step_by(rows_per_strip as usize) { let rows = rows_per_strip.min(height - first); let from = (first * width * 3) as usize; @@ -345,7 +359,7 @@ mod tests { &header, Some(crop), decoder, - &[ThumbSize::Grid, ThumbSize::Large], + &[ThumbSize::Grid, ThumbSize::Large, ThumbSize::Wide], ) .unwrap(); diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index b0237b4..b01720e 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -1463,6 +1463,7 @@ in property panel-visible: true; expanded: root.expanded; cells: Library.library-cells; total: Library.library-total; + total-slots: Library.library-total-slots; scanning: Library.library-scanning; opening: Library.library-opening; scan-status: Library.library-status; @@ -1584,9 +1585,13 @@ in property panel-visible: true; assign-keyword(word) => { Library.library-assign-keyword(word); } unassign-keyword(word) => { Library.library-unassign-keyword(word); } cursor: Library.library-cursor; + cursor-slot: Library.library-cursor-slot; move-cursor(delta, extend) => { Library.library-move-cursor(delta, extend); } + move-cursor-rows(rows, extend) => { + Library.library-move-cursor-rows(rows, extend); + } open-cursor() => { Library.library-open-cursor(); } drag-image: Library.library-drag-image; drag-payload() => { return Library.library-drag-payload(); } diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index c9821cb..8093ed6 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -483,6 +483,13 @@ export struct LibraryCell { // the one cell on screen is the representative by construction, and a mark // saying so would be telling the user what they can already see. burst-representative: bool, + // 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 + // layout (`library_ui::layout`) is the one place these are computed. + slot: int, + span: int, } // TRACES: FR-CAT-3 | FR-CAT-4 | FR-CAT-5 | FR-CAT-6 | FR-CAT-7 | FR-CULL-4 | FR-CULL-5 @@ -535,8 +542,14 @@ export global Library { callback library-pinch-cells(float); /// A pinch on the grid has begun, so the press that opened it was not one. callback library-pinch-started(); + /// The slot the grid's first row should show — a slot, not an ordinal: + /// see `LibraryCell.slot`. in property library-scroll-to: 0; in property library-scroll-token: 0; + /// TRACES: FR-MRG-6 + /// The slots the whole list takes, which a panorama's cell makes more + /// than its count of photographs: what the scrollbar spans. + in property library-total-slots: 0; /// Which row of the loaded window is the photograph currently open in /// develop, so the roll can mark it. `-1` when it is not in the window. /// Rust re-finds it on every reload, so it follows the photograph and not @@ -559,6 +572,7 @@ export global Library { /// what the roll happens to hold. callback library-roll-step(int); callback library-columns-changed(); + /// The first visible row's first slot. callback library-scrolled(int); /// How many cells the grid's viewport shows at once. Rust sizes the /// loaded window from it — see `library_ui::window_move`. @@ -616,7 +630,15 @@ export global Library { // ordinal. Rust owns it — clamping it needs the library's length, and // moving it may have to swap the loaded window underneath. in property library-cursor: -1; + /// The slot the cursor's photograph starts at, for keeping its row in + /// view. + in property library-cursor-slot: -1; callback library-move-cursor(int, bool); + /// TRACES: FR-MRG-6 + /// Move the cursor by rows, which with a panorama's cell in the way is + /// not a fixed number of photographs: Rust asks the layout what is under + /// the cursor that many rows on. + callback library-move-cursor-rows(int, bool); callback library-open-cursor(); /// TRACES: FR-CAT-5 | FR-CAT-6 /// Keywording the grid's selection. The catalog has been searchable by @@ -1461,6 +1483,10 @@ export component LibraryGrid inherits Rectangle { in property <[LibraryCell]> cells; in property total: 0; + /// TRACES: FR-MRG-6 + /// The slots the whole list takes: `total` where every cell is one slot, + /// more where a panorama spans several or a row ends short of one. + in property total-slots: 0; in property scanning: false; /// The catalog on this device is being opened and checked before the scan /// starts. Distinct from `scanning` for the reason the empty state below @@ -2036,6 +2062,9 @@ export component LibraryGrid inherits Rectangle { // and the first arrow press picks up where the view already is rather than // teleporting to image zero. in property cursor: -1; + /// The slot the cursor's photograph starts at; its row is what is kept + /// in view. + in property cursor-slot: -1; /// Move the cursor by a number of images; the flag extends the selection /// from the anchor instead of replacing it. /// @@ -2043,6 +2072,10 @@ export component LibraryGrid inherits Rectangle { /// longer than the library, which Rust clamps — so this file needs to know /// neither how many images there are nor where the loaded window starts. callback move-cursor(int, bool); + /// Move the cursor by rows. Up and down are not a row's worth of + /// photographs once a panorama spans columns, so Rust finds the one + /// under the cursor that many rows on. + callback move-cursor-rows(int, bool); /// Open the image under the cursor. `Return`, and the reason the arrows /// are worth having: a cull is walk, judge, open, back, without the hand /// ever leaving the keyboard. @@ -2205,7 +2238,6 @@ export component LibraryGrid inherits Rectangle { // start-up. Every month then began a row and was announced wherever // its first cell fell, mid-row included. init => { root.columns-changed(root.columns); } - property row-count: ceil(root.cells.length / max(1, columns)); // --- while a pinch is happening, and just after ----------------------- // @@ -2276,7 +2308,8 @@ export component LibraryGrid inherits Rectangle { property viewport-cells: root.columns * (root.visible-rows + 1); changed viewport-cells => { root.viewport-cells-changed(root.viewport-cells); } /// Rows the *whole library* occupies, which is what the scrollbar spans. - property total-rows: ceil(root.total / max(1, columns)); + /// Counted in slots, since a panorama's cell takes several. + property total-rows: ceil(max(root.total, root.total-slots) / max(1, columns)); background: Theme.ground; @@ -3437,21 +3470,19 @@ export component LibraryGrid inherits Rectangle { return accept; } if (Keys.chord(event) == "Up" || Keys.chord(event) == "Shift+Up") { - root.move-cursor(-root.columns, event.modifiers.shift); + root.move-cursor-rows(-1, event.modifiers.shift); return accept; } if (Keys.chord(event) == "Down" || Keys.chord(event) == "Shift+Down") { - root.move-cursor(root.columns, event.modifiers.shift); + root.move-cursor-rows(1, event.modifiers.shift); return accept; } if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") { - root.move-cursor(-root.columns * root.visible-rows, - event.modifiers.shift); + root.move-cursor-rows(-root.visible-rows, event.modifiers.shift); return accept; } if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") { - root.move-cursor(root.columns * root.visible-rows, - event.modifiers.shift); + root.move-cursor-rows(root.visible-rows, event.modifiers.shift); return accept; } // A step longer than the library, clamped at the far end. @@ -3603,7 +3634,7 @@ export component LibraryGrid inherits Rectangle { // on screen moves nothing at all, and one that has just left // brings in exactly its own row. property pitch: root.cell-size + Theme.gap; - property cursor-row: floor(root.cursor / max(1, root.columns)); + property cursor-row: floor(max(0, root.cursor-slot) / max(1, root.columns)); changed cursor-row => { self.reveal(); } function reveal() { @@ -3722,7 +3753,7 @@ export component LibraryGrid inherits Rectangle { // Sits in the gap above its row, so it labels the row // rather than displacing it. y: Theme.gap - + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap) + + floor(cell.slot / root.columns) * (root.cell-size + Theme.gap) - 15px; width: parent.width - 2 * Theme.gap; text: cell.period-heading; @@ -3756,10 +3787,12 @@ export component LibraryGrid inherits Rectangle { // Cells are positioned at their **absolute** place in the // library, not their index in the loaded window: the window // starts at `offset`, so a cell drawn at window-index 0 belongs - // wherever `offset` sits in the full grid. - x: Theme.gap + mod(i + root.offset, root.columns) * (root.cell-size + Theme.gap); - y: Theme.gap + floor((i + root.offset) / root.columns) * (root.cell-size + Theme.gap); - width: root.cell-size; + // wherever `offset` sits in the full grid. That place is its + // slot, which Rust computes (FR-MRG-6): a panorama before it + // pushes it along, and a panorama itself spans `span` slots. + x: Theme.gap + mod(cell.slot, root.columns) * (root.cell-size + Theme.gap); + y: Theme.gap + floor(cell.slot / root.columns) * (root.cell-size + Theme.gap); + width: max(1, cell.span) * root.cell-size + (max(1, cell.span) - 1) * Theme.gap; height: root.cell-size; // Copy, not move: dropping into a collection files the @@ -4433,12 +4466,14 @@ export component LibraryGrid inherits Rectangle { visible: root.held-row >= 0; x: Theme.gap - + mod(root.held-row + root.offset, root.columns) * self.pitch + + mod(root.cells[root.held-row].slot, root.columns) * self.pitch - self.reach; y: Theme.gap - + floor((root.held-row + root.offset) / root.columns) * self.pitch + + floor(root.cells[root.held-row].slot / root.columns) * self.pitch - self.reach; - width: root.cell-size + 2 * self.reach; + property span: max(1, root.cells[root.held-row].span); + width: self.span * root.cell-size + (self.span - 1) * Theme.gap + + 2 * self.reach; height: root.cell-size + 2 * self.reach; animate x, y, width, height { duration: 120ms; easing: ease-out; }