Files
lightcraft/crates/ui-egui/src/shortcuts.rs
T
Brandon ThomasandClaude Opus 5.5 b2c8d9611f KEY-SLIDER-NUDGE + KEY-FLAGADVANCE: slider nudges, ⇧Z pick and advance, Missing Photos source
- ↑/↓ over any slider nudge it by ≈ 1/200 of its range (exposure 0.05, most sliders 1,
  temperature 50 K; ⇧ ×5), one undo step each; the key doesn't also move the selection.
- ⇧Z picks and moves to the next photo (with ⇧X / ⇧U).
- library.source {kind: missing}: photos whose file can't be found; a "Missing Photos" row
  appears in the left panel when there are any (checked every 5 s).
- Loupe context menu: Zoom submenu. Parity: stale rows fixed (compare/survey, per-camera
  defaults, paste from previous, capture-time edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:27:35 -04:00

316 lines
14 KiB
Rust

//! Keyboard shortcuts: parse `Cmd+Shift+X` style strings and dispatch UI and engine commands.
use egui::{Key, Modifiers};
use serde_json::json;
use crate::LightcraftApp;
/// 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.
pub const ALIASES: &[(&str, &str, &str)] = &[
("Cmd+D", "library.selectNone", "{}"),
("Shift+E", "dialog.export", "{}"),
("Cmd+E", "app.exportPrevious", "{}"),
("Space", "view.zoomToggle", "{}"),
("Shift+M", "version.create", "{}"),
("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#),
("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#),
("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#),
// Shift+[ / Shift+] arrive as { / } on most layouts
("Shift+{", "brush.featherLess", "{}"),
("Shift+}", "brush.featherMore", "{}"),
];
pub fn parse(s: &str) -> Option<(Modifiers, Key)> {
let mut m = Modifiers::NONE;
let mut key = None;
for part in s.split('+') {
match part {
"Cmd" => m.command = true,
"Shift" => m.shift = true,
"Alt" => m.alt = true,
"Ctrl" => m.ctrl = true,
// "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,
})
}
}
}
key.map(|k| (m, k))
}
fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool {
i.events.iter().any(|e| match e {
egui::Event::Key { key, pressed: true, modifiers, .. } => {
// `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl);
// "Delete" (⌫ = Backspace) also matches forward-delete
let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command };
let cmd_ok = m.ctrl || modifiers.command == m.command;
(*key == k || (k == Key::Backspace && *key == Key::Delete)) && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt
}
_ => false,
})
}
pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) {
// don't steal keys from text fields
if ctx.egui_wants_keyboard_input() {
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();
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))
&& matches(i, m, k)
{
fire.push(c.id.to_string());
}
}
for (sc, id, params) in ALIASES {
if let Some((m, k)) = parse(sc).filter(|_| !native(sc))
&& matches(i, m, k)
{
aliased.push((id, serde_json::from_str(params).unwrap_or_default()));
}
}
// 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()) {
fire.push(format!("rate:{n}:0"));
}
if matches(i, 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) {
fire.push(format!("label:{label}"));
}
}
});
use crate::panels::compare;
// rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or
// Auto Advance then moves on (next candidate in Compare, next photo elsewhere)
let cull = |app: &mut LightcraftApp, id: &str, mut params: serde_json::Value, advance: bool| {
compare::target_active(app, &mut params);
let ok = app.run(id, params).is_ok();
if ok && (advance || app.ui.auto_advance) {
compare::advance(app);
}
};
for (id, params) in aliased {
// flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey,
// `advance` moves to the next candidate there
if matches!(id, "photo.flag" | "photo.rate" | "photo.label") {
let mut params = params;
let advance = params.get("advance").and_then(serde_json::Value::as_bool).unwrap_or(false);
if let Some(o) = params.as_object_mut() {
o.remove("advance");
}
cull(app, id, params, advance);
} else {
let _ = app.run(id, params);
}
}
for f in fire {
if let Some(rest) = f.strip_prefix("rate:") {
let (n, adv) = rest.split_once(':').unwrap_or(("0", "0"));
cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1");
let label = if n == "0" { "Rating cleared".to_string() } else { format!("Rated {}", "★".repeat(n.parse().unwrap_or(0))) };
app.toast(ctx, label);
} else if let Some(l) = f.strip_prefix("label:") {
cull(app, "photo.label", json!({"label": l}), false);
} else if compare::culling(app) && (f == "library.next" || f == "library.previous") {
let d = if f == "library.next" { 1 } else { -1 };
let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) };
} else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop {
cull(app, &f, json!({}), false);
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
_ => app.toast(ctx, "Unflagged"),
}
} else {
// in the full-screen preview (no panels) I cycles the info overlay instead
if f == "panel.info" && app.ui.fullscreen {
let _ = app.run("view.infoOverlay", json!({}));
continue;
}
// Delete acts on what's being edited: the active mask in the Masking panel; never the
// photo while retouching (spots are removed from their own panel).
if f == "photo.delete" {
use crate::state::RightPanel as R;
match app.ui.right {
R::Masking => {
if app.session.active_mask.is_some() {
let _ = app.run("mask.delete", json!({}));
}
continue;
}
R::Remove => {
if app.session.active_spot.is_some() {
let _ = app.run("spot.delete", json!({}));
}
continue;
}
R::RedEye => continue,
_ => {}
}
if crate::menus::confirm_delete(app) {
continue;
}
}
// X is both reject (library) and swap crop aspect (crop tool)
if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop {
let _ = app.run("crop.rotateAspect", json!({}));
continue;
}
if f == "crop.rotateAspect" && app.ui.right != crate::state::RightPanel::Crop {
continue;
}
// / refreshes the selected spot's source in the Remove tool (the filmstrip elsewhere)
if f == "view.filmstrip" && app.ui.right == crate::state::RightPanel::Remove && app.session.active_spot.is_some() {
let _ = app.run("spot.refreshSource", json!({}));
continue;
}
// Shift+O cycles the mask overlay mode while masking (the crop overlay elsewhere)
if f == "view.cropOverlay" && app.ui.right == crate::state::RightPanel::Masking {
let _ = app.run("view.maskOverlayMode", json!({}));
continue;
}
// while cropping: O cycles the guides, Shift+O their orientation, A locks the aspect
if app.ui.right == crate::state::RightPanel::Crop {
let crop_key = match f.as_str() {
"view.maskOverlay" => Some(("view.cropOverlay", json!({}))),
"view.cropOverlay" => Some(("view.cropOverlayOrientation", json!({}))),
"view.visualizeSpots" => Some(("crop.aspect", json!({"aspect": "toggle"}))),
_ => None,
};
if let Some((cmd, p)) = crop_key {
let _ = app.run(cmd, p);
continue;
}
}
let _ = app.run(&f, json!({}));
match f.as_str() {
"photo.pick" => app.toast(ctx, "Flagged as Pick"),
"photo.reject" => app.toast(ctx, "Flagged as Reject"),
"photo.unflag" => app.toast(ctx, "Unflagged"),
"edit.undo" => app.toast(ctx, "Undo"),
"edit.redo" => app.toast(ctx, "Redo"),
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn delete_means_the_backspace_key() {
assert_eq!(parse("Delete"), Some((Modifiers::NONE, Key::Backspace)));
assert_eq!(parse("Cmd+Delete"), Some((Modifiers::COMMAND, Key::Backspace)));
}
use super::*;
#[test]
fn parses_shortcuts() {
let (m, k) = parse("Cmd+Shift+Z").unwrap();
assert!(m.command && m.shift && !m.alt);
assert_eq!(k, Key::Z);
assert_eq!(parse("Right").unwrap().1, Key::ArrowRight);
assert_eq!(parse("\\").unwrap().1, Key::Backslash);
// every declared shortcut parses
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
if let Some(sc) = sc {
assert!(parse(sc).is_some(), "{id}: {sc}");
}
}
for c in lightcraft_engine::command_specs() {
if let Some(sc) = c.shortcut {
assert!(parse(sc).is_some(), "{}: {sc}", c.id);
}
}
}
#[test]
fn aliases_parse_and_target_existing_commands() {
let ui: Vec<&str> = crate::menus::UI_COMMANDS.iter().map(|c| c.0).collect();
for (sc, id, params) in ALIASES {
assert!(parse(sc).is_some(), "{id}: {sc}");
assert!(ui.contains(id) || lightcraft_engine::find_command(id).is_some(), "alias {sc} → unknown command {id}");
assert!(serde_json::from_str::<serde_json::Value>(params).is_ok(), "alias {sc}: bad params");
}
}
/// Engine commands that intentionally share a key and are disambiguated by context in [`handle`].
const CONTEXTUAL: &[(&str, &str)] = &[("photo.reject", "crop.rotateAspect")];
/// No key fires two different actions (a UI command may shadow the engine command it wraps).
#[test]
fn no_conflicting_bindings() {
let mut ui: Vec<((Modifiers, Key), String)> = Vec::new();
for (id, _, sc, _) in crate::menus::UI_COMMANDS {
if let Some(k) = sc.and_then(parse) {
ui.push((k, id.to_string()));
}
}
for (sc, id, _) in ALIASES {
ui.push((parse(sc).unwrap(), format!("alias {id}")));
}
let mut engine: Vec<((Modifiers, Key), &str)> = Vec::new();
for c in lightcraft_engine::command_specs() {
if let Some(k) = c.shortcut.and_then(parse) {
engine.push((k, c.id));
}
}
for (sc, id, _) in ALIASES {
let k = parse(sc).unwrap();
assert!(!engine.iter().any(|(k2, _)| *k2 == k), "alias {sc} ({id}) shadows an engine shortcut");
}
for (i, (k, a)) in ui.iter().enumerate() {
for (k2, b) in &ui[i + 1..] {
assert!(k != k2, "{a} and {b} share a key");
}
}
for (i, (k, a)) in engine.iter().enumerate() {
for (k2, b) in &engine[i + 1..] {
let contextual = CONTEXTUAL.iter().any(|(x, y)| (x == a && y == b) || (x == b && y == a));
assert!(k != k2 || contextual, "{a} and {b} share a key");
}
}
}
}