From 6c55a76e95629ed58592970f76b1a9f37d2a71c9 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 17:38:34 +0200 Subject: [PATCH 1/3] LR-LIB-FILTER: Cmd+L / Ctrl+L (View > Enable Filters) switches the library filter off and on again as it was --- crates/engine/src/cmd/filters.rs | 1 + crates/engine/src/cmd/library.rs | 27 ++++ .../src/cmd/library/tests_enable_filters.rs | 118 ++++++++++++++++++ crates/engine/src/lib.rs | 8 ++ crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/menubar.rs | 2 + crates/ui-egui/src/shortcuts.rs | 4 + .../src/shortcuts/tests_enable_filters.rs | 69 ++++++++++ docs/gaps.md | 5 +- docs/library-shortcuts.md | 1 + docs/parity-checklist.md | 2 +- docs/ui-parity.md | 5 +- 12 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/library/tests_enable_filters.rs create mode 100644 crates/ui-egui/src/shortcuts/tests_enable_filters.rs diff --git a/crates/engine/src/cmd/filters.rs b/crates/engine/src/cmd/filters.rs index 73bae384f..afe2011ac 100644 --- a/crates/engine/src/cmd/filters.rs +++ b/crates/engine/src/cmd/filters.rs @@ -38,6 +38,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { 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()})) } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index e437dccc2..0f59a027f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -426,6 +426,7 @@ pub fn specs() -> Vec { return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::>().join("; "))); } s.filter = f; + s.shelved_filter = None; Ok(json!({"count": s.visible().len()})) } ), @@ -510,8 +511,30 @@ pub fn specs() -> Vec { ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| { s.filter = Default::default(); + s.shelved_filter = None; Ok(json!({"count": s.visible().len()})) }), + // Classic: Library > Enable Filters (Cmd+L) switches the filter off and on again as it was + cmd!( + "library.toggleFilters", + "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}", + 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()})) + } + ), cmd!( "library.sort", "Sort", @@ -1771,3 +1794,7 @@ mod relative_rating_tests; #[cfg(test)] #[path = "library/tests_flag_cycle.rs"] mod flag_cycle_tests; + +#[cfg(test)] +#[path = "library/tests_enable_filters.rs"] +mod enable_filters_tests; diff --git a/crates/engine/src/cmd/library/tests_enable_filters.rs b/crates/engine/src/cmd/library/tests_enable_filters.rs new file mode 100644 index 000000000..0833dcd94 --- /dev/null +++ b/crates/engine/src/cmd/library/tests_enable_filters.rs @@ -0,0 +1,118 @@ +//! `library.toggleFilters` (Library > Enable Filters, Cmd+L) through the real engine. +use lightcraft_catalog::{Filter, Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{LibrarySource, Result, Session}; + +fn session() -> Result { + let mut s = Session::new(); + for (id, rating, flag) in [(1, 5, Flag::Pick), (2, 5, Flag::None), (3, 2, Flag::Pick), (4, 1, Flag::None)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-enable-filters/{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) })?; + } + Ok(s) +} + +fn stars_and_pick(s: &mut Session) { + s.execute("library.filter", &json!({"rating": 4, "flag": "pick"})).unwrap(); +} + +#[test] +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(); + 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.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.visible().len(), 1); +} + +#[test] +fn with_no_filter_it_does_nothing_and_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + let r = s.execute("library.toggleFilters", &json!({})).unwrap(); + 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()); +} + +#[test] +fn setting_a_filter_while_off_turns_filtering_on_and_forgets_the_old_one() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + s.execute("library.filter", &json!({"rating": 2})).unwrap(); + assert_eq!(s.visible().len(), 3); + // 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); +} + +#[test] +fn emptying_the_filter_by_hand_while_off_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + 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()); + 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.visible().len(), 4); +} + +#[test] +fn clearing_the_filter_while_off_leaves_nothing_to_bring_back() { + let mut s = session().unwrap(); + stars_and_pick(&mut s); + 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.visible().len(), 4); +} + +#[test] +fn applying_a_preset_while_off_turns_filtering_on_with_the_preset() { + let mut s = session().unwrap(); + s.filter_presets.push(super::super::filters::FilterPreset { name: "Fives".into(), filter: Filter { rating: 5, ..Default::default() } }); + stars_and_pick(&mut s); + s.execute("library.toggleFilters", &json!({})).unwrap(); + 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); +} + +#[test] +fn a_folder_filter_does_not_come_back_over_the_folder_now_shown() { + let mut s = session().unwrap(); + s.execute("library.filter", &json!({"rating": 4, "libraryFolder": "/lightcraft-enable-filters"})).unwrap(); + s.execute("library.toggleFilters", &json!({})).unwrap(); + 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); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 323ed90ef..4ad5695dc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -207,6 +207,8 @@ pub struct Session { 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, pub sort: Sort, pub selection: Selection, /// The grid order for the current source/filter/sort (cached by catalog revision). @@ -422,6 +424,7 @@ impl Session { catalog: Catalog::new(), source: LibrarySource::All, filter: Filter::default(), + shelved_filter: None, sort: Sort::default(), selection: Selection::default(), visible: Vec::new().into(), @@ -1027,6 +1030,11 @@ impl Session { // ---------------------------------------------------------------- library view + /// Library > Enable Filters is off: a filter is put aside (`library.toggleFilters`). + pub fn filters_off(&self) -> bool { + self.shelved_filter.is_some() + } + /// Photos shown in the grid/filmstrip for the current source, filter and sort. pub fn visible(&mut self) -> &[PhotoId] { let mut key = diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b2517907..edaf1ec63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -635,6 +635,10 @@ impl LightcraftApp { .map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0]))) }; } + 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(); + } if r.is_ok() && id == "library.clearFilter" { // the search field shows the text filter: cleared from anywhere (the loupe's pill, the // menu), it must not keep a search that no longer applies (issue #1648) diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1ac3e9926..22657c787 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -172,6 +172,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sort", "@Stacks", "view.filterBar", + "library.toggleFilters", "library.clearFilter", "library.refresh", "library.holdView", @@ -353,6 +354,7 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "library.toggleFilters" => Some(!app.session.filters_off()), "view.soloSections" => Some(u.single_panel), "view.navigator" => Some(u.navigator), "view.layoutImage" => Some(u.layout_image.show && !u.layout_image.path.trim().is_empty()), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e7b58a9b5..91e6555b9 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1317,6 +1317,10 @@ mod relative_rating_tests; #[path = "shortcuts/tests_flag_cycle.rs"] mod flag_cycle_tests; +#[cfg(test)] +#[path = "shortcuts/tests_enable_filters.rs"] +mod enable_filters_tests; + #[cfg(test)] #[path = "shortcuts/tests_filter_bar_key.rs"] mod filter_bar_key_tests; diff --git a/crates/ui-egui/src/shortcuts/tests_enable_filters.rs b/crates/ui-egui/src/shortcuts/tests_enable_filters.rs new file mode 100644 index 000000000..0f42c3eff --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_enable_filters.rs @@ -0,0 +1,69 @@ +//! Cmd+L (Library > Enable Filters) switches the filter off and on from the keyboard and the menu. +use egui::{Key, Modifiers}; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::state::ViewMode; + +fn app() -> lightcraft_engine::Result { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=3 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-enable-filters-keys/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-23", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.select", &json!({"ids": [1]}))?; + s.execute("photo.rate", &json!({"ids": [1], "rating": 4}))?; + s.execute("library.filter", &json!({"rating": 4, "text": "1.jpg"}))?; + let mut app = LightcraftApp::new(s, crate::Services { png: None, ..Default::default() }); + app.ui.search = "1.jpg".into(); + Ok(app) +} + +fn press(app: &mut LightcraftApp, ctx: &egui::Context) { + let events = vec![egui::Event::Key { key: Key::L, physical_key: Some(Key::L), pressed: true, repeat: false, modifiers: Modifiers::COMMAND }]; + let mut out = ctx.run_ui( + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| super::handle(app, ui.ctx()), + ); + out.textures_delta.clear(); +} + +#[test] +fn the_key_is_cmd_l() { + assert_eq!(super::shortcut_of(&Default::default(), "library.toggleFilters"), Some("Cmd+L")); +} + +#[test] +fn cmd_l_switches_the_filter_off_and_on_in_the_grids_and_the_loupe() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut app = app().unwrap(); + app.ui.view = view; + let ctx = egui::Context::default(); + press(&mut app, &ctx); + assert_eq!(app.session.visible().len(), 3, "{view:?}: off shows every photo"); + assert_eq!(app.ui.search, "", "{view:?}: the search field follows"); + assert_eq!(crate::menubar::checked(&app, "library.toggleFilters"), Some(false)); + press(&mut app, &ctx); + assert_eq!(app.session.visible().len(), 1, "{view:?}: on brings the filter back"); + assert_eq!(app.ui.search, "1.jpg"); + assert_eq!(crate::menubar::checked(&app, "library.toggleFilters"), Some(true)); + } +} + +#[test] +fn the_menu_item_does_the_same() { + let mut app = app().unwrap(); + crate::menubar::run_item(&mut app, "library.toggleFilters", serde_json::Value::Null).unwrap(); + assert_eq!(app.session.visible().len(), 3); + assert_eq!(app.ui.search, ""); + assert_eq!(crate::menubar::shortcut_text("Cmd+L", true), "⌘L"); +} diff --git a/docs/gaps.md b/docs/gaps.md index be411a205..51d93abef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (gap 28: ⌘L Enable Filters switches the filter off and on; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -43,7 +43,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: the Windows x64 installer is verified on Windows 11 (0.9.1: install and upgrade end on the completion page, a custom folder is kept, Launch works, removal is clean), but uninstalling from Settings → Apps ends with no confirmation (#892, #931); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | | 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | -| 28 | **Small Classic shortcuts and keys**: ⌘L (Enable Filters), Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | +| 28 | **Small Classic shortcuts and keys**: Tab / ⇧Tab (hide the side panels / all panels; Tab moves the keyboard focus here) and F5. Rating `[` `]`, flag cycling (`` ` ``), `\` (Filter Bar), ⌘L (Enable Filters), F1 help, F6 / F7 / F8 (filmstrip, left panel, right panel) are bound | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 2–4 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). @@ -86,6 +86,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | | 2026-10-12 | minor | Gap 10: SR2/SRF folder discovery fixed; F828 imports as preview-only. Sensor decoding remains a gap; readiness scores unchanged | +| 2026-10-23 | minor | Gap 28: ⌘L / Ctrl+L (Library ▸ Enable Filters) puts the filter aside and brings it back as it was; the entry drops ⌘L; hours unchanged | | 2026-10-15 | minor | Gap 28: `\` shows / hides the Filter Bar in the grids; the entry drops `[` `]` (#509), flag cycling (#1490) and F1 (Help), already on main; ⌘L is the filter-bar key left; hours unchanged | | 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | | 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 211b00b76..81bc2cede 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,6 +17,7 @@ Select photos in Photo Grid or Square Grid, then use: | `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | | `\` (backslash) | Show / hide the Filter Bar | +| ⌘L / Ctrl+L | Enable Filters: switch the filter off and on | Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..ed6d52e58 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -779,7 +779,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | | KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail). Missing: Shift+P outside grids (Presets), ⌘L Enable Filters | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag`, `cmd:library.toggleFilters` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus; `\` shows / hides the Filter Bar in the grids (Show Original in Detail); ⌘L (Ctrl+L) Enable Filters puts the filter aside and brings it back as it was. Missing: Shift+P outside grids (Presets) | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f267092eb..cf27d83e4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (KEYC-RATING: ⌘L Enable Filters; context menus: a right-click elsewhere on the same widget moves its open menu instead of flashing it, issue #1877; Reference Escape, catalog-local pins and deleted-photo handling verified on Windows; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; earlier: zoom row soft proofing sharp at 1:1, canvas tools by keyboard, focus-order test, ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -66,7 +66,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. Remaining Classic gaps: ⌘L (Enable Filters). +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. F6, F7 and F8 show / hide the filmstrip, the left panel and the right panel (F8 brings back the panel it hid; `view.rightPanel`). `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. `\` shows / hides the Filter Bar in Photo Grid / Square Grid and keeps Show Original in Detail. ⌘L / Ctrl+L (Enable Filters) puts the filter aside and shows every photo, and the next press brings back the same filter, stars, flag and text together; setting or clearing a filter while it is off drops the one put aside (not documented by Adobe, our choice). **Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). @@ -149,6 +149,7 @@ establish OS keyboard delivery or cross-platform acceptance. | Date | Change | Summary | |---|---|---| | 2026-10-15 | patch | Context menus: a right-click elsewhere on the same widget while its menu is open moves the menu to the pointer; before it flashed at the new spot and closed (issue #1877); every site uses `widgets::context_menu`; 70% unchanged | +| 2026-10-23 | patch | KEYC-RATING: ⌘L / Ctrl+L (Library ▸ Enable Filters) switches the filter off and on, restoring it as it was | | 2026-10-15 | patch | KEYC-RATING: `\` shows / hides the Filter Bar in Photo Grid and Square Grid (Show Original in Detail); ⌘L is the filter-bar key left | | 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | | 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | From b6d94b447bf93aac1b7ca5e6888400747d5cd2f7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 19:35:06 +0200 Subject: [PATCH 2/3] Drop a removed folder from the filter Enable Filters put aside --- crates/engine/src/cmd/library.rs | 9 +++++++++ .../src/cmd/library/tests_enable_filters.rs | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0f59a027f..334d9c92d 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -470,6 +470,15 @@ pub fn specs() -> Vec { 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 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; diff --git a/crates/engine/src/cmd/library/tests_enable_filters.rs b/crates/engine/src/cmd/library/tests_enable_filters.rs index 0833dcd94..7ec0f2a39 100644 --- a/crates/engine/src/cmd/library/tests_enable_filters.rs +++ b/crates/engine/src/cmd/library/tests_enable_filters.rs @@ -116,3 +116,23 @@ fn a_folder_filter_does_not_come_back_over_the_folder_now_shown() { assert_eq!(s.filter.rating, 4); assert_eq!(s.filter.library_folder, None); } + +#[test] +fn removing_a_folder_while_off_drops_it_from_the_filter_put_aside() { + let mut s = session().unwrap(); + s.execute("library.filter", &json!({"rating": 4, "libraryFolder": "/lightcraft-enable-filters"})).unwrap(); + 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"); +} + +#[test] +fn removing_the_only_criterion_put_aside_leaves_filtering_on() { + let mut s = session().unwrap(); + 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"); +} From 5e5602b4ca5b17c9087a7579d57f810942815783 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 17:20:11 -0400 Subject: [PATCH 3/3] Enable Filters: Find Similar turns filtering back on; a filter put aside follows renamed keywords, people and folders Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/browse.rs | 3 +++ crates/engine/src/cmd/cull.rs | 1 + crates/engine/src/cmd/face_recognize.rs | 5 +++++ crates/engine/src/cmd/keywords.rs | 6 ++++++ .../src/cmd/library/tests_enable_filters.rs | 16 ++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 01c67c267..6f3f88963 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -482,6 +482,9 @@ 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); + } if let Some(b) = &mut s.browse && let Ok(rel) = Path::new(&b.path).strip_prefix(from) { diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 142accd5a..ec6f59feb 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -115,6 +115,7 @@ fn find_similar(s: &mut Session, p: &Value) -> Result { } 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(); } Ok(json!({"photos": hits.iter().map(|(i, v)| json!({"id": i.0, "similarity": (v * 1000.0).round() / 1000.0})).collect::>()})) diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e66bb56f3..e3b385e2c 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -176,6 +176,11 @@ fn rename_person(s: &mut Session, p: &Value) -> Result { 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()); + } Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) } diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index fe09a92f9..0f3b8827a 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -32,6 +32,12 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol 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); + } Ok(json!({"changed": photos, "listed": listed})) } diff --git a/crates/engine/src/cmd/library/tests_enable_filters.rs b/crates/engine/src/cmd/library/tests_enable_filters.rs index 7ec0f2a39..9eb3c1ad0 100644 --- a/crates/engine/src/cmd/library/tests_enable_filters.rs +++ b/crates/engine/src/cmd/library/tests_enable_filters.rs @@ -136,3 +136,19 @@ fn removing_the_only_criterion_put_aside_leaves_filtering_on() { s.execute("library.removeFolder", &json!({"path": "/lightcraft-enable-filters"})).unwrap(); assert!(s.shelved_filter.is_none(), "an empty filter is not left put aside"); } + +#[test] +fn a_keyword_renamed_while_off_is_followed_by_the_filter_put_aside() { + let mut s = session().unwrap(); + 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()); + 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.visible().len(), 1); +}