From 6277af55bf1cd2aa044607ad5f69266c1a5c36a6 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Thu, 8 Oct 2026 20:25:37 +0800 Subject: [PATCH 1/3] 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. From 57c9e252c1d8bd3f8bf7f408c0b03af79b4aa058 Mon Sep 17 00:00:00 2001 From: Hanmo Date: Thu, 8 Oct 2026 21:25:02 +0800 Subject: [PATCH 2/3] M1: show bottom confirmation for colour label actions --- crates/ui-egui/locales/ja.json | 1 + crates/ui-egui/locales/zh-hans.json | 1 + crates/ui-egui/locales/zh-hant.json | 1 + crates/ui-egui/src/lib.rs | 17 +++++++++++++++- crates/ui-egui/src/shortcuts.rs | 31 +++++++++++++++++++++++++++++ docs/library-shortcuts.md | 2 ++ docs/parity.md | 2 +- docs/whats-new.md | 1 + 8 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f131d0b4..eaab8398 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -147,6 +147,7 @@ "Cloudy": "曇り", "Collapse All Stacks": "すべてのスタックを閉じる", "Color": "カラー", + "Color label cleared": "カラーラベルを解除しました", "Color Grading": "カラーグレーディング", "Color Mixer": "カラーミキサー", "Color Noise Reduction": "カラーノイズ軽減", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 7dc4d7c3..9f1c6262 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -147,6 +147,7 @@ "Cloudy": "阴天", "Collapse All Stacks": "折叠所有堆叠", "Color": "颜色", + "Color label cleared": "已清除颜色标签", "Color Grading": "颜色分级", "Color Mixer": "颜色混合器", "Color Noise Reduction": "颜色降噪", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 79c95291..2af33327 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -187,6 +187,7 @@ "Cloudy": "陰天", "Collapse All Stacks": "收合所有堆疊", "Color": "色彩", + "Color label cleared": "已清除色彩標籤", "Color Grading": "色彩分級", "Color Label Names": "色彩標籤名稱", "Color Label Sets": "色彩標籤集", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4addbf3..e46e8663 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,8 @@ use serde_json::Value; pub use control::{ControlRequest, ControlResponse}; pub use state::UiState; +const TOAST_SECONDS: f64 = 1.4; + pub type PickFiles = Box Vec>; /// A save dialog: suggested file name → chosen path (`None` = cancelled). pub type SaveFile = Box Option>; @@ -265,6 +267,19 @@ impl LightcraftApp { return r; } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() && id == "photo.label" { + let text = match params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse) { + Some(label) => { + let name = + self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); + i18n::tr_format!("{name} Label", name = name) + } + None => i18n::tr("Color label cleared").into(), + }; + // Native menu clicks can arrive before logic() updates last_time after an idle gap. + let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); + self.ui.toast = Some((text, now + TOAST_SECONDS)); + } if let Err(e) = &r { log::warn!("{id}: {e}"); self.ui.status = e.clone(); @@ -351,7 +366,7 @@ impl LightcraftApp { } pub fn toast(&mut self, ctx: &egui::Context, text: impl Into) { - self.toast_for(ctx, text, 1.4); + self.toast_for(ctx, text, TOAST_SECONDS); } /// A toast that stays `secs` seconds (messages that say where to look or what to do next). diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2a501664..8c47e191 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -424,6 +424,37 @@ mod tests { } } + #[test] + fn color_label_actions_show_feedback_only_after_success() { + use lightcraft_catalog::PhotoId; + let mut h = library(); + for shift in [false, true] { + for (k, name) in [("6", "Red"), ("7", "Yellow"), ("8", "Green"), ("9", "Blue")] { + h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + h.app.ui.toast = None; + key(&mut h, k, shift); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); + assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); + } + } + // Menus and the Info-panel swatches dispatch the same command, including purple/clear. + h.app.run("photo.label", json!({"label": "purple"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); + h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); + key(&mut h, "6", false); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); + // Native menu callbacks run before logic() catches up to the current frame's clock. + let now = h.view.ctx.input(|i| i.time); + h.app.last_time = now - 10.0; + h.app.run("photo.label", json!({"label": "blue"})).unwrap(); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.1 > now), "menu feedback must survive an idle gap"); + h.app.ui.toast = None; + assert!(h.app.run("photo.label", json!({"label": "invalid"})).is_err()); + assert!(h.app.ui.toast.is_none(), "failed actions must not announce success"); + } + #[test] fn shifted_number_punctuation_rates_and_advances() { use lightcraft_catalog::PhotoId; diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 8ef88291..4adddd51 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -15,6 +15,8 @@ Select photos in Photo Grid or Square Grid, then use: 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. +Setting a colour label shows the same brief bottom toast as a rating, using the colour or your +custom label name. Clearing it shows a confirmation too, whether applied by a key, menu or swatch. 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 diff --git a/docs/parity.md b/docs/parity.md index 3950286e..4fbd4184 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -151,7 +151,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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` | 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-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; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; 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 | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7293ffd9..e01893cf 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -3,6 +3,7 @@ ## October 2026 ### Library keyboard culling +- Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too. - 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). From 4256865cf49624d2b33874831519ab5d0a2fea6a Mon Sep 17 00:00:00 2001 From: Hanmo Date: Thu, 8 Oct 2026 21:42:21 +0800 Subject: [PATCH 3/3] M1: tint labelled thumbnails and colour label feedback --- apps/lightcraft-web/src/web.rs | 2 +- crates/ui-egui/src/lib.rs | 13 +++-- crates/ui-egui/src/merge.rs | 2 +- crates/ui-egui/src/panels/detail.rs | 12 ++-- crates/ui-egui/src/panels/filterbar.rs | 10 +--- crates/ui-egui/src/panels/grid.rs | 12 +++- crates/ui-egui/src/panels/mod.rs | 10 ++-- crates/ui-egui/src/shortcuts.rs | 2 + crates/ui-egui/src/state.rs | 4 +- crates/ui-egui/src/tests_labels.rs | 80 ++++++++++++++++++++++++++ crates/ui-egui/src/theme.rs | 22 +++++++ docs/library-shortcuts.md | 4 ++ docs/parity.md | 6 +- docs/whats-new.md | 1 + 14 files changed, 149 insertions(+), 31 deletions(-) create mode 100644 crates/ui-egui/src/tests_labels.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 6d0483a9..3201bf83 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -600,7 +600,7 @@ impl eframe::App for WebApp { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { for text in safety::take_notices() { let now = ctx.input(|i| i.time); - self.app.ui.toast = Some((text, now + NOTICE_SECS)); + self.app.ui.toast = Some((text, now + NOTICE_SECS, None)); } self.run_inbox(); self.import_dropped(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e46e8663..2b15b418 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,8 @@ mod tests_curve; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_labels; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_masking; @@ -268,7 +270,8 @@ impl LightcraftApp { } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); if r.is_ok() && id == "photo.label" { - let text = match params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse) { + let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse); + let text = match label { Some(label) => { let name = self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into()); @@ -278,7 +281,7 @@ impl LightcraftApp { }; // Native menu clicks can arrive before logic() updates last_time after an idle gap. let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time); - self.ui.toast = Some((text, now + TOAST_SECONDS)); + self.ui.toast = Some((text, now + TOAST_SECONDS, label)); } if let Err(e) = &r { log::warn!("{id}: {e}"); @@ -354,7 +357,7 @@ impl LightcraftApp { self.ui.unsaved_seen = true; let t = ctx.input(|i| i.time); let what = if n == 1 { "1 change".to_string() } else { format!("{n} changes") }; - self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0)); + self.ui.toast = Some((format!("{what} saved in memory but not written to disk: {e} — LightCraft will retry"), t + 6.0, None)); } (None, true) => { self.ui.unsaved_seen = false; @@ -372,7 +375,7 @@ impl LightcraftApp { /// A toast that stays `secs` seconds (messages that say where to look or what to do next). pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into, secs: f64) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + secs)); + self.ui.toast = Some((text.into(), t + secs, None)); } /// AI masks: apply finished background requests (clicks, descriptions, detail passes) and @@ -450,7 +453,7 @@ impl LightcraftApp { /// A toast for an error the user has to read and act on (stays 6 s). pub fn toast_error(&mut self, ctx: &egui::Context, text: impl Into) { let t = ctx.input(|i| i.time); - self.ui.toast = Some((text.into(), t + 6.0)); + self.ui.toast = Some((text.into(), t + 6.0, None)); } fn drain_control(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index bbb5252a..cf9efd17 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -268,7 +268,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); let now = ctx.input(|i| i.time); - app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5)); + app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None)); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3b3622be..732dd019 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1528,11 +1528,15 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { register(ui.ctx(), format!("film:{}", id.0), cr); let sel = Some(*id) == active; let p = ui.painter(); - if sel { - p.rect_filled(cr, 0.0, t.cell_selected); + let label = app.session.catalog.photo(*id).and_then(|ph| ph.label); + let base = if sel { + t.cell_selected } else if resp.hovered() { - p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6)); - } + t.cell_selected.gamma_multiply(0.6) + } else { + t.canvas + }; + p.rect_filled(cr, 0.0, crate::theme::label_background(base, label, sel)); let names = app.ui.settings.film_names; if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c8de2cff..e1240967 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -16,15 +16,7 @@ pub const HEIGHT: f32 = 40.0; const WIDE: f32 = 1240.0; /// Colour-label swatches (UI colours, our own choice). -pub fn label_color(l: ColorLabel) -> Color32 { - match l { - ColorLabel::Red => Color32::from_rgb(222, 72, 72), - ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), - ColorLabel::Green => Color32::from_rgb(88, 176, 92), - ColorLabel::Blue => Color32::from_rgb(72, 130, 222), - ColorLabel::Purple => Color32::from_rgb(158, 100, 210), - } -} +pub use crate::theme::label_color; fn filter(app: &mut LightcraftApp, patch: Value) { let _ = app.run("library.filter", patch); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index e83fec8b..0a38a6a4 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -476,7 +476,8 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square let active = app.session.selection.active == Some(id); let p = ui.painter(); let img_rect = if square { - p.rect_filled(r, 0.0, if selected { t.cell_selected } else { t.cell }); + let base = if selected { t.cell_selected } else { t.cell }; + p.rect_filled(r, 0.0, crate::theme::label_background(base, photo.label, selected)); Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0)) } else { r @@ -539,9 +540,14 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square crate::state::GridBadges::Always => true, crate::state::GridBadges::Never => false, }; + let badge_bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); + if !square && photo.label.is_some() { + // Justified photos fill the whole cell: reserve the badge footer as coloured chrome. + p.rect_filled(badge_bar, 0.0, crate::theme::label_background(t.cell, photo.label, selected)); + } if show_badges { - let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); - if resp.hovered() || selected { + let bar = badge_bar; + if (resp.hovered() || selected) && (square || photo.label.is_none()) { p.rect_filled(bar, 0.0, Color32::from_black_alpha(120)); } let mut x = bar.left() + 6.0; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8317dda7..7b8f1e2d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -63,7 +63,7 @@ pub fn resizable_side( /// The HUD toast at the bottom centre of the canvas. pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((text, until)) = app.ui.toast.clone() else { return }; + let Some((text, until, label)) = app.ui.toast.clone() else { return }; if now > until { app.ui.toast = None; return; @@ -74,11 +74,13 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("toast"))); // long messages (a save warning, the web build's notices) wrap within the canvas let wrap = (canvas.width() - 80.0).clamp(200.0, 720.0); - let galley = painter.layout(text, t.font(14.0), t.text.gamma_multiply(fade), wrap); + let (background, foreground) = label.map(crate::theme::label_toast_colors).unwrap_or((egui::Color32::from_black_alpha(200), t.text)); + let foreground = foreground.gamma_multiply(fade); + let galley = painter.layout(text, t.font(14.0), foreground, wrap); let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); - painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8)); - painter.galley(r.center() - galley.size() / 2.0, galley, t.text); + painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); + painter.galley(r.center() - galley.size() / 2.0, galley, foreground); ctx.request_repaint_after(std::time::Duration::from_millis(30)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 8c47e191..b91306cf 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -434,6 +434,7 @@ mod tests { h.app.ui.toast = None; key(&mut h, k, shift); assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some(format!("{name} Label").as_str())); + assert_eq!(h.app.ui.toast.as_ref().and_then(|t| t.2), h.app.session.catalog.photo(PhotoId(1)).unwrap().label); assert_eq!(h.app.session.active(), Some(PhotoId(if shift { 2 } else { 1 }))); } } @@ -442,6 +443,7 @@ mod tests { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Purple Label")); h.app.run("photo.label", json!({"label": "none"})).unwrap(); assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Color label cleared")); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.2.is_none()), "clear uses neutral feedback"); h.app.session.execute("label.setNames", &json!({"names": {"red": "Needs review"}})).unwrap(); key(&mut h, "6", false); assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Needs review Label")); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bdbbcf29..45288568 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -371,9 +371,9 @@ pub struct UiState { /// Reference view: the reference photo. #[serde(skip)] pub reference: Option, - /// Transient toast text and its expiry (seconds of app time). + /// Transient toast text, expiry (seconds of app time), and optional colour-label styling. #[serde(skip)] - pub toast: Option<(String, f64)>, + pub toast: Option<(String, f64, Option)>, /// The result of the last Find Missing Photos (it searches in the background). #[serde(skip)] pub last_find_missing: Option, diff --git a/crates/ui-egui/src/tests_labels.rs b/crates/ui-egui/src/tests_labels.rs new file mode 100644 index 00000000..8a24b988 --- /dev/null +++ b/crates/ui-egui/src/tests_labels.rs @@ -0,0 +1,80 @@ +//! Rendered regressions: label colour belongs on thumbnail chrome and the label HUD, not pixels. + +use std::time::Duration; + +use egui::{Color32, ColorImage, Pos2}; +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +fn app(view: ViewMode) -> Headless { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=6 { + let p = Photo::new(PhotoId(id), Source::Demo { scene: 1 }, &format!("{id}.jpg"), "JPEG", 640, 480, "2026-10-08"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true, "group": "none"})).unwrap(); + s.execute("library.select", &json!({"ids": [1]})).unwrap(); + let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.app.ui.view = view; + h.app.ui.right = crate::state::RightPanel::None; + h.app.ui.thumb_size = 160.0; + assert!(h.settle(Duration::from_secs(30))); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).unwrap().1 +} + +fn pixel(image: &ColorImage, p: Pos2) -> Color32 { + image.pixels[p.y as usize * image.size[0] + p.x as usize] +} + +#[test] +fn label_tints_grid_and_filmstrip_chrome_without_changing_photo_pixels() { + for view in [ViewMode::SquareGrid, ViewMode::PhotoGrid, ViewMode::Detail] { + let mut h = app(view); + let widget = if view == ViewMode::Detail { "film:1" } else { "thumb:1" }; + let cell = rect(&h, widget); + let sample = if view == ViewMode::PhotoGrid { + egui::pos2(cell.right() - 40.0, cell.bottom() - 8.0) + } else { + egui::pos2(cell.left() + 4.0, cell.center().y) + }; + let neutral = h.paint(); + let centre = pixel(&neutral, cell.center()); + let mut colours = Vec::new(); + for label in ["red", "yellow", "green", "blue", "purple"] { + h.app.run("photo.label", json!({"label": label})).unwrap(); + h.app.ui.toast = None; + h.step(); + let image = h.paint(); + let c = pixel(&image, sample); + assert_ne!(c, pixel(&neutral, sample), "{view:?} {label} must tint the thumbnail surround/footer"); + assert!(!colours.contains(&c), "each label has a distinct tint: {view:?} {label}"); + colours.push(c); + assert_eq!(pixel(&image, cell.center()), centre, "{view:?} {label} must not tint image pixels"); + } + h.app.run("photo.label", json!({"label": "none"})).unwrap(); + h.app.ui.toast = None; + h.step(); + assert_eq!(pixel(&h.paint(), sample), pixel(&neutral, sample), "clearing restores neutral chrome"); + } +} + +#[test] +fn label_toast_has_pale_colour_and_rating_restores_neutral_style() { + let mut h = app(ViewMode::SquareGrid); + h.app.run("photo.label", json!({"label": "red"})).unwrap(); + h.step(); + let at = egui::pos2(h.app.canvas_rect.unwrap().center().x, h.app.canvas_rect.unwrap().bottom() - 45.0); + let red = pixel(&h.paint(), at); + assert!(red.r() > 180 && red.r() > red.g() && red.r() > red.b(), "pale red toast: {red:?}"); + h.request("ui.key", json!({"key": "5"}), Duration::from_secs(20)); + let rating = pixel(&h.paint(), at); + assert!(rating.r() < 80 && rating.r() == rating.g() && rating.g() == rating.b(), "neutral rating toast: {rating:?}"); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c77fb0a2..9534ebb6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -7,6 +7,28 @@ use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, pub const FONT_SEMIBOLD: &str = "semibold"; +/// Shared colour-label palette for badges, thumbnail surrounds and feedback. +pub fn label_color(label: lightcraft_catalog::ColorLabel) -> Color32 { + use lightcraft_catalog::ColorLabel; + match label { + ColorLabel::Red => Color32::from_rgb(222, 72, 72), + ColorLabel::Yellow => Color32::from_rgb(232, 196, 58), + ColorLabel::Green => Color32::from_rgb(88, 176, 92), + ColorLabel::Blue => Color32::from_rgb(72, 130, 222), + ColorLabel::Purple => Color32::from_rgb(158, 100, 210), + } +} + +/// A translucent label colour over thumbnail chrome; selection remains brighter. +pub fn label_background(base: Color32, label: Option, selected: bool) -> Color32 { + label.map_or(base, |l| base.lerp_to_gamma(label_color(l), if selected { 0.32 } else { 0.22 })) +} + +/// Label confirmations use a pale colour and dark text, distinct from neutral/error HUDs. +pub fn label_toast_colors(label: lightcraft_catalog::ColorLabel) -> (Color32, Color32) { + (Color32::WHITE.lerp_to_gamma(label_color(label), 0.3), Color32::from_rgb(36, 24, 24)) +} + #[derive(Clone, Copy, Debug)] pub struct Tokens { /// Top bar, side panels, bottom bar, tool strip. diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index 4adddd51..5305fe57 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -17,6 +17,10 @@ menu. Purple has no default number key, matching Lightroom Classic. Rejection ma culling; it does not delete the original or remove it from the library. Setting a colour label shows the same brief bottom toast as a rating, using the colour or your custom label name. Clearing it shows a confirmation too, whether applied by a key, menu or swatch. +Labelled thumbnails have a translucent matching colour on the square-grid surround, photo-grid +footer and Detail filmstrip surround. The photo pixels and white active-photo outline remain clear. +The label confirmation uses a pale matching background with dark text; ratings and errors retain +their neutral HUD styling. Custom names use the underlying label's colour. 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 diff --git a/docs/parity.md b/docs/parity.md index 4fbd4184..47f805c5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -60,7 +60,9 @@ that decide whether a photographer can switch (see the honest assessment in [ROA 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: +[library shortcuts](library-shortcuts.md), issue #283 and PR #261). Colour labels also tint thumbnail +surrounds/footers and use matching pale confirmations, with rendered regressions for all five colours. +**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. @@ -151,7 +153,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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` | 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; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | +| 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 plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; 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 | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | diff --git a/docs/whats-new.md b/docs/whats-new.md index e01893cf..be89ddbc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -3,6 +3,7 @@ ## October 2026 ### Library keyboard culling +- Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and the footer in Photo Grid. Label confirmations use a matching pale colour; the photo pixels retain their original appearance. - Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too. - 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.