From e2b626b7f732e87359c9d2f33b0d9b19c9a1d1a7 Mon Sep 17 00:00:00 2001 From: parkerHurst Date: Thu, 8 Oct 2026 14:54:50 -0500 Subject: [PATCH] =?UTF-8?q?LR-PREF-SHORTCUTS:=20editable=20keyboard=20shor?= =?UTF-8?q?tcuts=20in=20Help=20=E2=96=B8=20Keyboard=20Shortcuts=20(?= =?UTF-8?q?=E2=8C=98/)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ⌘/ dialog is now a keymap editor: click a shortcut and press the new keys (Esc cancels), × removes it, ↺ restores the default, Reset All clears every change. A key another command had moves to the new command, with a toast. Changes go through the new app.setShortcut / app.resetShortcuts commands into AppSettings.keymap (saved in ui.json) and show up in the menus, the native macOS menu and the control channel's command list. Modifier keys alone never record (holding ⌘ waits for the key it modifies), ⌘C/⌘X/⌘V are recorded from the copy/cut/paste events, and saved entries that don't parse fall back to the default shortcut. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft/src/native_menu.rs | 16 +- crates/ui-egui/src/control.rs | 15 +- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/menus.rs | 11 +- crates/ui-egui/src/panels/dialogs.rs | 35 +-- crates/ui-egui/src/panels/keymap.rs | 218 ++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/shortcuts.rs | 330 +++++++++++++++++++++++---- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/src/tests_keymap.rs | 148 ++++++++++++ docs/parity.md | 8 +- docs/whats-new.md | 5 + 12 files changed, 712 insertions(+), 84 deletions(-) create mode 100644 crates/ui-egui/src/panels/keymap.rs create mode 100644 crates/ui-egui/src/tests_keymap.rs diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 557c9abfb..a36c5d333 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -51,6 +51,9 @@ pub struct NativeMenu { /// Structure of the installed menu (ids, kinds, submenu names); a change rebuilds it. structure: String, text_focus: bool, + /// The keyboard shortcuts editor is recording a key: every accelerator is off so the key + /// press reaches it. + capturing: bool, } /// `Cmd+Shift+Z` → a muda accelerator (`None` for keys we leave to egui, e.g. Escape). @@ -196,7 +199,7 @@ impl NativeMenu { let _ = tx.send(e.id.0); repaint.request_repaint(); })); - let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false }; + let mut m = NativeMenu { menu: Menu::new(), items: HashMap::new(), rx, structure: String::new(), text_focus: false, capturing: false }; m.rebuild(app); app.native_menu = true; m @@ -266,6 +269,7 @@ impl NativeMenu { } self.menu.init_for_nsapp(); self.text_focus = false; + self.capturing = false; self.publish_shortcuts(app); } @@ -334,7 +338,7 @@ impl NativeMenu { .values() .filter(|i| i.accel.is_some()) .filter_map(|i| i.shortcut.clone()) - .filter(|sc| !(self.text_focus && yields_to_text(sc))) + .filter(|sc| !self.capturing && !(self.text_focus && yields_to_text(sc))) .collect::>(); app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } @@ -397,14 +401,16 @@ impl NativeMenu { walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::>(), &mut self.items); let focus = ctx.egui_wants_keyboard_input(); - if focus != self.text_focus { + let capturing = app.recording_shortcut.is_some(); + if focus != self.text_focus || capturing != self.capturing { self.text_focus = focus; + self.capturing = capturing; for it in self.items.values() { let Some(sc) = it.shortcut.as_deref() else { continue }; - if it.accel.is_none() || !yields_to_text(sc) { + if it.accel.is_none() { continue; } - let accel = if focus { None } else { it.accel }; + let accel = if capturing || (focus && yields_to_text(sc)) { None } else { it.accel }; let _ = match &it.handle { Handle::Plain(h) => h.set_accelerator(accel), Handle::Check(h) => h.set_accelerator(accel), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1a6abddef..71a8ae1c0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -54,8 +54,21 @@ fn wrap(r: Result) -> Outcome { } pub fn all_commands(app: &LightcraftApp) -> Value { - let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + let keymap = &app.ui.settings.keymap; + let mut v: Vec = app + .session + .commands() + .into_iter() + .map(|c| { + let mut v = serde_json::to_value(&c).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.insert("shortcut".into(), json!(crate::shortcuts::shortcut_of(keymap, c.id))); + } + v + }) + .collect(); for (id, label, sc, menu) in crate::menus::ui_commands() { + let sc = crate::shortcuts::binding(keymap, id, *sc); v.push(json!({"id": id, "label": label, "shortcut": sc, "menu": [menu], "enabled": crate::menus::ui_enabled(app, id), "ui": true})); } Value::Array(v) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7f4d6d6d..ed4c601cf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,6 +31,8 @@ mod tests_curve; #[cfg(test)] mod tests_grid; #[cfg(test)] +mod tests_keymap; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_masking; @@ -140,6 +142,9 @@ pub struct LightcraftApp { /// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler /// leaves them alone so nothing fires twice. pub native_shortcuts: std::collections::HashSet, + /// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut + /// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled. + pub recording_shortcut: Option, /// The host is [`headless::Headless`] (it answers viewport screenshot commands itself). pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). @@ -219,6 +224,7 @@ impl LightcraftApp { integrated_titlebar: false, native_menu: false, native_shortcuts: Default::default(), + recording_shortcut: None, headless_host: false, notices: vec![], quit_prompt: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9e62a382c..6fd7e1aa3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -168,6 +168,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.github", "LightCraft on GitHub", None, "Help"), ("app.artcraft", "ArtCraft Website", None, "Help"), ("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"), + ("app.setShortcut", "Set Keyboard Shortcut", None, ""), + ("app.resetShortcuts", "Reset All Keyboard Shortcuts", None, ""), ("app.export", "Export Now", None, ""), ("app.showInFinder", "Show in Finder", Some("Cmd+R"), "Photo"), ("dialog.rename", "Rename Photos…", Some("F2"), "Photo"), @@ -946,6 +948,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::shortcuts::set_shortcut(app, p), + "app.resetShortcuts" => { + app.ui.settings.keymap.clear(); + Ok(Value::Null) + } "library.browse" if !cfg!(target_arch = "wasm32") => { // listed and read in the background (see `import::browse`) let path = p.get("path").and_then(Value::as_str)?; @@ -1297,7 +1304,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { id: id.to_string(), label: label.to_string(), menu: m.split('>').map(str::to_string).collect(), - shortcut: sc.map(str::to_string), + shortcut: crate::shortcuts::binding(&app.ui.settings.keymap, id, *sc).map(str::to_string), enabled: ui_enabled(app, id), }) .collect(); @@ -1307,7 +1314,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), menu: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(str::to_string), + shortcut: crate::shortcuts::shortcut_of(&app.ui.settings.keymap, c.id).map(str::to_string), enabled: c.enabled, }); } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 91ffb1c99..f1e90549f 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -754,39 +754,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } } - Dialog::Shortcuts => { - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - 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)); - ui.label(*sc); - ui.label(egui::RichText::new(*id).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)); - ui.label(sc); - ui.label(egui::RichText::new(c.id).color(t.text_dim)); - ui.end_row(); - } - } - for (sc, id, _) in crate::shortcuts::ALIASES { - let label = crate::menus::ui_commands() - .find(|c| c.0 == *id) - .map(|c| c.1) - .or_else(|| lightcraft_engine::find_command(id).map(|c| c.label)) - .unwrap_or(id); - ui.label(crate::i18n::tr(label)); - ui.label(*sc); - ui.label(egui::RichText::new(*id).color(t.text_dim)); - ui.end_row(); - } - }); - }); - } + Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), } ui.add_space(4.0); ui.horizontal(|ui| { @@ -841,6 +809,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.move_to_top(w.response.layer_id); crate::widgets::register(ctx, "dialog:window", w.response.rect); } + // (while the shortcuts editor records a key, it takes Esc itself to cancel) if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs new file mode 100644 index 000000000..d74fbc72d --- /dev/null +++ b/crates/ui-egui/src/panels/keymap.rs @@ -0,0 +1,218 @@ +//! Help ▸ Keyboard Shortcuts (⌘/): every command's shortcut, editable. +//! +//! Click a shortcut, then press the new keys (Esc cancels); × removes it, ↺ restores the declared +//! one. A key another command had moves to the new command. Changes go through +//! [`crate::shortcuts::assign`] into [`crate::state::AppSettings::keymap`] (saved in `ui.json`), +//! the same path as the `app.setShortcut` command. + +use egui::RichText; + +use crate::LightcraftApp; +use crate::shortcuts::{self, Bindable}; +use crate::theme::Tokens; +use crate::widgets::register; + +const SEARCH: &str = "shortcuts-search"; +const SHOW_ALL: &str = "shortcuts-show-all"; + +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + capture(app, ui.ctx()); + ui.set_min_width(560.0); + let (search_id, all_id) = (egui::Id::new(SEARCH), egui::Id::new(SHOW_ALL)); + let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); + let mut show_all: bool = ui.data(|d| d.get_temp(all_id)).unwrap_or(false); + ui.horizontal(|ui| { + let r = ui.add(egui::TextEdit::singleline(&mut search).hint_text(crate::i18n::tr("Search commands or keys")).desired_width(220.0)); + register(ui.ctx(), "field:shortcutsSearch", r.rect); + let r = ui.checkbox(&mut show_all, crate::i18n::tr("Show commands without a shortcut")); + register(ui.ctx(), "check:shortcuts.showAll", r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add_enabled(!app.ui.settings.keymap.is_empty(), egui::Button::new(crate::i18n::tr("Reset All"))); + register(ui.ctx(), "button:shortcutsResetAll", r.rect); + if r.clicked() { + let _ = app.run("app.resetShortcuts", serde_json::json!({})); + app.recording_shortcut = None; + } + }); + }); + ui.data_mut(|d| { + d.insert_temp(search_id, search.clone()); + d.insert_temp(all_id, show_all); + }); + ui.label(RichText::new(crate::i18n::tr("Click a shortcut, then press the new keys (Esc cancels).")).size(11.0).color(t.text_dim)); + ui.add_space(4.0); + + let needle = search.trim().to_lowercase(); + let mut rows: Vec<(&'static Bindable, Option)> = shortcuts::bindable() + .iter() + .map(|b| (b, shortcuts::binding(&app.ui.settings.keymap, b.id, b.default).map(str::to_string))) + .filter(|(b, sc)| show_all || sc.is_some() || app.recording_shortcut.as_deref() == Some(b.id)) + .filter(|(b, sc)| { + needle.is_empty() + || crate::i18n::tr(b.label).to_lowercase().contains(&needle) + || b.id.to_lowercase().contains(&needle) + || sc.as_deref().is_some_and(|sc| sc.to_lowercase().contains(&needle) || menu_text(sc, mac).to_lowercase().contains(&needle)) + }) + .collect(); + rows.sort_by_cached_key(|(b, _)| crate::i18n::tr(b.label).to_lowercase()); + + // a fixed height, so the dialog doesn't jump around while the search narrows the list + egui::ScrollArea::vertical().max_height(400.0).min_scrolled_height(400.0).auto_shrink([false, false]).id_salt("shortcuts-list").show(ui, |ui| { + egui::Grid::new("shortcuts").striped(true).num_columns(4).spacing([12.0, 4.0]).show(ui, |ui| { + for (b, sc) in &rows { + row(app, ui, t, b, sc.as_deref(), mac); + } + if rows.is_empty() { + ui.label(RichText::new(crate::i18n::tr("No matching commands")).color(t.text_dim)); + ui.end_row(); + } + }); + fixed_keys(app, ui, t, mac, &needle); + }); +} + +/// `Cmd+Shift+Z` as menus show it (`⌘⇧Z` on macOS). +fn menu_text(sc: &str, mac: bool) -> String { + crate::menubar::shortcut_text(sc, mac) +} + +fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, b: &Bindable, sc: Option<&str>, mac: bool) { + let changed = app.ui.settings.keymap.contains_key(b.id); + let mut name = RichText::new(crate::i18n::tr(b.label)).color(t.text); + if changed { + name = name.strong(); + } + ui.label(name).on_hover_text(b.id); + let recording = app.recording_shortcut.as_deref() == Some(b.id); + let text = if recording { + RichText::new(crate::i18n::tr("Press keys…")).color(egui::Color32::WHITE) + } else { + match sc { + Some(sc) => RichText::new(menu_text(sc, mac)).color(t.text), + None => RichText::new("—").color(t.text_dim), + } + }; + let r = ui.add(egui::Button::new(text).min_size(egui::vec2(110.0, 0.0)).selected(recording)); + register(ui.ctx(), format!("button:shortcut-{}", b.id), r.rect); + if r.clicked() { + app.recording_shortcut = if recording { None } else { Some(b.id.to_string()) }; + } + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + let r = ui.add_enabled(sc.is_some(), egui::Button::new("×").small()).on_hover_text(crate::i18n::tr("Remove shortcut")); + register(ui.ctx(), format!("button:shortcutClear-{}", b.id), r.rect); + if r.clicked() { + apply(app, ui.ctx(), serde_json::json!({"id": b.id, "shortcut": null})); + } + let r = ui.add_enabled(changed, egui::Button::new("↺").small()).on_hover_text(match b.default { + Some(d) => format!("Restore {}", menu_text(d, mac)), + None => crate::i18n::tr("Restore (no shortcut)").to_string(), + }); + register(ui.ctx(), format!("button:shortcutReset-{}", b.id), r.rect); + if r.clicked() { + apply(app, ui.ctx(), serde_json::json!({"id": b.id, "reset": true})); + } + }); + ui.label(RichText::new(b.id).size(10.5).color(t.text_dim)); + ui.end_row(); +} + +/// Keys that aren't a command's own shortcut (ratings, labels, secondary keys): listed, not editable. +/// One the user gave to a command shows struck through. +fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, needle: &str) { + let mut fixed: Vec<(String, &str)> = Vec::new(); + for (sc, id, _) in shortcuts::ALIASES { + let label = shortcuts::find_bindable(id).map_or(*id, |b| b.label); + fixed.push((sc.to_string(), label)); + } + for n in 0..=5 { + fixed.push((n.to_string(), ["Clear rating", "Rate ★", "Rate ★★", "Rate ★★★", "Rate ★★★★", "Rate ★★★★★"][n])); + fixed.push(( + format!("Shift+{n}"), + [ + "Clear rating and advance", + "Rate ★ and advance", + "Rate ★★ and advance", + "Rate ★★★ and advance", + "Rate ★★★★ and advance", + "Rate ★★★★★ and advance", + ][n], + )); + } + for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] { + fixed.push((sc.to_string(), label)); + } + let fixed: Vec<_> = fixed + .into_iter() + .filter(|(sc, label)| { + needle.is_empty() || crate::i18n::tr(label).to_lowercase().contains(needle) || menu_text(sc, mac).to_lowercase().contains(needle) + }) + .collect(); + if fixed.is_empty() { + return; + } + ui.add_space(8.0); + ui.label(RichText::new(crate::i18n::tr("Fixed keys")).font(t.semibold(12.5)).color(t.text)); + let taken: Vec<_> = app.ui.settings.keymap.values().filter_map(|s| shortcuts::parse(s)).collect(); + egui::Grid::new("shortcuts-fixed").striped(true).num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| { + for (sc, label) in fixed { + ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_label)); + let mut text = RichText::new(menu_text(&sc, mac)).color(t.text_dim); + if shortcuts::parse(&sc).is_some_and(|k| taken.contains(&k)) { + text = text.strikethrough(); + } + ui.label(text); + ui.end_row(); + } + }); +} + +/// Run `app.setShortcut` and say what changed (a key taken from another command, or why not). +fn apply(app: &mut LightcraftApp, ctx: &egui::Context, params: serde_json::Value) { + match app.run("app.setShortcut", params) { + Ok(r) => { + let lost: Vec = r["removedFrom"] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_str()) + .map(|id| shortcuts::find_bindable(id).map_or(id, |b| crate::i18n::tr(b.label)).to_string()) + .collect(); + if !lost.is_empty() { + app.toast_for(ctx, format!("Removed from {}", lost.join(", ")), 3.0); + } + } + Err(e) => app.toast_for(ctx, e, 3.0), + } +} + +/// While a row is recording: the first key press becomes its shortcut, Esc cancels. +fn capture(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.recording_shortcut.clone() else { return }; + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + // ⌘, ⇧, ⌥ and ⌃ on their own arrive as keys too: keep waiting for the key they modify + egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } if !shortcuts::is_modifier(*key) => Some((*key, *modifiers)), + // the windowing layer turns ⌘C / ⌘X / ⌘V (with or without other modifiers) into these + egui::Event::Copy => Some((egui::Key::C, i.modifiers)), + egui::Event::Cut => Some((egui::Key::X, i.modifiers)), + egui::Event::Paste(_) => Some((egui::Key::V, i.modifiers)), + _ => None, + }) + }); + let Some((key, modifiers)) = pressed else { return }; + // the key press is ours: nothing else (a focused button's Space, Tab navigation) acts on it + ctx.input_mut(|i| { + i.events + .retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))); + }); + app.recording_shortcut = None; + if key == egui::Key::Escape && modifiers.is_none() { + return; + } + match shortcuts::format(modifiers, key) { + Some(sc) => apply(app, ctx, serde_json::json!({"id": id, "shortcut": sc})), + None => app.toast_for(ctx, crate::i18n::tr("That key can't be used as a shortcut"), 3.0), + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 8317dda78..2bb4eaca3 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,6 +9,7 @@ pub mod dialogs; pub mod edit; pub mod filterbar; pub mod grid; +pub mod keymap; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f76757e00..5b7b1ba8c 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1,10 +1,195 @@ -//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands. +//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings, apply the user's keymap (Help ▸ +//! Keyboard Shortcuts, `app.setShortcut`) and dispatch UI and engine commands. + +use std::collections::BTreeMap; +use std::sync::OnceLock; use egui::{Key, Modifiers}; -use serde_json::json; +use serde_json::{Value, json}; use crate::LightcraftApp; +/// The user's changes to the keymap: command id → shortcut (`""` = no shortcut). Saved with the +/// app settings (`ui.json`); commands not listed keep their declared shortcut. +pub type Keymap = BTreeMap; + +/// Shortcuts the app menu owns (Settings…, Quit), which no command can take. +pub const RESERVED: &[&str] = &["Cmd+,", "Cmd+Q"]; + +/// A command that can have a shortcut. +#[derive(Clone, Copy, Debug)] +pub struct Bindable { + pub id: &'static str, + pub label: &'static str, + /// The declared shortcut. An engine command whose key a UI command wraps (e.g. `W` opens the + /// White Balance Selector rather than sampling without a point) has none: the UI one owns it. + pub default: Option<&'static str>, +} + +/// Every command that can have a shortcut: UI commands, then engine commands (one entry per id). +pub fn bindable() -> &'static [Bindable] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| { + let mut v: Vec = Vec::new(); + for (id, label, sc, _) in crate::menus::ui_commands() { + if !v.iter().any(|b| b.id == *id) { + v.push(Bindable { id, label, default: *sc }); + } + } + let ui_keys: Vec<(Modifiers, Key)> = v.iter().filter_map(|b| b.default.and_then(parse)).collect(); + for c in lightcraft_engine::command_specs() { + if v.iter().any(|b| b.id == c.id) { + continue; + } + let default = c.shortcut.filter(|sc| parse(sc).is_some_and(|k| !ui_keys.contains(&k))); + v.push(Bindable { id: c.id, label: c.label, default }); + } + v + }) +} + +pub fn find_bindable(id: &str) -> Option<&'static Bindable> { + bindable().iter().find(|b| b.id == id) +} + +/// The shortcut `id` has now: the user's choice (`""` = none), else the declared one. An entry +/// that doesn't parse (a hand-edited `ui.json`, a modifier-only key saved by an older build) is +/// ignored. +pub fn binding<'a>(keymap: &'a Keymap, id: &str, default: Option<&'a str>) -> Option<&'a str> { + match keymap.get(id) { + Some(sc) if sc.is_empty() => None, + Some(sc) if parse(sc).is_some() => Some(sc.as_str()), + _ => default, + } +} + +/// The effective shortcut of command `id` (`None` for unknown commands). +pub fn shortcut_of<'a>(keymap: &'a Keymap, id: &str) -> Option<&'a str> { + find_bindable(id).and_then(|b| binding(keymap, id, b.default)) +} + +/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. +pub const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + +/// Commands other than `id` whose shortcut is the key `sc` (contextual partners excepted). +pub fn conflicts(keymap: &Keymap, id: &str, sc: &str) -> Vec<&'static str> { + let Some(key) = parse(sc) else { return vec![] }; + bindable() + .iter() + .filter(|b| b.id != id && !CONTEXTUAL.iter().any(|(x, y)| (*x == id && *y == b.id) || (*y == id && *x == b.id))) + .filter(|b| binding(keymap, b.id, b.default).and_then(parse) == Some(key)) + .map(|b| b.id) + .collect() +} + +/// Give `id` the shortcut `sc` (`None` = no shortcut), taking it away from commands that had the +/// same key. Returns the commands that lost it. +pub fn assign(keymap: &mut Keymap, id: &str, sc: Option<&str>) -> Result, String> { + let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?; + let sc = match sc.map(str::trim).filter(|s| !s.is_empty()) { + None => None, + Some(s) => { + let key = parse(s).ok_or_else(|| format!("not a shortcut: {s}"))?; + // store the canonical spelling so equal keys compare equal (menus, native accelerators) + let canonical = format(key.0, key.1).unwrap_or_else(|| s.to_string()); + if RESERVED.iter().any(|r| parse(r) == Some(key)) { + return Err(format!("{canonical} is reserved for the app menu")); + } + Some(canonical) + } + }; + let lost = sc.as_deref().map(|s| conflicts(keymap, id, s)).unwrap_or_default(); + for other in &lost { + set(keymap, other, None); + } + set(keymap, b.id, sc.as_deref()); + Ok(lost) +} + +/// Store `sc` for `id`, dropping the entry when it equals the declared shortcut. +fn set(keymap: &mut Keymap, id: &str, sc: Option<&str>) { + let default = find_bindable(id).and_then(|b| b.default); + let same = match (sc, default) { + (None, None) => true, + (Some(a), Some(b)) => parse(a) == parse(b), + _ => false, + }; + if same { + keymap.remove(id); + } else { + keymap.insert(id.to_string(), sc.unwrap_or_default().to_string()); + } +} + +/// Restore the declared shortcut of `id`, taking it away from a command the user gave it to. +pub fn reset(keymap: &mut Keymap, id: &str) -> Result, String> { + let b = find_bindable(id).ok_or_else(|| format!("unknown command: {id}"))?; + assign(keymap, id, b.default) +} + +/// `app.setShortcut {id, shortcut?, reset?}`: `shortcut` null or `""` removes it. +pub fn set_shortcut(app: &mut LightcraftApp, p: &Value) -> Result { + let id = p.get("id").and_then(Value::as_str).ok_or("missing id")?; + let keymap = &mut app.ui.settings.keymap; + let lost = if p.get("reset").and_then(Value::as_bool).unwrap_or(false) { + reset(keymap, id)? + } else { + match p.get("shortcut") { + None => return Err("missing shortcut (null removes it)".into()), + Some(Value::Null) => assign(keymap, id, None)?, + Some(Value::String(s)) => assign(keymap, id, Some(s))?, + Some(_) => return Err("shortcut must be a string or null".into()), + } + }; + Ok(json!({"id": id, "shortcut": shortcut_of(&app.ui.settings.keymap, id), "removedFrom": lost})) +} + +/// ⌘ / ⇧ / ⌥ / ⌃ themselves: they modify a shortcut's key, they can't be it. +pub fn is_modifier(k: Key) -> bool { + matches!( + k, + Key::ShiftLeft | Key::ShiftRight | Key::ControlLeft | Key::ControlRight | Key::AltLeft | Key::AltRight | Key::SuperLeft | Key::SuperRight + ) +} + +/// The shortcut text for a key press (`Cmd+Shift+K`), or `None` for keys a shortcut can't name. +/// `Cmd` is ⌘ on macOS and Ctrl elsewhere; `Ctrl` is the macOS Control key. +pub fn format(m: Modifiers, k: Key) -> Option { + if is_modifier(k) { + return None; + } + let key = match k { + Key::Backspace | Key::Delete => "Delete", + Key::Slash => "/", + Key::Backslash => "\\", + Key::Equals => "=", + Key::Minus => "-", + Key::OpenBracket => "[", + Key::CloseBracket => "]", + Key::Quote => "'", + Key::Comma => ",", + k => k.name(), + }; + let mut s = String::new(); + if m.command || m.mac_cmd { + s.push_str("Cmd+"); + } + // off macOS Ctrl *is* Cmd; on macOS it is the separate Control key + if m.ctrl && (m.mac_cmd || !m.command) { + s.push_str("Ctrl+"); + } + if m.alt { + s.push_str("Alt+"); + } + if m.shift { + s.push_str("Shift+"); + } + s.push_str(key); + let back = parse(&s)?; + let want = if k == Key::Delete { Key::Backspace } else { k }; + (back.1 == want).then_some(s) +} + /// Secondary key bindings for commands that already exist: `(shortcut, command id, params JSON)`. /// They complement the primary shortcut declared on the command (Lightroom-desktop keys that our /// primary keymap assigns elsewhere, see docs/parity.md → Shortcuts). Shown in Help → Keyboard Shortcuts. @@ -44,21 +229,26 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { // "Delete" means the key labelled ⌫ (egui's Backspace); forward-delete also matches, see `matches`. "Delete" => key = Some(Key::Backspace), k => { - key = Key::from_name(k).or(match k { - "Right" => Some(Key::ArrowRight), - "Left" => Some(Key::ArrowLeft), - "Up" => Some(Key::ArrowUp), - "Down" => Some(Key::ArrowDown), - "\\" => Some(Key::Backslash), - "/" => Some(Key::Slash), - "=" => Some(Key::Equals), - "-" => Some(Key::Minus), - "[" => Some(Key::OpenBracket), - "]" => Some(Key::CloseBracket), - "'" => Some(Key::Quote), - "," => Some(Key::Comma), - _ => None, - }) + // an unknown part (`Hyper`, a typo) makes the whole shortcut invalid + key = Some( + Key::from_name(k) + .or(match k { + "Right" => Some(Key::ArrowRight), + "Left" => Some(Key::ArrowLeft), + "Up" => Some(Key::ArrowUp), + "Down" => Some(Key::ArrowDown), + "\\" => Some(Key::Backslash), + "/" => Some(Key::Slash), + "=" => Some(Key::Equals), + "-" => Some(Key::Minus), + "[" => Some(Key::OpenBracket), + "]" => Some(Key::CloseBracket), + "'" => Some(Key::Quote), + "," => Some(Key::Comma), + _ => None, + }) + .filter(|k| !is_modifier(*k))?, + ) } } } @@ -79,36 +269,33 @@ fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { } pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { - // don't steal keys from text fields - if ctx.egui_wants_keyboard_input() { + if !matches!(app.ui.dialog, Some(crate::state::Dialog::Shortcuts)) { + app.recording_shortcut = None; + } + // don't steal keys from text fields or from the keymap editor recording a shortcut + if ctx.egui_wants_keyboard_input() || app.recording_shortcut.is_some() { return; } let mut fire: Vec = Vec::new(); // shortcuts the native menu bar handles (it consumes those key presses itself) let native = |sc: &str| app.native_shortcuts.contains(sc); let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); + let keymap = &app.ui.settings.keymap; + // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them + let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); ctx.input(|i| { - let mut ui_keys = Vec::new(); - for (id, _, sc, _) in crate::menus::ui_commands() { - if let Some((m, k)) = sc.and_then(parse) { - ui_keys.push((m, k)); - if !native(sc.unwrap_or_default()) && matches(i, m, k) { - fire.push(id.to_string()); - } - } - } - for c in lightcraft_engine::command_specs() { - // A UI command bound to the same key wraps the engine command (e.g. `W` opens the - // White Balance Selector tool rather than sampling without a point): the UI one wins. - if let Some((m, k)) = c.shortcut.filter(|s| !native(s)).and_then(parse) - && !ui_keys.contains(&(m, k)) + for b in bindable() { + if let Some(sc) = binding(keymap, b.id, b.default) + && let Some((m, k)) = parse(sc) + && !native(sc) && matches(i, m, k) { - fire.push(c.id.to_string()); + fire.push(b.id.to_string()); } } for (sc, id, params) in ALIASES { if let Some((m, k)) = parse(sc).filter(|_| !native(sc)) + && !taken.contains(&(m, k)) && matches(i, m, k) { aliased.push((id, serde_json::from_str(params).unwrap_or_default())); @@ -116,15 +303,15 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } // rating 0-5, colour labels 6-9 (with Shift: and advance) for (n, key) in [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5].iter().enumerate() { - if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) { + if matches(i, Modifiers::NONE, *key) && !native(&n.to_string()) && !taken.contains(&(Modifiers::NONE, *key)) { fire.push(format!("rate:{n}:0")); } - if matches(i, Modifiers::SHIFT, *key) { + if matches(i, Modifiers::SHIFT, *key) && !taken.contains(&(Modifiers::SHIFT, *key)) { fire.push(format!("rate:{n}:1")); } } for (label, key, sc) in [("red", Key::Num6, "6"), ("yellow", Key::Num7, "7"), ("green", Key::Num8, "8"), ("blue", Key::Num9, "9")] { - if matches(i, Modifiers::NONE, key) && !native(sc) { + if matches(i, Modifiers::NONE, key) && !native(sc) && !taken.contains(&(Modifiers::NONE, key)) { fire.push(format!("label:{label}")); } } @@ -316,8 +503,73 @@ mod tests { } } - /// Engine commands that intentionally share a key and are disambiguated by context in [`handle`]. - const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")]; + #[test] + fn key_presses_format_to_shortcuts_that_parse_back() { + for k in Key::ALL { + for m in [Modifiers::NONE, Modifiers::SHIFT, Modifiers::COMMAND, Modifiers::ALT | Modifiers::SHIFT] { + if let Some(sc) = format(m, *k) { + let want = if *k == Key::Delete { Key::Backspace } else { *k }; + assert_eq!(parse(&sc).map(|p| p.1), Some(want), "{sc}"); + } + } + } + assert_eq!(format(Modifiers::COMMAND | Modifiers::SHIFT, Key::K).as_deref(), Some("Cmd+Shift+K")); + assert_eq!(format(Modifiers::NONE, Key::Slash).as_deref(), Some("/")); + assert_eq!(format(Modifiers::NONE, Key::Backspace).as_deref(), Some("Delete")); + } + + #[test] + fn assigning_a_key_moves_it_and_reset_restores_it() { + let mut keymap = Keymap::new(); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + // D belongs to Detail: Survey takes it, Detail loses it + let lost = assign(&mut keymap, "view.survey", Some("D")).unwrap(); + assert_eq!(lost, vec!["view.detail"]); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("D")); + assert_eq!(shortcut_of(&keymap, "view.detail"), None); + // restoring Detail takes D back + let lost = reset(&mut keymap, "view.detail").unwrap(); + assert_eq!(lost, vec!["view.survey"]); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + reset(&mut keymap, "view.survey").unwrap(); + assert!(keymap.is_empty(), "declared shortcuts aren't stored: {keymap:?}"); + // a command without a declared shortcut can get one, and lose it again + assign(&mut keymap, "view.photoGrid", Some("cmd+shift+1")).unwrap_err(); + assign(&mut keymap, "view.photoGrid", Some("Cmd+Shift+1")).unwrap(); + assert_eq!(shortcut_of(&keymap, "view.photoGrid"), Some("Cmd+Shift+1")); + assign(&mut keymap, "view.photoGrid", None).unwrap(); + assert!(keymap.is_empty()); + } + + #[test] + fn bad_assignments_are_errors() { + let mut keymap = Keymap::new(); + assert!(assign(&mut keymap, "no.suchCommand", Some("K")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Nonsense")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+Q")).is_err()); + assert!(assign(&mut keymap, "view.survey", Some("Cmd+,")).is_err()); + assert!(keymap.is_empty()); + } + + /// Junk in a hand-edited `ui.json` keymap is ignored (the declared shortcut stays), never a panic. + #[test] + fn junk_keymap_entries_mean_no_shortcut() { + let mut keymap = Keymap::new(); + keymap.insert("view.survey".into(), "Hyper+☃".into()); + keymap.insert("no.suchCommand".into(), "K".into()); + keymap.insert("view.detail".into(), "Cmd+SuperLeft".into()); + assert_eq!(shortcut_of(&keymap, "view.survey"), Some("N")); + assert_eq!(shortcut_of(&keymap, "view.detail"), Some("D")); + assert!(conflicts(&keymap, "view.compare", "N").contains(&"view.survey")); + assert_eq!(parse("Hyper+K"), None); + } + + /// Contextual partners keep sharing their key when one is reassigned. + #[test] + fn contextual_partners_are_not_conflicts() { + let keymap = Keymap::new(); + assert!(conflicts(&keymap, "photo.reject", "X").is_empty()); + } /// No key fires two different actions (a UI command may shadow the engine command it wraps). #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 9d4df8592..27d357ea5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -152,6 +152,8 @@ pub struct AppSettings { pub film_badges: bool, /// Grid: when to show the rating / flag / edited badges. pub grid_badges: GridBadges, + /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. + pub keymap: crate::shortcuts::Keymap, } impl Default for AppSettings { @@ -167,6 +169,7 @@ impl Default for AppSettings { film_names: true, film_badges: true, grid_badges: GridBadges::Auto, + keymap: Default::default(), } } } diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs new file mode 100644 index 000000000..a0a1e1d06 --- /dev/null +++ b/crates/ui-egui/src/tests_keymap.rs @@ -0,0 +1,148 @@ +//! Headless tests of the editable keymap (Help ▸ Keyboard Shortcuts): a changed shortcut fires +//! its command and not the old one's, the editor records a key press without running it, Esc +//! cancels recording without closing the dialog, and the keymap survives a save/load of `ui.json`. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{Dialog, ViewMode}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn key(h: &mut Headless, k: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn rebound_shortcut_fires_and_the_old_key_does_not() { + let mut h = demo(); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+K"})).unwrap(); + assert_eq!(r["shortcut"], "Shift+K", "{r}"); + // the old key (N) no longer opens Survey… + key(&mut h, "N", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + // …the new one does, and menus show it + key(&mut h, "K", true); + assert_eq!(h.app.ui.view, ViewMode::Survey); + let entry = crate::menus::menu_entries(&h.app).into_iter().find(|e| e.id == "view.survey").unwrap(); + assert_eq!(entry.shortcut.as_deref(), Some("Shift+K")); + // the keymap is saved with the UI state + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap(); + assert_eq!(back.settings.keymap.get("view.survey").map(String::as_str), Some("Shift+K")); +} + +#[test] +fn the_editor_records_a_key_and_esc_cancels_without_closing() { + let mut h = demo(); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + // the list is longer than the dialog: narrow it so the row is on screen + click(&mut h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": "compare"}), T)["ok"], true); + h.step(); + h.step(); + // record Shift+J for Compare: the key press is taken, not run + click(&mut h, "button:shortcut-view.compare"); + assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.compare")); + key(&mut h, "J", true); + assert_eq!(h.app.recording_shortcut, None); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J")); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // Esc while recording cancels; the dialog stays open and nothing changes + click(&mut h, "button:shortcut-view.compare"); + key(&mut h, "Escape", false); + assert_eq!(h.app.recording_shortcut, None); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+J")); + // the next Esc closes it; the new key works + key(&mut h, "Escape", false); + assert!(h.app.ui.dialog.is_none()); + key(&mut h, "J", true); + assert_eq!(h.app.ui.view, ViewMode::Compare); + // Reset All restores the declared keys + h.app.run("app.resetShortcuts", json!({})).unwrap(); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.compare"), Some("Shift+C")); +} + +#[test] +fn a_key_given_to_a_command_overrides_a_fixed_key() { + let mut h = demo(); + let before = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating); + // 3 (rate ★★★) now opens Survey instead + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "3"})).unwrap(); + key(&mut h, "3", false); + assert_eq!(h.app.ui.view, ViewMode::Survey); + let after = h.app.session.active().and_then(|id| h.app.session.catalog.photo(id)).map(|p| p.rating); + assert_eq!(before, after, "the rating key must not fire too"); +} + +/// Open the editor, filtered to `search`, and start recording for `id`. +fn record(h: &mut Headless, search: &str, id: &str) { + if h.app.ui.dialog.is_none() { + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + click(h, "field:shortcutsSearch"); + assert_eq!(h.request("ui.text", json!({"text": search}), T)["ok"], true); + h.step(); + h.step(); + } + click(h, &format!("button:shortcut-{id}")); + assert_eq!(h.app.recording_shortcut.as_deref(), Some(id)); +} + +/// Pressing ⌘ (a key event of its own) before S used to record `Cmd+SuperLeft`, so no ⌘ shortcut +/// could be set; ⌘C/⌘X/⌘V arrive as copy/cut/paste events, not keys, and were ignored. +#[test] +fn modifiers_wait_for_their_key_and_clipboard_keys_record() { + let mut h = demo(); + record(&mut h, "survey", "view.survey"); + let r = h.request("ui.key", json!({"key": "SuperLeft", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.recording_shortcut.as_deref(), Some("view.survey"), "still waiting after ⌘ alone"); + let r = h.request("ui.key", json!({"key": "K", "cmd": true, "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.recording_shortcut, None); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Shift+K")); + // ⌘⌥C: the windowing layer sends a Copy event with the modifiers held + record(&mut h, "survey", "view.survey"); + let m = egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND }; + h.app.synthetic.push(egui::Event::Key { key: egui::Key::AltLeft, physical_key: None, pressed: true, repeat: false, modifiers: m }); + h.app.synthetic.push(egui::Event::Copy); + h.step(); + h.step(); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Cmd+Alt+C")); + // a saved modifier-only shortcut (from the bug) means no shortcut + assert_eq!(crate::shortcuts::parse("Cmd+SuperLeft"), None); +} diff --git a/docs/parity.md b/docs/parity.md index d5a31ea9c..841720f7f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) | +| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) | | Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) | -| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) | +| **Total** | 390 | 34 | 86 | 36 | 194/200 (97%) | 139/149 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 510 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160. ## Top gaps @@ -470,7 +470,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets | -| LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | +| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command | | LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | | | LR-PREF-NOTIFICATIONS | Notifications | OOS | 🚫 | | | | LR-PREF-DEVICE | Device settings | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index ea0243f42..bad18f5fc 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,11 @@ ## October 2026 +### Keyboard shortcuts +- Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and + press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that + belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`. + ### RAW decoding - Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB, preserving RAW editing & full-resolution export instead of using embedded JPEG previews.