diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3e7ddf1e..adbdc495 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -72,6 +72,9 @@ pub struct Interaction { pub original: Arc, } +/// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). +static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + pub struct Session { /// Auto Sync: edits to the active photo also change the other selected photos (the settings /// that changed, nothing else). @@ -82,8 +85,12 @@ pub struct Session { pub sort: Sort, pub selection: Selection, /// The grid order for the current source/filter/sort (cached by catalog revision). - visible: Vec, + /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). + visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// Identifies the current `visible` list: a new value (unique in the process) every time it + /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. + visible_gen: u64, /// Photos in the current source with no filter on (cached like `visible`). total: Option<((u64, String), usize)>, pub undo: Vec, @@ -177,8 +184,9 @@ impl Session { filter: Filter::default(), sort: Sort::default(), selection: Selection::default(), - visible: Vec::new(), + visible: Vec::new().into(), visible_key: None, + visible_gen: 0, total: None, undo: Vec::new(), redo: Vec::new(), @@ -486,7 +494,7 @@ impl Session { f.folder = Some(b.path); f.subfolders = b.subfolders; } - self.visible = self.catalog.query(&f, &self.sort); + let mut visible = self.catalog.query(&f, &self.sort); if matches!(self.source, LibrarySource::Album(_)) && self.sort.key == lightcraft_catalog::SortKey::CaptureDate && let LibrarySource::Album(a) = self.source @@ -495,15 +503,15 @@ impl Session { && self.filter == Filter::default() { let order = al.photos.clone(); - self.visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); if !self.sort.ascending { - self.visible.reverse(); + visible.reverse(); } } if self.source == LibrarySource::RecentlyAdded { // newest import first, whatever the sort (the grid groups by import day) let cat = &self.catalog; - self.visible.sort_by(|a, b| { + visible.sort_by(|a, b| { let key = |id: &PhotoId| cat.photo(*id).map(|p| p.imported.clone()).unwrap_or_default(); key(b).cmp(&key(a)).then(a.cmp(b)) }); @@ -511,11 +519,13 @@ impl Session { if self.source == LibrarySource::Missing { // only the photos the query kept (library photos, not Local browse records) are checked let cat = &self.catalog; - self.visible.retain(|id| cmd::missing::is_missing(cat, *id)); + visible.retain(|id| cmd::missing::is_missing(cat, *id)); } if self.source != LibrarySource::RecentlyDeleted { - self.visible = self.catalog.arrange_stacks(&self.visible); + visible = self.catalog.arrange_stacks(&visible); } + self.visible = visible.into(); + self.visible_gen = VISIBLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.visible_key = Some(key); } &self.visible @@ -545,6 +555,14 @@ impl Session { self.visible().to_vec() } + /// The visible photos without copying them, with their generation: equal generations mean + /// the very same list (same catalog revision, source, filter, sort), so a frontend can cache + /// whatever it derives from the list under that number. + pub fn visible_shared(&mut self) -> (u64, Arc<[PhotoId]>) { + self.visible(); + (self.visible_gen, self.visible.clone()) + } + /// Targets of photo commands: explicit `ids`/`id` param, else the selection. pub fn targets(&self, p: &Value) -> Vec { if let Some(a) = p.get("ids").and_then(Value::as_array) { diff --git a/crates/ui-egui/examples/grid_bench.rs b/crates/ui-egui/examples/grid_bench.rs new file mode 100644 index 00000000..7c038a49 --- /dev/null +++ b/crates/ui-egui/examples/grid_bench.rs @@ -0,0 +1,171 @@ +//! Unchanged-frame cost of the photo grid (issue #35), on synthetic libraries: +//! `cargo run --release -p lightcraft-ui-egui --example grid_bench` +//! (`SIZES=200,10000,85000` photos, `FRAMES=60` measured frames per scenario). +//! +//! Each scenario builds a library of N photos (mixed aspect ratios, ~40 photos per capture day, +//! a three-photo stack every 100 photos), shows it in the Photo Grid (justified rows) or the +//! Square Grid, with All Photos or a recursive Local folder view as the source, settles (missing +//! files: thumbnails fail fast, so no decoding is timed), scrolls to the middle and then times +//! frames in which nothing changes. Prints per-frame wall-clock (min / median / p95 of the whole +//! app frame and of the grid panel alone), heap allocations per frame on the UI thread, and the +//! grid's own counters (cells visited, layout / view / index rebuilds). +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source, Stack, StackId}; +use lightcraft_engine::{Browse, LibrarySource, Session}; +use lightcraft_ui_egui::headless::Headless; +use lightcraft_ui_egui::state::ViewMode; +use lightcraft_ui_egui::{LightcraftApp, Services}; + +/// Counts allocations made by the current thread (the UI thread is the one measured). +struct Counting; + +thread_local! { + static ALLOCS: Cell = const { Cell::new(0) }; + static BYTES: Cell = const { Cell::new(0) }; +} + +fn count(size: usize) { + let _ = ALLOCS.try_with(|n| n.set(n.get() + 1)); + let _ = BYTES.try_with(|n| n.set(n.get() + size as u64)); +} + +// SAFETY: every call is forwarded unchanged to the system allocator; the counters are plain +// thread-locals that never allocate. Dev-only example. +#[allow(unsafe_code)] +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + count(l.size()); + // SAFETY: same contract as ours. + unsafe { System.alloc(l) } + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + count(l.size()); + // SAFETY: same contract as ours. + unsafe { System.alloc_zeroed(l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + count(new); + // SAFETY: same contract as ours. + unsafe { System.realloc(p, l, new) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + // SAFETY: same contract as ours. + unsafe { System.dealloc(p, l) } + } +} + +#[global_allocator] +static A: Counting = Counting; + +fn allocs() -> (u64, u64) { + (ALLOCS.with(Cell::get), BYTES.with(Cell::get)) +} + +const ROOT: &str = "/lightcraft-grid-bench/root"; + +fn library(n: usize, local: bool) -> Session { + let mut s = Session::new(); + let shapes = [(6000, 4000), (4000, 6000), (4000, 4000), (6000, 3375), (3000, 4000)]; + for i in 0..n { + let id = PhotoId(i as u64 + 1); + let (w, h) = shapes[i % shapes.len()]; + let path = format!("{ROOT}/sub{}/IMG_{i:06}.jpg", i / 1000); + let mut p = Photo::new(id, Source::File { path }, &format!("IMG_{i:06}.jpg"), "JPEG", w, h, "2026-01-01T00:00:00"); + // ~40 photos a day, going back in time + let day = i / 40; + let (y, m, d) = (2026 - (day / 336) as i32, 12 - (day / 28) % 12, 28 - day % 28); + p.captured = Some(format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:00", 8 + (i % 40) / 4, i % 60)); + p.local = local; + let _ = s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }); + } + for k in 0..n / 100 { + let base = k as u64 * 100 + 1; + let stack = Stack { id: StackId(k as u64 + 1), photos: vec![PhotoId(base), PhotoId(base + 1), PhotoId(base + 2)], collapsed: false }; + let _ = s.catalog.apply(Op::AddStack { stack }); + } + if local { + s.source = LibrarySource::Folder; + s.browse = Some(Browse { path: ROOT.into(), subfolders: true }); + } + s +} + +struct Dist { + min: f64, + med: f64, + p95: f64, +} + +fn dist(mut v: Vec) -> Dist { + v.sort_by(f64::total_cmp); + let at = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0); + Dist { min: at(0.0), med: at(0.5), p95: at(0.95) } +} + +fn scenario(n: usize, view: ViewMode, local: bool, frames: usize) { + let t0 = Instant::now(); + let session = library(n, local); + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.app.ui.view = view; + h.app.ui.thumb_size = 160.0; + h.settle(Duration::from_secs(120)); + // to the middle of the grid (and let the newly visible thumbnails fail / settle) + if let Some(mid) = h.app.session.visible().get(n / 2).copied() { + h.app.session.selection.active = Some(mid); + h.app.session.selection.ids = vec![mid]; + } + h.settle(Duration::from_secs(120)); + let setup = t0.elapsed(); + let stats0 = h.app.caches.grid_stats.clone(); + let mut frame_ms = Vec::with_capacity(frames); + let mut grid_ms = Vec::with_capacity(frames); + let (a0, b0) = allocs(); + for _ in 0..frames { + let t = Instant::now(); + h.step(); + frame_ms.push(t.elapsed().as_secs_f64() * 1e3); + grid_ms.push(h.app.caches.grid_stats.last_show.as_secs_f64() * 1e3); + } + let (a1, b1) = allocs(); + let st = &h.app.caches.grid_stats; + let f = dist(frame_ms); + let g = dist(grid_ms); + let name = format!("{n:>6} {:<7} {:<5}", if view == ViewMode::SquareGrid { "square" } else { "photo" }, if local { "local" } else { "all" }); + println!( + "{name} frame {:>7.2} / {:>7.2} / {:>7.2} ms grid {:>7.2} / {:>7.2} / {:>7.2} ms allocs/frame {:>7} ({:>9} B) cells/frame {:>6} rebuilds view {} layout {} stacks {} (setup {:.1} s)", + f.min, + f.med, + f.p95, + g.min, + g.med, + g.p95, + (a1 - a0) / frames as u64, + (b1 - b0) / frames as u64, + (st.cells_visited - stats0.cells_visited) / frames as u64, + st.view_builds - stats0.view_builds, + st.layout_builds - stats0.layout_builds, + st.stack_index_builds - stats0.stack_index_builds, + setup.as_secs_f64(), + ); +} + +fn main() { + let sizes: Vec = std::env::var("SIZES") + .ok() + .map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect()) + .unwrap_or_else(|| vec![200, 10_000, 85_000]); + let frames = std::env::var("FRAMES").ok().and_then(|s| s.parse().ok()).unwrap_or(60); + println!("photos layout source frame min / median / p95 grid min / median / p95"); + for &n in &sizes { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + for local in [false, true] { + scenario(n, view, local, frames); + } + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aa60a80e..49b70cad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,8 @@ pub mod widgets; #[cfg(test)] mod tests_curve; #[cfg(test)] +mod tests_grid; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_panels; @@ -659,7 +661,6 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, - date_runs: Option<(u64, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -667,8 +668,10 @@ pub struct Caches { album_counts: Option<(u64, std::sync::Arc>)>, /// How often the album counts were recomputed (tests check that unchanged frames don't). pub album_count_scans: usize, - /// The grid's layout (by photos, shapes, width, thumbnail size, grouping). - pub grid_layout: Option<(u64, std::sync::Arc)>, + /// The grid's date runs, layout and indexes (by the visible list's generation). + pub grid: panels::grid::GridCache, + /// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap). + pub grid_stats: panels::grid::GridStats, } /// The left panel's counts. @@ -758,24 +761,6 @@ impl Caches { } } } - /// Date headers for `ids` in the grid. - pub fn date_runs( - &mut self, - cat: &lightcraft_catalog::Catalog, - ids: &[lightcraft_catalog::PhotoId], - key: lightcraft_catalog::SortKey, - by: lightcraft_catalog::GroupBy, - ) -> std::sync::Arc> { - let k = key_of((cat.revision, ids, format!("{key:?}{by:?}"))); - match &self.date_runs { - Some((h, r)) if *h == k => r.clone(), - _ => { - let r = std::sync::Arc::new(cat.date_runs(ids, key, by)); - self.date_runs = Some((k, r.clone())); - r - } - } - } /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { let k = key_of((cat.revision, current, prefix, n)); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 43fd84b8..eda9e6db 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -1,9 +1,10 @@ //! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use lightcraft_catalog::{ColorLabel, DateRun, Flag, GroupBy, PhotoId}; +use lightcraft_catalog::{Catalog, ColorLabel, DateRun, Flag, GroupBy, PhotoId, SortKey, StackId}; use serde_json::json; use crate::LightcraftApp; @@ -25,9 +26,116 @@ fn thumb_px(pts: f32, ppp: f32) -> usize { } } +/// Counters of the grid's work, cumulative over frames (benchmarks and tests: an unchanged frame +/// must not rebuild the view, its layout or its indexes, and visits only the cells near the screen). +#[derive(Clone, Debug, Default)] +pub struct GridStats { + /// Frames the grid was drawn. + pub frames: u64, + /// Cells looked at (tested against the viewport or drawn). + pub cells_visited: u64, + /// Layouts computed. + pub layout_builds: u64, + /// Per-view data (date runs, row index) built. + pub view_builds: u64, + /// Stack indexes built. + pub stack_index_builds: u64, + /// Wall-clock time of the last [`show`]. + pub last_show: web_time::Duration, +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t0 = web_time::Instant::now(); + show_inner(app, ui); + app.caches.grid_stats.frames += 1; + app.caches.grid_stats.last_show = t0.elapsed(); +} + +/// Photo → (stack, position in the stack). +type StackIndex = HashMap; + +/// What the date runs depend on: the visible list's generation, the date the grid groups by +/// (`None`: no headers) and the grouping. +type RunsKey = (u64, Option, GroupBy); + +/// What the grid derives from the visible photos, kept across frames. Everything is keyed by the +/// session's visible-list generation ([`lightcraft_engine::Session::visible_shared`]), which +/// changes whenever the list is recomputed (any catalog change: crops, orientation, dates, stacks; +/// or another source, filter or sort) — so an unchanged frame neither copies nor hashes the ids. +#[derive(Default)] +pub struct GridCache { + runs: Option<(RunsKey, Arc>)>, + /// Layout by (runs key, width, thumbnail size, square). + layout: Option<((RunsKey, u32, u32, bool), Arc)>, + /// Stack index by generation. + stacks: Option<(u64, Arc)>, + /// How many of the photos are browsed (Local) ones, by generation. + local: Option<(u64, usize)>, +} + +impl GridCache { + fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &[PhotoId], key: RunsKey) -> Arc> { + match &self.runs { + Some((k, r)) if *k == key => r.clone(), + _ => { + stats.view_builds += 1; + let r = Arc::new(key.1.map(|sort| cat.date_runs(ids, sort, key.2)).unwrap_or_default()); + self.runs = Some((key, r.clone())); + r + } + } + } + + fn stacks(&mut self, stats: &mut GridStats, cat: &Catalog, generation: u64) -> Arc { + match &self.stacks { + Some((g, s)) if *g == generation => s.clone(), + _ => { + stats.stack_index_builds += 1; + let s = Arc::new(cat.stack_index()); + self.stacks = Some((generation, s.clone())); + s + } + } + } + + fn local(&mut self, cat: &Catalog, ids: &[PhotoId], generation: u64) -> usize { + match self.local { + Some((g, n)) if g == generation => n, + _ => { + let n = ids.iter().filter(|id| cat.photo(**id).is_some_and(|p| p.local)).count(); + self.local = Some((generation, n)); + n + } + } + } +} + +/// Width / height of each photo's cell: its cropped, oriented shape. +fn aspects(cat: &Catalog, ids: &[PhotoId]) -> Vec { + ids.iter() + .map(|id| { + let p = cat.photo(*id); + let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); + let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); + let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); + let (w, h) = if swap { (h, w) } else { (w, h) }; + (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) + }) + .collect() +} + +/// The rows (of a layout's `rows`, top to bottom) that meet the y range `top..=bottom`, found by +/// binary search. +pub fn rows_between(rows: &[GridRow], top: f32, bottom: f32) -> &[GridRow] { + let first = rows.partition_point(|r| r.bottom < top); + let rest = rows.get(first..).unwrap_or(&[]); + let n = rest.partition_point(|r| r.top <= bottom); + rest.get(..n).unwrap_or(&[]) +} + +fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let ids = app.session.visible_cloned(); + let (generation, ids) = app.session.visible_shared(); // header: source title + count let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); ui.painter().rect_filled(hr, 0.0, t.canvas); @@ -37,7 +145,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let counted = super::chips::count_text(ids.len(), total, !chips.is_empty()); let cnt = if sel_n > 1 { format!("{sel_n} selected · {counted}") } else { counted }; match app.session.browse.clone().filter(|_| app.session.source == lightcraft_engine::LibrarySource::Folder) { - Some(b) => folder_header(app, ui, hr, &b, &ids, &cnt), + Some(b) => { + let local = app.caches.grid.local(&app.session.catalog, &ids, generation); + folder_header(app, ui, hr, &b, &ids, local, &cnt) + } None => { let title = app.session.source.label(&app.session.catalog); ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text); @@ -75,57 +186,40 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let target = app.ui.thumb_size; let avail_w = ui.available_width() - 8.0; let by = resolve_group(app.session.sort.group, target); - let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted { - Vec::new() - } else { - let key = if app.session.source == lightcraft_engine::LibrarySource::RecentlyAdded { - lightcraft_catalog::SortKey::ImportDate - } else { - app.session.sort.key - }; - (*app.caches.date_runs(&app.session.catalog, &ids, key, by)).clone() + let group_key = match app.session.source { + lightcraft_engine::LibrarySource::RecentlyDeleted => None, + lightcraft_engine::LibrarySource::RecentlyAdded => Some(SortKey::ImportDate), + _ => Some(app.session.sort.key), }; - let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); - // the layout only changes with the photos, their shapes, the width and the thumbnail size - let lay_key = crate::key_of((app.session.catalog.revision, &ids, avail_w.to_bits(), target.to_bits(), square, &spans)); - let lay = match &app.caches.grid_layout { + let runs_key = (generation, group_key, by); + let stats = &mut app.caches.grid_stats; + let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key); + // the layout only changes with the photos (and their shapes), the grouping, the width and + // the thumbnail size + let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square); + let lay = match &app.caches.grid.layout { Some((k, l)) if *k == lay_key => l.clone(), _ => { - let aspects: Vec = if square { - vec![1.0; ids.len()] - } else { - ids.iter() - .map(|id| { - let p = app.session.catalog.photo(*id); - let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); - let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes()); - let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT); - let (w, h) = if swap { (h, w) } else { (w, h) }; - (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) - }) - .collect() - }; - let l = std::sync::Arc::new(layout(&aspects, &spans, avail_w, target, square)); - app.caches.grid_layout = Some((lay_key, l.clone())); + stats.layout_builds += 1; + let aspects = if square { vec![1.0; ids.len()] } else { aspects(&app.session.catalog, &ids) }; + let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); + let l = Arc::new(layout(&aspects, &spans, avail_w, target, square)); + app.caches.grid.layout = Some((lay_key, l.clone())); l } }; - struct Cell { - id: PhotoId, - rect: Rect, - } - let cells: Vec = ids.iter().zip(&lay.cells).map(|(id, r)| Cell { id: *id, rect: *r }).collect(); + let stacks = app.caches.grid.stacks(stats, &app.session.catalog, generation); let total_h = lay.height + 12.0; let active = app.session.selection.active; // bring the active photo into view when it changes (keyboard, click, command) or the grid // comes back on screen; otherwise the scroll position is the user's let scroll_to: Option = if follow_active(ui.ctx(), egui::Id::new("grid-follow-active"), active) { - active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) + active.and_then(|a| ids.iter().position(|x| *x == a)).and_then(|i| lay.cells.get(i).copied()) } else { None }; - let stacks = app.session.catalog.stack_index(); let mut visible_ids = HashSet::new(); + let mut visited = 0u64; egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); let origin = area.min; @@ -133,39 +227,49 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if let Some(r) = scroll_to { ui.scroll_to_rect(r.translate(origin.to_vec2()), None); } + // only the rows on screen and a screen above and below it (their thumbnails are prefetched) let prefetch = viewport.expand2(vec2(0.0, viewport.height())); - for c in &cells { - if !c.rect.intersects(prefetch) { - continue; - } - visible_ids.insert(c.id); - let r = c.rect.translate(origin.to_vec2()); - let onscreen = c.rect.intersects(viewport); - cell(app, ui, c.id, r, square, onscreen, ppp); - if let Some((sid, pos)) = stacks.get(&c.id) { - stack_badge(app, ui, c.id, *sid, *pos, r, square); + for row in rows_between(&lay.rows, prefetch.top(), prefetch.bottom()) { + for i in row.start..row.end { + let (Some(&id), Some(&rect)) = (ids.get(i), lay.cells.get(i)) else { continue }; + visited += 1; + if !rect.intersects(prefetch) { + continue; + } + visible_ids.insert(id); + let r = rect.translate(origin.to_vec2()); + let onscreen = rect.intersects(viewport); + cell(app, ui, id, r, square, onscreen, ppp); + if let Some((sid, pos)) = stacks.get(&id) { + stack_badge(app, ui, id, *sid, *pos, r, square); + } } } - // date headers; the current group's header sticks to the top while its photos scroll by - let mut sticky: Option = None; - for (g, hr) in lay.headers.iter().enumerate() { - if hr.top() <= viewport.top() { - sticky = Some(g); + // date headers (top to bottom); the current group's header sticks to the top while its + // photos scroll by + let first = lay.headers.partition_point(|h| h.bottom() < viewport.top()); + for (g, hr) in lay.headers.iter().enumerate().skip(first) { + if hr.top() > viewport.bottom() { + break; } - if hr.intersects(viewport) { - group_header(app, ui, &runs[g], &ids, hr.translate(origin.to_vec2()), false); + if let Some(run) = runs.get(g) { + group_header(app, ui, run, &ids, hr.translate(origin.to_vec2()), false); } } + let sticky = lay.headers.partition_point(|h| h.top() <= viewport.top()).checked_sub(1); if let Some(g) = sticky - && lay.headers[g].top() < viewport.top() + && let Some(head) = lay.headers.get(g) + && head.top() < viewport.top() + && let Some(run) = runs.get(g) { // pushed up by the next header as it arrives let next_top = lay.headers.get(g + 1).map(|h| h.top()).unwrap_or(f32::INFINITY); let y = viewport.top().min(next_top - HEADER_H); let hr = Rect::from_min_size(pos2(0.0, y), vec2(area.width(), HEADER_H)).translate(origin.to_vec2()); - group_header(app, ui, &runs[g], &ids, hr, true); + group_header(app, ui, run, &ids, hr, true); } }); + app.caches.grid_stats.cells_visited += visited; app.renderer.evict_thumbs(&visible_ids, 600); let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE); } @@ -188,15 +292,26 @@ pub(crate) fn follow_active(ctx: &egui::Context, key: egui::Id, active: Option

