Issue #35: Grid: unchanged frames reuse view data and visit only nearby rows

An unchanged grid frame used to copy every visible id, hash them twice
(date-run and layout cache keys), clone the date runs, build a full
Vec<Cell>, rebuild the stack index, loop over every cell for the
viewport test and, in Local folder views, look up every photo to count
the browsed ones for "Add N to My Photos".

- engine: Session keeps the visible list as Arc<[PhotoId]> with a
  process-unique generation that changes whenever the list is
  recomputed; `visible_shared()` hands both out without copying.
- grid: date runs, layout, stack index and the Local count are cached
  by that generation (GridCache; replaces the id-hashing date_runs and
  grid_layout caches). The layout gains a row index (GridRow), so the
  visible + prefetch rows and the date headers are found by binary
  search; the sticky header too.
- GridStats counters (frames, cells visited, view/layout/stack-index
  builds, last frame time) for the bench and tests.

Measured with the new `cargo run --release -p lightcraft-ui-egui
--example grid_bench` (synthetic library, 1400x900, thumbs 160, missing
files so no decoding is timed, scrolled to the middle, 120 unchanged
frames; machine at load ~200-250, so medians of the whole app frame,
before/after interleaved in the same session):

  photos layout  source  median frame ms  allocs/frame   cells visited
                         before -> after  before -> after  before -> after
     200 photo   all      1.29 -> 1.28    4747 -> 4729     200 -> 93
     200 square  all      1.09 -> 1.09    4771 -> 4753     200 -> 74
  10 000 photo   all      1.52 -> 1.41    5425 -> 4911   10000 -> 100
  10 000 photo   local    1.90 -> 1.42    5475 -> 4948   10000 -> 100
  10 000 square  local    1.62 -> 1.07    5365 -> 4838   10000 -> 76
  85 000 photo   all      2.41 -> 1.29    8996 -> 4729   85000 -> 93
  85 000 square  all      2.29 -> 1.08    9020 -> 4753   85000 -> 74
  85 000 photo   local   17.6-34.0 -> 1.32  9049 -> 4766   85000 -> 93
  85 000 square  local   16.2-25.0 -> 1.10  9073 -> 4790   85000 -> 74

Frame cost no longer depends on the number of results; what is left
(~1 ms, ~4.7k allocs) is drawing the ~75-100 cells near the screen.
Headless snapshots of the demo grid (justified with a sticky header,
after keyboard navigation, square) are byte-identical before/after.

Tests: layout rows cover every cell in order and the binary search
matches a linear scan (square + justified, groups); a 3000-photo
headless library checks that unchanged frames build nothing and visit
< 300 cells, plus date headers, scroll-to-selection, stack badges,
keyboard navigation and a rotation re-laying the grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 15:16:01 -04:00
parent ba5fff320b
commit 73a2c01676
5 changed files with 555 additions and 95 deletions

View File

@ -72,6 +72,9 @@ pub struct Interaction {
pub original: Arc<DevelopSettings>,
}
/// 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<PhotoId>,
/// 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<UndoEntry>,
@ -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<PhotoId> {
if let Some(a) = p.get("ids").and_then(Value::as_array) {

View File

@ -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<u64> = const { Cell::new(0) };
static BYTES: Cell<u64> = 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<f64>) -> 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<usize> = 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);
}
}
}
}

View File

@ -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<Vec<lightcraft_catalog::KeywordNode>>)>,
date_runs: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateRun>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
@ -667,8 +668,10 @@ pub struct Caches {
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// 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<panels::grid::GridLayout>)>,
/// 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<Vec<lightcraft_catalog::DateRun>> {
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<Vec<String>> {
let k = key_of((cat.revision, current, prefix, n));

View File

@ -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<PhotoId, (StackId, usize)>;
/// 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<SortKey>, 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<Vec<DateRun>>)>,
/// Layout by (runs key, width, thumbnail size, square).
layout: Option<((RunsKey, u32, u32, bool), Arc<GridLayout>)>,
/// Stack index by generation.
stacks: Option<(u64, Arc<StackIndex>)>,
/// 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<Vec<DateRun>> {
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<StackIndex> {
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<f32> {
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<f32> = 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<Cell> = 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<Rect> = 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<usize> = 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<P
/// Height of a date header row (points).
pub const HEADER_H: f32 = 40.0;
/// Cell rectangles (one per photo, in order), date-header rectangles (one per group) and the total
/// height of the grid content.
/// Cell rectangles (one per photo, in order), date-header rectangles (one per group, top to
/// bottom), the rows of cells (top to bottom, for finding the visible ones by binary search) and
/// the total height of the grid content.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct GridLayout {
pub cells: Vec<Rect>,
pub headers: Vec<Rect>,
pub rows: Vec<GridRow>,
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<u64> = 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<u64> = 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<f32> = (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<usize> = (0..aspects.len()).filter(|i| l.cells[*i].intersects(band)).collect();
let got: Vec<usize> = 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);

View File

@ -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<egui::Rect> {
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::<Vec<_>>());
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);
}