Browse / github.com/storytold/lightcraft / patch
LRC-LIB-KEYWORDS, LRC-LIB-LIBFOLDERS: undo and redo carry what a keyword, person or folder step did to the filter (stacked on #3157)
LRC-LIB-KEYWORDS, LRC-LIB-LIBFOLDERS: undo and redo carry what a keyword, person or folder step did to the filter (stacked on #3157) Stacked on #3157: this waits for #3157, then adds what undo and redo do with the filter. Part of #2758 (its "Not in this PR"). The commits that are this PR's own are the last three (`23c40dc388`, `dd101354e5`, `940a39b707`); the two below them are #3157's. Reworked from the version of this PR that reviewers read at `2c049cd37`, on @THEtheChad's review (below). ## Problem Deleting a keyword, renaming a keyword or a person, and removing a folder from the library change the filter that names them: the filter being shown, and the one View ▸ Enable Filters (⌘L / Ctrl+L) put aside. Undo brings the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nledford (imported from pull request #3093, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +1227 −270 in 44 files
- Review
- pending
- Shared
- 2026-10-28
- Id
60f080173982d73a99a0435e
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/browse.rs crates/engine/src/cmd/cull.rs crates/engine/src/cmd/face_recognize.rs crates/engine/src/cmd/filters.rs crates/engine/src/cmd/keywords.rs crates/engine/src/cmd/library.rs crates/engine/src/cmd/library/filters.rs crates/engine/src/cmd/library/specs.rs crates/engine/src/cmd/library/tests_enable_filters.rs crates/engine/src/cmd/library/tests_filter_reconcile.rs crates/engine/src/cmd/library/tests_source_album_filter.rs crates/engine/src/cmd/query.rs crates/engine/src/filter_state.rs crates/engine/src/lib.rs crates/engine/src/library.rs crates/engine/src/tests.rs crates/engine/src/tests/face_recognize.rs crates/engine/src/tests/folders.rs crates/engine/src/tests/keyword_list.rs crates/engine/src/tests/libops.rs crates/engine/src/tests/library.rs crates/engine/src/tests/merge.rs crates/engine/src/tests/organize.rs crates/engine/src/tests/undo_filters.rs crates/engine/src/undo_filters.rs crates/ui-egui/src/app.rs crates/ui-egui/src/headless/tests.rs crates/ui-egui/src/lightroom_import.rs crates/ui-egui/src/panels/chips.rs crates/ui-egui/src/panels/detail/filmstrip.rs crates/ui-egui/src/panels/detail/info.rs crates/ui-egui/src/panels/filterbar.rs crates/ui-egui/src/panels/grid.rs crates/ui-egui/src/panels/left/dates.rs crates/ui-egui/src/panels/left/keywords.rs crates/ui-egui/src/panels/people.rs crates/ui-egui/src/panels/topbar.rs crates/ui-egui/src/tests/grid.rs crates/ui-egui/src/tests/keyword_list.rs crates/ui-egui/src/tests/panels.rs crates/ui-egui/src/tests/rename_person.rs crates/ui-egui/src/tests/view_cost.rs docs/parity-checklist.md docs/whats-new.md
@@ -481,16 +481,13 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> { pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { // a library folder chosen at or below it follows too, as a source or as a filter follow_library_folder(&mut s.library_folder, from, to);- follow_library_folder(&mut s.filter.library_folder, from, to);- if let Some(f) = s.shelved_filter.as_mut() {- follow_library_folder(&mut f.library_folder, from, to);- }+ s.filters.follow(|f| follow_library_folder(&mut f.library_folder, from, to)); if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { b.path = Path::new(to).join(rel).to_string_lossy().to_string(); let f = b.path.clone();- s.filter.folder = Some(f);+ s.filters.follow_current(|filter| filter.folder = Some(f)); } } @@ -134,10 +134,12 @@ fn find_similar(s: &mut Session, p: &Value) -> Result<Value> { } hits.sort_by(|a, b| b.1.total_cmp(&a.1)); if p.get("filter").and_then(Value::as_bool).unwrap_or(true) {- s.shelved_filter = None;- s.filter.only = hits.iter().map(|h| h.0).collect();- // an empty `only` means "no constraint": no hits is its own state that shows no photos- s.filter.only_none = s.filter.only.is_empty();+ let only: Vec<PhotoId> = hits.iter().map(|h| h.0).collect();+ s.filters.change(s.source, |f| {+ // an empty `only` means "no constraint": no hits is its own state that shows no photos+ f.only_none = only.is_empty();+ f.only = only;+ }); } Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::<Vec<_>>()})) }@@ -173,14 +173,11 @@ fn rename_person(s: &mut Session, p: &Value) -> Result<Value> { let photos = ops.len(); s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?; s.skip_auto_write = true;- if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {- s.filter.person = Some(to.clone());- }- if let Some(f) = s.shelved_filter.as_mut()- && f.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key)- {- f.person = Some(to.clone());- }+ s.filters.follow(|f| {+ if f.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {+ f.person = Some(to.clone());+ }+ }); Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) } @@ -21,10 +21,10 @@ fn list(s: &Session) -> Value { fn save(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "filter.savePreset"; let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "missing `name`"))?.to_string();- if s.filter == Filter::default() {+ if *s.filter() == Filter::default() { return Err(bad(C, "the filter is empty: set some filters first")); }- let preset = FilterPreset { name: name.clone(), filter: s.filter.clone() };+ let preset = FilterPreset { name: name.clone(), filter: s.filter().clone() }; match s.filter_presets.iter_mut().find(|f| f.name.eq_ignore_ascii_case(&name)) { Some(f) => *f = preset, None => s.filter_presets.push(preset),@@ -37,9 +37,15 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "filter.applyPreset"; let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; let f = s.filter_presets.iter().find(|f| f.name.eq_ignore_ascii_case(name)).ok_or_else(|| bad(C, format!("no filter preset `{name}`")))?;- s.filter = f.filter.clone();- s.shelved_filter = None;- Ok(json!({"photos": s.visible().len()}))+ let f = f.filter.clone();+ let skipped = s.set_filter(f);+ let photos = s.visible().len();+ // what the source shown owns is left out, and said (a preset made before it was shown holds it)+ if skipped.is_empty() {+ Ok(json!({"photos": photos}))+ } else {+ Ok(json!({"photos": photos, "skipped": skipped.iter().map(|f| f.name()).collect::<Vec<_>>()}))+ } } fn delete(s: &mut Session, p: &Value) -> Result<Value> {@@ -66,7 +72,15 @@ pub fn specs() -> Vec<CommandSpec> { always, save ),- cmd!("filter.applyPreset", "Apply Filter Preset", [], None, "{name} — replaces the current filter → {photos}", always, apply),+ cmd!(+ "filter.applyPreset",+ "Apply Filter Preset",+ [],+ None,+ "{name} — replaces the current filter; what the source shown owns (an album, the library folder shown, Picks' flag) is left out and named → {photos, skipped?: [album|libraryFolder|flag]}",+ always,+ apply+ ), cmd!("filter.deletePreset", "Delete Filter Preset", [], None, "{name}", always, delete), ] }@@ -29,15 +29,12 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol if photos + listed > 0 { s.commit(label, op)?; }- if let Some(k) = s.filter.keyword.clone() {- s.filter.keyword = follow(&k);- } // a filter put aside by Enable Filters follows the keyword too- if let Some(f) = s.shelved_filter.as_mut()- && let Some(k) = f.keyword.clone()- {- f.keyword = follow(&k);- }+ s.filters.follow(|f| {+ if let Some(k) = f.keyword.clone() {+ f.keyword = follow(&k);+ }+ }); Ok(json!({"changed": photos, "listed": listed})) } @@ -5,6 +5,7 @@ use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Fl use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param};+use crate::filter_state::SourceOwned; use crate::{LibrarySource, Result, Selection, Session}; mod filters;@@ -34,5 +35,5 @@ mod rated_out_tests; mod enable_filters_tests; #[cfg(test)]-#[path = "library/tests_source_album_filter.rs"]-mod source_album_filter_tests;+#[path = "library/tests_filter_reconcile.rs"]+mod filter_reconcile_tests;@@ -59,7 +59,7 @@ pub(super) fn view_rules(s: &Session) -> lightcraft_catalog::Filter { match smart { Some(base) => { // overlay the fields the filter bar changed- let cur = serde_json::to_value(&s.filter).unwrap_or_default();+ let cur = serde_json::to_value(s.filter()).unwrap_or_default(); let def = serde_json::to_value(Filter::default()).unwrap_or_default(); let mut patch = serde_json::Map::new(); if let (Some(c), Some(d)) = (cur.as_object(), def.as_object()) {@@ -74,7 +74,7 @@ pub(super) fn view_rules(s: &Session) -> lightcraft_catalog::Filter { merge_filter(&base, &Value::Object(patch), "").unwrap_or(base) } None => {- let mut f = s.source.to_filter(&s.filter, &s.catalog);+ let mut f = s.source.to_filter(s.filter(), &s.catalog); f.deleted = false; if s.source == LibrarySource::LibraryFolder { f.library_folder = s.library_folder.clone();@@ -49,9 +49,7 @@ pub fn specs() -> Vec<CommandSpec> { if s.catalog.album(a).is_none_or(|a| a.folder) { return Err(bad("library.source", "no such album")); }- // one album at a time: `to_filter` makes the album shown replace the filter's, and the- // filter bar has no album chip, so a left-over one would narrow the next source unseen- s.filter.album = None;+ // (the filter never holds an album: the filter bar has no chip for one) LibrarySource::Album(a) } "libraryFolder" => {@@ -69,12 +67,12 @@ pub fn specs() -> Vec<CommandSpec> { return Err(bad("library.source", format!("{path} holds other disks as well: choose a disk or a folder on one"))); } s.library_folder = Some(path.to_string());- // one folder at a time: a folder filter left over would be ignored- s.filter.library_folder = None;+ // one folder at a time: `source_shown` below takes a folder filter out LibrarySource::LibraryFolder } other => return Err(bad("library.source", format!("unknown source `{other}`"))), };+ s.filters.source_shown(s.source); s.keep_active_in_view(); Ok(json!({"count": s.visible().len()})) }@@ -84,17 +82,20 @@ pub fn specs() -> Vec<CommandSpec> { "Filter", [], None,- "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?}",+ "partial Filter: {text?, rating?, ratingOp?: atLeast|exactly|atMost, flag?: pick|reject|none|null, label?, kind?, merged?: hdr|panorama|hdrPanorama|any, edited?, date?, libraryFolder?: a path from library.folders, keyword?, person?, camera?} — the source shown owns some fields: an album is never part of the filter (show one with library.source), nor is a library folder while one is shown, nor a flag while Picks is shown; setting one is refused and changes nothing", always, |s, p| {- let mut v = serde_json::to_value(&s.filter).unwrap_or_default();+ let mut v = serde_json::to_value(s.filter()).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); let f: Filter = serde_json::from_value(v).map_err(|e| bad("library.filter", e.to_string()))?; let mut f = f; // a blank folder is no folder: no hidden "filters active" state f.library_folder = f.library_folder.filter(|d| !d.trim().is_empty());- if f.library_folder.is_some() && s.source == LibrarySource::LibraryFolder {- return Err(bad("library.filter", "a folder is already shown (library.source): show another source first"));+ // only what this call sets is refused: a field the source owns that is left in the filter (a source set+ // without `source_shown`) must not block every other change, and `set` below takes it out+ let sets = |o: &&SourceOwned| p.get(o.name()).is_some_and(|v| !v.is_null());+ if let Some(field) = s.source.owned_fields_set_in(&f).iter().find(sets) {+ return Err(bad("library.filter", field.refusal())); } // checked like a smart album's rules when this call sets them: an unknown field or a value // that can't match is an error, not an empty grid. Rules that stopped checking since@@ -106,8 +107,7 @@ pub fn specs() -> Vec<CommandSpec> { if let Some(problems) = f.rule_set.as_ref().filter(|_| sets_rules).map(|rs| rs.check(&s.catalog)).filter(|p| !p.is_empty()) { return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; "))); }- s.filter = f;- s.shelved_filter = None;+ s.filters.set(s.source, f); Ok(json!({"count": s.visible().len()})) } ),@@ -148,18 +148,13 @@ pub fn specs() -> Vec<CommandSpec> { return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); } mark_deleted(s, &ids, "Remove Folder from Library")?;- if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {- s.filter.library_folder = None;- }- // a filter put aside by Enable Filters must not bring the removed folder back- if let Some(f) = s.shelved_filter.as_mut()- && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path))- {- f.library_folder = None;- }- if s.shelved_filter.as_ref() == Some(&Filter::default()) {- s.shelved_filter = None;- }+ // the filter and the one put aside by Enable Filters must not bring the removed folder back+ s.filters.follow(|f| {+ if f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {+ f.library_folder = None;+ }+ });+ s.filters.drop_aside_if_empty(); // the folder being shown is gone: back to everything, as for a deleted album if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.library_folder = None;@@ -200,8 +195,7 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {- s.filter = Default::default();- s.shelved_filter = None;+ s.filters.clear(); Ok(json!({"count": s.visible().len()})) }), // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was@@ -210,19 +204,11 @@ pub fn specs() -> Vec<CommandSpec> { "Enable Filters", ["View"], Some("Cmd+L"),- "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together). Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}",+ "{} — with a filter set, puts it aside and shows everything; with one put aside, brings it back as it was (stars, flag, text… together), except its library folder while a library folder is shown now and its flag while Picks is shown now. Setting or clearing a filter while it is off forgets the one put aside → {enabled, count}", always, |s, _| {- if s.filter != Filter::default() {- s.shelved_filter = Some(std::mem::take(&mut s.filter));- } else if let Some(mut f) = s.shelved_filter.take() {- // one folder at a time: the folder shown now wins over the one the filter had- if s.source == LibrarySource::LibraryFolder {- f.library_folder = None;- }- s.filter = f;- }- Ok(json!({"enabled": s.shelved_filter.is_none(), "count": s.visible().len()}))+ let enabled = s.filters.toggle(s.source);+ Ok(json!({"enabled": enabled, "count": s.visible().len()})) } ), cmd!(@@ -31,15 +31,15 @@ fn stars_and_pick(s: &mut Session) { fn off_shows_everything_and_on_brings_back_the_same_filter() { let mut s = session().unwrap(); stars_and_pick(&mut s);- let set = s.filter.clone();+ let set = s.filter().clone(); assert_eq!(s.visible().len(), 1, "the filter hides photos, or off and on would look alike"); let r = s.execute("library.toggleFilters", &json!({})).unwrap(); assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(false), json!(4)));- assert_eq!(s.filter, Filter::default());+ assert_eq!(*s.filter(), Filter::default()); assert_eq!(s.visible().len(), 4); let r = s.execute("library.toggleFilters", &json!({})).unwrap(); assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(true), json!(1)));- assert_eq!(s.filter, set, "stars and flag come back together");+ assert_eq!(*s.filter(), set, "stars and flag come back together"); assert_eq!(s.visible().len(), 1); } @@ -50,8 +50,8 @@ fn with_no_filter_it_does_nothing_and_leaves_nothing_to_bring_back() { assert_eq!((r["enabled"].clone(), r["count"].clone()), (json!(true), json!(4))); let r = s.execute("library.toggleFilters", &json!({})).unwrap(); assert_eq!(r["enabled"], json!(true));- assert_eq!(s.filter, Filter::default());- assert!(s.shelved_filter.is_none());+ assert_eq!(*s.filter(), Filter::default());+ assert!(s.filters.aside().is_none()); } #[test]@@ -64,8 +64,8 @@ fn setting_a_filter_while_off_turns_filtering_on_and_forgets_the_old_one() { // off again, then on: the filter that was set last, not the first one s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter.rating, 2);- assert_eq!(s.filter.flag, None);+ assert_eq!(s.filter().rating, 2);+ assert_eq!(s.filter().flag, None); } #[test]@@ -75,9 +75,9 @@ fn emptying_the_filter_by_hand_while_off_leaves_nothing_to_bring_back() { s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.filter", &json!({"rating": 2})).unwrap(); s.execute("library.filter", &json!({"rating": 0})).unwrap();- assert_eq!(s.filter, Filter::default());+ assert_eq!(*s.filter(), Filter::default()); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter, Filter::default(), "the filter from before it was switched off is not back");+ assert_eq!(*s.filter(), Filter::default(), "the filter from before it was switched off is not back"); assert_eq!(s.visible().len(), 4); } @@ -88,7 +88,7 @@ fn clearing_the_filter_while_off_leaves_nothing_to_bring_back() { s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.clearFilter", &json!({})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter, Filter::default());+ assert_eq!(*s.filter(), Filter::default()); assert_eq!(s.visible().len(), 4); } @@ -101,8 +101,8 @@ fn applying_a_preset_while_off_turns_filtering_on_with_the_preset() { s.execute("filter.applyPreset", &json!({"name": "Fives"})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter.rating, 5);- assert_eq!(s.filter.flag, None);+ assert_eq!(s.filter().rating, 5);+ assert_eq!(s.filter().flag, None); } #[test]@@ -113,8 +113,8 @@ fn a_folder_filter_does_not_come_back_over_the_folder_now_shown() { s.source = LibrarySource::LibraryFolder; s.library_folder = Some("/lightcraft-enable-filters".into()); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter.rating, 4);- assert_eq!(s.filter.library_folder, None);+ assert_eq!(s.filter().rating, 4);+ assert_eq!(s.filter().library_folder, None); } #[test]@@ -124,8 +124,8 @@ fn removing_a_folder_while_off_drops_it_from_the_filter_put_aside() { s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.removeFolder", &json!({"path": "/lightcraft-enable-filters"})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap();- assert_eq!(s.filter.library_folder, None, "the removed folder is not brought back");- assert_eq!(s.filter.rating, 4, "the rest of the filter is");+ assert_eq!(s.filter().library_folder, None, "the removed folder is not brought back");+ assert_eq!(s.filter().rating, 4, "the rest of the filter is"); } #[test]@@ -134,7 +134,7 @@ fn removing_the_only_criterion_put_aside_leaves_filtering_on() { s.execute("library.filter", &json!({"libraryFolder": "/lightcraft-enable-filters"})).unwrap(); s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("library.removeFolder", &json!({"path": "/lightcraft-enable-filters"})).unwrap();- assert!(s.shelved_filter.is_none(), "an empty filter is not left put aside");+ assert!(s.filters.aside().is_none(), "an empty filter is not left put aside"); } #[test]@@ -143,12 +143,12 @@ fn a_keyword_renamed_while_off_is_followed_by_the_filter_put_aside() { let mut photo = Photo::new(PhotoId(5), Source::File { path: "/lightcraft-enable-filters/5.jpg".into() }, "5.jpg", "JPEG", 40, 30, "2026-10-23"); photo.meta.keywords = vec!["cat".into()]; s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap();- s.filter.keyword = Some("cat".into());+ s.set_filter(Filter { keyword: Some("cat".into()), ..Default::default() }); assert_eq!(s.visible().len(), 1); s.execute("library.toggleFilters", &json!({})).unwrap(); s.execute("keyword.rename", &json!({"from": "cat", "to": "dog"})).unwrap(); let r = s.execute("library.toggleFilters", &json!({})).unwrap(); assert_eq!(r["enabled"], json!(true));- assert_eq!(s.filter.keyword.as_deref(), Some("dog"), "the put-aside filter follows the rename");+ assert_eq!(s.filter().keyword.as_deref(), Some("dog"), "the put-aside filter follows the rename"); assert_eq!(s.visible().len(), 1); }new file mode 100644@@ -0,0 +1,159 @@+//! The filter and the source shown: what the source owns (the album, the library folder, Picks' flag) never+//! stays in the filter, whichever command sets it (`library.filter`, `filter.applyPreset`,+//! `library.toggleFilters`, `library.source`, `Session::set_filter`).+use lightcraft_catalog::{AlbumId, Filter, Flag, Op, Photo, PhotoId, Source};+use serde_json::{Value, json};++use crate::cmd::filters::FilterPreset;+use crate::filter_state::SourceOwned;+use crate::{Result, Selection, Session};++const A: &str = "/lightcraft-reconcile/a";+const B: &str = "/lightcraft-reconcile/b";++/// Photos 1 and 2 (rated 5, 5; photo 1 picked) are in folder A, photos 3 and 4 (rated 2, 1; photo 3 picked)+/// in folder B. Album X holds photo 1, album Y photo 3.+fn session() -> Result<(Session, AlbumId, AlbumId)> {+ let mut s = Session::new();+ for (id, dir, rating, flag) in [(1, A, 5, Flag::Pick), (2, A, 5, Flag::None), (3, B, 2, Flag::Pick), (4, B, 1, Flag::None)] {+ let mut photo = Photo::new(PhotoId(id), Source::File { path: format!("{dir}/{id}.jpg") }, &format!("{id}.jpg"), "JPEG", 40, 30, "2026-10-23");+ photo.rating = rating;+ photo.flag = flag;+ s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;+ }+ let mut make = |name: &str, id: u64| {+ s.selection = Selection { ids: vec![PhotoId(id)], active: None };+ AlbumId(s.execute("album.create", &json!({"name": name, "addSelected": true})).unwrap()["id"].as_u64().unwrap())+ };+ let (x, y) = (make("X", 1), make("Y", 3));+ Ok((s, x, y))+}++fn folder(s: &mut Session, path: &str) {+ s.execute("library.source", &json!({"kind": "libraryFolder", "path": path})).unwrap();+}++fn album(s: &mut Session, id: AlbumId) {+ s.execute("library.source", &json!({"kind": "album", "id": id.0})).unwrap();+}++fn preset(s: &mut Session, filter: Filter) {+ s.filter_presets.push(FilterPreset { name: "p".into(), filter });+}++fn apply(s: &mut Session) -> Value {+ s.execute("filter.applyPreset", &json!({"name": "p"})).unwrap()+}++#[test]+fn a_preset_with_a_library_folder_leaves_it_out_while_a_folder_is_shown() {+ let (mut s, ..) = session().unwrap();+ preset(&mut s, Filter { library_folder: Some(B.into()), rating: 1, ..Default::default() });+ folder(&mut s, A);+ let r = apply(&mut s);+ assert_eq!(r, json!({"photos": 2, "skipped": ["libraryFolder"]}));+ assert_eq!(s.filter().library_folder, None, "no \"Folder: B\" chip over folder A");+ assert_eq!(s.filter().rating, 1, "the rest of the preset applies");+ s.execute("library.source", &json!({"kind": "all"})).unwrap();+ assert_eq!(s.visible().len(), 4, "folder B does not narrow the next source");+}++#[test]+fn a_preset_with_a_library_folder_applies_whole_while_no_folder_is_shown() {+ let (mut s, ..) = session().unwrap();+ preset(&mut s, Filter { library_folder: Some(B.into()), ..Default::default() });+ assert_eq!(apply(&mut s), json!({"photos": 2}));+ assert_eq!(s.filter().library_folder.as_deref(), Some(B));+}++#[test]+fn a_preset_with_an_album_leaves_it_out_whichever_source_is_shown() {+ let (mut s, x, y) = session().unwrap();+ preset(&mut s, Filter { album: Some(x), rating: 4, ..Default::default() });+ album(&mut s, y);+ assert_eq!(apply(&mut s), json!({"photos": 0, "skipped": ["album"]}), "album Y holds photo 3, rated 2");+ assert_eq!(s.filter().album, None);+ s.execute("library.source", &json!({"kind": "all"})).unwrap();+ assert_eq!(apply(&mut s), json!({"photos": 2, "skipped": ["album"]}), "no album chip, so no album narrowing the grid");+ assert_eq!(s.filter().album, None);+ assert_eq!(s.filter().rating, 4);+}++#[test]+fn a_preset_with_a_flag_leaves_it_out_while_picks_is_shown() {+ let (mut s, ..) = session().unwrap();+ preset(&mut s, Filter { flag: Some(Flag::Reject), rating: 2, ..Default::default() });+ s.execute("library.source", &json!({"kind": "picks"})).unwrap();+ assert_eq!(apply(&mut s), json!({"photos": 2, "skipped": ["flag"]}));+ assert_eq!(s.filter().flag, None, "no \"Rejected\" chip over the picks");+ s.execute("library.source", &json!({"kind": "all"})).unwrap();+ assert_eq!(apply(&mut s), json!({"photos": 0}), "off Picks the flag applies whole");+ assert_eq!(s.filter().flag, Some(Flag::Reject));+}++#[test]+fn library_filter_refuses_what_the_source_owns_and_changes_nothing() {+ let (mut s, x, y) = session().unwrap();+ s.execute("library.filter", &json!({"rating": 2})).unwrap();+ let before = s.filter().clone();+ let refuse = |s: &mut Session, patch: Value, why: &str| {+ let e = s.execute("library.filter", &patch).unwrap_err().to_string();+ assert!(e.contains(why), "{e}");+ assert_eq!(*s.filter(), before, "nothing of the call is applied: {patch}");+ };+ // under every source an album is refused: the filter bar has no album chip+ refuse(&mut s, json!({"rating": 3, "album": x.0}), "holds no album");+ album(&mut s, y);+ refuse(&mut s, json!({"rating": 3, "album": x.0}), "holds no album");+ folder(&mut s, A);+ refuse(&mut s, json!({"rating": 3, "libraryFolder": B}), "a folder is already shown");+ s.execute("library.source", &json!({"kind": "picks"})).unwrap();+ refuse(&mut s, json!({"rating": 3, "flag": "reject"}), "Picks is already shown");+ refuse(&mut s, json!({"flag": "pick"}), "Picks is already shown");+}++#[test]+fn library_filter_still_clears_and_sets_the_other_fields_while_a_source_owns_one() {+ let (mut s, ..) = session().unwrap();+ s.execute("library.source", &json!({"kind": "picks"})).unwrap();+ s.execute("library.filter", &json!({"rating": 4, "flag": null})).unwrap();+ assert_eq!(s.filter().rating, 4);+ folder(&mut s, A);+ s.execute("library.filter", &json!({"libraryFolder": null, "album": null, "rating": 5})).unwrap();+ assert_eq!(s.filter().rating, 5);+}++#[test]+fn set_filter_leaves_out_what_the_source_owns_and_says_so() {+ let (mut s, x, _) = session().unwrap();+ let left_out = s.set_filter(Filter { album: Some(x), rating: 3, flag: Some(Flag::Pick), ..Default::default() });+ assert_eq!(left_out, [SourceOwned::Album], "the album goes under any source; the flag only under Picks");+ assert_eq!((s.filter().album, s.filter().rating, s.filter().flag), (None, 3, Some(Flag::Pick)));+ s.execute("library.source", &json!({"kind": "picks"})).unwrap();+ assert_eq!(s.filter().flag, None, "showing Picks takes the flag out too");+ let left_out = s.set_filter(Filter { flag: Some(Flag::Pick), library_folder: Some(A.into()), ..Default::default() });+ assert_eq!(left_out, [SourceOwned::Flag], "a library folder stays while no folder is shown");+}++#[test]+fn enable_filters_brings_a_filter_back_without_what_the_source_owns() {+ let (mut s, ..) = session().unwrap();+ s.execute("library.filter", &json!({"rating": 1, "flag": "pick"})).unwrap();+ s.execute("library.toggleFilters", &json!({})).unwrap();+ s.execute("library.source", &json!({"kind": "picks"})).unwrap();+ s.execute("library.toggleFilters", &json!({})).unwrap();+ assert_eq!(s.filter().rating, 1, "the rest of the filter is back");+ assert_eq!(s.filter().flag, None, "Picks shows the picks: no flag chip over it");+ s.execute("library.source", &json!({"kind": "all"})).unwrap();+ assert_eq!(s.visible().len(), 4, "the old flag does not narrow the next source");+}++#[test]+fn a_flag_carried_into_picks_does_not_block_the_other_filter_changes() {+ // a source set without `source_shown` (as a library opening did before it reconciled) over a flag filter: here Picks+ let (mut s, ..) = session().unwrap();+ s.execute("library.filter", &json!({"flag": "reject"})).unwrap();+ s.source = crate::LibrarySource::Picks;+ s.execute("library.filter", &json!({"rating": 2})).expect("a change that does not set the flag is not refused");+ assert_eq!((s.filter().rating, s.filter().flag), (2, None), "and the flag Picks owns is taken out");+}deleted file mode 100644@@ -1,61 +0,0 @@-//! `library.source` with an album while the filter holds an album: the album shown wins.-use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};-use serde_json::json;--use crate::{Result, Selection, Session};--/// Photos 1 to 3 (rated 5, 5 and 2). Album X holds photo 1 and album A holds photo 3.-fn session_with_two_albums() -> Result<(Session, u64, u64)> {- let mut s = Session::new();- for (id, rating) in [(1, 5), (2, 5), (3, 2)] {- let mut photo = Photo::new(- PhotoId(id),- Source::File { path: format!("/lightcraft-source-album/{id}.jpg") },- &format!("{id}.jpg"),- "JPEG",- 40,- 30,- "2026-10-23",- );- photo.rating = rating;- photo.flag = Flag::None;- s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;- }- let mut make = |name: &str, id: u64| {- s.selection = Selection { ids: vec![PhotoId(id)], active: None };- s.execute("album.create", &json!({"name": name, "addSelected": true})).unwrap()["id"].as_u64().unwrap()- };- let (x, a) = (make("X", 1), make("A", 3));- Ok((s, x, a))-}--#[test]-fn showing_an_album_drops_the_album_the_filter_held() {- let (mut s, x, a) = session_with_two_albums().unwrap();- s.execute("library.filter", &json!({"album": x, "rating": 4})).unwrap();- assert_eq!(s.visible().len(), 1, "the album narrows the grid, or the hidden album would not matter");- s.execute("library.source", &json!({"kind": "album", "id": a})).unwrap();- assert_eq!(s.filter.album, None, "one album at a time: the one shown wins, as for a library folder");- assert_eq!(s.filter.rating, 4, "the rest of the filter stays");- // the old album must not narrow the next source, with no chip to show it- s.execute("library.source", &json!({"kind": "all"})).unwrap();- assert_eq!(s.visible().len(), 2, "photos 1 and 2 have four stars or more");-}--#[test]-fn the_album_filter_stays_while_the_source_is_not_an_album() {- let (mut s, x, _) = session_with_two_albums().unwrap();- s.execute("library.filter", &json!({"album": x})).unwrap();- s.execute("library.source", &json!({"kind": "picks"})).unwrap();- s.execute("library.source", &json!({"kind": "all"})).unwrap();- assert_eq!(s.filter.album.map(|a| a.0), Some(x));- assert_eq!(s.visible().len(), 1);-}--#[test]-fn a_refused_album_source_leaves_the_filter_alone() {- let (mut s, x, _) = session_with_two_albums().unwrap();- s.execute("library.filter", &json!({"album": x})).unwrap();- assert!(s.execute("library.source", &json!({"kind": "album", "id": 9999})).is_err());- assert_eq!(s.filter.album.map(|a| a.0), Some(x));-}@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> { "source": s.source, "libraryFolder": s.library_folder.as_ref().filter(|_| s.source == LibrarySource::LibraryFolder), "sourceLabel": label,- "filter": s.filter,+ "filter": s.filter(), "sort": s.sort, "selection": s.selection, "visibleCount": n,new file mode 100644@@ -0,0 +1,268 @@+//! The library filter and the one Enable Filters put aside, behind one door.+//!+//! The filter is session state with owners besides the filter bar: the source shown supplies some of+//! its fields itself (the album shown, the library folder shown, Picks' flag). A copy left in the+//! filter would be hidden state: the filter bar has no chip for an album, and for a folder or a flag the+//! source shown replaces it on screen, so it would narrow the next source with nothing to explain why.+//! Every write to the filter therefore goes through [`FilterState`], whose fields stay private to this module, so+//! no writer can skip [`LibrarySource::reconcile`]. The source itself is shown through `library.source` (and a+//! library opening), which reconcile the filter against it: a new way to change `Session::source` does the same.++use lightcraft_catalog::Filter;++use crate::LibrarySource;++/// A filter field its source owns (see [`LibrarySource::reconcile`]).+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum SourceOwned {+ Album,+ LibraryFolder,+ Flag,+}++impl SourceOwned {+ /// The field's name in commands (`album`, `libraryFolder`, `flag`).+ pub fn name(self) -> &'static str {+ match self {+ SourceOwned::Album => "album",+ SourceOwned::LibraryFolder => "libraryFolder",+ SourceOwned::Flag => "flag",+ }+ }++ /// Why a command that sets the field is refused.+ pub fn refusal(self) -> &'static str {+ match self {+ SourceOwned::Album => "the filter holds no album: show one with library.source {kind: album}",+ SourceOwned::LibraryFolder => "a folder is already shown (library.source): show another source first",+ SourceOwned::Flag => "Picks is already shown (library.source): show another source first",+ }+ }+}++impl LibrarySource {+ /// The fields of `f` that this source supplies itself: the ones [`LibrarySource::reconcile`] takes out.+ /// - album: the album shown replaces the filter's; under any other source it would narrow the view with+ /// no chip to say so, so the filter never holds one.+ /// - library folder: the folder shown replaces the filter's.+ /// - flag: Picks shows the picked photos whatever flag the filter asks for.+ pub fn owned_fields_set_in(&self, f: &Filter) -> Vec<SourceOwned> {+ let mut set = Vec::new();+ if f.album.is_some() {+ set.push(SourceOwned::Album);+ }+ if f.library_folder.is_some() && *self == LibrarySource::LibraryFolder {+ set.push(SourceOwned::LibraryFolder);+ }+ if f.flag.is_some() && *self == LibrarySource::Picks {+ set.push(SourceOwned::Flag);+ }+ set+ }++ /// Take out of `f` what this source owns; returns what was taken out.+ pub fn reconcile(&self, f: &mut Filter) -> Vec<SourceOwned> {+ let owned = self.owned_fields_set_in(f);+ for field in &owned {+ match field {+ SourceOwned::Album => f.album = None,+ SourceOwned::LibraryFolder => f.library_folder = None,+ SourceOwned::Flag => f.flag = None,+ }+ }+ owned+ }+}++/// What [`FilterState::carry`] does with the filter once its fields are carried.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub(crate) enum Carried {+ InPlace,+ /// The filter put aside is dropped.+ Drop,+ /// The filter in use is put aside (if it was empty before and holds something now, or `even_if_empty`).+ PutAside {+ even_if_empty: bool,+ },+}++/// The filter being shown and the one View ▸ Enable Filters put aside (not persisted).+#[derive(Debug, Default)]+pub struct FilterState {+ current: Filter,+ aside: Option<Filter>,+ /// How many times the person's filter was thrown away (replaced, emptied, or a put-aside one forgotten): what+ /// tells a filter built afterwards from the one a step left ([`crate::undo_filters`]). Putting a filter aside+ /// and bringing it back, and editing it, do not count.+ replaced: u64,+ /// A top-level command is running ([`FilterState::begin_recording`]): the person's filter as it was before the+ /// first change the command made, copied only then.+ recording: bool,+ before: Option<Snapshot>,+}++/// The person's filter and whether Enable Filters has it put aside.+#[derive(Clone, Debug, PartialEq)]+pub(crate) struct Snapshot {+ pub(crate) filter: Filter,+ pub(crate) aside: bool,+}++impl FilterState {+ pub fn current(&self) -> &Filter {+ &self.current+ }++ /// The filter Enable Filters put aside.+ pub fn aside(&self) -> Option<&Filter> {+ self.aside.as_ref()+ }++ /// A command starts: the first change to the filter from now to [`FilterState::end_recording`] keeps what it was+ /// (nothing is copied for a command that does not change the filter).+ pub(crate) fn begin_recording(&mut self) {+ self.recording = true;+ self.before = None;+ }++ /// The command ended: the person's filter as it was before the command's first change, if it made one.+ pub(crate) fn end_recording(&mut self) -> Option<Snapshot> {+ self.recording = false;+ self.before.take()+ }++ /// Called before every change: keeps the filter as it was while a command is recording.+ fn touch(&mut self) {+ if self.recording && self.before.is_none() {+ self.before = Some(self.snapshot());+ }+ }++ /// The person's filter now.+ pub(crate) fn snapshot(&self) -> Snapshot {+ let (filter, aside) = self.person();+ Snapshot { filter: filter.clone(), aside }+ }++ /// How many times the person's filter was thrown away.+ pub(crate) fn replaced(&self) -> u64 {+ self.replaced+ }++ /// The person's filter (the one put aside while filters are off, else the one in use) and whether it is put aside.+ pub(crate) fn person(&self) -> (&Filter, bool) {+ match &self.aside {+ Some(f) => (f, true),+ None => (&self.current, false),+ }+ }++ /// Show `f` as an edit of the filter, over `source`: what the source owns is left out and named in the answer.+ /// Setting a filter forgets the one put aside.+ pub fn set(&mut self, source: LibrarySource, mut f: Filter) -> Vec<SourceOwned> {+ self.touch();+ let left_out = source.reconcile(&mut f);+ // an edit that empties the filter ends it, as forgetting the one put aside does+ self.replaced += u64::from(self.aside.is_some() || (f == Filter::default() && self.current != Filter::default()));+ self.current = f;+ self.aside = None;+ left_out+ }++ /// Show `f` in place of the filter, over `source` (a saved preset applied): [`FilterState::set`] for a filter+ /// that is not an edit of the one before.+ pub fn replace(&mut self, source: LibrarySource, f: Filter) -> Vec<SourceOwned> {+ self.replaced += 1;+ self.set(source, f)+ }++ /// [`FilterState::set`] with `edit` applied to a copy of the current filter.+ pub fn change(&mut self, source: LibrarySource, edit: impl FnOnce(&mut Filter)) -> Vec<SourceOwned> {+ let mut f = self.current.clone();+ edit(&mut f);+ self.set(source, f)+ }++ /// No filter, none put aside.+ pub fn clear(&mut self) {+ self.touch();+ self.replaced += u64::from(self.aside.is_some() || self.current != Filter::default());+ self.current = Filter::default();+ self.aside = None;+ }++ /// `source` was shown: the filter lets go of what that source owns. The one put aside is+ /// reconciled when it comes back, against the source shown then.+ pub fn source_shown(&mut self, source: LibrarySource) {+ self.touch();+ source.reconcile(&mut self.current);+ }++ /// Enable Filters: a filter set is put aside, one put aside comes back as it was except what the+ /// source shown now owns. Returns whether filters are on afterwards.+ pub fn toggle(&mut self, source: LibrarySource) -> bool {+ self.touch();+ if self.current != Filter::default() {+ self.aside = Some(std::mem::take(&mut self.current));+ } else if let Some(mut f) = self.aside.take() {+ source.reconcile(&mut f);+ self.current = f;+ }+ self.aside.is_none()+ }++ /// An entity the filter names changed (a keyword or person renamed, a folder moved or removed): `edit` is+ /// applied to the filter and to the one put aside, and to nothing else. It must not set a field a source+ /// owns.+ pub fn follow(&mut self, mut edit: impl FnMut(&mut Filter)) {+ self.touch();+ edit(&mut self.current);+ if let Some(f) = self.aside.as_mut() {+ edit(f);+ }+ }++ /// [`FilterState::follow`] for the filter shown only (a field no put-aside filter keeps).+ pub fn follow_current(&mut self, edit: impl FnOnce(&mut Filter)) {+ self.touch();+ edit(&mut self.current);+ }++ /// A put-aside filter that [`FilterState::follow`] left empty has nothing to bring back.+ pub fn drop_aside_if_empty(&mut self) {+ self.touch();+ if self.aside.as_ref() == Some(&Filter::default()) {+ self.aside = None;+ }+ }++ /// Carry a change onto the person's filter: `edit` gets it, and whether it is put aside. A filter in use is+ /// then cleared of what the source owns (one put aside is, when it comes back). `edit` returns whether the+ /// filter goes aside again (a filter in use) or is dropped (one put aside): that is the step's own undo.+ pub(crate) fn carry(&mut self, source: LibrarySource, edit: impl FnOnce(&mut Filter, bool) -> Carried) {+ self.touch();+ let was_empty = self.current == Filter::default();+ let (aside, filter) = match self.aside.as_mut() {+ Some(f) => (true, f),+ None => (false, &mut self.current),+ };+ let verdict = edit(filter, aside);+ if !aside {+ source.reconcile(&mut self.current);+ }+ match verdict {+ Carried::InPlace => {}+ Carried::Drop if aside => self.aside = None,+ // (a filter that is now empty has nothing to put aside, unless it was the step's)+ Carried::PutAside { even_if_empty } if !aside && was_empty && (self.current != Filter::default() || even_if_empty) => {+ self.aside = Some(std::mem::take(&mut self.current));+ }+ Carried::Drop | Carried::PutAside { .. } => {}+ }+ }++ /// Enable Filters is off (a filter is put aside).+ pub fn is_off(&self) -> bool {+ self.aside.is_some()+ }+}@@ -39,6 +39,7 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files;+pub mod filter_state; #[cfg(not(target_arch = "wasm32"))] mod focus_scan; pub mod fonts;@@ -80,6 +81,7 @@ pub mod slideshow_export; pub mod smart; pub mod sync; pub mod text;+mod undo_filters; mod view; pub mod walk; @@ -139,6 +141,9 @@ pub struct UndoEntry { /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An /// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder). pub folder: Option<FolderMove>,+ /// What the step did to the person's filter (a keyword deleted or renamed, a folder removed…): undo carries it+ /// back and redo forward (see [`undo_filters`]).+ pub(crate) filters: Option<undo_filters::FilterChange>, /// Inverses of logged path corrections from a partially failed history step. pub(crate) retry_inverse: Vec<Op>, /// Sidecars still separated from their photo after a failed move or rollback.@@ -231,9 +236,9 @@ pub struct Session { pub auto_sync: bool, pub catalog: Catalog, pub source: LibrarySource,- pub filter: Filter,- /// The filter `library.toggleFilters` put aside (Library > Enable Filters off); not persisted.- pub(crate) shelved_filter: Option<Filter>,+ /// The filter bar's filter and the one `library.toggleFilters` put aside (Library > Enable Filters off); not+ /// persisted. Written only through its methods, which keep it clear of what the source shown owns.+ pub(crate) filters: filter_state::FilterState, pub sort: Sort, pub selection: Selection, /// The grid order for the current source/filter/sort (cached by catalog revision).@@ -450,8 +455,7 @@ impl Session { auto_sync: false, catalog: Catalog::new(), source: LibrarySource::All,- filter: Filter::default(),- shelved_filter: None,+ filters: Default::default(), sort: Sort::default(), selection: Selection::default(), visible: Vec::new().into(),@@ -580,10 +584,21 @@ impl Session { fn run_command(&mut self, id: &str, journal: Option<&Value>, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> { let log_start = self.pending_log.len(); let was_active = self.active();+ // what the step does to the person's filter is kept with it for undo (see `undo_filters`)+ let commits = self.commits;+ if self.depth == 0 {+ self.filters.begin_recording();+ } self.depth += 1; // last-resort guard: a panic in a command is that command's error, not a crash let r = guard::catch(&format!("`{id}`"), || f(self)).unwrap_or_else(|e| Err(EngineError::Other(e))); self.depth -= 1;+ if self.depth == 0+ && let Some(before) = self.filters.end_recording()+ && r.is_ok()+ {+ self.note_filter_change(before, commits);+ } if self.depth == 0 && was_active.is_some() && self.active() != was_active { self.previous_active = was_active; if let Some(left) = was_active {@@ -633,7 +648,14 @@ impl Session { let fwd = op.clone(); let inv = self.apply_catalog(op)?; self.pending_log.push(fwd);- self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new() });+ self.undo.push(UndoEntry {+ label: label.to_string(),+ op: inv,+ folder: None,+ filters: None,+ retry_inverse: Vec::new(),+ retry_sidecars: Vec::new(),+ }); self.commits = self.commits.wrapping_add(1); if self.undo.len() > 1000 { let mut dropped = self.undo.remove(0);@@ -745,8 +767,8 @@ impl Session { return; } let Some(entries) = self.undo.get(self.undo.len() - n..) else { return };- // Folder moves and partially applied file steps stay on their own.- if entries.iter().any(|e| e.folder.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) {+ // Folder moves, steps that carry a filter change and partially applied file steps stay on their own.+ if entries.iter().any(|e| e.folder.is_some() || e.filters.is_some() || !e.retry_inverse.is_empty() || !e.retry_sidecars.is_empty()) { return; } let tail = self.undo.split_off(self.undo.len() - n);@@ -755,6 +777,7 @@ impl Session { label: label.to_string(), op: Op::Batch { ops }, folder: None,+ filters: None, retry_inverse: Vec::new(), retry_sidecars: Vec::new(), });@@ -779,12 +802,15 @@ impl Session { return Err(err); } };+ // (the filter first: the photo is shown if it is in view under the filter the step's undo leaves)+ let carried = self.carry_filters(e.filters.as_ref()); self.show_undone(&e.op); self.pending_log.push(e.op); self.redo.push(UndoEntry { label: e.label.clone(), op: redo, folder: e.folder.as_ref().map(FolderMove::reversed),+ filters: carried, retry_inverse: Vec::new(), retry_sidecars: Vec::new(), });@@ -804,12 +830,15 @@ impl Session { return Err(err); } };+ // (the filter first: the photo is shown if it is in view under the filter the step leaves)+ let carried = self.carry_filters(e.filters.as_ref()); self.show_undone(&e.op); self.pending_log.push(e.op); self.undo.push(UndoEntry { label: e.label.clone(), op: undo, folder: e.folder.as_ref().map(FolderMove::reversed),+ filters: carried, retry_inverse: Vec::new(), retry_sidecars: Vec::new(), });@@ -1058,15 +1087,29 @@ impl Session { // ---------------------------------------------------------------- library view + /// The filter bar's filter. It changes through the commands (`library.filter`, `filter.applyPreset`,+ /// `library.clearFilter`, `library.toggleFilters`, `library.source`…), never beside them.+ pub fn filter(&self) -> &Filter {+ self.filters.current()+ }++ /// Show `f` as the filter bar's filter, over the source shown: what that source owns is left out+ /// ([`LibrarySource::reconcile`]) and returned. Setting a filter forgets the one Enable Filters put aside.+ pub fn set_filter(&mut self, f: Filter) -> Vec<filter_state::SourceOwned> {+ self.filters.replace(self.source, f)+ }+ /// Library > Enable Filters is off: a filter is put aside (`library.toggleFilters`). pub fn filters_off(&self) -> bool {- self.shelved_filter.is_some()+ self.filters.is_off() } /// 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));+ let mut key = (+ self.catalog.revision,+ format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filters.current(), self.sort, self.browse, self.library_folder),+ ); // What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were // changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are // no other view.@@ -1092,14 +1135,14 @@ impl Session { // Use this same value to evaluate relative rules and stamp the result. let now = current_now.unwrap_or_else(|| (self.clock)()); lightcraft_catalog::rules::set_now(Some(now.clone()));- let f = self.view_filter(self.source, &self.filter);+ let f = self.view_filter(self.source, self.filters.current()); // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for // a view that is only a question, else in the album or folder shown. Taking a photo out of an album // or the library takes it out of the view at once, held or not. // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no // cost for the hold)- let holds = self.hold_view && (asks || self.filter != Filter::default());+ let holds = self.hold_view && (asks || *self.filters.current() != Filter::default()); let within = holds.then(|| { if asks { self.view_filter(LibrarySource::All, &Filter::default())@@ -1113,7 +1156,7 @@ impl Session { // Views whose order or photos are not the query's alone are worked out whole each time: an album in // its own order, Recently Added (how far back it reaches moves with every import), Missing Photos. let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate- && self.filter == Filter::default()+ && *self.filters.current() == Filter::default() && self.catalog.album(a).is_some_and(|al| !al.is_smart())); let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes@@ -1155,7 +1198,7 @@ impl Session { && let LibrarySource::Album(a) = self.source && let Some(al) = self.catalog.album(a) && !al.is_smart()- && self.filter == Filter::default()+ && *self.filters.current() == Filter::default() { // each photo's place looked up, not searched for (a big album sorted in O(n log n)) let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len());@@ -1255,7 +1298,7 @@ impl Session { if self.source == LibrarySource::Missing { return None; }- if self.filter == Filter::default() && !self.hold_view {+ if *self.filters.current() == Filter::default() && !self.hold_view { // nothing narrows the source: what the view lists is all of it, and is counted already (not a view // that holds photos that stopped matching: those are not of the source any more) let _ = self.visible();@@ -558,6 +558,8 @@ impl Session { self.selection.ids.retain(|id| self.catalog.photo(*id).is_some()); self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some()); }+ // the filter stays across libraries: what the source restored here owns leaves it, as when that source is shown+ self.filters.source_shown(self.source); // not on Missing Photos: working that view out checks every original (minutes on a // sleeping NAS), and the app checks them in the background once it runs if self.selection.active.is_none()@@ -46,6 +46,7 @@ mod slide; mod slideshow_export; mod spots; mod sync;+mod undo_filters; mod view_follow; pub(super) mod xmp; @@ -1369,11 +1370,11 @@ fn find_similar_of_a_flat_photo_shows_an_empty_view_and_counts_zero() { let r = s.execute("library.findSimilar", &json!({"id": photo.id.0})).unwrap(); assert_eq!(r["photos"].as_array().map(Vec::len), Some(0)); assert_eq!(s.visible_cloned().len(), 0, "no matches show nothing, not the whole library");- assert_eq!(s.catalog.count(&s.filter), 0);- let chips = crate::filter_chips(&s.filter, &s.catalog);+ assert_eq!(s.catalog.count(s.filter()), 0);+ let chips = crate::filter_chips(s.filter(), &s.catalog); assert_eq!(chips.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(), ["Only 0 photos"]); s.execute("library.filter", &chips[0].clear).unwrap();- assert_eq!(s.filter, Default::default(), "the chip clears the empty state");+ assert_eq!(*s.filter(), Default::default(), "the chip clears the empty state"); assert_eq!(s.visible_cloned().len(), s.catalog.len()); } @@ -619,7 +619,7 @@ fn rename_person_rewrites_every_spelling_and_undoes() { assert_eq!(face_names(&s, b)[0], Some("Ada".into())); assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person"); assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada"));- assert_eq!(s.filter.person.as_deref(), Some("Ada"));+ assert_eq!(s.filter().person.as_deref(), Some("Ada")); assert_eq!(s.undo.len(), undo + 1); assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person")); s.execute("edit.undo", &json!({})).unwrap();@@ -14,7 +14,7 @@ //! * A labelled folder keeps its label when it is renamed or moved on disk, and so do the folders //! inside it; undo puts the labels back where they were. -use lightcraft_catalog::{Op, Photo, Source};+use lightcraft_catalog::{Filter, Op, Photo, Source}; use serde_json::json; use crate::{LibrarySource, Session, filter_chips};@@ -77,7 +77,7 @@ fn choosing_a_folder_shows_its_photos_and_a_chip_that_clears_it() { assert_eq!(s.visible().len(), 3); let r = s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); assert_eq!(r["count"], 2);- let chips = filter_chips(&s.filter, &s.catalog);+ let chips = filter_chips(s.filter(), &s.catalog); assert_eq!(chips.len(), 1); assert_eq!(chips[0].label, "Folder: pics/trip", "two names, so two folders called trip are told apart"); s.execute("library.filter", &chips[0].clear).unwrap();@@ -151,11 +151,11 @@ fn removing_the_folder_that_is_chosen_clears_the_choice_but_a_parent_stays() { let mut s = session(); s.execute("library.filter", &json!({"libraryFolder": "/pics/trip"})).unwrap(); s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();- assert_eq!(s.filter.library_folder, None, "no empty grid under a chip for a folder that is gone");+ assert_eq!(s.filter().library_folder, None, "no empty grid under a chip for a folder that is gone"); let mut s = session(); s.execute("library.filter", &json!({"libraryFolder": "/pics"})).unwrap(); s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap();- assert_eq!(s.filter.library_folder.as_deref(), Some("/pics"));+ assert_eq!(s.filter().library_folder.as_deref(), Some("/pics")); } #[test]@@ -203,7 +203,7 @@ fn a_folder_that_spans_several_disks_is_removed_only_when_asked_for_by_name() { fn the_folder_filter_takes_only_a_text_path() { let mut s = session(); assert!(s.execute("library.filter", &json!({"libraryFolder": 5})).is_err());- assert_eq!(s.filter.library_folder, None, "refused, nothing changed");+ assert_eq!(s.filter().library_folder, None, "refused, nothing changed"); s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap(); assert_eq!(s.visible().len(), 3, "a blank path is no choice"); s.execute("library.filter", &json!({"libraryFolder": "."})).unwrap();@@ -227,10 +227,10 @@ fn a_renamed_folder_stays_the_chosen_one_and_undo_follows_it_back() { s.execute("library.filter", &json!({"libraryFolder": trip})).unwrap(); assert_eq!(s.visible().len(), 2); s.execute("folder.rename", &json!({"path": trip, "name": "holiday"})).unwrap();- same_folder(s.filter.library_folder.as_deref(), &renamed, "the choice follows the folder");+ same_folder(s.filter().library_folder.as_deref(), &renamed, "the choice follows the folder"); assert_eq!(s.visible().len(), 2, "and still shows its photos"); s.execute("edit.undo", &json!({})).unwrap();- same_folder(s.filter.library_folder.as_deref(), &trip, "the choice is the same folder");+ same_folder(s.filter().library_folder.as_deref(), &trip, "the choice is the same folder"); assert_eq!(s.visible().len(), 2); } @@ -244,31 +244,31 @@ fn a_moved_folder_keeps_a_chosen_subfolder_chosen() { add(&mut s, &format!("{day1}/b.jpg")); s.execute("library.filter", &json!({"libraryFolder": day1})).unwrap(); s.execute("folder.move", &json!({"path": dir.path("trip"), "into": dir.path("archive")})).unwrap();- same_folder(s.filter.library_folder.as_deref(), &moved, "the subfolder follows its parent");+ same_folder(s.filter().library_folder.as_deref(), &moved, "the subfolder follows its parent"); assert_eq!(s.visible().len(), 1); s.execute("edit.undo", &json!({})).unwrap();- same_folder(s.filter.library_folder.as_deref(), &day1, "the choice is the same folder");+ same_folder(s.filter().library_folder.as_deref(), &day1, "the choice is the same folder"); } #[test] fn following_a_folder_reads_both_spellings_the_same_way() { // the choice was typed with a `..` in it; the folder that moved was not let mut s = Session::new();- s.filter.library_folder = Some("/a/x/../b/sub".into());+ s.set_filter(Filter { library_folder: Some("/a/x/../b/sub".into()), ..Default::default() }); crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");- same_folder(s.filter.library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent");- s.filter.library_folder = Some("/elsewhere/b".into());+ same_folder(s.filter().library_folder.as_deref(), "/a/c/sub", "a subfolder stays a subfolder, never widens to its parent");+ s.set_filter(Filter { library_folder: Some("/elsewhere/b".into()), ..Default::default() }); crate::cmd::browse::follow_folder(&mut s, "/a/b", "/a/c");- assert_eq!(s.filter.library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone");+ assert_eq!(s.filter().library_folder.as_deref(), Some("/elsewhere/b"), "other folders are left alone"); } #[test] fn following_a_folder_keeps_the_case_of_the_subfolder_names() { // Windows compares folders in lower case; the name the user sees must not change with it let mut s = Session::new();- s.filter.library_folder = Some("/A/Trip/Day1".into());+ s.set_filter(Filter { library_folder: Some("/A/Trip/Day1".into()), ..Default::default() }); crate::cmd::browse::follow_folder(&mut s, "/A/Trip", "/B");- assert_eq!(s.filter.library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap()));+ assert_eq!(s.filter().library_folder.as_deref(), Some(std::path::Path::new("/B").join("Day1").to_str().unwrap())); } #[test]@@ -279,7 +279,7 @@ fn choosing_a_folder_replaces_the_source_the_way_an_album_does() { let r = source_folder(&mut s, "/pics/trip"); assert_eq!(r["count"], 2, "the folder's photos, whatever was shown before"); assert_eq!((s.source, s.library_folder.as_deref()), (LibrarySource::LibraryFolder, Some("/pics/trip")));- assert!(filter_chips(&s.filter, &s.catalog).is_empty(), "a source is not a filter chip");+ assert!(filter_chips(s.filter(), &s.catalog).is_empty(), "a source is not a filter chip"); let state = s.execute("library.state", &json!({})).unwrap(); assert_eq!((state["sourceLabel"].as_str(), state["libraryFolder"].as_str()), (Some("pics/trip"), Some("/pics/trip")), "agents see which folder"); // the filter bar narrows it further, as it does an album@@ -371,7 +371,7 @@ fn a_folder_source_and_a_folder_filter_never_disagree() { let mut s = session(); s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).unwrap(); source_folder(&mut s, "/pics/trip");- assert_eq!(s.filter.library_folder, None, "choosing a folder drops a folder filter left over");+ assert_eq!(s.filter().library_folder, None, "choosing a folder drops a folder filter left over"); assert!(s.execute("library.filter", &json!({"libraryFolder": "/pics/home"})).is_err(), "one folder at a time"); assert_eq!(s.visible().len(), 2); s.execute("library.filter", &json!({"libraryFolder": null, "rating": 0})).unwrap();@@ -482,7 +482,7 @@ fn the_state_names_a_folder_only_while_it_is_shown() { fn a_blank_folder_filter_is_no_filter() { let mut s = session(); s.execute("library.filter", &json!({"libraryFolder": " "})).unwrap();- assert_eq!(s.filter, lightcraft_catalog::Filter::default(), "no hidden 'filters active' state");+ assert_eq!(*s.filter(), lightcraft_catalog::Filter::default(), "no hidden 'filters active' state"); } /// The label `library.folders` reports for the row at `path`.@@ -15,9 +15,9 @@ fn the_filter_follows_a_rename_whatever_the_case() { s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap(); s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap(); s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap();- assert_eq!(s.filter.keyword.as_deref(), Some("Road|é"));+ assert_eq!(s.filter().keyword.as_deref(), Some("Road|é")); s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap();- assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é"));+ assert_eq!(s.filter().keyword.as_deref(), Some("Weg|é")); } fn temp_dir(tag: &str) -> std::path::PathBuf {@@ -140,7 +140,7 @@ fn the_filter_follows_a_keyword_moved_into_a_new_parent() { s.execute("library.filter", &json!({"keyword": "Rome"})).unwrap(); s.execute("keyword.move", &json!({"keyword": "rome", "parent": "Italy"})).unwrap(); assert_eq!(keywords_of(&s, id), ["Italy|Rome"]);- assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome"));+ assert_eq!(s.filter().keyword.as_deref(), Some("Italy|Rome")); } /// Keywords given to photos are stored cleaned (" beach " is "beach", "Travel | Italy" is@@ -67,7 +67,7 @@ fn keyword_rename_merge_delete_undo_and_replay() { assert_eq!(r["changed"], 2); assert_eq!(s.undo.len(), undo_before + 1, "one undo step"); assert_eq!(keywords_of(&s, ids[0]), ["Europe|Italy|rome", "sea"]);- assert_eq!(s.filter.keyword.as_deref(), Some("Europe|Italy"), "the filter follows the rename");+ assert_eq!(s.filter().keyword.as_deref(), Some("Europe|Italy"), "the filter follows the rename"); assert_eq!(s.visible().len(), 2); s.execute("keyword.merge", &json!({"from": ["Italia"], "into": "Europe|Italy"})).unwrap(); assert_eq!(keywords_of(&s, ids[2]), ["Europe|Italy"]);@@ -212,6 +212,20 @@ fn profile_favorites_and_recent_survive_reopen() { let _ = std::fs::remove_dir_all(&dir); } +#[test]+fn opening_a_library_on_picks_takes_out_the_flag_filter_carried_over() {+ // the filter stays when another library opens, and this library was left on Picks, which owns the flag+ let dir = temp_dir("picksflag");+ drop(open(&dir, true));+ std::fs::write(dir.join("view.json"), br#"{"source": {"kind": "picks"}}"#).unwrap();+ let mut s = Session::new();+ s.set_filter(lightcraft_catalog::Filter { flag: Some(lightcraft_catalog::Flag::Reject), rating: 2, ..Default::default() });+ s.open_library(&dir, false).unwrap();+ assert_eq!(s.source, crate::LibrarySource::Picks);+ assert_eq!((s.filter().flag, s.filter().rating), (None, 2), "no flag chip over the picks; the rest of the filter stays");+ let _ = std::fs::remove_dir_all(&dir);+}+ #[test] fn the_shown_library_folder_survives_reopen() { let dir = temp_dir("libfolder");@@ -278,7 +292,7 @@ fn smart_album_rule_sets() { let r = s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": rules}})); assert!(r.is_err(), "{rules}"); }- assert!(s.filter.rule_set.is_none(), "a refused filter isn't applied");+ assert!(s.filter().rule_set.is_none(), "a refused filter isn't applied"); s.execute("library.filter", &serde_json::json!({"ruleSet": {"rules": [{"field": "edited", "op": "is", "value": "no"}]}})).unwrap(); s.execute("library.filter", &serde_json::json!({"ruleSet": null})).unwrap(); let r = s@@ -325,7 +339,7 @@ fn filter_label_and_labels_together_chip() { s.execute("photo.label", &json!({"ids": [id], "label": l})).unwrap(); } let reds = s.catalog.photos().filter(|p| p.in_library() && p.label == Some(ColorLabel::Red)).count();- let chip = |s: &Session| crate::filter_chips(&s.filter, &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip");+ let chip = |s: &Session| crate::filter_chips(s.filter(), &s.catalog).into_iter().find(|c| c.label.starts_with("Label")).expect("a label chip"); s.execute("library.filter", &json!({"label": "red", "labels": ["yellow", "red"]})).unwrap(); assert_eq!(s.visible_cloned().len(), reds); assert_eq!(chip(&s).label, "Label: Red");@@ -334,7 +348,7 @@ fn filter_label_and_labels_together_chip() { assert_eq!(s.visible_cloned().len(), 0); assert_eq!(chip(&s).label, "Label: Red and (Yellow or Green)"); s.execute("library.filter", &chip(&s).clear).unwrap();- assert_eq!((s.filter.label, s.filter.labels.len()), (None, 0));+ assert_eq!((s.filter().label, s.filter().labels.len()), (None, 0)); } /// The filter bar's label multi-select: any of the chosen labels.@@ -352,7 +366,7 @@ fn filter_any_of_several_labels() { assert_eq!(want, 2); assert_eq!(vis.len(), want); assert!(vis.iter().all(|id| matches!(s.catalog.photo(*id).unwrap().label, Some(ColorLabel::Red | ColorLabel::Yellow))));- assert!(s.filter.describe().contains("label red or yellow"));+ assert!(s.filter().describe().contains("label red or yellow")); s.execute("library.filter", &serde_json::json!({"labels": []})).unwrap(); assert!(s.visible_cloned().len() > want); }@@ -750,7 +764,7 @@ fn a_stale_rule_doesnt_block_other_changes() { s.execute("album.delete", &serde_json::json!({"id": trip})).unwrap(); // the filter bar: a rating still applies on top of the stale rule s.execute("library.filter", &serde_json::json!({"rating": 3})).unwrap();- assert_eq!(s.filter.rating, 3);+ assert_eq!(s.filter().rating, 3); // the album: a partial edit that leaves its rules alone s.execute("album.setRules", &serde_json::json!({"id": smart, "rules": {"rating": 2}})).unwrap(); // changing the rules is still checked@@ -62,7 +62,7 @@ fn hdr_merge_command_creates_and_imports_a_dng() { assert!(s.catalog.stack_of(id2).is_none(), "no stack without the option"); // the type filter finds merge results (and only them) s.execute("library.filter", &json!({"merged": "hdr"})).unwrap();- let found: std::collections::HashSet<_> = s.catalog.query(&s.filter, &Default::default()).into_iter().collect();+ let found: std::collections::HashSet<_> = s.catalog.query(s.filter(), &Default::default()).into_iter().collect(); assert_eq!(found, [id, id2].into_iter().collect()); let _ = std::fs::remove_dir_all(&dir); }@@ -253,7 +253,7 @@ fn filters_are_not_restored_when_a_library_reopens() { s.close_library().unwrap(); drop(s); let mut s2 = open(&dir);- assert_eq!(s2.filter, lightcraft_catalog::Filter::default());+ assert_eq!(*s2.filter(), lightcraft_catalog::Filter::default()); assert_eq!(s2.visible_cloned().len(), all); assert_eq!(serde_json::to_value(s2.sort).unwrap()["key"], "fileName", "the rest of the view is restored"); let _ = std::fs::remove_dir_all(&dir);@@ -269,17 +269,17 @@ fn filter_chips_clear_one_filter_and_total_ignores_the_filter() { s.execute("library.filter", &json!({"rating": 4, "date": "2026-01-16", "text": "x"})).unwrap(); // the total is the source's, whatever the filter hides assert_eq!(s.source_total(), Some(all));- let chips = crate::filter_chips(&s.filter, &s.catalog);+ let chips = crate::filter_chips(s.filter(), &s.catalog); let labels: Vec<&str> = chips.iter().map(|c| c.label.as_str()).collect(); assert_eq!(labels, ["Search: “x”", "Rating ≥ 4★", "Date: January 16, 2026"]); // clearing the date chip leaves the other two filters alone s.execute("library.filter", &chips[2].clear).unwrap();- assert_eq!(s.filter.date, None);- assert_eq!((s.filter.rating, s.filter.text.as_str()), (4, "x"));- for c in crate::filter_chips(&s.filter, &s.catalog) {+ assert_eq!(s.filter().date, None);+ assert_eq!((s.filter().rating, s.filter().text.as_str()), (4, "x"));+ for c in crate::filter_chips(s.filter(), &s.catalog) { s.execute("library.filter", &c.clear).unwrap(); }- assert_eq!(s.filter, Default::default());+ assert_eq!(*s.filter(), Default::default()); } /// A shuffle is part of the saved view: the same seed (and so the same grid) is back on the next launch.new file mode 100644@@ -0,0 +1,384 @@+//! Undo and redo carry what a step did to the person's filter: a keyword deleted or renamed, a person renamed, a+//! library folder removed. Nothing in those commands records it; `run_command` compares the filter before and after+//! any command that committed a step.+//!+//! * Given a filter that names a keyword, person or folder the step took out or renamed, when the step is undone, then+//! the filter names it again (put aside or in use, wherever the filter is now), and redo carries it forward again.+//! * Given the filter was changed after the step, when it is undone, then only what the step changed comes back and+//! nothing the person did since is overwritten.+//! * Given the person threw that filter away after the step and built another one, when the step is undone, then the+//! new filter is left alone.++use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Source};+use lightcraft_geom::Rect;+use lightcraft_meta::{Region, RegionKind};+use serde_json::{Value, json};++use crate::Session;++/// Photos in two folders; the ones in /pics/trip carry the keyword "cat" and the one in /pics/home "dog".+fn session() -> Session {+ let mut s = Session::new();+ for (path, keyword) in [("/pics/trip/a.jpg", "cat"), ("/pics/trip/b.jpg", "cat"), ("/pics/home/c.jpg", "dog")] {+ let id = s.catalog.alloc_photo_id();+ let mut p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");+ p.meta.keywords = vec![keyword.into()];+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+ }+ s+}++fn run(s: &mut Session, command: &str, params: Value) {+ s.execute(command, ¶ms).unwrap();+}++fn delete_cat(s: &mut Session) {+ run(s, "keyword.delete", json!({"keyword": "cat"}));+}++fn remove_trip(s: &mut Session) {+ run(s, "library.removeFolder", json!({"path": "/pics/trip"}));+}++fn undo(s: &mut Session) {+ run(s, "edit.undo", json!({}));+}++fn redo(s: &mut Session) {+ run(s, "edit.redo", json!({}));+}++fn toggle(s: &mut Session) {+ run(s, "library.toggleFilters", json!({}));+}++fn filtered(keyword: Option<&str>, folder: Option<&str>, rating: u8) -> Filter {+ Filter { keyword: keyword.map(Into::into), library_folder: folder.map(Into::into), rating, ..Default::default() }+}++#[test]+fn undoing_a_keyword_delete_brings_the_live_filters_keyword_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ delete_cat(&mut s);+ assert_eq!(s.filter().keyword, None, "the deletion takes the keyword out of the filter");+ undo(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("cat"), "undo puts the keyword back in the filter");+ assert_eq!(s.visible().len(), 2);+ redo(&mut s);+ assert_eq!(s.filter().keyword, None, "redo takes it out again");+ assert_eq!(s.visible().len(), 3);+}++#[test]+fn undoing_a_keyword_delete_brings_the_put_aside_filters_keyword_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 0}));+ toggle(&mut s);+ delete_cat(&mut s);+ undo(&mut s);+ assert!(s.filters_off(), "the filter is still put aside, and Enable Filters has something to bring back");+ toggle(&mut s);+ assert_eq!(*s.filter(), filtered(Some("cat"), None, 0));+ assert_eq!(s.visible().len(), 2);+}++#[test]+fn undoing_a_keyword_rename_brings_the_filters_old_keyword_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 0}));+ run(&mut s, "keyword.rename", json!({"from": "cat", "to": "kitten"}));+ assert_eq!(s.filter().keyword.as_deref(), Some("kitten"), "the filter follows the rename");+ undo(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("cat"), "undo gives the photos their old keyword, so the filter too");+ assert_eq!(s.visible().len(), 2);+ redo(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("kitten"));+ assert_eq!(s.visible().len(), 2);+}++#[test]+fn undoing_a_keyword_rename_that_merged_two_keywords_brings_the_filters_old_keyword_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ run(&mut s, "keyword.rename", json!({"from": "cat", "to": "dog"}));+ assert_eq!(s.filter().keyword.as_deref(), Some("dog"));+ assert_eq!(s.visible().len(), 3, "the merged keyword is on every photo");+ undo(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("cat"));+ assert_eq!(s.visible().len(), 2);+}++#[test]+fn undoing_a_keyword_rename_follows_the_filter_put_aside_since() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 3}));+ run(&mut s, "keyword.rename", json!({"from": "cat", "to": "kitten"}));+ toggle(&mut s);+ undo(&mut s);+ assert!(s.filters_off());+ toggle(&mut s);+ assert_eq!(*s.filter(), filtered(Some("cat"), None, 3), "the put-aside filter has the old keyword again, and its stars");+}++#[test]+fn undoing_a_person_rename_brings_the_filters_old_name_back() {+ let mut s = session();+ let id = s.catalog.photos().map(|p| p.id).min().unwrap();+ let mut meta = s.catalog.photo(id).unwrap().meta.clone();+ let rect = Rect { x0: 0.1, y0: 0.2, x1: 0.35, y1: 0.6 };+ meta.regions = vec![Region { rect, kind: RegionKind::Face, name: Some("Ann".into()), description: None }];+ s.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap();+ run(&mut s, "library.filter", json!({"person": "Ann"}));+ assert_eq!(s.visible().len(), 1);+ run(&mut s, "faces.renamePerson", json!({"from": "Ann", "to": "Ada"}));+ assert_eq!(s.filter().person.as_deref(), Some("Ada"), "the filter follows the rename");+ undo(&mut s);+ assert_eq!(s.filter().person.as_deref(), Some("Ann"), "no empty grid under a name nobody has");+ assert_eq!(s.visible().len(), 1);+ redo(&mut s);+ assert_eq!(s.filter().person.as_deref(), Some("Ada"));+ assert_eq!(s.visible().len(), 1);+}++#[test]+fn undoing_a_person_rename_shows_the_one_photo_it_changed_under_the_filter_it_gives_back() {+ // one photo has the person: undo shows it (makes it the active photo) when it is in view, and it is in view+ // under the old name the filter gets back, not under the new one the filter still has while the catalog is undone+ let mut s = session();+ let mut ids: Vec<PhotoId> = s.catalog.photos().map(|p| p.id).collect();+ ids.sort();+ let mut meta = s.catalog.photo(ids[0]).unwrap().meta.clone();+ let rect = Rect { x0: 0.1, y0: 0.2, x1: 0.35, y1: 0.6 };+ meta.regions = vec![Region { rect, kind: RegionKind::Face, name: Some("Ann".into()), description: None }];+ s.commit("setup", Op::SetMeta { id: ids[0], meta: Box::new(meta) }).unwrap();+ run(&mut s, "library.filter", json!({"person": "Ann"}));+ run(&mut s, "faces.renamePerson", json!({"from": "Ann", "to": "Ada"}));+ s.selection = crate::Selection::single(ids[1]);+ undo(&mut s);+ assert_eq!(s.filter().person.as_deref(), Some("Ann"));+ assert_eq!(s.selection.active, Some(ids[0]), "the photo the undo changed is on screen");+ s.selection = crate::Selection::single(ids[1]);+ redo(&mut s);+ assert_eq!(s.selection.active, Some(ids[0]), "and so it is for redo");+}++#[test]+fn undoing_a_step_keeps_what_the_filter_got_since() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ delete_cat(&mut s);+ run(&mut s, "library.filter", json!({"rating": 3}));+ undo(&mut s);+ assert_eq!(*s.filter(), filtered(Some("cat"), None, 3), "the stars stay, the keyword returns");+ // a keyword the person chose after the delete is not replaced+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ delete_cat(&mut s);+ run(&mut s, "library.filter", json!({"keyword": "dog"}));+ undo(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("dog"));+}++#[test]+fn undoing_a_step_leaves_a_filter_built_after_the_old_one_was_put_aside_alone() {+ // the keyword the step took out of the put-aside filter does not join the filter built since: setting a filter while+ // filters are off forgets the one put aside (`library.toggleFilters`)+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ toggle(&mut s);+ delete_cat(&mut s);+ run(&mut s, "library.filter", json!({"rating": 3}));+ undo(&mut s);+ assert!(!s.filters_off(), "the filter the person set after the delete stays in use");+ assert_eq!(*s.filter(), filtered(None, None, 3), "and nothing joins it");+ // the same for a folder+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ toggle(&mut s);+ remove_trip(&mut s);+ run(&mut s, "library.filter", json!({"rating": 3}));+ undo(&mut s);+ assert!(!s.filters_off());+ assert_eq!(*s.filter(), filtered(None, None, 3));+ // redo leaves it alone too+ redo(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 3));+}++#[test]+fn undoing_a_step_leaves_a_filter_built_after_the_old_one_was_emptied_or_replaced_alone() {+ // taking the last criterion off by hand ends the filter+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 3}));+ delete_cat(&mut s);+ run(&mut s, "library.filter", json!({"rating": 0}));+ run(&mut s, "library.filter", json!({"rating": 4}));+ undo(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 4));+ // so does Clear Filters+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 3}));+ delete_cat(&mut s);+ run(&mut s, "library.clearFilter", json!({}));+ run(&mut s, "library.filter", json!({"rating": 4}));+ undo(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 4));+ // and a saved preset replaces it+ let mut s = session();+ run(&mut s, "library.filter", json!({"rating": 5}));+ run(&mut s, "filter.savePreset", json!({"name": "five"}));+ run(&mut s, "library.filter", json!({"rating": 0, "keyword": "cat"}));+ delete_cat(&mut s);+ run(&mut s, "filter.applyPreset", json!({"name": "five"}));+ undo(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 5), "the keyword does not join the preset's filter");+}++#[test]+fn undoing_a_step_follows_the_filter_to_where_it_is_now() {+ // put aside after the delete: the keyword goes back into the put-aside filter, and the live filter stays empty+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 4}));+ delete_cat(&mut s);+ toggle(&mut s);+ undo(&mut s);+ assert!(s.filters_off());+ assert_eq!(*s.filter(), Filter::default(), "nothing is filtered while the filter is put aside");+ toggle(&mut s);+ assert_eq!(*s.filter(), filtered(Some("cat"), None, 4));+ // brought back after the delete: the keyword goes back into the filter in use+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat", "rating": 4}));+ toggle(&mut s);+ delete_cat(&mut s);+ toggle(&mut s);+ undo(&mut s);+ assert!(!s.filters_off());+ assert_eq!(*s.filter(), filtered(Some("cat"), None, 4));+ // and redo after the filter was put aside takes it out of the put-aside filter+ toggle(&mut s);+ redo(&mut s);+ assert!(s.filters_off());+ toggle(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 4));+}++#[test]+fn undoing_a_folder_removal_brings_the_live_filters_folder_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ remove_trip(&mut s);+ assert_eq!(s.filter().library_folder, None);+ undo(&mut s);+ assert_eq!(s.filter().library_folder.as_deref(), Some("/pics/trip"));+ assert_eq!(s.visible().len(), 2);+ redo(&mut s);+ assert_eq!(s.filter().library_folder, None, "redo takes it out again");+}++#[test]+fn undoing_a_folder_removal_brings_the_put_aside_filters_folder_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip", "rating": 4}));+ toggle(&mut s);+ remove_trip(&mut s);+ undo(&mut s);+ toggle(&mut s);+ assert_eq!(*s.filter(), filtered(None, Some("/pics/trip"), 4));+}++#[test]+fn undoing_a_folder_removal_that_dropped_the_put_aside_filter_brings_it_back() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ toggle(&mut s);+ remove_trip(&mut s);+ assert!(!s.filters_off(), "the emptied put-aside filter is dropped");+ undo(&mut s);+ assert!(s.filters_off(), "undo puts it aside again");+ toggle(&mut s);+ assert_eq!(s.filter().library_folder.as_deref(), Some("/pics/trip"));+ assert_eq!(s.visible().len(), 2);+}++#[test]+fn redoing_a_folder_removal_drops_the_put_aside_filter_it_emptied_again() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ toggle(&mut s);+ remove_trip(&mut s);+ undo(&mut s);+ redo(&mut s);+ assert!(!s.filters_off(), "nothing is left to bring back");+ assert_eq!(*s.filter(), Filter::default());+}++#[test]+fn undoing_a_folder_removal_does_not_put_an_empty_filter_aside_when_another_folder_is_shown() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ toggle(&mut s);+ remove_trip(&mut s);+ run(&mut s, "library.source", json!({"kind": "libraryFolder", "path": "/pics/home"}));+ undo(&mut s);+ assert!(!s.filters_off(), "the folder shown wins, so nothing is left to put aside");+ assert_eq!(*s.filter(), Filter::default());+}++#[test]+fn undoing_a_folder_removal_does_not_replace_the_folder_shown_since() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"libraryFolder": "/pics/trip"}));+ remove_trip(&mut s);+ run(&mut s, "library.source", json!({"kind": "libraryFolder", "path": "/pics/home"}));+ undo(&mut s);+ assert_eq!(s.filter().library_folder, None, "one folder at a time: the one shown wins");+ run(&mut s, "library.filter", json!({"rating": 3}));+ assert_eq!(*s.filter(), filtered(None, None, 3), "and filtering goes on, as no folder filter is refused");+}++#[test]+fn undoing_two_steps_puts_an_emptied_put_aside_filter_back_as_it_was() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"keyword": "cat"}));+ toggle(&mut s);+ delete_cat(&mut s);+ remove_trip(&mut s);+ assert!(!s.filters_off(), "the removal dropped the put-aside filter the delete had emptied");+ undo(&mut s);+ undo(&mut s);+ assert!(s.filters_off(), "filtering is still off, as before the two steps");+ assert_eq!(*s.filter(), Filter::default());+ toggle(&mut s);+ assert_eq!(s.filter().keyword.as_deref(), Some("cat"));+}++#[test]+fn a_step_that_never_touched_the_filter_leaves_it_alone_on_undo() {+ let mut s = session();+ run(&mut s, "library.filter", json!({"rating": 2}));+ remove_trip(&mut s);+ undo(&mut s);+ assert_eq!(*s.filter(), filtered(None, None, 2));+ assert!(!s.filters_off());+ assert!(s.undo.iter().chain(s.redo.iter()).all(|e| e.filters.is_none()), "no filter change is kept for it");+}++/// The fields one chip clears move together, or not at all: `only` and `onlyNone` are one constraint.+#[test]+fn a_step_carries_the_fields_of_a_chip_together() {+ let cat = Catalog::new();+ let from = Filter { only: vec![PhotoId(1)], only_none: false, ..Default::default() };+ let to = Filter { only: Vec::new(), only_none: true, ..Default::default() };+ let mut now = from.clone();+ crate::undo_filters::carry(&mut now, &from, &to, &cat);+ assert_eq!(now, to, "both fields move");+ let mut half = Filter { only: vec![PhotoId(1)], only_none: true, ..Default::default() };+ crate::undo_filters::carry(&mut half, &from, &to, &cat);+ assert_eq!(half, Filter { only: vec![PhotoId(1)], only_none: true, ..Default::default() }, "a chip changed since moves not at all");+ // what the step left alone is never touched, whatever it holds now+ let mut other = Filter { only: vec![PhotoId(1)], rating: 5, text: "x".into(), ..Default::default() };+ crate::undo_filters::carry(&mut other, &from, &to, &cat);+ assert_eq!((other.only_none, other.rating, other.text.as_str()), (true, 5, "x"));+}new file mode 100644@@ -0,0 +1,126 @@+//! What a step did to the person's filter, for undo and redo to carry back and forth.+//!+//! Deleting a keyword, renaming it or a person, removing a library folder: each changes the filter that names it, in use+//! or put aside by Enable Filters. The filter is session state, not catalog state, so the step's inverse op cannot+//! restore it. `Session::run_command` therefore compares the person's filter before and after every+//! command that committed a step, and the step's undo entry keeps the difference as a [`FilterChange`]. No command+//! records anything itself, so a command that changes the filter next needs nothing here.+//!+//! Undo moves the filter from what the step left to what it found, and the entry it hands to redo does the reverse.+//! Only what the step changed is carried, group by group (the fields one filter chip clears together, see+//! [`filter_chips`]), and a group only moves while it still holds what the step left: a keyword or folder chosen+//! since is not replaced, and the rest of the filter is never touched. What the source shown now owns is left out+//! ([`crate::LibrarySource::reconcile`]), as for every other way into the filter.+//!+//! A filter the person built after the step is left alone, and the one the step put aside is not brought back beside+//! it (setting a filter while filters are off forgets the one put aside, `library.toggleFilters`): nothing is carried+//! once the filter was thrown away since (replaced, emptied, or the one put aside forgotten by setting another: the+//! filter's `replaced` count), nor into a filter the person has again after the step dropped the put-aside one.++use lightcraft_catalog::{Catalog, Filter};+use serde_json::{Map, Value};++use crate::filter_state::{Carried, Snapshot};+use crate::{Session, filter_chips};++/// Moving the person's filter from the state a step left it in (`from`) to the one it found (`to`).+#[derive(Clone, Debug)]+pub(crate) struct FilterChange {+ from: Snapshot,+ to: Snapshot,+ /// [`crate::filter_state::FilterState`]'s `replaced` count after the step.+ replaced: u64,+}++impl FilterChange {+ fn reversed(&self) -> Self {+ Self { from: self.to.clone(), to: self.from.clone(), replaced: self.replaced }+ }+}++/// The fields of a filter by their names in commands (a few at their default are left out).+fn fields(f: &Filter) -> Map<String, Value> {+ match serde_json::to_value(f) {+ Ok(Value::Object(m)) => m,+ _ => Map::new(),+ }+}++/// Fields that change together: those one chip of either filter clears (`label` and `labels`, `only` and `onlyNone`…),+/// and every other field of either on its own.+fn groups(from: &Map<String, Value>, to: &Map<String, Value>, chips: Vec<Vec<String>>) -> Vec<Vec<String>> {+ let mut groups = chips;+ for key in from.keys().chain(to.keys()) {+ if !groups.iter().flatten().any(|k| k == key) {+ groups.push(vec![key.clone()]);+ }+ }+ groups+}++/// Move the groups of `filter` that the step changed (`from` → `to`) and that still hold what the step left.+pub(crate) fn carry(filter: &mut Filter, from: &Filter, to: &Filter, cat: &Catalog) {+ let (mut now, left, found) = (fields(filter), fields(from), fields(to));+ let chips = filter_chips(from, cat).into_iter().chain(filter_chips(to, cat));+ let chips = chips.filter_map(|c| c.clear.as_object().map(|o| o.keys().cloned().collect())).collect();+ for group in groups(&left, &found, chips) {+ let changed = group.iter().any(|k| left.get(k) != found.get(k));+ if changed && group.iter().all(|k| now.get(k) == left.get(k)) {+ for k in &group {+ match found.get(k) {+ Some(v) => now.insert(k.clone(), v.clone()),+ None => now.remove(k),+ };+ }+ }+ }+ if let Ok(moved) = serde_json::from_value(Value::Object(now)) {+ *filter = moved;+ }+}++impl Session {+ /// A command that changed the person's filter ran: if it also committed a step, keep what it did to the filter+ /// with the step, so undo can bring it back. `before` is the filter before its first change, `commits` is+ /// [`Session::commits`] before it ran: what shows a step was committed.+ pub(crate) fn note_filter_change(&mut self, before: Snapshot, commits: u64) {+ if self.commits == commits {+ return;+ }+ let after = self.filters.snapshot();+ if before == after {+ return;+ }+ let replaced = self.filters.replaced();+ if let Some(step) = self.undo.last_mut() {+ step.filters = Some(FilterChange { from: after, to: before, replaced });+ }+ }++ /// Apply a step's filter change (undo or redo); returns the change that takes it back, for the other history stack.+ pub(crate) fn carry_filters(&mut self, change: Option<&FilterChange>) -> Option<FilterChange> {+ let change = change?;+ if self.filters.replaced() == change.replaced {+ let (from, to) = (&change.from, &change.to);+ let cat = &self.catalog;+ self.filters.carry(self.source, |filter, aside| {+ // the step dropped the put-aside filter it emptied, and the person has a filter again: built since,+ // so left alone (the put-aside one is not brought back beside it)+ if !from.aside && to.aside && *filter != Filter::default() {+ return Carried::InPlace;+ }+ carry(filter, &from.filter, &to.filter, cat);+ // the step had dropped the put-aside filter after emptying it: drop it again once nothing is left in it+ if aside && from.aside && !to.aside && *filter == to.filter {+ Carried::Drop+ // ... or it had dropped it and the person has no filter now: put it aside again+ } else if !aside && !from.aside && to.aside {+ Carried::PutAside { even_if_empty: to.filter == Filter::default() }+ } else {+ Carried::InPlace+ }+ });+ }+ Some(change.reversed())+ }+}@@ -214,7 +214,7 @@ impl LightcraftApp { } if r.is_ok() && id == "library.toggleFilters" { // the search field shows the text filter: empty while the filters are off, back with them- self.ui.search = self.session.filter.text.clone();+ self.ui.search = self.session.filter().text.clone(); } if r.is_ok() && id == "library.clearFilter" { // the search field shows the text filter: cleared from anywhere (the loupe's pill, the@@ -464,7 +464,7 @@ fn the_search_field_pastes_from_its_menu() { h.settle(SETTLE); } assert_eq!(h.app.ui.search, "wedding");- assert_eq!(h.app.session.filter.text, "wedding", "and the library is filtered by it");+ assert_eq!(h.app.session.filter().text, "wedding", "and the library is filtered by it"); } /// Issue #1648: clearing the filters from the loupe's pill empties the search field too, so@@ -479,14 +479,14 @@ fn clearing_the_loupe_filters_empties_the_search_field() { assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); }- assert_eq!(h.app.session.filter.text, "a", "filtered while typing");+ assert_eq!(h.app.session.filter().text, "a", "filtered while typing"); assert!(!h.app.session.visible_cloned().is_empty(), "the search finds photos, so the loupe has one to show"); h.request("engine.execute", json!({"command": "view.detail"}), t); h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "loupe:filters"}), t); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE);- assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", ""));+ assert_eq!((h.app.ui.search.as_str(), h.app.session.filter().text.as_str()), ("", "")); } /// Esc in the search field gives back the search from before the edit, and the filter with it.@@ -500,11 +500,11 @@ fn escape_in_the_search_field_gives_back_the_search() { assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); }- assert_eq!(h.app.session.filter.text, "travel", "filtered while typing");+ assert_eq!(h.app.session.filter().text, "travel", "filtered while typing"); let r = h.request("ui.key", json!({"key": "Escape"}), t); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE);- assert_eq!((h.app.ui.search.as_str(), h.app.session.filter.text.as_str()), ("", ""));+ assert_eq!((h.app.ui.search.as_str(), h.app.session.filter().text.as_str()), ("", "")); } #[test]@@ -1544,13 +1544,13 @@ fn filter_bar_labels_from_label_and_labels() { h.step(); let r = h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t); assert_eq!(r["ok"], true, "{r}");- let f = h.app.session.filter.clone();+ let f = h.app.session.filter().clone(); assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Red, ColorLabel::Green])); // nothing passes both: nothing is lit, so a click shows just that label h.app.session.execute("library.filter", &json!({"label": "red", "labels": ["yellow"]})).unwrap(); h.step(); h.request("ui.clickWidget", json!({"id": "filter:label-green"}), t);- let f = h.app.session.filter.clone();+ let f = h.app.session.filter().clone(); assert_eq!((f.label, f.labels), (None, vec![ColorLabel::Green])); h.settle(SETTLE); }@@ -1567,7 +1567,7 @@ fn filter_bar_filters_and_saves_a_smart_album() { let r = h.request("ui.clickWidget", json!({"id": w}), t); assert_eq!(r["ok"], true, "{w}: {r}"); }- let f = h.app.session.filter.clone();+ let f = h.app.session.filter().clone(); assert_eq!((f.rating, f.flag, f.label), (3, Some(lightcraft_catalog::Flag::Pick), None), "a second click clears the label"); let n = h.app.session.visible_cloned().len(); assert!(n > 0 && n < all);@@ -1578,7 +1578,7 @@ fn filter_bar_filters_and_saves_a_smart_album() { let smart = h.app.session.catalog.albums().find(|a| a.is_smart()).expect("smart album").id; assert_eq!(h.app.session.catalog.album_count(smart), n); h.request("ui.clickWidget", json!({"id": "button:filterClear"}), t);- assert_eq!(h.app.session.filter, Default::default());+ assert_eq!(*h.app.session.filter(), Default::default()); assert_eq!(h.app.session.visible_cloned().len(), all); h.settle(SETTLE); }@@ -1634,7 +1634,7 @@ fn keyword_list_filters_renames_and_suggests() { h.settle(SETTLE); let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t); assert_eq!(r["ok"], true, "{r}");- assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel"));+ assert_eq!(h.app.session.filter().keyword.as_deref(), Some("travel")); assert_eq!(h.app.session.visible_cloned().len(), 3); // open the level, filter by the child h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t);@@ -1646,7 +1646,7 @@ fn keyword_list_filters_renames_and_suggests() { h.settle(SETTLE); let r = h.request("ui.dialog.confirm", json!({}), t); assert_eq!(r["ok"], true, "{r}");- assert_eq!(h.app.session.filter.keyword.as_deref(), Some("trips|italy"));+ assert_eq!(h.app.session.filter().keyword.as_deref(), Some("trips|italy")); assert_eq!(h.app.session.visible_cloned().len(), 2); // Keywords panel: suggestions co-occurring with the photo's keywords h.request("engine.execute", json!({"command": "library.clearFilter"}), t);@@ -642,12 +642,15 @@ mod tests { let mut h = demo(); let id = h.app.session.catalog.photos().next().unwrap().id.0; h.app.session.source = lightcraft_engine::LibrarySource::Picks;- h.app.session.filter.text = "no-photo-has-this-name".into();- h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)];- h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red];- h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet {- mode: lightcraft_catalog::rules::Match::All,- rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }],+ h.app.session.set_filter(lightcraft_catalog::Filter {+ text: "no-photo-has-this-name".into(),+ only: vec![lightcraft_catalog::PhotoId(u64::MAX)],+ labels: vec![lightcraft_catalog::ColorLabel::Red],+ rule_set: Some(lightcraft_catalog::RuleSet {+ mode: lightcraft_catalog::rules::Match::All,+ rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }],+ }),+ ..Default::default() }); let (task, _tx, _, _) = fake_import(&h); let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"});@@ -667,7 +670,7 @@ mod tests { let dialog = h.app.ui.dialog.clone().unwrap(); crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All);- assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default());+ assert_eq!(*h.app.session.filter(), lightcraft_catalog::Filter::default()); assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(id)); assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid); assert!(!h.app.session.visible().is_empty());@@ -95,7 +95,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { let mut x = bar.left() + 20.0; let mut shown = 0; for (i, c) in chips.iter().enumerate() {- let label = display_label(c, &app.session.filter, &app.session.catalog);+ let label = display_label(c, app.session.filter(), &app.session.catalog); let w = chip_width(ui, &label, font.clone()); // always show the first chip; keep room for the tail unless this is the last one that fits let room = right - x - if i + 1 == chips.len() { 90.0 } else { TAIL };@@ -136,7 +136,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { egui::Popup::menu(&more).show(|ui| { for c in hidden { if ui- .button(format!("{} ✕", display_label(c, &app.session.filter, &app.session.catalog)))+ .button(format!("{} ✕", display_label(c, app.session.filter(), &app.session.catalog))) .on_hover_text(crate::i18n::tr("Remove this filter")) .clicked() {@@ -64,7 +64,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let cell_w = 120.0; let ppp = ui.ctx().pixels_per_point(); if ids.is_empty() {- let why = if app.session.filter != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" };+ let why = if *app.session.filter() != Default::default() { "No photos match the filters (View → Clear Filters)" } else { "No photos" }; ui.painter().text(r.center(), Align2::CENTER_CENTER, why, t.font(12.5), t.text_dim); return; }@@ -167,21 +167,21 @@ pub(super) const FILTER_PILL_HEIGHT: f32 = 20.0; /// Whether [`filter_pill`] has something to show (a filter is active). pub(super) fn filter_pill_shown(app: &LightcraftApp) -> bool {- !lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).is_empty()+ !lightcraft_engine::filter_chips(app.session.filter(), &app.session.catalog).is_empty() } /// A small pill at the loupe's top left naming the active filters: the filmstrip and Next / Previous /// follow them, so a filter must never be invisible here. A click clears them all. It sits in the /// canvas margin, so the photo does not move. pub(super) fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) {- let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);+ let chips = lightcraft_engine::filter_chips(app.session.filter(), &app.session.catalog); if chips.is_empty() { return; } let t = Tokens::get(ui.ctx()); let p = ui.painter_at(canvas); let label =- chips.iter().map(|c| crate::panels::chips::display_label(c, &app.session.filter, &app.session.catalog)).collect::<Vec<_>>().join(" · ");+ chips.iter().map(|c| crate::panels::chips::display_label(c, app.session.filter(), &app.session.catalog)).collect::<Vec<_>>().join(" · "); let g = p.layout_no_wrap(crate::i18n::tr_format!("Filtered: {label}", label = label), t.font(12.0), Color32::from_gray(225)); let inset = FILTER_PILL_INSET; let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0));@@ -344,7 +344,7 @@ mod filter_pill_tests { #[test] fn the_filter_pill_is_inset_equally_from_both_edges() { let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });- app.session.filter.text = "717".into();+ app.session.set_filter(lightcraft_catalog::Filter { text: "717".into(), ..Default::default() }); let ctx = egui::Context::default(); let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]);@@ -101,7 +101,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().rect_filled(bar, 0.0, t.chrome); ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.divider)); register(ui, "filterbar", bar);- let f = app.session.filter.clone();+ let f = app.session.filter().clone(); let row = |ui: &mut egui::Ui, i: f32| { let r = Rect::from_min_size(bar.min + vec2(16.0, 4.0 + i * (HEIGHT - 8.0)), vec2(bar.width() - 32.0, HEIGHT - 8.0)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(r).layout(egui::Layout::left_to_right(egui::Align::Center)));@@ -137,23 +137,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // flags (one at a time; click again to clear) caption(ui, "Flag");- for (id, icon, flag, tip) in [- ("pick", Icon::FlagPick, Flag::Pick, "Picked"),- ("reject", Icon::FlagReject, Flag::Reject, "Rejected"),- ("none", Icon::Circle, Flag::None, "Unflagged"),- ] {- let on = f.flag == Some(flag);- let color = match flag {- Flag::Pick if on => t.pick,- Flag::Reject if on => t.reject,- _ => Color32::TRANSPARENT,- };- let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon };- let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color }));- if resp.clicked() {- filter(app, json!({"flag": if on { Value::Null } else { json!(id) }}));+ // (Picks shown: the source is the flag, and `library.filter` refuses another)+ ui.add_enabled_ui(app.session.source != lightcraft_engine::LibrarySource::Picks, |ui| {+ for (id, icon, flag, tip) in [+ ("pick", Icon::FlagPick, Flag::Pick, "Picked"),+ ("reject", Icon::FlagReject, Flag::Reject, "Rejected"),+ ("none", Icon::Circle, Flag::None, "Unflagged"),+ ] {+ let on = f.flag == Some(flag);+ let color = match flag {+ Flag::Pick if on => t.pick,+ Flag::Reject if on => t.reject,+ _ => Color32::TRANSPARENT,+ };+ let icon = if on && flag == Flag::Pick { Icon::FlagPickFilled } else { icon };+ let resp = toggle(ui, id, on, tip, |p, r, c| paint(p, r, icon, if color == Color32::TRANSPARENT { c } else { color }));+ if resp.clicked() {+ filter(app, json!({"flag": if on { Value::Null } else { json!(id) }}));+ } }- }+ }); ui.add_space(14.0); // colour labels@@ -251,7 +254,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // save / clear ui.add_space(14.0);- let filtering = app.session.filter != Default::default();+ let filtering = *app.session.filter() != Default::default(); let save = ui.add_enabled(filtering, egui::Button::new(egui::RichText::new(crate::i18n::tr("Save as Smart Album…")).font(t.font(12.5)))); register(ui, "button:filterSave", save.rect); if save.clicked() {@@ -288,7 +288,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (generation, ids) = app.session.visible_shared(); // header: source title + count let sel_n = app.session.selection.ids.len();- let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);+ let chips = lightcraft_engine::filter_chips(app.session.filter(), &app.session.catalog); let total = app.session.source_total(); let counted = super::chips::count_text(ids.len(), total, !chips.is_empty()); let cnt = if sel_n > 1 { crate::i18n::tr_format!("{sel_n} selected · {counted}", counted = counted, sel_n = sel_n) } else { counted };@@ -329,7 +329,7 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { _ => "Remove a filter above, or choose Clear all".to_string(), }; super::empty_message(ui, ui.max_rect(), "No photos match the active filters", &body);- } else if app.session.filter != Default::default() {+ } else if *app.session.filter() != Default::default() { super::empty_message( ui, ui.max_rect(),@@ -23,7 +23,7 @@ pub(super) fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { let open_id = egui::Id::unique(("date-open", key.to_string())); let mut open = tree_open(ui, open_id, false);- let sel = app.session.filter.date.as_deref() == Some(key);+ let sel = app.session.filter().date.as_deref() == Some(key); let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); if key.len() < 10 { tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}"));@@ -22,7 +22,7 @@ pub(super) fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[ /// One drawn row of [`keyword_rows`]; `open_id` and `open` are its triangle's when it has rows inside it. fn keyword_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &KeywordNode, id: &str, indent: f32, open_id: Option<egui::Id>, open: &mut bool) {- let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));+ let sel = app.session.filter().keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); let resp = row(app, ui, id, Icon::Tag, &n.name, Some(n.count), sel, indent); if let Some(k) = open_id { tree_toggle(ui, &resp, indent, k, open, format!("keywordToggle:{}", n.path));@@ -63,7 +63,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let now = ui.input(|i| i.time); let thumb = app.ui.thumb_size;- let people = app.caches.people(&app.session.catalog, &app.session.filter);+ let people = app.caches.people(&app.session.catalog, app.session.filter()); let (head, _) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER_H), Sense::hover()); let title = ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text);@@ -83,7 +83,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // until face recognition is set up: what it takes, and a button that does the next step super::faces::setup_banner(app, ui); // the filters narrowing the list (a date, a keyword…), removable here- let chips = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog);+ let chips = lightcraft_engine::filter_chips(app.session.filter(), &app.session.catalog); super::chips::show(app, ui, &chips); // what is selected below, and the box to name it with super::unnamed::naming_bar(app, ui);@@ -98,7 +98,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { return; } let ppp = ui.ctx().pixels_per_point();- let active = app.session.filter.person.clone();+ let active = app.session.filter().person.clone(); let (edge, tile) = (card_edge(thumb), tile_edge(thumb)); egui::ScrollArea::vertical().auto_shrink(false).show_viewport(ui, |ui, viewport| { let width = ui.available_width();@@ -110,7 +110,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let fresp = ui.interact(fr, egui::Id::unique("filter-btn"), Sense::click()); crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); register(ui, "icon:filter", fr);- let filtering = app.session.filter != Default::default() || app.ui.filter_bar;+ let filtering = *app.session.filter() != Default::default() || app.ui.filter_bar; paint( ui.painter(), fr.shrink(6.0),@@ -124,7 +124,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }, ); // badge: how many filters are on, even with the filter bar closed- let active = lightcraft_engine::filter_chips(&app.session.filter, &app.session.catalog).len();+ let active = lightcraft_engine::filter_chips(app.session.filter(), &app.session.catalog).len(); if active > 0 { let c = fr.right_top() + vec2(-3.0, 8.0); ui.painter().circle_filled(c, 7.0, t.accent);@@ -315,7 +315,7 @@ fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() { 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;+ h.app.session.set_filter(lightcraft_catalog::Filter::default()); continue; } let id = vis[roll(vis.len() as u64) as usize].0;@@ -371,7 +371,10 @@ fn a_grid_followed_through_edits_shows_what_a_grid_made_afresh_shows() { } } 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),+ _ => {+ let flag = n.is_multiple_of(2).then_some(lightcraft_catalog::Flag::Pick);+ h.app.session.set_filter(lightcraft_catalog::Filter { flag, ..Default::default() });+ } } h.step(); assert_as_made_afresh(&mut h, &format!("seed {seed}, after step {step}"));@@ -101,7 +101,7 @@ fn the_arrow_shows_the_photos_with_the_keyword() { let (mut h, _) = keywords_panel(); ask(&mut h, "ui.hoverWidget", json!({"id": "keywordRow:mountains"})); ask(&mut h, "ui.clickWidget", json!({"id": "keywordShow:mountains"}));- assert_eq!(h.app.session.filter.keyword.as_deref(), Some("mountains"));+ assert_eq!(h.app.session.filter().keyword.as_deref(), Some("mountains")); } fn find(h: &mut Headless, text: &str) {@@ -1536,7 +1536,7 @@ fn folders_section_lists_where_photos_were_imported_from_and_fills_the_grid() { assert_eq!(h.app.session.visible().len(), 2, "only the photos imported from that folder"); assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::LibraryFolder); assert_eq!(h.app.session.library_folder.as_deref(), Some("/pics/trip"));- assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default(), "a source, not a filter");+ assert_eq!(*h.app.session.filter(), lightcraft_catalog::Filter::default(), "a source, not a filter"); click(&mut h, "source:all"); assert_eq!(h.app.session.visible().len(), 3, "All Photos shows everything again"); // the section folds like the others@@ -2131,7 +2131,7 @@ fn date_and_keyword_rows_show_their_photos_from_any_source() { let r = h.request("ui.clickWidget", json!({"id": row}), T); assert_eq!(r["ok"], true, "{r}"); h.step();- assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default(), "{key}");+ assert_eq!(*h.app.session.filter(), lightcraft_catalog::Filter::default(), "{key}"); } /// Issue #501: use the actual context-menu button on the populated demo folder.@@ -103,7 +103,7 @@ fn rename_person_from_the_card_and_the_page() { assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace")); assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace")); assert_eq!(h.app.ui.person_from.as_ref().and_then(|(n, _)| n.as_deref()), Some("Ada Lovelace"));- assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace"));+ assert_eq!(h.app.session.filter().person.as_deref(), Some("Ada Lovelace")); h.app.session.execute("edit.undo", &json!({})).unwrap(); assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); }@@ -67,7 +67,7 @@ fn what_one_keyword_costs_in_a_library_of_67_000_photos() { // its parts let ids = s.visible_cloned(); let t = Instant::now();- let f = s.source.to_filter(&s.filter, &s.catalog);+ let f = s.source.to_filter(s.filter(), &s.catalog); let q = s.catalog.query(&f, &s.sort); let query = t.elapsed(); let t = Instant::now();@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; 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; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (LRC-LIB-KEYWORDS, LRC-LIB-LIBFOLDERS, LRC-LIB-FILTERBAR: undo and redo carry what a keyword delete or rename, a person rename or a folder removal did to the filter; LRC-LIB-FILTERBAR: one step keeps the filter clear of what the source shown owns (an album, the folder shown, Picks' flag); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; 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; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; 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; LR-META-XMP: fewer false unimported-XMP warnings) · **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@@ -703,15 +703,15 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/dialogs/settings.rs`, `crates/ui-egui/src/panels/left.rs`, `crates/ui-egui/src/panels/library_problem.rs` (`launch_plan`, `show_chooser`), `crates/ui-egui/src/tests/launch_library.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Remove from List ▸ one entry, or `app.recentLibraries {remove}`, which forgets only the path; Clear), the open library's name under My Photos and in the window title (issue #902); Open/New refused during imports and their folder scans; a library that can't be opened leaves the current one and its reference pin intact; a successful switch clears the catalog-local reference pin; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests/keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key; Settings ▸ General ▸ At launch: Open the last library (default) or Ask me, a window listing the recent libraries (name, folder, when last opened; missing, unreadable or no-longer-library ones greyed out with the reason, checked read-only — nothing is locked or written, so a library another program has open is reported, with who has it, when Open is pressed; the last one selected; Open, Open Another…, New Library…, Remove from List, Quit; ↑ ↓ Return Esc), also shown once when Alt/Option is held in the first 2 s after the window appears (macOS reports an Option key held since before launch only once it changes); `--library` and `LIGHTCRAFT_LIBRARY` come first and `lightcraft-cli` never asks (issue #1290) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) |-| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged |+| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests/panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; a folder with none of these changes only says the library is up to date with it, with one Close and no choices, and closing it starts no job; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files stops when the scan is cancelled, and its depth/entry limits are only logged. Undoing `library.removeFolder` puts the folder back in the filter (in use, or put aside by Enable Filters, also a put-aside filter the removal dropped because it was left empty), and redo takes it out again; a folder is not put back over another folder shown since | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/dialogs/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/widgets/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/widgets/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |-| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change albums (an album the filter held is replaced by the album you show; always locked, as in the desktop app); arbitrary conditions via the smart-album rule editor |+| LRC-LIB-FILTERBAR | Library filter bar | P1 | ✅ | `cmd:view.filterBar`, `cmd:filter.applyPreset`, `cmd:library.filter` (`labels`, `ruleSet`), `crates/ui-egui/src/panels/filterbar.rs` | rating/flag/label (several labels at once = any of them)/kind/edited/camera/lens/keyword, clear, save as smart album, filter presets; the filter stays as you change sources, except what the source shown owns: the album, the library folder while a folder is shown and the flag while Picks is shown are never part of the filter (`library.filter` refuses them, a preset or Enable Filters brings the rest and leaves them out, the preset's answer names them in `skipped`, showing the source takes them out, and the flag buttons are off under Picks); always locked, as in the desktop app; arbitrary conditions via the smart-album rule editor. Undo and redo carry what a step did to the filter (a keyword deleted or renamed, a person renamed, a folder removed) group by group, as far as nobody changed that part since; a filter the person built after emptying or replacing the old one is left alone | | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` |-| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:keyword.nextSet`, `cmd:keyword.previousSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9; ⌥0 / ⌥⇧0 go to the next / previous set and wrap around at either end); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied |+| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:keyword.nextSet`, `cmd:keyword.previousSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9; ⌥0 / ⌥⇧0 go to the next / previous set and wrap around at either end); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields. In the Keyword List a keyword's empty box carries a dot when the selected photos have a keyword inside it (its tooltip names them), so a folded keyword shows that something in it is applied. Undoing a keyword delete, rename or merge puts the keyword back in the filter (in use or put aside by Enable Filters), and redo takes it forward again (`crates/engine/src/undo_filters.rs`) | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | | LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people@@ -959,3 +959,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |+| 2026-10-27 | patch | LRC-LIB-FILTERBAR: every change to the filter goes through one step that leaves out what the source shown owns: the album (under every source: the filter bar has no album chip, so a copy narrowed the view unseen; `library.filter` refuses one, a preset leaves it out), the library folder while a folder is shown (a preset's folder was applied over the folder shown) and the flag while Picks is shown; the flag buttons are off under Picks; status unchanged (✅) |+| 2026-10-27 | patch | LRC-LIB-KEYWORDS, LRC-LIB-LIBFOLDERS, LRC-LIB-FILTERBAR: undoing a keyword delete, rename or merge, a person rename (`faces.renamePerson`) or a library folder removal brings back what the step did to the filter, in use or put aside by Enable Filters (also a put-aside filter a folder removal dropped because it was left empty), and redo carries it forward again; the step's change is kept once, where every command that commits a step is run, and moves group by group (the fields one filter chip clears together), only while nobody changed that part since; a filter the person built after emptying or replacing the old one is left alone; before, undo restored the catalog but the filter kept the new name or stayed without the keyword or folder; status unchanged (✅) |@@ -1,5 +1,21 @@ # What's new in LightCraft +## 2026-10-27++### The filter never holds what the source shown already supplies+- The filter bar has no chip for an album, and a library folder or Picks already decide what they show, so a copy of+ one of them left in the filter narrowed the next source with nothing on screen to explain why. Now every way of+ changing the filter (the bar, a saved preset, View ▸ Enable Filters, showing a source) leaves them out. A preset that+ holds an album, the library folder shown or Picks' flag applies the rest and leaves them out (a script is told+ which); setting one of them in the filter by hand is refused, and the flag buttons are off while Picks is shown.++### Undo puts the filter back with the keyword, person or folder+- Deleting or renaming a keyword, renaming a person and removing a library folder change the filter that names them, and+ undo brought the keyword, name or folder back but left the filter as it was changed, so the grid showed everything or+ nothing. Now undo gives the filter its old keyword, name or folder, also in the filter View ▸ Enable Filters put aside,+ and redo changes it again. Only what the step changed moves, and only while you have not changed that part of the+ filter since; a filter you built after emptying or replacing the old one is left alone.+ ## 2026-10-24 ### Relative-date filters and smart albums follow the clock