, pub headers: Vec, + pub rows: Vec, pub height: f32, } +/// One row of cells: `cells[start..end]`, between `top` and `bottom`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridRow { + pub top: f32, + pub bottom: f32, + pub start: usize, + pub end: usize, +} + /// The grouping actually shown: `Auto` is days with large thumbnails, months with medium ones and /// years when zoomed far out. pub fn resolve_group(by: GroupBy, thumb_size: f32) -> GroupBy { @@ -218,7 +333,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: let whole = [(0, n)]; let (groups, headed) = if groups.is_empty() { (&whole[..], false) } else { (groups, true) }; let gap = if square { 1.0 } else { 6.0 }; - let mut out = GridLayout { cells: vec![Rect::NOTHING; n], headers: Vec::with_capacity(groups.len()), height: 0.0 }; + let mut out = GridLayout { cells: vec![Rect::NOTHING; n], headers: Vec::with_capacity(groups.len()), rows: Vec::new(), height: 0.0 }; let mut y = 4.0f32; for &(start, count) in groups { let end = (start + count).min(n); @@ -237,6 +352,11 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: let (c, r) = (k % cols, (k / cols).floor()); out.cells[i] = Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw)); } + let per_row = (cols as usize).max(1); + for (r, row_start) in (start..end).step_by(per_row).enumerate() { + let top = y + r as f32 * (cw + gap); + out.rows.push(GridRow { top, bottom: top + cw, start: row_start, end: (row_start + per_row).min(end) }); + } let rows = ((end - start) as f32 / cols).ceil(); y += rows * (cw + gap); } else { @@ -261,6 +381,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: out.cells[k] = Rect::from_min_size(pos2(x, y), vec2(w, h)); x += w + gap; } + out.rows.push(GridRow { top: y, bottom: y + h, start: i, end: j }); y += h + gap; i = j; } @@ -503,7 +624,7 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square /// The header of a Local folder view: the path as a breadcrumb (each part opens that folder), /// Include subfolders, Add to My Photos. -fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], cnt: &str) { +fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &lightcraft_engine::Browse, ids: &[PhotoId], local_n: usize, cnt: &str) { let t = Tokens::get(ui.ctx()); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(hr.shrink2(vec2(16.0, 6.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); @@ -533,10 +654,11 @@ fn folder_header(app: &mut LightcraftApp, ui: &mut egui::Ui, hr: Rect, b: &light child.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(cnt).size(12.5).color(t.text_dim)); ui.add_space(10.0); - let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); - if !local.is_empty() { - let label = format!("Add {} to My Photos", local.len()); + // counted once per view (cached); the ids are only gathered on a click + if local_n > 0 { + let label = format!("Add {local_n} to My Photos"); if crate::widgets::text_button(ui, "addToLibrary", &label, false).clicked() { + let local: Vec = ids.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|p| p.local)).map(|id| id.0).collect(); let n = local.len(); match app.run("photo.addToLibrary", json!({"ids": local})) { Ok(_) => app.toast(ui.ctx(), format!("Added {n} photo{} to My Photos", if n == 1 { "" } else { "s" })), @@ -825,6 +947,37 @@ mod tests { } } + /// The row index covers every cell once, in order and top to bottom, so the visible cells + /// are found by binary search. + #[test] + fn rows_index_every_cell_top_to_bottom() { + let aspects: Vec = (0..500).map(|i| [1.5f32, 0.66, 1.0, 1.78][i % 4]).collect(); + let groups = [(0, 7), (7, 200), (207, 1), (208, 292)]; + for square in [false, true] { + let l = layout(&aspects, &groups, 900.0, 140.0, square); + let mut next = 0; + for (k, row) in l.rows.iter().enumerate() { + assert_eq!(row.start, next, "rows are contiguous"); + assert!(row.end > row.start); + for c in &l.cells[row.start..row.end] { + assert!((c.top() - row.top).abs() < 0.01 && (c.bottom() - row.bottom).abs() < 0.01, "{c:?} {row:?}"); + } + if let Some(prev) = k.checked_sub(1).map(|p| l.rows[p]) { + assert!(row.top > prev.bottom, "{prev:?} {row:?}"); + } + next = row.end; + } + assert_eq!(next, aspects.len()); + // the binary search finds exactly the cells a linear scan does + for (top, bottom) in [(0.0, 300.0), (1234.0, 2100.0), (l.height - 50.0, l.height + 900.0), (-500.0, -1.0)] { + let band = Rect::from_min_max(pos2(0.0, top), pos2(2000.0, bottom)); + let want: Vec = (0..aspects.len()).filter(|i| l.cells[*i].intersects(band)).collect(); + let got: Vec = rows_between(&l.rows, top, bottom).iter().flat_map(|r| r.start..r.end).collect(); + assert_eq!(got, want, "square {square} {top}..{bottom}"); + } + } + } + #[test] fn square_grid_groups_and_auto_levels() { let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs new file mode 100644 index 00000000..3fe92327 --- /dev/null +++ b/crates/ui-egui/src/tests_grid.rs @@ -0,0 +1,133 @@ +//! Headless tests of the photo grid on a larger synthetic library (issue #35): unchanged frames +//! reuse the view's date runs, layout and stack index and visit only the rows near the screen, +//! while date headers, scrolling to the active photo, keyboard navigation, stacks and +//! crop/orientation changes keep working. + +use std::time::Duration; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source, Stack, StackId}; +use lightcraft_engine::Session; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); +const SETTLE: Duration = Duration::from_secs(120); +const N: u64 = 3000; + +/// `N` photos (missing files: thumbnails fail fast), 40 per capture day, landscape except every +/// fifth; a three-photo stack every 100 photos. +fn library() -> Session { + let mut s = Session::new(); + for i in 0..N { + let (w, h) = if i % 5 == 4 { (4000, 6000) } else { (6000, 4000) }; + let path = format!("/lightcraft-grid-test/IMG_{i:05}.jpg"); + let mut p = Photo::new(PhotoId(i + 1), Source::File { path }, &format!("IMG_{i:05}.jpg"), "JPEG", w, h, "2026-01-01T00:00:00"); + let day = i / 40; + p.captured = Some(format!("2026-{:02}-{:02}T{:02}:00:00", 12 - day / 28, 28 - day % 28, 8 + i % 40 / 4)); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + for k in 0..N / 100 { + let b = k * 100 + 1; + let stack = Stack { id: StackId(k + 1), photos: vec![PhotoId(b), PhotoId(b + 1), PhotoId(b + 2)], collapsed: false }; + s.catalog.apply(Op::AddStack { stack }).unwrap(); + } + s +} + +fn grid(view: ViewMode) -> Headless { + let app = LightcraftApp::new(library(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.view = view; + h.app.ui.thumb_size = 160.0; + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn select(h: &mut Headless, id: PhotoId) { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); +} + +/// The photo's cell is drawn inside the grid's canvas. +fn on_screen(h: &Headless, id: PhotoId) -> bool { + let canvas = h.app.canvas_rect.expect("grid drawn"); + widget(h, &format!("thumb:{}", id.0)).is_some_and(|r| canvas.contains_rect(r)) +} + +/// Unchanged frames build nothing and look only at the cells near the screen. +fn assert_cheap_frames(h: &mut Headless) { + let before = h.app.caches.grid_stats.clone(); + let frames = 10; + for _ in 0..frames { + h.step(); + } + let s = &h.app.caches.grid_stats; + assert_eq!(s.frames - before.frames, frames); + assert_eq!(s.view_builds, before.view_builds, "date runs rebuilt on an unchanged frame"); + assert_eq!(s.layout_builds, before.layout_builds, "layout rebuilt on an unchanged frame"); + assert_eq!(s.stack_index_builds, before.stack_index_builds, "stack index rebuilt on an unchanged frame"); + let per_frame = (s.cells_visited - before.cells_visited) / frames; + assert!(per_frame > 0 && per_frame < 300, "visited {per_frame} of {N} cells per frame"); +} + +#[test] +fn unchanged_grid_frames_reuse_the_view_and_visit_only_nearby_rows() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.app.caches.grid_stats.view_builds, 1, "{view:?}"); + assert_eq!(h.app.caches.grid_stats.stack_index_builds, 1, "{view:?}"); + assert_cheap_frames(&mut h); + // far down the grid too + let far = h.app.session.visible()[2500]; + select(&mut h, far); + assert_cheap_frames(&mut h); + } +} + +#[test] +fn grid_headers_selection_scrolling_stacks_and_shape_changes() { + let mut h = grid(ViewMode::PhotoGrid); + let vis = h.app.session.visible().to_vec(); + // the newest day's header heads the grid + assert!(widget(&h, "group:2026-12-28").is_some(), "{:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("group:")).collect::>()); + assert_eq!(h.app.grid_scroll, Some(0.0)); + // selecting a photo far down scrolls the grid to it; its day's header is drawn (pinned or not) + let target = PhotoId(2001); // the top of a stack, captured on day 50 + let i = vis.iter().position(|x| *x == target).unwrap(); + select(&mut h, target); + assert!(h.app.grid_scroll.unwrap() > 1000.0, "{:?}", h.app.grid_scroll); + assert!(on_screen(&h, target), "the active photo is scrolled into view"); + // stacks: the badge on the stack's top and members + assert!(widget(&h, &format!("stack:{}", target.0)).is_some(), "stack badge drawn"); + assert!(widget(&h, "stack:2002").is_some(), "stack member badge drawn"); + // keyboard: the next photo becomes active and stays in view + h.request("ui.key", json!({"key": "right"}), T); + h.settle(SETTLE); + let next = vis[i + 1]; + assert_eq!(h.app.session.selection.active, Some(next)); + assert!(on_screen(&h, next)); + // the user's scroll position is kept on unchanged frames + let y = h.app.grid_scroll; + assert_cheap_frames(&mut h); + assert_eq!(h.app.grid_scroll, y); + // rotating a landscape photo re-lays the grid with its new (portrait) shape + let landscape = vis[i + 2]; + let before = widget(&h, &format!("thumb:{}", landscape.0)).unwrap(); + assert!(before.width() > before.height(), "{before:?}"); + let layouts = h.app.caches.grid_stats.layout_builds; + let r = h.request("engine.execute", json!({"command": "photo.rotateRight", "params": {"ids": [landscape.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.caches.grid_stats.layout_builds > layouts, "a shape change re-lays the grid"); + let after = widget(&h, &format!("thumb:{}", landscape.0)).unwrap(); + assert!(after.height() > after.width(), "{after:?}"); + assert_cheap_frames(&mut h); +}