diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a387c77..c14ec2c6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1454,7 +1454,10 @@ mod drop_tests { } } -/// Results recomputed only when the catalog (or their inputs) change. +/// Results recomputed only when the catalog (or their inputs) change. Views that don't read +/// develop state key on [`lightcraft_catalog::Catalog::library_revision`], so a slider drag (a +/// develop edit per tick) doesn't redo them; the others on +/// [`lightcraft_catalog::Catalog::view_revision`] or the full revision. #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, @@ -1466,7 +1469,7 @@ pub struct Caches { /// The Keyword List's tick boxes, by library revision and selection. keyword_ticks: Option<(u64, u64, std::sync::Arc)>, folder_tree: Option<(u64, std::sync::Arc>)>, - people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc>)>, + people: Option<(lightcraft_catalog::ViewRevision, lightcraft_catalog::Filter, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc>)>, @@ -1546,10 +1549,10 @@ impl Caches { /// The folders the library's photos were imported from. pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { match &self.folder_tree { - Some((r, t)) if *r == cat.revision => t.clone(), + Some((r, t)) if *r == cat.library_revision => t.clone(), _ => { let t = std::sync::Arc::new(cat.folder_tree()); - self.folder_tree = Some((cat.revision, t.clone())); + self.folder_tree = Some((cat.library_revision, t.clone())); t } } @@ -1557,10 +1560,10 @@ impl Caches { /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { match &self.keyword_tree { - Some((r, t)) if *r == cat.revision => t.clone(), + Some((r, t)) if *r == cat.library_revision => t.clone(), _ => { let t = std::sync::Arc::new(cat.keyword_tree()); - self.keyword_tree = Some((cat.revision, t.clone())); + self.keyword_tree = Some((cat.library_revision, t.clone())); t } } @@ -1575,14 +1578,14 @@ impl Caches { ) -> std::sync::Arc> { let key = key_of((selection, export)); match &self.keyword_names { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + Some((r, k, c)) if *r == cat.library_revision && *k == key => c.clone(), _ => { let c = std::sync::Arc::new(if export { panels::keywording::will_export(cat, selection) } else { panels::keywording::with_containing(cat, selection) }); - self.keyword_names = Some((cat.revision, key, c.clone())); + self.keyword_names = Some((cat.library_revision, key, c.clone())); c } } @@ -1595,10 +1598,10 @@ impl Caches { ) -> std::sync::Arc> { let key = key_of(selection); match &self.keyword_chips { - Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(), + Some((r, k, c)) if *r == cat.library_revision && *k == key => c.clone(), _ => { let c = std::sync::Arc::new(panels::keywording::chips(cat, selection)); - self.keyword_chips = Some((cat.revision, key, c.clone())); + self.keyword_chips = Some((cat.library_revision, key, c.clone())); c } } @@ -1611,10 +1614,10 @@ impl Caches { ) -> std::sync::Arc { let key = key_of(selection); match &self.keyword_ticks { - Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), + Some((r, k, t)) if *r == cat.library_revision && *k == key => t.clone(), _ => { let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); - self.keyword_ticks = Some((cat.revision, key, t.clone())); + self.keyword_ticks = Some((cat.library_revision, key, t.clone())); t } } @@ -1627,11 +1630,13 @@ impl Caches { filter: &lightcraft_catalog::Filter, ) -> std::sync::Arc> { let key = lightcraft_catalog::Filter { person: None, ..filter.clone() }; + // a develop edit only counts again when the filter reads develop state (Edited, …) + let rev = cat.view_revision(cat.filter_reads_develop(&key)); match &self.people { - Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(), + Some((r, f, t)) if *r == rev && *f == key => t.clone(), _ => { let t = std::sync::Arc::new(cat.people_in(&key)); - self.people = Some((cat.revision, key, t.clone())); + self.people = Some((rev, key, t.clone())); t } } @@ -1639,7 +1644,7 @@ impl Caches { /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts - && r == cat.revision + && r == cat.library_revision { return c; } @@ -1654,16 +1659,16 @@ impl Caches { c.deleted += 1; } } - self.counts = Some((cat.revision, c)); + self.counts = Some((cat.library_revision, c)); c } /// Everyone named on a face in the library (for completing a name as it is typed). pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { match &self.person_names { - Some((r, v)) if *r == cat.revision => v.clone(), + Some((r, v)) if *r == cat.library_revision => v.clone(), _ => { let v = std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::>()); - self.person_names = Some((cat.revision, v.clone())); + self.person_names = Some((cat.library_revision, v.clone())); v } } @@ -1671,10 +1676,10 @@ impl Caches { /// The By Date tree. pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc> { match &self.date_groups { - Some((r, g)) if *r == cat.revision => g.clone(), + Some((r, g)) if *r == cat.library_revision => g.clone(), _ => { let g = std::sync::Arc::new(cat.date_groups()); - self.date_groups = Some((cat.revision, g.clone())); + self.date_groups = Some((cat.library_revision, g.clone())); g } } @@ -1682,7 +1687,7 @@ impl Caches { /// Cameras, lenses and keywords in the library (filter bar pickers). pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc { match &self.filter_values { - Some((r, v)) if *r == cat.revision => v.clone(), + Some((r, v)) if *r == cat.library_revision => v.clone(), _ => { let distinct = |mut v: Vec| { v.retain(|s| !s.trim().is_empty()); @@ -1696,14 +1701,14 @@ impl Caches { lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()), keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(), }); - self.filter_values = Some((cat.revision, v.clone())); + self.filter_values = Some((cat.library_revision, v.clone())); v } } } /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc> { - let k = key_of((cat.revision, current, prefix, n)); + let k = key_of((cat.library_revision, current, prefix, n)); match &self.suggestions { Some((h, v)) if *h == k => v.clone(), _ => { @@ -1714,8 +1719,9 @@ impl Caches { } } /// Every album's photo count for the sidebar (a smart album evaluates its rules, which - /// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules - /// all bump its revision) and — only while some smart album has an "in the last…" rule — + /// scans the catalog). Recomputed when the library changes (photos, metadata, album rules + /// all bump its revision; a develop edit only while some smart album's rules read develop + /// state) and — only while some smart album has an "in the last…" rule — /// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock /// within a minute without rescanning every frame. /// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within @@ -1731,7 +1737,10 @@ impl Caches { ) -> std::sync::Arc { let relative = rules.depends_on_now(); let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); + // the count only changes with a develop edit when the rules (or the albums they test) read develop state + let reads_develop = rules.reads_develop() || rules.albums_tested().into_iter().any(|a| cat.album_reads_develop(a)); + let rev = cat.view_revision(reads_develop); + let k = key_of((rev, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code())); if let Some((key, v)) = &self.rules_view && *key == k { @@ -1756,7 +1765,7 @@ impl Caches { cat: &lightcraft_catalog::Catalog, ) -> std::sync::Arc>> { if let Some((rev, v)) = &self.smart_problems - && *rev == cat.revision + && *rev == cat.library_revision { return v.clone(); } @@ -1764,7 +1773,7 @@ impl Caches { cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(), ); self.smart_problem_scans += 1; - self.smart_problems = Some((cat.revision, v.clone())); + self.smart_problems = Some((cat.library_revision, v.clone())); v } @@ -1775,7 +1784,8 @@ impl Caches { ) -> std::sync::Arc> { let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, minute)); + // plain albums' counts never change with a develop edit; smart albums' do when their rules read develop state + let k = key_of((cat.view_revision(cat.smart_albums_read_develop()), minute)); match &self.album_counts { Some((h, v)) if *h == k => v.clone(), _ => { @@ -1879,6 +1889,56 @@ mod cache_tests { assert_eq!(c.smart_problem_scans, 2); } + /// A develop edit (one slider tick: settings, edit time and a History step) changes no + /// library-wide view unless it reads develop state: the sidebar's counts and rule-dialog count + /// aren't worked out again, while a smart album and a rule testing Has Edits / Cropped follow + /// the edit. + #[test] + fn develop_edits_only_redo_views_that_read_develop_state() { + let mut cat = Catalog::default(); + let mut c = super::Caches::default(); + for i in 0..3u64 { + cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap(); + } + cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap(); + let now = "2026-09-30T12:00:00"; + let rating = + |v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() }; + let cropped: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "cropped", "op": "is", "value": true}]})).unwrap(); + let develop = |cat: &mut Catalog, id: u64, f: &dyn Fn(&mut lightcraft_develop::DevelopSettings)| { + let mut s = (*cat.photo(PhotoId(id)).unwrap().develop).clone(); + f(&mut s); + let settings = std::sync::Arc::new(s); + let step = lightcraft_catalog::HistoryStep { label: "Edit".into(), settings: settings.clone() }; + let set = Op::SetDevelop { id: PhotoId(id), settings, label: "Edit".into(), edited: Some(now.into()) }; + cat.apply(Op::Batch { ops: vec![set, Op::PushHistory { id: PhotoId(id), step }] }).unwrap(); + }; + c.album_counts(&cat, now); + c.rules_view(&cat, &rating(3), None, None, now); + let (tree, counts) = (c.keyword_tree(&cat), c.counts(&cat).total); + for i in 0..20 { + develop(&mut cat, 2, &|s| s.light.exposure = i as f64 / 10.0); + c.album_counts(&cat, now); + c.rules_view(&cat, &rating(3), None, None, now); + assert!(std::sync::Arc::ptr_eq(&tree, &c.keyword_tree(&cat))); + assert_eq!(c.counts(&cat).total, counts); + } + assert_eq!((c.album_count_scans, c.rules_view_scans), (1, 1), "slider ticks don't recount"); + // views that read develop state follow the edit + assert_eq!(c.rules_view(&cat, &cropped, None, None, now).count, 0); + cat.apply(smart(11, serde_json::json!({"rules": [{"field": "edited", "op": "is", "value": true}]}))).unwrap(); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 1, "photo 2 has edits"); + develop(&mut cat, 3, &|s| s.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.9)); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 2, "a smart album testing Has Edits counts the new edit"); + assert_eq!(c.rules_view(&cat, &cropped, None, None, now).count, 1, "a rule testing Cropped sees the crop"); + // a rule that tests such an album through an Album rule follows it too + let via: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 11}]})).unwrap(); + assert_eq!(c.rules_view(&cat, &via, None, None, now).count, 2); + develop(&mut cat, 1, &|s| s.light.exposure = 1.0); + assert_eq!(c.rules_view(&cat, &via, None, None, now).count, 3); + assert_eq!(c.album_counts(&cat, now)[&AlbumId(11)], 3); + } + /// Without "in the last…" rules the clock never causes a rescan. #[test] fn absolute_rules_ignore_the_clock() { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 23447616..431c954c 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -487,7 +487,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and // kept, view render cached) so stepping to them is instant if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) { - let ids = app.session.visible_cloned(); + let ids = app.session.visible_shared().1; if let Some(i) = ids.iter().position(|p| *p == id) { let next = ids.get(i + 1).copied(); let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied(); @@ -2132,7 +2132,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20)); - let ids = app.session.visible_cloned(); + // shared, not copied: the strip is drawn every frame + let ids = app.session.visible_shared().1; let active = app.session.selection.active; let cell_w = 120.0; let ppp = ui.ctx().pixels_per_point(); diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe032..cd540f76 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -555,7 +555,8 @@ pub fn hints_for(app: &mut LightcraftApp, photo: u64) -> Option, GroupBy); /// What the grid derives from the visible photos, kept across frames. Everything is keyed by the /// session's visible-list generation ([`lightcraft_engine::Session::visible_shared`]), which -/// changes whenever the list is recomputed (any catalog change: crops, orientation, dates, stacks; -/// or another source, filter or sort) — so an unchanged frame neither copies nor hashes the ids. +/// changes whenever the list is recomputed (a library change: dates, stacks, photos; a develop +/// edit when the filter or sort reads it; or another source, filter or sort) — so an unchanged +/// frame neither copies nor hashes the ids. Photo shapes (crop, orientation) are develop state: +/// the justified layout keys on the catalog revision too. #[derive(Default)] pub struct GridCache { runs: Option<(RunsKey, Arc>)>, - /// Layout by (runs key, width, thumbnail size, square). - layout: Option<((RunsKey, u32, u32, bool), Arc)>, + /// Layout by (runs key, width, thumbnail size, square, catalog revision for photo shapes). + layout: Option<((RunsKey, u32, u32, bool, u64), Arc)>, /// Stack index by generation. stacks: Option<(u64, Arc)>, /// How many of the photos are browsed (Local) ones, by generation. @@ -211,8 +213,10 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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); + // the thumbnail size; a develop edit (crop, rotation) changes a shape without changing the + // list, so justified rows also key on the catalog revision + let shapes = if square { 0 } else { app.session.catalog.revision }; + let lay_key = (runs_key, avail_w.to_bits(), target.to_bits(), square, shapes); let lay = match &app.caches.grid.layout { Some((k, l)) if *k == lay_key => l.clone(), _ => { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a14332c8..33e0eab9 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -732,7 +732,8 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { } let id = egui::Id::new("missing-count"); let now = ui.input(|i| i.time); - let rev = app.session.catalog.revision; + // which files the library expects is not develop state: a slider drag doesn't check them all again + let rev = app.session.catalog.library_revision; let mut job: Job = ui.data(|d| d.get_temp(id)).unwrap_or(Job { rev: u64::MAX, at: f64::MIN, ..Default::default() }); let fresh = now - job.at < 5.0 && job.rev == rev; if !fresh && !job.running.load(Relaxed) { diff --git a/crates/ui-egui/src/tests_grid.rs b/crates/ui-egui/src/tests_grid.rs index 7aafb212..c832f00f 100644 --- a/crates/ui-egui/src/tests_grid.rs +++ b/crates/ui-egui/src/tests_grid.rs @@ -132,6 +132,32 @@ fn grid_headers_selection_scrolling_stacks_and_shape_changes() { assert_cheap_frames(&mut h); } +/// A develop edit (a Quick Develop click, a slider tick) leaves the list in view as it is: the +/// grid's date runs and stack index aren't rebuilt, and the square grid's layout neither; the +/// justified grid lays out again since a crop or rotation can change a photo's shape. +#[test] +fn develop_edits_keep_the_grid_view() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + let (generation, _) = h.app.session.visible_shared(); + let before = h.app.caches.grid_stats.clone(); + let id = h.app.session.visible()[10]; + select(&mut h, id); + for v in [0.5, 1.0] { + let r = h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": v}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + } + assert_eq!(h.app.session.visible_shared().0, generation, "{view:?}: the query didn't run again"); + let s = &h.app.caches.grid_stats; + assert_eq!((s.view_builds, s.stack_index_builds), (before.view_builds, before.stack_index_builds), "{view:?}"); + if view == ViewMode::SquareGrid { + assert_eq!(s.layout_builds, before.layout_builds, "square cells don't depend on shapes"); + } + assert_cheap_frames(&mut h); + } +} + /// Selected photos whose thumbnails have not loaded (or can't) are still marked selected in the /// justified grid (issue #298): the active one white, the others grey. #[test] diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4..a439822e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (LR-LIB-SMARTALBUM: develop edits no longer recount the sidebar unless a rule reads develop state, #1057; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -96,7 +96,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per library revision (develop edits recount only while a smart album's rules read develop state: Has Edits, Edit Date, Cropped, Treatment, sizes; #1057) (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | @@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| +| 2026-10-11 | patch | LR-LIB-SMARTALBUM: sidebar counts key on the library revision, so develop edits recount them only while a smart album reads develop state (issue #1057); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |