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:
parent
ba5fff320b
commit
73a2c01676
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue