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 <armandpro50@gmail.com>
This commit is contained in:
parent
b35487add2
commit
6277af55bf
|
|
@ -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::<u8>().is_ok()))
|
||||
}
|
||||
|
||||
/// The shortcuts the menu bar runs itself, for `LightcraftApp::native_shortcuts`.
|
||||
fn owned_by_menu<'a>(installed: impl Iterator<Item = &'a str>, text_focus: bool) -> HashSet<String> {
|
||||
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::<HashSet<_>>();
|
||||
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<String>) {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -555,7 +555,9 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
|||
}
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue