diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index dd92b5e2..a4f2b2ed 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -455,7 +455,7 @@ impl Journal { Err(e) => log::warn!("catalog: can't write the format v{VERSION} snapshot: {e}"), } } - catalog.revision = 0; + catalog.set_revision(0); Ok((j, catalog, report)) } diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ecde973b..4ed5b5b9 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -21,6 +21,7 @@ pub mod local; pub mod lock; pub mod model; pub mod query; +pub mod revision; pub mod rules; pub mod safe_file; pub mod stacks; @@ -38,6 +39,7 @@ pub use local::{DEFAULT_FORGET_DAYS, ForgetPlan, folder_of}; pub use lock::{LibraryLock, LockError, LockOwner}; pub use model::*; pub use query::{DateGroup, Filter, Person, RatingOp, Sort, SortKey, mix64}; +pub use revision::ViewRevision; pub use rules::{Match, Rule, RuleSet}; use serde::{Deserialize, Serialize}; pub use store::{FsStore, MemStore, Store}; @@ -281,6 +283,11 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// Increments on every applied op that changes more than photos' develop state + /// ([`Op::is_develop_only`]): what views that don't read develop state key on (see + /// [`revision`]), so a slider drag doesn't work them out again on every tick. + #[serde(skip)] + pub library_revision: u64, } impl Catalog { @@ -485,8 +492,12 @@ impl Catalog { /// Apply an op; returns its inverse. On error nothing changes. pub fn apply(&mut self, op: Op) -> Result { + let develop_only = op.is_develop_only(); let inv = self.apply_inner(op)?; self.revision += 1; + if !develop_only { + self.library_revision += 1; + } Ok(inv) } diff --git a/crates/catalog/src/revision.rs b/crates/catalog/src/revision.rs new file mode 100644 index 00000000..04f31261 --- /dev/null +++ b/crates/catalog/src/revision.rs @@ -0,0 +1,275 @@ +//! What changed, for caches: [`Catalog::revision`] counts every applied op, and +//! [`Catalog::library_revision`] every op except those that only change photos' develop state +//! ([`Op::is_develop_only`]: settings, edit time, History, snapshots). A slider drag applies a +//! develop op per tick; views that don't read develop state (most filters and sorts, the sidebar's +//! counts and trees) key on the library revision and aren't worked out again on every tick. +//! +//! Some views *do* read develop state: "edited" filters and rules, Edit Date, Cropped, Treatment +//! and the size rules (crop and orientation change a photo's shown size). Those key on the full +//! revision ([`Catalog::view_revision`] with `reads_develop`), through the smart albums a filter +//! or rule tests too. + +use std::collections::HashSet; + +use crate::{AlbumId, Catalog, Filter, Op, Rule, RuleSet, Sort, SortKey}; + +/// What a cached view of the catalog was worked out from: the library revision, and the full +/// revision as well when the view reads develop state. Equal values mean the view is unchanged. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct ViewRevision { + pub library: u64, + /// [`Catalog::revision`] for views that read develop state; `None` for the others. + pub all: Option, +} + +/// Smart-album rule fields whose matches don't depend on a photo's develop state. Every other +/// field (including one added later and not listed here) counts as reading it, so a forgotten +/// field costs a query per slider tick, never a stale view. +const FIELDS_WITHOUT_DEVELOP: &[&str] = &[ + "rating", + "flag", + "label", + "text", + // an album rule reads what that album holds: see `Catalog::filter_reads_develop` + "album", + "virtualCopy", + "copyName", + "stacked", + "fileName", + "extension", + "filePath", + "kind", + "format", + "duration", + "captureDate", + "importDate", + "keywords", + "keywordCount", + "person", + "personCount", + "title", + "caption", + "altText", + "creator", + "copyright", + "copyrightStatus", + "camera", + "lens", + "focalLength", + "aperture", + "shutterSpeed", + "iso", + "location", + "city", + "state", + "country", + "hasGps", + "sharpness", + "bestOfGroup", +]; + +/// Whether a rule on `field` can match differently after a develop edit (Has Edits, Edit Date, +/// Cropped, Treatment, and the sizes a crop or rotation changes). +pub fn field_reads_develop(field: &str) -> bool { + !FIELDS_WITHOUT_DEVELOP.contains(&field) +} + +impl Op { + /// Whether the op only changes photos' develop state: their settings and edit time, History + /// and snapshots. Such ops bump [`Catalog::revision`] but not [`Catalog::library_revision`]. + pub fn is_develop_only(&self) -> bool { + let mut next = vec![self]; + while let Some(op) = next.pop() { + match op { + Op::SetDevelop { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetVersions { .. } => {} + Op::Batch { ops } => next.extend(ops), + _ => return false, + } + } + true + } +} + +impl RuleSet { + /// Whether any of the rules (nested groups included) tests develop state itself; the albums + /// an Album rule tests are [`Catalog::filter_reads_develop`]'s concern. + pub fn reads_develop(&self) -> bool { + let mut next = vec![self]; + while let Some(set) = next.pop() { + for rule in &set.rules { + match rule { + Rule::Group { group } => next.push(group), + Rule::Field { field, .. } if field_reads_develop(field) => return true, + Rule::Field { .. } => {} + } + } + } + false + } +} + +impl Filter { + /// Whether the filter itself tests develop state: Edited, an `edited:` search token or a rule + /// that does. The albums it tests are [`Catalog::filter_reads_develop`]'s concern. + pub fn reads_develop(&self) -> bool { + self.edited.is_some() + || self.text.split_whitespace().any(|t| t.to_lowercase().split_once(':').is_some_and(|(field, _)| field == "edited")) + || self.rule_set.as_ref().is_some_and(RuleSet::reads_develop) + } +} + +impl Sort { + /// Whether the order depends on develop state (Edit Date: every edit moves the photo). + pub fn reads_develop(&self) -> bool { + self.key == SortKey::EditDate + } +} + +impl Catalog { + /// Whether which photos `filter` lets through can change with a develop edit: the filter's + /// own fields and rules, and those of every smart album it tests, directly or through other + /// smart albums (loops in saved rules end the search). + pub fn filter_reads_develop(&self, filter: &Filter) -> bool { + if filter.reads_develop() { + return true; + } + self.albums_read_develop(filter.albums_tested()) + } + + /// Whether what album `id` holds can change with a develop edit (a smart album whose rules, + /// or those of the smart albums they test, read develop state). Plain albums never do. + pub fn album_reads_develop(&self, id: AlbumId) -> bool { + self.albums_read_develop(vec![id]) + } + + /// Whether any smart album's contents can change with a develop edit (the sidebar's counts). + pub fn smart_albums_read_develop(&self) -> bool { + self.albums_read_develop(self.albums().filter(|a| a.is_smart()).map(|a| a.id).collect()) + } + + fn albums_read_develop(&self, mut next: Vec) -> bool { + let mut seen: HashSet = HashSet::new(); + while let Some(id) = next.pop() { + if !seen.insert(id) { + continue; + } + let Some(rules) = self.album(id).and_then(|a| a.smart.as_deref()) else { continue }; + if rules.reads_develop() { + return true; + } + next.extend(rules.albums_tested()); + } + false + } + + /// The revision a cached view keys on: the library revision, plus the full revision when the + /// view reads develop state (see the module docs). + pub fn view_revision(&self, reads_develop: bool) -> ViewRevision { + ViewRevision { library: self.library_revision, all: reads_develop.then_some(self.revision) } + } + + /// Count revisions from `at` (both of them), as a library just loaded does. + pub fn set_revision(&mut self, at: u64) { + self.revision = at; + self.library_revision = at; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + + use super::*; + use crate::{Album, Photo, PhotoId, Source}; + + fn photo(id: u64) -> Photo { + Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("IMG_{id}.jpg"), "JPEG", 6000, 4000, "2026-01-01T00:00:00") + } + + fn rules(v: serde_json::Value) -> RuleSet { + serde_json::from_value(v).unwrap() + } + + fn smart(id: u64, rules: RuleSet) -> Op { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); + Op::AddAlbum { album: a } + } + + #[test] + fn develop_ops_bump_only_the_full_revision() { + let mut c = Catalog::new(); + c.apply(Op::AddPhoto { photo: Box::new(photo(1)) }).unwrap(); + let (rev, lib) = (c.revision, c.library_revision); + let mut s = (*c.photo(PhotoId(1)).unwrap().develop).clone(); + s.light.exposure = 1.0; + let set = + Op::SetDevelop { id: PhotoId(1), settings: Arc::new(s.clone()), label: "Exposure".into(), edited: Some("2026-02-01T00:00:00".into()) }; + let step = Op::PushHistory { id: PhotoId(1), step: crate::HistoryStep { label: "Exposure".into(), settings: Arc::new(s) } }; + c.apply(Op::Batch { ops: vec![set, step] }).unwrap(); + assert_eq!((c.revision, c.library_revision), (rev + 1, lib), "a develop edit leaves the library revision"); + c.apply(Op::SetRating { id: PhotoId(1), rating: 3 }).unwrap(); + assert_eq!((c.revision, c.library_revision), (rev + 2, lib + 1)); + // a batch with any library op is a library change + let mixed = + Op::Batch { ops: vec![Op::SetHistory { id: PhotoId(1), history: Vec::new() }, Op::SetFlag { id: PhotoId(1), flag: crate::Flag::Pick }] }; + c.apply(mixed).unwrap(); + assert_eq!(c.library_revision, lib + 2); + // a failed op changes neither + assert!(c.apply(Op::SetRating { id: PhotoId(9), rating: 3 }).is_err()); + assert_eq!((c.revision, c.library_revision), (rev + 3, lib + 2)); + } + + #[test] + fn filters_sorts_and_rules_that_read_develop_state() { + assert!(!Filter::default().reads_develop()); + assert!(!Filter { text: "harbour rating:3".into(), rating: 2, keyword: Some("travel".into()), ..Default::default() }.reads_develop()); + assert!(Filter { edited: Some(false), ..Default::default() }.reads_develop()); + assert!(Filter { text: "harbour Edited:yes".into(), ..Default::default() }.reads_develop()); + assert!(Sort { key: SortKey::EditDate, ..Default::default() }.reads_develop()); + assert!(!Sort { key: SortKey::FileName, ..Default::default() }.reads_develop()); + for field in ["edited", "cropped", "treatment", "editDate", "longEdge", "shortEdge", "aspect", "megapixels"] { + let nested = rules( + json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"group": {"match": "any", "rules": [{"field": field, "op": "is", "value": true}]}}]}), + ); + assert!(nested.reads_develop(), "{field}"); + } + assert!( + !rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 1}, {"field": "keywords", "op": "contains", "value": "x"}]})) + .reads_develop() + ); + } + + /// Every rule field is either known not to read develop state or counts as reading it: a + /// new field must be put on one side on purpose. + #[test] + fn every_rule_field_is_classified() { + let reads: Vec<&str> = crate::rules::FIELDS.iter().map(|f| f.0).filter(|f| field_reads_develop(f)).collect(); + assert_eq!(reads, ["editDate", "longEdge", "shortEdge", "aspect", "megapixels", "edited", "cropped", "treatment"]); + for f in FIELDS_WITHOUT_DEVELOP { + assert!(crate::rules::FIELDS.iter().any(|x| x.0 == *f), "{f} is not a rule field"); + } + } + + #[test] + fn smart_albums_read_develop_through_the_albums_they_test() { + let mut c = Catalog::new(); + c.apply(smart(1, rules(json!({"rules": [{"field": "rating", "op": ">=", "value": 3}]})))).unwrap(); + c.apply(smart(2, rules(json!({"rules": [{"field": "edited", "op": "is", "value": true}]})))).unwrap(); + // 3 tests 2 (which reads develop); 4 and 5 test each other (a loop) and 1 + c.apply(smart(3, rules(json!({"rules": [{"field": "album", "op": "is", "value": 2}]})))).unwrap(); + c.apply(smart(4, rules(json!({"rules": [{"field": "album", "op": "is", "value": 5}, {"field": "album", "op": "is", "value": 1}]})))).unwrap(); + c.apply(smart(5, rules(json!({"rules": [{"field": "album", "op": "is", "value": 4}]})))).unwrap(); + c.apply(Op::AddAlbum { album: Album::new(AlbumId(6), "Plain") }).unwrap(); + let reads = |id: u64| c.album_reads_develop(AlbumId(id)); + assert_eq!([1, 2, 3, 4, 5, 6, 7].map(reads), [false, true, true, false, false, false, false]); + assert!(c.filter_reads_develop(&Filter { album: Some(AlbumId(3)), ..Default::default() })); + assert!(!c.filter_reads_develop(&Filter { album: Some(AlbumId(4)), ..Default::default() })); + assert!(c.smart_albums_read_develop()); + c.apply(Op::RemoveAlbum { id: AlbumId(2) }).unwrap(); + c.apply(Op::RemoveAlbum { id: AlbumId(3) }).unwrap(); + assert!(!c.smart_albums_read_develop()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0e69f141..d4db47e2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -176,15 +176,16 @@ pub struct Session { pub filter: Filter, pub sort: Sort, pub selection: Selection, - /// The grid order for the current source/filter/sort (cached by catalog revision). + /// The grid order for the current source/filter/sort (cached by library revision, and by the + /// full catalog revision when the filter, sort or source reads develop state). /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, - visible_key: Option<(u64, String)>, + visible_key: Option, /// 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)>, + total: Option<(ViewKey, usize)>, pub undo: Vec, /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so /// its length can't tell how many steps a command just made; see [`Session::commits`]). @@ -840,13 +841,14 @@ impl Session { /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { - let mut key = - (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); - if self.source == LibrarySource::Missing && self.media.availability.is_background() { - // the view fills in as the background checks find files gone - key.1.push_str(&format!("|{}", self.media.availability.generation())); - } - if self.visible_key.as_ref() != Some(&key) { + // a develop edit (a slider tick) only redoes the query when the filter, sort or source reads develop state + let reads_develop = self.source_reads_develop() || self.sort.reads_develop() || self.catalog.filter_reads_develop(&self.filter); + let revision = self.catalog.view_revision(reads_develop); + // the Missing Photos view fills in as the background checks find files gone + let availability = + (self.source == LibrarySource::Missing && self.media.availability.is_background()).then(|| self.media.availability.generation()); + if !self.visible_key.as_ref().is_some_and(|k| k.is(self, revision, true, availability)) { + let key = ViewKey::of(self, revision, true, availability); // "in the last N days" rules count back from the session's clock lightcraft_catalog::rules::set_now(Some((self.clock)())); let mut f = self.source.to_filter(&self.filter, &self.catalog); @@ -923,8 +925,9 @@ impl Session { if self.source == LibrarySource::Missing { return None; } - let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); - if self.total.as_ref().map(|t| &t.0) != Some(&key) { + let revision = self.catalog.view_revision(self.source_reads_develop()); + if !self.total.as_ref().is_some_and(|t| t.0.is(self, revision, false, None)) { + let key = ViewKey::of(self, revision, false, None); let mut f = self.source.to_filter(&Filter::default(), &self.catalog); if self.source == LibrarySource::Folder { let b = self.browse.clone().unwrap_or_default(); @@ -940,12 +943,21 @@ impl Session { self.total.as_ref().map(|t| t.1) } + /// Whether what the current source holds can change with a develop edit (a smart album + /// whose rules read develop state; see [`lightcraft_catalog::revision`]). + fn source_reads_develop(&self) -> bool { + match self.source { + LibrarySource::Album(a) => self.catalog.album_reads_develop(a), + _ => false, + } + } + pub fn visible_cloned(&mut self) -> Vec { 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 + /// the very same list (same library, 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(); @@ -964,6 +976,45 @@ impl Session { } } +/// What [`Session::visible`] (or [`Session::source_total`]) was worked out from. Compared every +/// frame, so without formatting or copying anything. +#[derive(Clone, Debug, PartialEq)] +struct ViewKey { + revision: lightcraft_catalog::ViewRevision, + source: LibrarySource, + /// The filter bar and sort (`None` for the source total, which ignores them). + view: Option<(Filter, Sort)>, + browse: Option, + library_folder: Option, + availability: Option, +} + +impl ViewKey { + fn of(s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option) -> ViewKey { + ViewKey { + revision, + source: s.source, + view: filtered.then(|| (s.filter.clone(), s.sort)), + browse: s.browse.clone(), + library_folder: s.library_folder.clone(), + availability, + } + } + + fn is(&self, s: &Session, revision: lightcraft_catalog::ViewRevision, filtered: bool, availability: Option) -> bool { + let view = match &self.view { + Some((f, sort)) => filtered && *f == s.filter && *sort == s.sort, + None => !filtered, + }; + self.revision == revision + && self.source == s.source + && view + && self.browse == s.browse + && self.library_folder == s.library_folder + && self.availability == availability + } +} + /// Automatic versions kept per photo. pub const AUTO_VERSIONS: usize = 20; @@ -1014,6 +1065,8 @@ mod tests_import; #[cfg(test)] mod tests_import_move; #[cfg(test)] +mod tests_invalidation; +#[cfg(test)] mod tests_keyword_list; #[cfg(test)] mod tests_libops; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 2bb5511c..5b47f354 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -370,7 +370,7 @@ impl Session { // in this process its own range: otherwise an empty library opened after one that was // never edited this session would look unchanged and show the old library's numbers. static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; + catalog.set_revision(LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32); self.catalog = catalog; self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { diff --git a/crates/engine/src/tests_invalidation.rs b/crates/engine/src/tests_invalidation.rs new file mode 100644 index 00000000..024a7e4f --- /dev/null +++ b/crates/engine/src/tests_invalidation.rs @@ -0,0 +1,162 @@ +//! What a develop edit invalidates: a slider tick changes one photo's develop settings, so the +//! library-wide grid query only runs again when the current filter, sort or smart album reads +//! develop state (see `lightcraft_catalog::revision`). + +use lightcraft_catalog::{Album, AlbumId, Filter, Op, Photo, PhotoId, RuleSet, Sort, SortKey, Source}; +use serde_json::json; + +use crate::{LibrarySource, Session}; + +/// A library of `n` photos with capture dates and a keyword each. +fn library(n: u64) -> Session { + let mut s = Session::new(); + let ops = (0..n) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{i:06}.jpg"), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28)); + p.meta.keywords = vec![format!("kw{}", i % 300)]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + s.commit("Add", Op::Batch { ops }).unwrap(); + s +} + +/// Change photo `id`'s exposure (a committed edit, or a slider tick inside an interaction). +fn expose(s: &mut Session, id: PhotoId, ev: f64) { + let mut d = (*s.develop_of(id).unwrap()).clone(); + d.light.exposure = ev; + s.set_develop(id, d, "Exposure").unwrap(); +} + +fn smart(s: &mut Session, id: u64, rules: serde_json::Value) { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + let rules: RuleSet = serde_json::from_value(rules).unwrap(); + a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() })); + s.commit("New Smart Album", Op::AddAlbum { album: a }).unwrap(); +} + +/// Slider ticks and committed develop edits leave the grid's list as it is (same generation: the +/// query didn't run) when nothing in view reads develop state; a library change still redoes it. +#[test] +fn develop_edits_dont_redo_the_library_query() { + let mut s = library(50); + let (generation, _) = s.visible_shared(); + let total = s.source_total(); + let id = s.visible()[7]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); + for i in 0..10 { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 10.0})).unwrap(); + assert_eq!(s.visible_shared().0, generation, "tick {i}"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + expose(&mut s, id, 2.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.visible_shared().0, generation, "a committed edit and its undo"); + assert_eq!(s.source_total(), total); + // a library change does + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + assert_ne!(s.visible_shared().0, generation); +} + +/// Filters, sorts and smart albums that read develop state follow every develop edit, slider +/// ticks included. +#[test] +fn views_that_read_develop_state_follow_develop_edits() { + let mut s = library(20); + let ids = s.visible().to_vec(); + let (a, b) = (ids[3], ids[11]); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + + // the filter bar's Edited, during a drag + s.filter = Filter { edited: Some(true), ..Default::default() }; + assert!(s.visible().is_empty()); + s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + assert_eq!(s.visible(), [a], "a slider tick makes the photo edited"); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.0})).unwrap(); + assert!(s.visible().is_empty(), "and back"); + s.execute("develop.endInteraction", &json!({})).unwrap(); + // a search token + s.filter = Filter { text: "edited:yes".into(), ..Default::default() }; + expose(&mut s, b, 1.0); + assert_eq!(s.visible(), [b]); + + // Edit Date: the photo edited last comes first + s.filter = Filter::default(); + s.sort = Sort { key: SortKey::EditDate, ascending: false, ..Default::default() }; + s.clock = Box::new(|| "2100-05-01T10:00:00".to_string()); + expose(&mut s, a, 0.3); + assert_eq!(s.visible()[0], a); + s.clock = Box::new(|| "2100-05-01T11:00:00".to_string()); + expose(&mut s, b, 0.7); + assert_eq!(s.visible()[0], b); + + // a smart album testing Cropped, and one testing it through an Album rule + s.sort = Sort::default(); + smart(&mut s, 100, json!({"rules": [{"field": "cropped", "op": "is", "value": true}]})); + smart(&mut s, 101, json!({"rules": [{"field": "album", "op": "is", "value": 100}]})); + for album in [100, 101] { + s.source = LibrarySource::Album(AlbumId(album)); + assert!(s.visible().is_empty()); + assert_eq!(s.source_total(), Some(0)); + } + let mut d = (*s.develop_of(a).unwrap()).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.1, 0.9, 0.9); + s.set_develop(a, d, "Crop").unwrap(); + for album in [100, 101] { + s.source = LibrarySource::Album(AlbumId(album)); + assert_eq!(s.visible(), [a], "album {album}"); + assert_eq!(s.source_total(), Some(1), "album {album}"); + } + // and the size rules (a crop changes the shown shape) + s.source = LibrarySource::All; + let square: RuleSet = serde_json::from_value(json!({"rules": [{"field": "aspect", "op": "is", "value": "square"}]})).unwrap(); + s.filter = Filter { rule_set: Some(square), ..Default::default() }; + assert!(s.visible().is_empty()); + let mut d = (*s.develop_of(b).unwrap()).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect::new(0.0, 0.0, 4000.0 / 6000.0, 1.0); + s.set_develop(b, d, "Crop").unwrap(); + assert_eq!(s.visible(), [b]); +} + +/// Per-tick cost of a slider drag on a big library (the develop op plus the grid query the +/// filmstrip asks for every frame): +/// `cargo test --release -p lightcraft-engine -- --ignored drag_tick_100k --nocapture`. +#[test] +#[ignore] +fn drag_tick_100k() { + let mut s = library(100_000); + let id = s.visible()[50_000]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.beginInteraction", &json!({"label": "Exposure"})).unwrap(); + let n = 60; + let tick = |s: &mut Session, name: &str| { + s.visible(); + let t = std::time::Instant::now(); + for i in 0..n { + s.execute("develop.set", &json!({"control": "light.exposure", "value": i as f64 / 100.0})).unwrap(); + s.visible(); + } + let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64; + eprintln!("slider tick at 100k photos, {name}: {ms:.3} ms (develop.set + visible)"); + }; + tick(&mut s, "warm-up"); + tick(&mut s, "by capture date"); + s.sort = Sort { key: SortKey::FileName, ..Default::default() }; + tick(&mut s, "by file name"); + // a view that reads develop state still asks again on every tick + s.sort = Sort::default(); + s.filter = Filter { edited: Some(false), ..Default::default() }; + tick(&mut s, "Unedited filter"); + s.execute("develop.endInteraction", &json!({})).unwrap(); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8fcd3f2b..f80c9f37 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1487,7 +1487,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>)>, @@ -1499,7 +1502,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>)>, @@ -1579,10 +1582,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 } } @@ -1590,10 +1593,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 } } @@ -1608,14 +1611,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 } } @@ -1628,10 +1631,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 } } @@ -1644,10 +1647,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 } } @@ -1660,11 +1663,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 } } @@ -1672,7 +1677,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; } @@ -1687,16 +1692,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 } } @@ -1704,10 +1709,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 } } @@ -1715,7 +1720,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()); @@ -1729,14 +1734,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(), _ => { @@ -1747,8 +1752,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 @@ -1764,7 +1770,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 { @@ -1789,7 +1798,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(); } @@ -1797,7 +1806,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 } @@ -1808,7 +1817,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(), _ => { @@ -1912,6 +1922,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 01d18229..0a64d21e 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -499,7 +499,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(); @@ -2249,7 +2249,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 78c19b7e..3c5f46c3 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -772,7 +772,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 7b664c27..e74742bc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | 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 | @@ -805,6 +805,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | +| 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 |