diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index ca463dc55..3b9c56bf0 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -156,38 +156,56 @@ impl Catalog { /// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`] /// ([`GroupBy::Auto`] means days here). pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec { - if by == GroupBy::None || ids.is_empty() { + if !groups_by_date(key, by) || ids.is_empty() { return Vec::new(); } - let n = by.prefix(); - let date_of = |id: &PhotoId| -> String { - let Some(p) = self.photo(*id) else { return String::new() }; - let d = match key { - SortKey::CaptureDate => p.captured.as_deref(), - SortKey::ImportDate => Some(p.imported.as_str()), - SortKey::EditDate => p.edited.as_deref(), - _ => None, - }; - d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string() - }; - if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) { - return Vec::new(); - } - let stacks = self.stack_index(); - let mut runs: Vec = Vec::new(); - let mut stack_key: std::collections::HashMap = Default::default(); - for (i, id) in ids.iter().enumerate() { - let k = match stacks.get(id) { - Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(), - None => date_of(id), - }; - match runs.last_mut() { - Some(r) if r.key == k => r.count += 1, - _ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }), - } - } - runs + date_runs_of(ids, &self.stack_index(), |_, id| self.date_group(id, key, by)) } + + /// The date group of photo `id` on its own for [`Catalog::date_runs`] (`2026-09-30` / + /// `2026-09` / `2026`; empty without a usable date): a stack's members take the group of its + /// first photo in the list there. + pub fn date_group(&self, id: PhotoId, key: SortKey, by: GroupBy) -> String { + let Some(p) = self.photo(id) else { return String::new() }; + let d = match key { + SortKey::CaptureDate => p.captured.as_deref(), + SortKey::ImportDate => Some(p.imported.as_str()), + SortKey::EditDate => p.edited.as_deref(), + _ => None, + }; + d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..by.prefix())).unwrap_or("").to_string() + } +} + +/// Whether [`Catalog::date_runs`] has runs for sort key `key` grouped `by`. +pub fn groups_by_date(key: SortKey, by: GroupBy) -> bool { + by != GroupBy::None && matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) +} + +/// [`Catalog::date_runs`] from each photo's own group (`group(position, id)`, see +/// [`Catalog::date_group`]) and the stack index ([`Catalog::stack_index`]). +pub fn date_runs_of>( + ids: &[PhotoId], + stacks: &std::collections::HashMap, + mut group: impl FnMut(usize, PhotoId) -> K, +) -> Vec { + let mut runs: Vec = Vec::new(); + let mut stack_key: std::collections::HashMap = Default::default(); + for (i, id) in ids.iter().enumerate() { + let own; + let k: &str = match stacks.get(id) { + Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| group(i, *id).as_ref().to_string()), + None => { + own = group(i, *id); + own.as_ref() + } + }; + match runs.last_mut() { + Some(r) if r.key == k => r.count += 1, + _ => runs.push(DateRun { label: group_label(k), key: k.to_string(), start: i, count: 1 }), + } + } + runs } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 8ec5fd6e4..6ce3c49d9 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -42,6 +42,9 @@ pub struct GridStats { pub layout_builds: u64, /// Per-view data (date runs, row index) built. pub view_builds: u64, + /// Photos whose date and shape were read from the catalog (every photo of a list read whole; + /// only the photos an edit touched of one followed: [`GridCache`]). + pub photos_read: u64, /// Stack indexes built. pub stack_index_builds: u64, /// Wall-clock time of the last [`show`]. @@ -62,6 +65,10 @@ type StackIndex = HashMap; /// (`None`: no headers) and the grouping. type RunsKey = (u64, Option, GroupBy); +/// The most photos an edit may touch for the grid to read only those again (each costs a lookup +/// and a date parse; past this, reading the whole list is as quick). +const MAX_FOLLOWED: usize = 4096; + /// 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; @@ -75,21 +82,161 @@ pub struct GridCache { stacks: Option<(u64, Arc)>, /// How many of the photos are browsed (Local) ones, by generation. local: Option<(u64, usize)>, + /// What was read of each photo of the list last shown. + read: Option, +} + +/// What the grid read of each photo of a list, position by position: its own date group (a +/// stack's members take their first photo's when the runs are made) and its cell's shape. A new +/// list that differs from this one by the photos an edit touched (an import's batch, a capture +/// time, a crop) is read again only for those: at 180,000 photos, reading every photo again took +/// most of a 100 ms frame for each batch an import added. +struct ListRead { + ids: Arc<[PhotoId]>, + /// The catalog revision it was read at. + revision: u64, + /// The date and grouping of the groups (`None`: the list has no date runs, no group is read). + dates: Option<(SortKey, GroupBy)>, + /// Each photo's group, an index into `names`. + groups: Vec, + names: Vec, + named: HashMap, + /// Each photo's shape; `None` for the square grid, whose cells are all alike. + shapes: Option>, +} + +impl ListRead { + fn new(ids: Arc<[PhotoId]>, revision: u64, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead { + let n = ids.len(); + let groups = Vec::with_capacity(if dates.is_some() { n } else { 0 }); + ListRead { ids, revision, dates, groups, names: Vec::new(), named: HashMap::new(), shapes: shaped.then(|| Vec::with_capacity(n)) } + } + + /// Read photo `id` from the catalog and put it next. + fn read(&mut self, cat: &Catalog, id: PhotoId) { + if let Some((key, by)) = self.dates { + let name = cat.date_group(id, key, by); + let next = u32::try_from(self.names.len()).unwrap_or(u32::MAX); + let g = match self.named.get(&name) { + Some(g) => *g, + None => { + self.named.insert(name.clone(), next); + self.names.push(name); + next + } + }; + self.groups.push(g); + } + if let Some(shapes) = self.shapes.as_mut() { + shapes.push(shape_of(cat, id)); + } + } + + /// Read every photo of `ids`. + fn whole(stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> ListRead { + let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped); + if r.dates.is_some() || shaped { + for id in ids.iter() { + r.read(cat, *id); + } + stats.photos_read += ids.len() as u64; + } + r + } + + /// `ids` from what was read of the list before it: the photos the catalog says changed since + /// are read again, the others are taken over. `None` when the list is not this one with + /// those photos changed, added or removed (another view, order or grouping; an edit to more + /// than photos of its own, such as a stack): read it whole then. + fn follow(self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, dates: Option<(SortKey, GroupBy)>, shaped: bool) -> Option { + if self.dates != dates || self.shapes.is_some() != shaped { + return None; + } + let touched: HashSet = cat.touched_since(self.revision)?.into_iter().collect(); + if touched.len() > MAX_FOLLOWED { + return None; + } + let mut r = ListRead::new(ids.clone(), cat.revision, dates, shaped); + // (the groups' names go on: an import adds a day or two at most) + r.names = self.names; + r.named = self.named; + let mut was = 0; + let mut fresh = 0u64; + for id in ids.iter() { + if touched.contains(id) { + r.read(cat, *id); + fresh += 1; + continue; + } + // (the photos touched are read again wherever they are now) + while self.ids.get(was).is_some_and(|w| touched.contains(w)) { + was += 1; + } + // an untouched photo is where it was, among the others: else this is another list + if self.ids.get(was) != Some(id) { + return None; + } + if dates.is_some() { + r.groups.push(*self.groups.get(was)?); + } + if let (Some(to), Some(from)) = (r.shapes.as_mut(), self.shapes.as_ref()) { + to.push(*from.get(was)?); + } + was += 1; + } + // no untouched photo left the list + if self.ids.get(was..).is_some_and(|rest| rest.iter().any(|w| !touched.contains(w))) { + return None; + } + stats.photos_read += fresh; + Some(r) + } } impl GridCache { - fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &[PhotoId], key: RunsKey) -> Arc> { + /// The date runs of `ids`, reading what has to be read of them (`square`: the cells' shapes + /// are not wanted). + fn runs(&mut self, stats: &mut GridStats, cat: &Catalog, ids: &Arc<[PhotoId]>, key: RunsKey, square: bool) -> Arc> { + let shaped = !square; match &self.runs { - Some((k, r)) if *k == key => r.clone(), + Some((k, r)) if *k == key && self.read.as_ref().is_some_and(|read| read.shapes.is_some() == shaped) => r.clone(), _ => { stats.view_builds += 1; - let r = Arc::new(key.1.map(|sort| cat.date_runs(ids, sort, key.2)).unwrap_or_default()); + let dates = key.1.map(|sort| (sort, key.2)).filter(|(sort, by)| lightcraft_catalog::dates::groups_by_date(*sort, *by)); + let read = match self.read.take() { + // (the same list, read at this revision) + Some(r) if Arc::ptr_eq(&r.ids, ids) && r.revision == cat.revision && r.dates == dates && r.shapes.is_some() == shaped => r, + Some(r) => r.follow(stats, cat, ids, dates, shaped).unwrap_or_else(|| ListRead::whole(stats, cat, ids, dates, shaped)), + None => ListRead::whole(stats, cat, ids, dates, shaped), + }; + let r = Arc::new(match dates { + Some(_) if !ids.is_empty() => { + let stacks = self.stacks(stats, cat, key.0); + let group = |i: usize, _: PhotoId| read.groups.get(i).and_then(|g| read.names.get(*g as usize)).map_or("", String::as_str); + lightcraft_catalog::dates::date_runs_of(ids, &stacks, group) + } + _ => Vec::new(), + }); + self.read = Some(read); self.runs = Some((key, r.clone())); r } } } + /// The shape of each cell of the list last shown (width / height), in list order; empty for + /// the square grid. + fn shapes(&self) -> &[f32] { + self.read.as_ref().and_then(|r| r.shapes.as_deref()).unwrap_or(&[]) + } + + /// The date runs and the cell shapes the grid shows now (tests compare them with a grid made + /// afresh). + #[cfg(test)] + pub(crate) fn runs_and_shapes(&self) -> Option<(Arc>, Vec)> { + Some((self.runs.as_ref()?.1.clone(), self.shapes().to_vec())) + } + fn stacks(&mut self, stats: &mut GridStats, cat: &Catalog, generation: u64) -> Arc { match &self.stacks { Some((g, s)) if *g == generation => s.clone(), @@ -114,18 +261,14 @@ impl GridCache { } } -/// 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() +/// Width / height of a photo's cell: its cropped, oriented shape. +pub(crate) fn shape_of(cat: &Catalog, id: PhotoId) -> f32 { + 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) } /// The rows (of a layout's `rows`, top to bottom) that meet the y range `top..=bottom`, found by @@ -211,7 +354,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; 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); + let runs = app.caches.grid.runs(stats, &app.session.catalog, &ids, runs_key, square); // 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); @@ -219,9 +362,15 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { Some((k, l)) if *k == lay_key => l.clone(), _ => { stats.layout_builds += 1; - let aspects = if square { vec![1.0; ids.len()] } else { aspects(&app.session.catalog, &ids) }; + let ones; + let shapes = if square { + ones = vec![1.0; ids.len()]; + &ones[..] + } else { + app.caches.grid.shapes() + }; 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)); + let l = Arc::new(layout(shapes, &spans, avail_w, target, square)); app.caches.grid.layout = Some((lay_key, l.clone())); l } diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 6c85ecf59..875598db0 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -217,3 +217,142 @@ fn double_clicking_a_stack_badge_toggles_the_stack_once() { h.settle(SETTLE); assert!(!collapsed(&h), "a single click toggles it"); } + +/// What the grid shows (its date runs, and each cell's shape) is what a grid made afresh for the +/// same list shows. +fn assert_as_made_afresh(h: &mut Headless, what: &str) { + let vis = h.app.session.visible().to_vec(); + if vis.is_empty() { + // (no photo matches: the grid says so and draws none) + return; + } + let (runs, shapes) = h.app.caches.grid.runs_and_shapes().expect("the grid was drawn"); + let cat = &h.app.session.catalog; + let by = crate::panels::grid::resolve_group(h.app.session.sort.group, h.app.ui.thumb_size); + assert_eq!(*runs, cat.date_runs(&vis, h.app.session.sort.key, by), "date runs {what}"); + // (the square grid's cells are all alike: it reads no shape) + let fresh: Vec = + if h.app.ui.view == ViewMode::SquareGrid { Vec::new() } else { vis.iter().map(|id| crate::panels::grid::shape_of(cat, *id)).collect() }; + assert_eq!(shapes, fresh, "cell shapes {what}"); +} + +fn photo(h: &mut Headless, n: u64, captured: &str) -> Op { + let id = h.app.session.catalog.alloc_photo_id(); + let (w, h_) = if n.is_multiple_of(3) { (4000, 6000) } else { (6000, 4000) }; + let mut p = Photo::new( + id, + Source::File { path: format!("/lightcraft-grid-test/NEW_{n:05}.jpg") }, + &format!("NEW_{n:05}.jpg"), + "JPEG", + w, + h_, + "2026-01-02T00:00:00", + ); + p.captured = Some(captured.to_string()); + Op::AddPhoto { photo: Box::new(p) } +} + +/// An import lands its photos a batch at a time: the grid reads the batch's photos, not the +/// whole list again (at 180,000 photos that was most of a 100 ms frame per batch). An edit to a +/// photo's date or shape reads that photo. +#[test] +fn a_batch_of_new_photos_or_an_edit_to_one_is_read_photo_by_photo() { + let mut h = grid(ViewMode::PhotoGrid); + let read = h.app.caches.grid_stats.photos_read; + assert_eq!(read, N, "the first frame reads every photo"); + // sixteen photos, on days already shown, a new day, and with no date at all + let ops: Vec = (0..16) + .map(|k| { + let day = ["2026-12-28T09:30:00", "2026-11-03T10:00:00", "2027-01-05T12:00:00", "nope"][k as usize % 4]; + photo(&mut h, k, day) + }) + .collect(); + h.app.session.commit("Import", Op::Batch { ops }).unwrap(); + h.step(); + assert_eq!(h.app.caches.grid_stats.photos_read - read, 16, "only the batch's photos are read"); + assert!(widget(&h, "group:2027-01-05").is_some(), "the new day heads the grid"); + assert_as_made_afresh(&mut h, "after the batch"); + // a photo taken on another day: it moves there, and only it is read + let read = h.app.caches.grid_stats.photos_read; + h.app.session.commit("Capture time", Op::SetCaptured { id: PhotoId(500), captured: Some("2027-02-01T08:00:00".into()) }).unwrap(); + h.step(); + assert_eq!(h.app.caches.grid_stats.photos_read - read, 1, "only the photo edited is read"); + assert!(widget(&h, "group:2027-02-01").is_some(), "its new day heads the grid"); + assert_as_made_afresh(&mut h, "after a capture time"); +} + +/// Whatever the edit, the grid shows what a grid made afresh would: followed photo by photo when +/// the list changed by the photos the edit touched, else read whole. +#[test] +fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() { + let mut h = grid(ViewMode::PhotoGrid); + let mut dice = 11u64; + let mut roll = |sides: u64| { + dice = lightcraft_catalog::mix64(dice); + dice % sides + }; + for step in 0..120 { + let vis = h.app.session.visible().to_vec(); + if vis.is_empty() { + // (no picks shown: back to every photo) + h.app.session.filter.flag = None; + continue; + } + let id = vis[roll(vis.len() as u64) as usize].0; + let n = roll(1000); + match roll(12) { + 0 => { + let ops: Vec = + (0..1 + roll(20)).map(|k| photo(&mut h, n * 100 + k, &format!("2026-{:02}-{:02}T10:00:00", 1 + n % 12, 1 + k % 28))).collect(); + h.app.session.commit("Import", Op::Batch { ops }).unwrap(); + } + 1 => { + let _ = h.app.session.commit("Delete", Op::SetDeleted { id: PhotoId(id), deleted: true }); + } + 2 => { + let _ = h + .app + .session + .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2026-{:02}-03T10:00:00", 1 + n % 12)) }); + } + 3 => { + h.app.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + let r = h.app.session.execute("photo.rotateRight", &json!({})); + assert!(r.is_ok(), "{r:?}"); + } + 4 => { + let mut d = (*h.app.session.catalog.photo(PhotoId(id)).unwrap().develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.0, 0.9 - 0.1 * (n % 4) as f64, 1.0); + h.app + .session + .commit("Crop", Op::SetDevelop { id: PhotoId(id), settings: std::sync::Arc::new(d), label: "Crop".into(), edited: None }) + .unwrap(); + } + 5 => h.app.session.commit("Rating", Op::SetRating { id: PhotoId(id), rating: (n % 6) as u8 }).unwrap(), + 6 => { + let key = ["captureDate", "fileName", "importDate"][n as usize % 3]; + let r = h.app.session.execute("library.sort", &json!({"key": key, "ascending": n.is_multiple_of(2)})); + assert!(r.is_ok(), "{r:?}"); + } + 7 => { + // a stack: an edit to more than its photos, read whole + let other = vis[n as usize % vis.len()].0; + let _ = h.app.session.execute("stack.group", &json!({"ids": [id, other], "top": id, "collapsed": n.is_multiple_of(2)})); + } + 8 => { + let _ = h.app.session.execute("edit.undo", &json!({})); + } + 9 => { + // picks, for the filter below to show some + for k in 0..1 + n % 40 { + let p = vis[(n as usize * 7 + k as usize * 13) % vis.len()]; + h.app.session.commit("Flag", Op::SetFlag { id: p, flag: lightcraft_catalog::Flag::Pick }).unwrap(); + } + } + 10 => h.app.ui.view = if h.app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }, + _ => h.app.session.filter.flag = n.is_multiple_of(2).then_some(lightcraft_catalog::Flag::Pick), + } + h.step(); + assert_as_made_afresh(&mut h, &format!("after step {step}")); + } +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 02f5bf08f..e53fa2525 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,4 +1,5 @@ # Lightroom parity checklist +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 @@ -896,3 +897,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | | 2026-10-16 | patch | LR-META-XMP: a TIFF or JPEG imported beside a raw of its stem reads its own `.xmp` and takes nothing from the raw's `IMG_0001.xmp`, whether it arrives with the raw or after it (a stem sidecar belongs to one file: a raw first, then the first by name); status unchanged (✅) | +| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index cfeed33ed..b42eb9dbc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -24,6 +24,13 @@ another ratio (4 × 5, 2 × 3…) keeps it portrait, as in Lightroom, and a Custom ratio taller than wide gives a portrait crop. +## 2026-10-16 + +### The photo grid keeps up with imports into a large library +- While photos are imported into a large library, the grid takes in each batch as it arrives instead of reading + every photo of the view again: at about 180,000 photos a batch cost the window a tenth of a second, over and over + for as long as the import ran. Changing one photo's capture time or crop is quicker too. + ## 2026-10-15 ### Constrain Crop tightens the crop you see