LR-PREF-SHORTCUTS: editable keyboard shortcuts in Help ▸ Keyboard Shortcuts (⌘/)

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 <noreply@anthropic.com>
This commit is contained in:
parkerHurst 2026-10-08 14:54:50 -05:00
parent 2472021091
commit e2b626b7f7
12 changed files with 712 additions and 84 deletions

View File

@ -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::<HashSet<_>>();
app.native_shortcuts.insert(SETTINGS_KEY.to_string());
}
@ -397,14 +401,16 @@ impl NativeMenu {
walk(&bar.iter().flat_map(|(_, v)| v.clone()).collect::<Vec<_>>(), &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),

View File

@ -54,8 +54,21 @@ fn wrap(r: Result<Value, String>) -> Outcome {
}
pub fn all_commands(app: &LightcraftApp) -> Value {
let mut v: Vec<Value> = 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<Value> = 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)

View File

@ -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<String>,
/// 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<String>,
/// 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,

View File

@ -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<Re
app.ui.dialog = Some(Dialog::Shortcuts);
Ok(Value::Null)
}
"app.setShortcut" => 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<MenuEntry> {
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<MenuEntry> {
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,
});
}

View File

@ -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;
}

View File

@ -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<String>)> = 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<String> = 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),
}
}

View File

@ -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;

View File

@ -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<String, String>;
/// 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<Vec<Bindable>> = OnceLock::new();
ALL.get_or_init(|| {
let mut v: Vec<Bindable> = 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<Vec<&'static str>, 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<Vec<&'static str>, 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<Value, String> {
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<String> {
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<String> = 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]

View File

@ -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(),
}
}
}

View File

@ -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::<crate::UiState>(&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);
}

View File

@ -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.
<!-- /parity:summary -->
## 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 | ⬜ | | |

View File

@ -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.