This commit is contained in:
Brandon Thomas
2026-10-23 17:21:16 -04:00
16 changed files with 301 additions and 5 deletions
+3
View File
@@ -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)
{
+1
View File
@@ -115,6 +115,7 @@ 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();
+5
View File
@@ -176,6 +176,11 @@ fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
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}))
}
+1
View File
@@ -38,6 +38,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
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()}))
}
+6
View File
@@ -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}))
}
+36
View File
@@ -426,6 +426,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad("library.filter", problems.iter().map(ToString::to_string).collect::<Vec<_>>().join("; ")));
}
s.filter = f;
s.shelved_filter = None;
Ok(json!({"count": s.visible().len()}))
}
),
@@ -469,6 +470,15 @@ pub fn specs() -> Vec<CommandSpec> {
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;
@@ -510,8 +520,30 @@ pub fn specs() -> Vec<CommandSpec> {
),
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 +1803,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;
@@ -0,0 +1,154 @@
//! `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<Session> {
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);
}
#[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");
}
#[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);
}
+8
View File
@@ -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<Filter>,
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 =
+4
View File
@@ -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)
+2
View File
@@ -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<bool> {
"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()),
+4
View File
@@ -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;
@@ -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<LightcraftApp> {
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");
}
+3 -2
View File
@@ -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) |
+1
View File
@@ -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.
+1 -1
View File
@@ -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 |
+3 -2
View File
@@ -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 |