From 6277af55bf1cd2aa044607ad5f69266c1a5c36a6 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Thu, 8 Oct 2026 20:25:37 +0800 Subject: [PATCH] M1: restore library culling shortcuts and Classic shift actions Keep macOS plain-key ratings, labels and flags with egui, adapting the native menu ownership correction from PR #261 (4e012a5, Gardy). Add Shift+6-9 and contextual grid Shift+P, recognize shifted digit punctuation, and document the verified Lightroom Classic mapping. Cover batch changes and undo, exactly-once advance, Compare targets, text and crop context, physical number keys and native menu ownership. Verified native demo/control screenshots and all seven cargo xtask ci gates. Co-authored-by: Gardy --- apps/lightcraft/src/native_menu.rs | 65 +++++++-- crates/ui-egui/src/menubar.rs | 4 +- crates/ui-egui/src/panels/dialogs.rs | 15 +- crates/ui-egui/src/shortcuts.rs | 199 ++++++++++++++++++++++++++- docs/library-shortcuts.md | 41 ++++++ docs/parity.md | 22 +-- docs/whats-new.md | 5 + 7 files changed, 328 insertions(+), 23 deletions(-) create mode 100644 docs/library-shortcuts.md diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 557c9abf..02d33db3 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -2,9 +2,12 @@ //! the in-window menus ([`lightcraft_ui_egui::menubar`]): the registry's menu paths, shortcuts, //! enabled and checked state, and live labels ("Undo Exposure"). //! -//! Key handling: a native key equivalent consumes the key press before egui sees it, so -//! - shortcuts installed natively are listed in `LightcraftApp::native_shortcuts` and skipped by -//! the egui shortcut handler (nothing fires twice); +//! Key handling: AppKit offers a key press to the menu bar only when it carries ⌘ or ⌃, or is a +//! function key (F1…). Every other key (`E`, `1`, `⇧P`, `⌥Y`…) goes straight to the window, whose +//! winit view always takes it, so a menu item never fires from it. Hence +//! - shortcuts the menu bar really receives ([`menu_delivers`]) are listed in +//! `LightcraftApp::native_shortcuts` and skipped by the egui shortcut handler (nothing fires +//! twice); the others stay on their menu items for display and egui runs them; //! - while a text field has keyboard focus, accelerators without ⌘ (`G`, `1`, `Delete`…) and the //! text-editing ones (⌘A/⌘C/⌘V/⌘X/⌘Z) are removed, so typing and text editing work; they come //! back when the field loses focus. @@ -146,6 +149,18 @@ fn yields_to_text(sc: &str) -> bool { !sc.contains("Cmd") && !sc.contains("Ctrl") || TEXT_EDIT.contains(&sc) } +/// Whether AppKit hands this shortcut to the menu bar: only key presses with ⌘ or ⌃, and function +/// keys. A plain `C` or `⇧P` never reaches it (the window's view takes the key), so egui has to run +/// those even though their menu item shows the key. +fn menu_delivers(sc: &str) -> bool { + sc.split('+').any(|part| matches!(part, "Cmd" | "Ctrl") || part.strip_prefix('F').is_some_and(|n| n.parse::().is_ok())) +} + +/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`. +fn owned_by_menu<'a>(installed: impl Iterator, text_focus: bool) -> HashSet { + installed.filter(|sc| menu_delivers(sc) && !(text_focus && yields_to_text(sc))).map(str::to_string).collect() +} + /// `&` marks a mnemonic in muda labels. fn label_text(s: &str) -> String { lightcraft_ui_egui::i18n::tr(s).replace('&', "&&") @@ -329,13 +344,8 @@ impl NativeMenu { /// Tell the egui shortcut handler which shortcuts the menu bar currently owns. fn publish_shortcuts(&self, app: &mut LightcraftApp) { - app.native_shortcuts = self - .items - .values() - .filter(|i| i.accel.is_some()) - .filter_map(|i| i.shortcut.clone()) - .filter(|sc| !(self.text_focus && yields_to_text(sc))) - .collect::>(); + let installed = self.items.values().filter(|i| i.accel.is_some()).filter_map(|i| i.shortcut.as_deref()); + app.native_shortcuts = owned_by_menu(installed, self.text_focus); app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } @@ -467,4 +477,39 @@ mod tests { assert!(accelerator(sc).is_some(), "{sc}"); } } + + /// Single keys shown in the menu bar (E, C, ⇧P, ratings…) never reach it on macOS, so egui must + /// keep handling them; ⌘ / ⌃ combinations and function keys are the menu bar's own. + #[test] + fn single_key_shortcuts_stay_with_egui() { + assert!(menu_delivers("Cmd+Shift+H") && menu_delivers("Ctrl+H") && menu_delivers("F2") && menu_delivers("Cmd+F11")); + assert!(!menu_delivers("E") && !menu_delivers("Shift+P") && !menu_delivers("Alt+Y") && !menu_delivers("1") && !menu_delivers("F")); + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + fn all(n: &[MenuNode], out: &mut Vec) { + for x in n { + match x { + MenuNode::Item { shortcut: Some(s), .. } if accelerator(s).is_some() => out.push(s.clone()), + MenuNode::Submenu { children, .. } => all(children, out), + _ => {} + } + } + } + let mut installed = Vec::new(); + for (_, v) in menu_bar(&app) { + all(&v, &mut installed); + } + for sc in ["E", "C", "Shift+P", "Cmd+Shift+H", "F2"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is in the menu bar"); + } + let owned = owned_by_menu(installed.iter().map(String::as_str), false); + for sc in ["E", "C", "Shift+P", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "P", "U"] { + assert!(installed.iter().any(|s| s == sc), "{sc} is displayed in the menu"); + assert!(!owned.contains(sc), "{sc} must be left to egui"); + } + for sc in ["Cmd+Shift+H", "F2"] { + assert!(owned.contains(sc), "{sc} is run by the menu bar"); + } + // while typing, the menu bar gives up F2 too (the text field has it) + assert!(!owned_by_menu(installed.iter().map(String::as_str), true).contains("F2")); + } } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 7e8054f2..5411f646 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -555,7 +555,9 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { } fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { - item(&e.id, Value::Null, live_label(app, &e.id, &e.label), e.shortcut.as_deref(), e.enabled, checked(app, &e.id)) + // Shift+P picks and advances in Library; don't advertise it on Presets in those views. + let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; + item(&e.id, Value::Null, live_label(app, &e.id, &e.label), shortcut, e.enabled, checked(app, &e.id)) } /// Drop leading, trailing and doubled separators (also inside submenus) and empty submenus. diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index bc23383b..4436fe84 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -737,12 +737,23 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { for (id, label, sc, _) in crate::menus::ui_commands() { if let Some(sc) = sc { - ui.label(crate::i18n::tr(label)); + let grid_pick = *id == "panel.presets" && crate::shortcuts::library_grid(app); + ui.label(crate::i18n::tr(if grid_pick { "Flag as Pick" } else { label })); ui.label(*sc); - ui.label(egui::RichText::new(*id).color(t.text_dim)); + ui.label(egui::RichText::new(if grid_pick { "photo.flag" } else { id }).color(t.text_dim)); ui.end_row(); } } + for sc in ["0–5", "Shift+0–5"] { + ui.label(crate::i18n::tr("Set Rating")); + ui.label(sc); + ui.label(egui::RichText::new("photo.rate").color(t.text_dim)); + ui.end_row(); + } + ui.label(crate::i18n::tr("Set Color Label")); + ui.label("6–9"); + ui.label(egui::RichText::new("photo.label").color(t.text_dim)); + ui.end_row(); for c in lightcraft_engine::command_specs() { if let Some(sc) = c.shortcut { ui.label(crate::i18n::tr(c.label)); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c5d2ce16..2a501664 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -17,6 +17,10 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), + ("Shift+6", "photo.label", r#"{"label": "red", "advance": true}"#), + ("Shift+7", "photo.label", r#"{"label": "yellow", "advance": true}"#), + ("Shift+8", "photo.label", r#"{"label": "green", "advance": true}"#), + ("Shift+9", "photo.label", r#"{"label": "blue", "advance": true}"#), // Shift+[ / Shift+] arrive as { / } on most layouts ("Shift+{", "brush.featherLess", "{}"), ("Shift+}", "brush.featherMore", "{}"), @@ -67,12 +71,21 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { i.events.iter().any(|e| match e { - egui::Event::Key { key, pressed: true, modifiers, .. } => { + egui::Event::Key { key, physical_key, pressed: true, modifiers, .. } => { // `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl); // "Delete" (⌫ = Backspace) also matches forward-delete let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command }; let cmd_ok = m.ctrl || modifiers.command == m.command; - (*key == k || (k == Key::Backspace && *key == Key::Delete)) && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt + // winit can report shifted digits as punctuation (Shift+1 = `!`). Recognize the + // physical number key for culling, without changing layout-aware letter shortcuts. + let shifted_digit = m.shift + && *physical_key == Some(k) + && matches!(k, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9); + (*key == k || shifted_digit || (k == Key::Backspace && *key == Key::Delete)) + && ctrl_ok + && cmd_ok + && modifiers.shift == m.shift + && modifiers.alt == m.alt } _ => false, }) @@ -171,6 +184,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast(ctx, label); } else if let Some(l) = f.strip_prefix("label:") { cull(app, "photo.label", json!({"label": l}), false); + } else if f == "panel.presets" && library_grid(app) { + // Classic's Shift+P picks and advances in Library; keep the Presets binding elsewhere. + cull(app, "photo.flag", json!({"flag": "pick"}), true); } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { // S in a grid: expand / collapse the stack (Lightroom's Library binding) let _ = app.run("stack.toggle", json!({})); @@ -268,9 +284,188 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } +pub(crate) fn library_grid(app: &LightcraftApp) -> bool { + matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) +} + #[cfg(test)] mod tests { + fn library() -> crate::headless::Headless { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let mut session = lightcraft_engine::Session::new(); + for id in 1..=4 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-shortcuts/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 100, + 100, + "2026-10-08", + ); + session.catalog.apply(Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + } + session.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + session.execute("library.select", &json!({"ids": [1]})).unwrap(); + let app = LightcraftApp::new(session, crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.view = crate::state::ViewMode::PhotoGrid; + for _ in 0..3 { + h.step(); + } + h + } + + fn key(h: &mut crate::headless::Headless, key: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": key, "shift": shift}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + } + + #[test] + fn library_rating_labels_flags_and_undo() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for view in [crate::state::ViewMode::PhotoGrid, crate::state::ViewMode::SquareGrid] { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + for rating in [1, 2, 3, 4, 5, 0] { + key(&mut h, &rating.to_string(), false); + for id in [1, 2] { + assert_eq!(h.app.session.catalog.photo(PhotoId(id)).unwrap().rating, rating); + } + } + for (k, label) in [("6", ColorLabel::Red), ("7", ColorLabel::Yellow), ("8", ColorLabel::Green), ("9", ColorLabel::Blue)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, Some(label)); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(label)); + } + for (k, flag) in [("P", Flag::Pick), ("X", Flag::Reject), ("U", Flag::None)] { + key(&mut h, k, false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, flag); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, flag); + } + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::Reject); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().flag, Flag::Reject); + h.app.session.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + } + } + + #[test] + fn shift_culling_keys_advance_exactly_once() { + use lightcraft_catalog::{ColorLabel, Flag, PhotoId}; + for auto in [false, true] { + for (k, rating, label, flag) in [ + ("0", 0, None, Flag::Pick), + ("5", 5, None, Flag::Pick), + ("6", 3, Some(ColorLabel::Red), Flag::Pick), + ("7", 3, Some(ColorLabel::Yellow), Flag::Pick), + ("8", 3, Some(ColorLabel::Green), Flag::Pick), + ("9", 3, Some(ColorLabel::Blue), Flag::Pick), + ("P", 3, None, Flag::Pick), + ("X", 3, None, Flag::Reject), + ("U", 3, None, Flag::None), + ] { + let mut h = library(); + h.app.ui.auto_advance = auto; + h.app.session.execute("photo.rate", &json!({"rating": 3})).unwrap(); + h.app.session.execute("photo.pick", &json!({})).unwrap(); + key(&mut h, k, true); + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, rating, "Shift+{k}"); + assert_eq!(p.label, label, "Shift+{k}"); + assert_eq!(p.flag, flag, "Shift+{k}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "Shift+{k}, auto={auto}"); + } + } + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [4]})).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.active(), Some(PhotoId(4)), "last photo stays selected"); + } + + #[test] + fn shift_pick_preserves_presets_outside_the_library_grids() { + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::SquareGrid; + key(&mut h, "P", true); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + assert!(!h.app.ui.presets, "grid Shift+P must not open Presets"); + assert_eq!(h.app.ui.view, crate::state::ViewMode::SquareGrid); + h.app.ui.view = crate::state::ViewMode::Detail; + key(&mut h, "P", true); + assert!(h.app.ui.presets); + assert_eq!(h.app.session.active(), Some(lightcraft_catalog::PhotoId(2))); + } + + #[test] + fn shift_label_targets_only_candidate_in_compare() { + use lightcraft_catalog::{ColorLabel, PhotoId}; + let mut h = library(); + h.app.session.execute("library.select", &json!({"ids": [1, 2], "active": 1})).unwrap(); + crate::panels::compare::enter_compare(&mut h.app).unwrap(); + key(&mut h, "6", true); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().label, None); + assert_eq!(h.app.session.catalog.photo(PhotoId(2)).unwrap().label, Some(ColorLabel::Red)); + assert_eq!(h.app.session.active(), Some(PhotoId(3))); + } + + #[test] + fn plain_culling_keys_auto_advance_once() { + use lightcraft_catalog::PhotoId; + for k in ["0", "5", "6", "9", "P", "X", "U"] { + let mut h = library(); + h.app.ui.auto_advance = true; + key(&mut h, k, false); + assert_eq!(h.app.session.active(), Some(PhotoId(2)), "{k}"); + } + } + + #[test] + fn shifted_number_punctuation_rates_and_advances() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + // A real winit event on a layout with Shift+1 = !, rather than ui.key's logical Num1. + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { + key: Key::Exclamationmark, + physical_key: Some(Key::Num1), + pressed, + repeat: false, + modifiers: Modifiers::SHIFT, + }); + } + h.step(); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().rating, 1); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn library_keys_yield_to_search_and_crop_context() { + use lightcraft_catalog::{Flag, PhotoId}; + let mut h = library(); + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), std::time::Duration::from_secs(20)); + assert_eq!(r["ok"], true, "{r}"); + for k in ["5", "6", "P", "X", "U"] { + key(&mut h, k, false); + key(&mut h, k, true); + } + let p = h.app.session.catalog.photo(PhotoId(1)).unwrap(); + assert_eq!(p.rating, 0); + assert_eq!(p.label, None); + assert_eq!(p.flag, Flag::None); + assert_eq!(h.app.session.active(), Some(PhotoId(1))); + + let mut h = library(); + h.app.ui.view = crate::state::ViewMode::Detail; + h.app.ui.right = crate::state::RightPanel::Crop; + key(&mut h, "X", false); + assert_eq!(h.app.session.catalog.photo(PhotoId(1)).unwrap().flag, Flag::None); + } + #[test] fn delete_means_the_backspace_key() { assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace))); diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md new file mode 100644 index 00000000..8ef88291 --- /dev/null +++ b/docs/library-shortcuts.md @@ -0,0 +1,41 @@ +# Library ratings, labels and flags + +Select photos in Photo Grid or Square Grid, then use: + +| Key | Action | +|---|---| +| `0` | Clear the star rating | +| `1`–`5` | Set the star rating | +| `6`, `7`, `8`, `9` | Set a red, yellow, green or blue label | +| `P` | Flag as a pick | +| `X` | Flag as rejected | +| `U` | Clear the flag | +| `Shift` + any key above | Apply the action and select the next photo | + +Purple and clearing a colour label are available in Photo → Set Color Label and the photo context +menu. Purple has no default number key, matching Lightroom Classic. Rejection marks a photo for +culling; it does not delete the original or remove it from the library. + +Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling +actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with +Auto Advance on still advances only once. The last photo stays selected. Text fields keep their +keyboard input, and `X` in the Crop panel swaps the crop aspect. +Shifted number keys also use the physical number key when the keyboard reports punctuation +(for example, `Shift+1` as `!`). Letter shortcuts continue to follow the keyboard layout. + +`Shift+P` picks and advances in the two library grids. In other views it retains LightCraft's +Presets-panel binding. `Shift+Z` remains an alternative pick-and-advance key in all views. + +The macOS native menu displays plain-key bindings but leaves execution to egui; only shortcuts +AppKit delivers to the menu (Command, Control or function keys) are skipped by egui. The native +menu correction is adapted from [PR #261](https://github.com/storytold/lightcraft/pull/261), +commit `4e012a59a6d2442ba749f109ea27aadb5c0b11a5` by Gardy, and addresses the cause reported in +[issue #283](https://github.com/storytold/lightcraft/issues/283). That PR was open when checked +on 2026-10-08; this work extends its fix with library culling bindings and tests. + +Public behaviour references, checked 2026-10-08: + +- [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) +- [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) + +Only public descriptions were consulted. No Adobe assets or application data are included. diff --git a/docs/parity.md b/docs/parity.md index 82729971..3950286e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -59,6 +59,10 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)). Take the first one nobody is working on. +Library culling keys now cover ratings, labels and flags with Shift-to-advance (see +[library shortcuts](library-shortcuts.md), issue #283 and PR #261). **KEYC-RATING** remains partial: +rating-step keys, flag cycling and filter-bar bindings are still open; Shift+P is contextual. + 1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW and Nikon NEF now get a guarded file-local fit to their own embedded JPEG (colour matrix + hue/saturation/value table + tone and chroma curves, relative WB; per-model profiles pooled from many photos via `lightcraft-cli calibrate`; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data. 2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon "lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see @@ -101,8 +105,10 @@ 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. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. + **Still missing / broken:** -- No command yet: F1 help, ⇧6–9 label + advance (verify the rest of the old list: full screen, settings, stacks, +- No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). - `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -143,8 +149,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | -| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | | -| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | pick key is P (see Shortcuts); no flag cycle | +| LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | +| LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot in grid cells; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; no keyword drag-and-drop | | LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back | @@ -662,7 +668,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | -| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | | +| KEY-PRESETS | Presets panel — ⇧P | P0 | ✅ | `cmd:panel.presets` | Shift+P opens Presets outside the two library grids; in grids it picks and advances (Classic convention). Menu hides the Presets key there; shortcut help follows the view | | KEY-HISTOGRAM | Histogram — ⌘0 | P0 | 🟡 | `cmd:view.histogram` | bound to ⌘⇧H; ⌘0 = zoom to fit | | KEY-BRUSHSIZE | Brush size — `[` / `]` | P0 | ✅ | `cmd:brush.smaller`, `cmd:brush.larger` | Masking brush and Remove tool (and its selected spot) | | KEY-BRUSHFEATHER | Brush feather — ⇧`[` / ⇧`]` | P0 | ✅ | `cmd:brush.featherLess`, `cmd:brush.featherMore` | | @@ -672,12 +678,12 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-PICK | Pick — Z | P0 | 🟡 | `cmd:photo.pick` | bound to P; Z toggles zoom | | KEY-UNFLAG | Unflag — U | P0 | ✅ | `cmd:photo.unflag` | | | KEY-REJECT | Reject — X | P0 | ✅ | `cmd:photo.reject` | swaps crop aspect while cropping | -| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | | -| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | | +| KEY-RATING | Ratings — 0…5 | P0 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` | 0 clears, 1–5 set; Shift applies and advances; native macOS plain keys stay with egui; grid multi-selection and undo/redo tested | +| KEY-LABELS | Labels — 6…9 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.label` | 6 red, 7 yellow, 8 green, 9 blue; Shift+6–9 labels and advances; purple/none available from menus | | KEY-MASKING | Masking — M | P0 | ✅ | `cmd:panel.masking` | | | KEY-ERASE | Erase while held — ⌥ | P0 | ✅ | `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/panels/masking.rs` | ⌥ while painting flips Add ↔ Erase for that stroke; Add/Erase buttons too | | KEY-RATEADVANCE | Rate and advance — ⇧0…5 | P1 | ✅ | `crates/ui-egui/src/shortcuts.rs` → `cmd:photo.rate` (`advance`) | | -| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | ⇧Z pick, ⇧X reject, ⇧U unflag, each moving to the next photo | +| KEY-FLAGADVANCE | Flag and advance — ⇧Z / ⇧X / ⇧U | P1 | ✅ | `cmd:photo.flag` (`advance`), `crates/ui-egui/src/shortcuts.rs` | Shift+Z pick, Shift+X reject, Shift+U clear; Shift+P also picks and advances in the library grids; exactly one step with Auto Advance enabled | | KEY-NEXTPREV | Next / previous — → / ← | P0 | ✅ | `cmd:library.next`, `cmd:library.previous` | | | KEY-BA-CYCLE | Before/after — Y | P0 | ✅ | `cmd:view.beforeAfter` | toggles side by side (no cycling) | | KEY-BA-TOPBOTTOM | Before/after top/bottom — ⌥Y | P1 | ✅ | `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | ⌥Y, ⇧⌥Y | @@ -790,7 +796,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` | Z toggles zoom; no compare, stacks, thumbnail-size 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` | most work; no ⇧P / ⇧6–9 advance, rating `[` `]`, flag cycle, filter-bar 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` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | | 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/whats-new.md b/docs/whats-new.md index 00480132..7293ffd9 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Library keyboard culling +- On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261). +- `Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once. +- Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md). + ### Presets and profiles - Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip` bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.