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:
parent
2472021091
commit
e2b626b7f7
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue