collective

Browse / github.com/storytold/lightcraft / patch

LRC-LIB-FILTERBAR: every filter change goes through one step that leaves out what the source shown owns

LRC-LIB-FILTERBAR: every filter change goes through one step that leaves out what the source shown owns Off `main`. Replaces #3089 and #2759, which close in favor of this one. ## Problem Three open PRs (#2762 merged, #2759, #3089) each enforced "the source shown owns some filter fields" at one writer, each in a different way (drop silently, refuse, skip and report), and @THEtheChad's reviews on #3089 and #3093 asked for the step to exist once. Two holes were left: - A preset that holds a library folder is applied whole while another folder is shown, so a "Folder: A" chip sits over folder B. `library.filter` refuses that state, the preset path reaches it. - An album in the filter has no chip a

Status
open
Score
0.00
Adopters
0 other operators
Author
nledford (imported from pull request #3157, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+566 −265 in 42 files
Review
pending
Shared
2026-10-28
Id
2c23a7a7ec6755c9a8cb810a

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/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

diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs--- a/crates/engine/src/cmd/browse.rs+++ b/crates/engine/src/cmd/browse.rs@@ -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));     } } diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs--- a/crates/engine/src/cmd/cull.rs+++ b/crates/engine/src/cmd/cull.rs@@ -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<_>>()})) }diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs--- a/crates/engine/src/cmd/face_recognize.rs+++ b/crates/engine/src/cmd/face_recognize.rs@@ -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})) } diff --git a/crates/engine/src/cmd/filters.rs b/crates/engine/src/cmd/filters.rs--- a/crates/engine/src/cmd/filters.rs+++ b/crates/engine/src/cmd/filters.rs@@ -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),     ] }diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs--- a/crates/engine/src/cmd/keywords.rs+++ b/crates/engine/src/cmd/keywords.rs@@ -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})) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs--- a/crates/engine/src/cmd/library.rs+++ b/crates/engine/src/cmd/library.rs@@ -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;diff --git a/crates/engine/src/cmd/library/filters.rs b/crates/engine/src/cmd/library/filters.rs--- a/crates/engine/src/cmd/library/filters.rs+++ b/crates/engine/src/cmd/library/filters.rs@@ -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();diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs--- a/crates/engine/src/cmd/library/specs.rs+++ b/crates/engine/src/cmd/library/specs.rs@@ -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.set_filter(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!(diff --git a/crates/engine/src/cmd/library/tests_enable_filters.rs b/crates/engine/src/cmd/library/tests_enable_filters.rs--- a/crates/engine/src/cmd/library/tests_enable_filters.rs+++ b/crates/engine/src/cmd/library/tests_enable_filters.rs@@ -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); }diff --git a/crates/engine/src/cmd/library/tests_filter_reconcile.rs b/crates/engine/src/cmd/library/tests_filter_reconcile.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/library/tests_filter_reconcile.rs@@ -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");+}diff --git a/crates/engine/src/cmd/library/tests_source_album_filter.rs b/crates/engine/src/cmd/library/tests_source_album_filter.rsdeleted file mode 100644--- a/crates/engine/src/cmd/library/tests_source_album_filter.rs+++ /dev/null@@ -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));-}diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs--- a/crates/engine/src/cmd/query.rs+++ b/crates/engine/src/cmd/query.rs@@ -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,diff --git a/crates/engine/src/filter_state.rs b/crates/engine/src/filter_state.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/filter_state.rs@@ -0,0 +1,160 @@+//! 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+    }+}++/// 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>,+}++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()+    }++    /// Show `f` as 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> {+        let left_out = source.reconcile(&mut f);+        self.current = f;+        self.aside = None;+        left_out+    }++    /// [`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.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) {+        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 {+        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)) {+        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)) {+        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) {+        if self.aside.as_ref() == Some(&Filter::default()) {+            self.aside = None;+        }+    }++    /// Enable Filters is off (a filter is put aside).+    pub fn is_off(&self) -> bool {+        self.aside.is_some()+    }+}diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -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;@@ -231,9 +232,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 +451,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(),@@ -1058,15 +1058,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.set(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 +1106,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 +1127,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 +1169,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 +1269,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();diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs--- a/crates/engine/src/library.rs+++ b/crates/engine/src/library.rs@@ -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()diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -1369,11 +1369,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()); } diff --git a/crates/engine/src/tests/face_recognize.rs b/crates/engine/src/tests/face_recognize.rs--- a/crates/engine/src/tests/face_recognize.rs+++ b/crates/engine/src/tests/face_recognize.rs@@ -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();diff --git a/crates/engine/src/tests/folders.rs b/crates/engine/src/tests/folders.rs--- a/crates/engine/src/tests/folders.rs+++ b/crates/engine/src/tests/folders.rs@@ -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`.diff --git a/crates/engine/src/tests/keyword_list.rs b/crates/engine/src/tests/keyword_list.rs--- a/crates/engine/src/tests/keyword_list.rs+++ b/crates/engine/src/tests/keyword_list.rs@@ -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" isdiff --git a/crates/engine/src/tests/libops.rs b/crates/engine/src/tests/libops.rs--- a/crates/engine/src/tests/libops.rs+++ b/crates/engine/src/tests/libops.rs@@ -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"]);diff --git a/crates/engine/src/tests/library.rs b/crates/engine/src/tests/library.rs--- a/crates/engine/src/tests/library.rs+++ b/crates/engine/src/tests/library.rs@@ -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 checkeddiff --git a/crates/engine/src/tests/merge.rs b/crates/engine/src/tests/merge.rs--- a/crates/engine/src/tests/merge.rs+++ b/crates/engine/src/tests/merge.rs@@ -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); }diff --git a/crates/engine/src/tests/organize.rs b/crates/engine/src/tests/organize.rs--- a/crates/engine/src/tests/organize.rs+++ b/crates/engine/src/tests/organize.rs@@ -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.diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -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, thediff --git a/crates/ui-egui/src/headless/tests.rs b/crates/ui-egui/src/headless/tests.rs--- a/crates/ui-egui/src/headless/tests.rs+++ b/crates/ui-egui/src/headless/tests.rs@@ -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);diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs--- a/crates/ui-egui/src/lightroom_import.rs+++ b/crates/ui-egui/src/lightroom_import.rs@@ -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());diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs--- a/crates/ui-egui/src/panels/chips.rs+++ b/crates/ui-egui/src/panels/chips.rs@@ -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()                 {diff --git a/crates/ui-egui/src/panels/detail/filmstrip.rs b/crates/ui-egui/src/panels/detail/filmstrip.rs--- a/crates/ui-egui/src/panels/detail/filmstrip.rs+++ b/crates/ui-egui/src/panels/detail/filmstrip.rs@@ -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;     }diff --git a/crates/ui-egui/src/panels/detail/info.rs b/crates/ui-egui/src/panels/detail/info.rs--- a/crates/ui-egui/src/panels/detail/info.rs+++ b/crates/ui-egui/src/panels/detail/info.rs@@ -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![]);diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs--- a/crates/ui-egui/src/panels/filterbar.rs+++ b/crates/ui-egui/src/panels/filterbar.rs@@ -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() {diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs--- a/crates/ui-egui/src/panels/grid.rs+++ b/crates/ui-egui/src/panels/grid.rs@@ -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(),diff --git a/crates/ui-egui/src/panels/left/dates.rs b/crates/ui-egui/src/panels/left/dates.rs--- a/crates/ui-egui/src/panels/left/dates.rs+++ b/crates/ui-egui/src/panels/left/dates.rs@@ -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}"));diff --git a/crates/ui-egui/src/panels/left/keywords.rs b/crates/ui-egui/src/panels/left/keywords.rs--- a/crates/ui-egui/src/panels/left/keywords.rs+++ b/crates/ui-egui/src/panels/left/keywords.rs@@ -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));diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs--- a/crates/ui-egui/src/panels/people.rs+++ b/crates/ui-egui/src/panels/people.rs@@ -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();diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs--- a/crates/ui-egui/src/panels/topbar.rs+++ b/crates/ui-egui/src/panels/topbar.rs@@ -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);diff --git a/crates/ui-egui/src/tests/grid.rs b/crates/ui-egui/src/tests/grid.rs--- a/crates/ui-egui/src/tests/grid.rs+++ b/crates/ui-egui/src/tests/grid.rs@@ -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}"));diff --git a/crates/ui-egui/src/tests/keyword_list.rs b/crates/ui-egui/src/tests/keyword_list.rs--- a/crates/ui-egui/src/tests/keyword_list.rs+++ b/crates/ui-egui/src/tests/keyword_list.rs@@ -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) {diff --git a/crates/ui-egui/src/tests/panels.rs b/crates/ui-egui/src/tests/panels.rs--- a/crates/ui-egui/src/tests/panels.rs+++ b/crates/ui-egui/src/tests/panels.rs@@ -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.diff --git a/crates/ui-egui/src/tests/rename_person.rs b/crates/ui-egui/src/tests/rename_person.rs--- a/crates/ui-egui/src/tests/rename_person.rs+++ b/crates/ui-egui/src/tests/rename_person.rs@@ -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())]); }diff --git a/crates/ui-egui/src/tests/view_cost.rs b/crates/ui-egui/src/tests/view_cost.rs--- a/crates/ui-egui/src/tests/view_cost.rs+++ b/crates/ui-egui/src/tests/view_cost.rs@@ -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();diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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-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@@ -707,7 +707,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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 | | 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` |@@ -959,3 +959,4 @@ 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 (✅) |diff --git a/docs/whats-new.md b/docs/whats-new.md--- a/docs/whats-new.md+++ b/docs/whats-new.md@@ -1,5 +1,14 @@ # 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.+ ## 2026-10-24  ### Relative-date filters and smart albums follow the clock