From 985a848c5ef1e18597f63d966f6c9d0581da4155 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 01:56:47 -0400 Subject: [PATCH] M3.11: keyboard shortcuts editor + presets - Engine shortcut set (crates/engine/src/shortcuts.rs): bindings as data (command, canonical chord, optional panel), panel shortcuts override app ones while the panel has focus; per-OS modifiers (Cmd = Cmd/Ctrl, Ctrl = macOS Control, folded into Ctrl elsewhere and reported as conflicts); conflict detection, reassignment, undo/redo, Cancel restores; frontend commands registered so tools/transport/panels are bindable too. - Presets: FilmCraft Default, Premiere Pro / Final Cut Pro / Avid Media Composer Compatible (key tables mapped onto FilmCraft command ids); custom presets as JSON in /Keyboard Shortcuts/, active set persisted, Save As / Delete / Export / Import (Windows-notation files converted). - shortcuts.* commands for agents/MCP (list/get/set/clear/undo/redo/conflicts/ forKey/resolve/presets/loadPreset/savePreset/deletePreset/export/import/audit). - Premiere-default audit: commands without a shortcut get Premiere's key when free (Shift+D Apply Default Transitions, Shift+J/L slow shuttle, Shift+K Play Around, Project > Shift+O Open in Source Monitor); Audio Clip/Track Mixer window shortcuts corrected (Shift+9 / Shift+6). - Edit > Keyboard Shortcuts... (Cmd+Alt+K): keyboard drawn in code with app/panel/both colouring and modifier filter, searchable command list with a record-on-click Shortcut column and +, Key: detail list, conflict warnings, Undo/Redo/Clear/Cancel/OK; automation ids shortcuts.*. - Menus (in-app and native macOS key equivalents), command.list and the input loop follow the active bindings live. - Tests: 7 engine tests; scripted kittest test (record keys, live menu update, reassignment, undo/redo, cancel, OK, panel-specific Left in History). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/main.rs | 6 +- apps/filmcraft/src/native_menu.rs | 16 +- crates/engine/src/commands.rs | 10 +- crates/engine/src/lib.rs | 8 + crates/engine/src/shortcut_presets.rs | 247 ++++ crates/engine/src/shortcuts.rs | 1152 +++++++++++++++++ crates/engine/src/shortcuts_tests.rs | 215 +++ crates/ui-egui/src/dock.rs | 4 +- crates/ui-egui/src/lib.rs | 33 +- crates/ui-egui/src/menus.rs | 70 +- crates/ui-egui/src/panels/dialogs.rs | 23 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/shortcuts_dialog.rs | 787 +++++++++++ crates/ui-egui/tests/scripted.rs | 105 ++ docs/control-protocol.md | 14 + 15 files changed, 2627 insertions(+), 64 deletions(-) create mode 100644 crates/engine/src/shortcut_presets.rs create mode 100644 crates/engine/src/shortcuts.rs create mode 100644 crates/engine/src/shortcuts_tests.rs create mode 100644 crates/ui-egui/src/panels/shortcuts_dialog.rs diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index b4111dd8..07e9359f 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -109,12 +109,16 @@ fn main() -> eframe::Result { app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { rfd::FileDialog::new().add_filter(filter, exts).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()) })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new().add_filter(filter, exts).pick_file().map(|p| p.to_string_lossy().to_string()) + })); app.hooks.pick_open_project = Some(Box::new(|| rfd::FileDialog::new().add_filter("FilmCraft Project", &["fcproj"]).pick_file().map(|p| p.to_string_lossy().to_string()))); #[cfg(target_os = "macos")] { - let rx = native_menu::install(&app, cc.egui_ctx.clone()); + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); app.ui.show_menu_bar = false; } if let Some(port) = control_port { diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 3f9a6543..fd142294 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -19,8 +19,12 @@ fn accel(s: &str) -> Option { mapped.parse().ok() } -pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { +/// Updates native key equivalents when the active keyboard shortcuts change. +pub type ShortcutUpdater = Box; + +pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, ShortcutUpdater) { let items = menu_items(app); + let mut native: Vec<(String, MenuItem)> = Vec::new(); let bar = Menu::new(); let app_menu = Submenu::new("FilmCraft", true); let _ = app_menu.append_items(&[ @@ -40,6 +44,7 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { let mut subs: Vec<(String, Submenu)> = Vec::new(); for it in mine { let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel)); + native.push((it.id.clone(), mi.clone())); if let Some(name) = it.path.get(1) { if let Some((_, s)) = subs.iter().find(|(n, _)| n == name) { let _ = s.append(&mi); @@ -69,5 +74,12 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver { ctx.request_repaint(); } }); - rx + let update: ShortcutUpdater = Box::new(move |items: &[Item]| { + for (id, mi) in &native { + if let Some(it) = items.iter().find(|i| &i.id == id) { + let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel)); + } + } + }); + (rx, update) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4c548344..11b63185 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -916,7 +916,7 @@ fn build() -> Vec { s.events.push(crate::Event::OpenSequence(id)); Ok(Value::Null) }), - cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id}"#, has_seq, |s, p| { + cmd!("sequence.close", "Close Sequence", [], None, r#"{"item":id?}"#, has_seq, |s, p| { let id = item_p(p, "item").or(s.state.active_sequence).ok_or(EngineError::NoSequence)?; s.state.open_sequences.retain(|x| *x != id); if s.state.active_sequence == Some(id) { @@ -1424,7 +1424,7 @@ fn build() -> Vec { let n = s.prefs.trim.large_trim_offset as i64; crate::trim::nudge(s, &json!({"frames": n})) }), - cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], Some("Shift+D"), "{}", has_edit_points, |s, _| { + cmd!("trim.applyDefaultTransition", "Apply Default Transitions to Selection", ["Sequence"], None, "{}", has_edit_points, |s, _| { crate::trim::apply_default_transitions(s) }), cmd!("trim.shuttle", "Dynamic Trim (Shuttle)", [], None, r#"{"direction":"forward|reverse","slow":bool?,"clock":seconds}"#, has_edit_points, |s, p| { @@ -1827,7 +1827,10 @@ fn build() -> Vec { Ok(Value::Array( command_specs() .iter() - .map(|c| json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()})) + .map(|c| { + let all: Vec<&str> = s.shortcuts.for_command(c.id).iter().map(|b| b.keys.as_str()).collect(); + json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": s.shortcuts.primary(c.id), "shortcuts": all, "defaultShortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(s).is_ok()}) + }) .collect(), )) }), @@ -1846,6 +1849,7 @@ fn build() -> Vec { )), ]; v.extend(crate::captions::commands()); + v.extend(crate::shortcuts::commands()); // Labels as individual commands (Edit ▸ Label ▸ ) for l in Label::ALL { let _ = l; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d44d094d..a78c2332 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,8 @@ pub mod demo; pub mod interchange; pub mod media_pool; pub mod previews; +pub mod shortcut_presets; +pub mod shortcuts; pub mod trim; use std::sync::Arc; @@ -167,6 +169,8 @@ pub struct Session { pub previews: Arc, /// Dynamic (J/K/L) trimming and trim-mode loop playback in progress. pub trim_play: trim::TrimPlayback, + /// Keyboard shortcuts (active bindings, presets; `shortcuts.*` commands). + pub shortcuts: shortcuts::Shortcuts, /// Nesting depth of [`Session::execute`] (commands that run other commands). exec_depth: u32, } @@ -229,6 +233,7 @@ impl Session { loaded_schema: filmcraft_format::SCHEMA_VERSION, previews: Arc::new(previews::PreviewStore::temp()), trim_play: Default::default(), + shortcuts: shortcuts::Shortcuts::new(), exec_depth: 0, } } @@ -239,6 +244,7 @@ impl Session { pub fn start_autosave(&mut self, cfg: autosave::AutosaveConfig) -> std::io::Result<()> { let prefs_path = cfg.data_dir.join("preferences.json"); self.prefs = autosave::Preferences::load(&prefs_path); + self.shortcuts.set_dir(&cfg.data_dir); self.prefs_path = Some(prefs_path); self.persistence = Some(autosave::Persistence::start(&cfg, self.prefs.auto_save.clone())?); self.sync_persistence(); @@ -519,6 +525,8 @@ mod file_tests; #[cfg(test)] mod previews_tests; #[cfg(test)] +mod shortcuts_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod trim_tests; diff --git a/crates/engine/src/shortcut_presets.rs b/crates/engine/src/shortcut_presets.rs new file mode 100644 index 00000000..4f4d36c5 --- /dev/null +++ b/crates/engine/src/shortcut_presets.rs @@ -0,0 +1,247 @@ +//! Built-in keyboard shortcut tables (command id, keys, panel; "" = application-wide). +//! +//! Key assignments are functional facts about how other editors are operated; these tables map +//! them onto FilmCraft's own command ids. No files from any other application are used. +//! Keys use FilmCraft's canonical notation (`Cmd` = ⌘ on macOS / Ctrl elsewhere, `Ctrl` = the +//! macOS Control key, `Alt` = ⌥ Option / Alt; `Backspace` = the Mac "delete" key, `Delete` = the +//! forward-delete key). + +pub type Entry = (&'static str, &'static str, &'static str); + +/// FilmCraft additions to the per-command defaults: panel-specific shortcuts. +pub const FILMCRAFT_PANEL: &[Entry] = &[ + ("project.delete", "Backspace", "Project"), + ("file.newBin", "Cmd+B", "Project"), + ("edit.undo", "Left", "History"), + ("edit.redo", "Right", "History"), + ("sequence.close", "Cmd+W", "Timeline"), +]; + +/// Premiere Pro's default keyboard (macOS) mapped onto FilmCraft commands. FilmCraft Default takes +/// every entry whose command has no shortcut yet and whose key is free (the shortcut audit); +/// the "Premiere Pro Compatible" preset applies all of them. +pub const PREMIERE: &[Entry] = &[ + // Tools + ("tool.selection", "V", ""), + ("tool.trackSelectBackward", "Shift+A", ""), + ("tool.trackSelectForward", "A", ""), + ("tool.ripple", "B", ""), + ("tool.rolling", "N", ""), + ("tool.rateStretch", "R", ""), + ("tool.razor", "C", ""), + ("tool.slip", "Y", ""), + ("tool.slide", "U", ""), + ("tool.pen", "P", ""), + ("tool.hand", "H", ""), + ("tool.zoom", "Z", ""), + ("tool.type", "T", ""), + // Application + ("app.preferences.autoSave", "Cmd+,", ""), + ("app.keyboardShortcuts", "Cmd+Alt+K", ""), + // File + ("file.newProject", "Cmd+Alt+N", ""), + ("file.newSequence", "Cmd+N", ""), + ("file.newBin", "Cmd+/", ""), + ("file.newBin", "Cmd+B", "Project"), + ("file.open", "Cmd+O", ""), + ("file.save", "Cmd+S", ""), + ("file.saveAs", "Cmd+Shift+S", ""), + ("file.saveCopy", "Cmd+Alt+S", ""), + ("file.import", "Cmd+I", ""), + ("mode.export", "Cmd+M", ""), + // Edit + ("edit.undo", "Cmd+Z", ""), + ("edit.redo", "Cmd+Shift+Z", ""), + ("edit.cut", "Cmd+X", ""), + ("edit.copy", "Cmd+C", ""), + ("edit.paste", "Cmd+V", ""), + ("edit.pasteInsert", "Cmd+Shift+V", ""), + ("edit.clear", "Delete", ""), + ("edit.clear", "Backspace", "Timeline"), + ("edit.rippleDelete", "Shift+Delete", ""), + ("edit.rippleDelete", "Alt+Backspace", "Timeline"), + ("edit.duplicate", "Cmd+Shift+/", ""), + ("edit.selectAll", "Cmd+A", ""), + ("edit.deselectAll", "Cmd+Shift+A", ""), + // Clip + ("clip.speedDuration", "Cmd+R", ""), + ("source.insert", ",", ""), + ("source.overwrite", ".", ""), + ("clip.enable", "Cmd+Shift+E", ""), + ("clip.link", "Cmd+L", ""), + ("clip.group", "Cmd+G", ""), + ("clip.ungroup", "Cmd+Shift+G", ""), + ("clip.audioGain", "G", ""), + // Sequence + ("sequence.renderEffectsInToOut", "Enter", ""), + ("sequence.matchFrame", "F", ""), + ("sequence.addEdit", "Cmd+K", ""), + ("sequence.addEditAllTracks", "Cmd+Shift+K", ""), + ("trim.edit", "Shift+T", ""), + ("trim.extendToPlayhead", "E", ""), + ("sequence.applyVideoTransition", "Cmd+D", ""), + ("sequence.applyAudioTransition", "Cmd+Shift+D", ""), + ("trim.applyDefaultTransition", "Shift+D", ""), + ("sequence.lift", ";", ""), + ("sequence.extract", "'", ""), + ("view.zoomIn", "=", ""), + ("view.zoomOut", "-", ""), + ("sequence.snap", "S", ""), + // Markers + ("markers.markIn", "I", ""), + ("markers.markOut", "O", ""), + ("markers.markClip", "X", ""), + ("markers.markSelection", "/", ""), + ("markers.goToIn", "Shift+I", ""), + ("markers.goToOut", "Shift+O", ""), + ("markers.clearIn", "Alt+I", ""), + ("markers.clearOut", "Alt+O", ""), + ("markers.clearInOut", "Alt+X", ""), + ("markers.add", "M", ""), + ("markers.goNext", "Shift+M", ""), + ("markers.goPrev", "Cmd+Shift+M", ""), + ("markers.clearCurrent", "Alt+M", ""), + ("markers.clearAll", "Cmd+Alt+M", ""), + // Window + ("window.workspace.reset", "Alt+Shift+0", ""), + ("window.workspace.editing", "Alt+Shift+1", ""), + ("window.workspace.assembly", "Alt+Shift+2", ""), + ("window.workspace.color", "Alt+Shift+3", ""), + ("window.workspace.effects", "Alt+Shift+4", ""), + ("window.workspace.audio", "Alt+Shift+5", ""), + ("window.workspace.captionsandgraphics", "Alt+Shift+6", ""), + ("window.panel.Project", "Shift+1", ""), + ("window.panel.Source", "Shift+2", ""), + ("window.panel.Timeline", "Shift+3", ""), + ("window.panel.Program", "Shift+4", ""), + ("window.panel.EffectControls", "Shift+5", ""), + ("window.panel.AudioTrackMixer", "Shift+6", ""), + ("window.panel.Effects", "Shift+7", ""), + ("window.panel.MediaBrowser", "Shift+8", ""), + ("window.panel.AudioClipMixer", "Shift+9", ""), + // Captions + ("captions.newTrack", "Cmd+Alt+A", ""), + ("captions.add", "Cmd+Alt+C", ""), + ("captions.next", "Cmd+Alt+Down", "Timeline"), + ("captions.previous", "Cmd+Alt+Up", "Timeline"), + // Navigation and playback + ("playhead.nextEdit", "Down", ""), + ("playhead.prevEdit", "Up", ""), + ("playhead.start", "Home", ""), + ("playhead.end", "End", ""), + ("playhead.stepBack", "Left", ""), + ("playhead.stepBack5", "Shift+Left", ""), + ("playhead.stepForward", "Right", ""), + ("playhead.stepForward5", "Shift+Right", ""), + ("playback.toggle", "Space", ""), + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.slowReverse", "Shift+J", ""), + ("playback.slowForward", "Shift+L", ""), + ("playback.playAround", "Shift+K", ""), + ("playback.inToOut", "Alt+K", ""), + ("view.zoomToSequence", "\\", ""), + // Trimming + ("trim.ripplePrevious", "Q", ""), + ("trim.rippleNext", "W", ""), + ("trim.previous", "Alt+Q", ""), + ("trim.next", "Alt+W", ""), + ("trim.toggleType", "Ctrl+T", ""), + ("trim.backward", "Alt+Left", ""), + ("trim.backwardMany", "Alt+Shift+Left", ""), + ("trim.forward", "Alt+Right", ""), + ("trim.forwardMany", "Alt+Shift+Right", ""), + // Panels + ("project.delete", "Backspace", "Project"), + ("source.open", "Shift+O", "Project"), + ("edit.undo", "Left", "History"), + ("edit.redo", "Right", "History"), +]; + +/// Final Cut Pro's default keys for the equivalent FilmCraft commands. +pub const FINAL_CUT: &[Entry] = &[ + ("tool.selection", "A", ""), + ("tool.razor", "B", ""), + ("tool.ripple", "T", ""), + ("tool.zoom", "Z", ""), + ("tool.hand", "H", ""), + ("source.insert", "W", ""), + ("source.overwrite", "D", ""), + ("sequence.addEdit", "Cmd+B", ""), + ("sequence.addEditAllTracks", "Cmd+Shift+B", ""), + ("edit.rippleDelete", "Backspace", ""), + ("sequence.lift", "Shift+Backspace", ""), + ("markers.markIn", "I", ""), + ("markers.markOut", "O", ""), + ("markers.markClip", "X", ""), + ("markers.clearInOut", "Alt+X", ""), + ("markers.add", "M", ""), + ("markers.goNext", "Ctrl+'", ""), + ("markers.goPrev", "Ctrl+;", ""), + ("playhead.nextEdit", "Down", ""), + ("playhead.prevEdit", "Up", ""), + ("playhead.stepForward5", "Shift+Right", ""), + ("playhead.stepBack5", "Shift+Left", ""), + ("playback.toggle", "Space", ""), + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.loop", "Cmd+L", ""), + ("playback.inToOut", "/", ""), + ("sequence.snap", "N", ""), + ("view.zoomIn", "Cmd+=", ""), + ("view.zoomOut", "Cmd+-", ""), + ("view.zoomToSequence", "Shift+Z", ""), + ("sequence.applyVideoTransition", "Cmd+T", ""), + ("clip.speedDuration", "Cmd+R", ""), + ("clip.enable", "V", ""), + ("clip.nest", "Alt+G", ""), + ("sequence.matchFrame", "Shift+F", ""), + ("trim.extendToPlayhead", "Shift+X", ""), + ("trim.backward", ",", ""), + ("trim.forward", ".", ""), + ("trim.backwardMany", "Shift+,", ""), + ("trim.forwardMany", "Shift+.", ""), + ("file.newBin", "Alt+N", ""), + ("mode.export", "Cmd+E", ""), + ("sequence.renderEffectsInToOut", "Ctrl+R", ""), + ("sequence.renderInToOut", "Ctrl+Shift+R", ""), +]; + +/// Avid Media Composer's default keys for the equivalent FilmCraft commands. +pub const AVID: &[Entry] = &[ + ("playback.reverse", "J", ""), + ("playback.stop", "K", ""), + ("playback.forward", "L", ""), + ("playback.toggle", "Space", ""), + ("markers.markIn", "I", ""), + ("markers.markIn", "E", ""), + ("markers.markOut", "O", ""), + ("markers.markOut", "R", ""), + ("markers.markClip", "T", ""), + ("markers.clearIn", "D", ""), + ("markers.clearOut", "F", ""), + ("markers.clearInOut", "G", ""), + ("markers.goToIn", "Q", ""), + ("markers.goToOut", "W", ""), + ("source.insert", "V", ""), + ("source.overwrite", "B", ""), + ("sequence.lift", "Z", ""), + ("sequence.extract", "X", ""), + ("sequence.addEdit", "H", ""), + ("playhead.stepBack5", "1", ""), + ("playhead.stepForward5", "2", ""), + ("playhead.stepBack", "3", ""), + ("playhead.stepForward", "4", ""), + ("playhead.prevEdit", "A", ""), + ("playhead.nextEdit", "S", ""), + ("trim.edit", "U", ""), + ("trim.backwardMany", "M", ""), + ("trim.backward", ",", ""), + ("trim.forward", ".", ""), + ("trim.forwardMany", "/", ""), + ("view.zoomIn", "Cmd+]", ""), + ("view.zoomOut", "Cmd+[", ""), + ("markers.add", "F5", ""), +]; diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs new file mode 100644 index 00000000..ac76902f --- /dev/null +++ b/crates/engine/src/shortcuts.rs @@ -0,0 +1,1152 @@ +//! Keyboard shortcuts as data: the active key bindings, presets, conflict detection and the +//! `shortcuts.*` commands (Edit ▸ Keyboard Shortcuts…, and the same operations for agents over MCP). +//! +//! A [`Binding`] maps a key chord to a command id, either application-wide or for one panel +//! (panel shortcuts apply while that panel has focus and override application shortcuts). +//! Chords are stored in a canonical, platform-neutral notation: +//! +//! | token | macOS | Windows / Linux | +//! |---|---|---| +//! | `Cmd` | ⌘ Command | Ctrl | +//! | `Ctrl` | ⌃ Control | Ctrl (folds into `Cmd`; conflicts are reported) | +//! | `Alt` | ⌥ Option | Alt | +//! | `Shift` | ⇧ Shift | Shift | +//! +//! so one preset works on every OS. Preset files written for Windows (`"platform": "windows"`) +//! are converted on import: their `Ctrl` is the primary modifier (`Cmd`) and `Win`/`Meta` maps to +//! the macOS Control key. +//! +//! Built-in presets: **FilmCraft Default** (each command's default plus the Premiere-default audit, +//! see [`Shortcuts::audit`]), **Premiere Pro Compatible**, **Final Cut Pro Compatible** and +//! **Avid Media Composer Compatible** ([`crate::shortcut_presets`]). Custom presets are JSON files in +//! `/Keyboard Shortcuts/`; the active set persists in `active.json` there. +//! +//! Commands the frontend owns (tools, transport, panels, workspaces) are registered with +//! [`Shortcuts::register_external`] so they can be listed, bound and resolved here too. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, always, bad, bool_p, str_p}; +use crate::shortcut_presets::{self as presets, Entry}; +use crate::{EngineError, Session}; + +pub const APPLICATION: &str = "Application"; + +/// Panels that can have panel-specific shortcuts (the shortcut editor's "Commands" menu). +pub const PANELS: &[&str] = &[ + "Timeline", + "Program", + "Source", + "Project", + "Effect Controls", + "Effects", + "History", + "Markers", + "Media Browser", + "Audio Track Mixer", + "Audio Clip Mixer", + "Essential Graphics", + "Text", + "Properties", + "Lumetri Color", +]; + +pub const DEFAULT_PRESET: &str = "FilmCraft Default"; +pub const PREMIERE_PRESET: &str = "Premiere Pro Compatible"; +pub const FCP_PRESET: &str = "Final Cut Pro Compatible"; +pub const AVID_PRESET: &str = "Avid Media Composer Compatible"; +pub const BUILTIN_PRESETS: [&str; 4] = [DEFAULT_PRESET, PREMIERE_PRESET, FCP_PRESET, AVID_PRESET]; + +const FILE_FORMAT: &str = "filmcraft-keyboard-shortcuts"; +const DIR_NAME: &str = "Keyboard Shortcuts"; +const ACTIVE_FILE: &str = "active.json"; + +/// Every key the editor's keyboard shows, in canonical spelling (row order). +pub const KEYS: &[&str] = &[ + "Escape", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", // + "`", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "-", + "=", + "Backspace", // + "Tab", + "Q", + "W", + "E", + "R", + "T", + "Y", + "U", + "I", + "O", + "P", + "[", + "]", + "\\", // + "A", + "S", + "D", + "F", + "G", + "H", + "J", + "K", + "L", + ";", + "'", + "Enter", // + "Z", + "X", + "C", + "V", + "B", + "N", + "M", + ",", + ".", + "/", // + "Space", + "Insert", + "Delete", + "Home", + "End", + "PageUp", + "PageDown", + "Up", + "Down", + "Left", + "Right", +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Platform { + Mac, + Windows, + Linux, +} + +impl Platform { + pub fn current() -> Self { + if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Linux + } + } + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "mac" | "macos" | "osx" | "darwin" => Some(Platform::Mac), + "windows" | "win" => Some(Platform::Windows), + "linux" => Some(Platform::Linux), + _ => None, + } + } + pub fn is_mac(self) -> bool { + self == Platform::Mac + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct Mods { + pub cmd: bool, + pub ctrl: bool, + pub alt: bool, + pub shift: bool, +} + +impl Mods { + pub fn count(self) -> u8 { + self.cmd as u8 + self.ctrl as u8 + self.alt as u8 + self.shift as u8 + } + /// Canonical prefix, e.g. `Cmd+Alt+`. + pub fn prefix(self) -> String { + let mut s = String::new(); + for (on, n) in [(self.cmd, "Cmd+"), (self.ctrl, "Ctrl+"), (self.alt, "Alt+"), (self.shift, "Shift+")] { + if on { + s.push_str(n); + } + } + s + } +} + +/// A key plus modifiers. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Chord { + pub mods: Mods, + pub key: &'static str, +} + +fn canon_key(tok: &str) -> Option<&'static str> { + if let Some(k) = KEYS.iter().find(|k| k.eq_ignore_ascii_case(tok)) { + return Some(k); + } + let t = tok.to_ascii_lowercase().replace([' ', '_'], ""); + Some(match t.as_str() { + "return" | "⏎" | "↩" | "numpadenter" => "Enter", + "esc" | "⎋" => "Escape", + "del" | "forwarddelete" | "fwddelete" | "⌦" => "Delete", + "⌫" | "back" => "Backspace", + "arrowup" | "↑" => "Up", + "arrowdown" | "↓" => "Down", + "arrowleft" | "←" => "Left", + "arrowright" | "→" => "Right", + "pgup" | "pageup" => "PageUp", + "pgdn" | "pagedown" => "PageDown", + "spacebar" | "␣" => "Space", + "help" | "ins" => "Insert", + "⇥" => "Tab", + "grave" | "backtick" | "backquote" => "`", + "minus" => "-", + "equal" | "equals" => "=", + "semicolon" => ";", + "quote" | "apostrophe" => "'", + "comma" => ",", + "period" | "dot" => ".", + "slash" => "/", + "backslash" => "\\", + "openbracket" | "bracketleft" => "[", + "closebracket" | "bracketright" => "]", + _ => return None, + }) +} + +impl Chord { + /// Parse canonical (or macOS-style) text: `Cmd+Shift+K`, `⌥⌘K`, `opt+left`… + pub fn parse(s: &str) -> std::result::Result { + Self::parse_for(s, Platform::Mac) + } + + /// Parse text written for `platform` (on Windows/Linux `Ctrl` is the primary modifier). + pub fn parse_for(s: &str, platform: Platform) -> std::result::Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty shortcut".into()); + } + // glyph runs like "⌥⇧⌘K" → tokens + let mut text = String::new(); + for ch in s.chars() { + match ch { + '⌘' => text.push_str("Cmd+"), + '⌃' => text.push_str("Ctrl+"), + '⌥' => text.push_str("Alt+"), + '⇧' => text.push_str("Shift+"), + c => text.push(c), + } + } + let text = text.replace("++", "+").trim_end_matches('+').to_string(); + let mut mods = Mods::default(); + let mut key = None; + let parts: Vec<&str> = text.split('+').map(str::trim).filter(|p| !p.is_empty()).collect(); + for (i, p) in parts.iter().enumerate() { + let low = p.to_ascii_lowercase(); + let is_last = i + 1 == parts.len(); + match low.as_str() { + "cmd" | "command" | "super" => mods.cmd = true, + "meta" | "win" | "windows" if !platform.is_mac() => mods.ctrl = true, + "meta" => mods.cmd = true, + "ctrl" | "control" | "ctl" => { + if platform.is_mac() { + mods.ctrl = true + } else { + mods.cmd = true + } + } + "alt" | "opt" | "option" => mods.alt = true, + "shift" => mods.shift = true, + _ if is_last => key = Some(canon_key(p).ok_or_else(|| format!("unknown key `{p}`"))?), + _ => return Err(format!("unknown modifier `{p}`")), + } + } + let key = key.ok_or_else(|| format!("`{s}` has no key"))?; + Ok(Chord { mods, key }) + } + + pub fn canonical(&self) -> String { + format!("{}{}", self.mods.prefix(), self.key) + } + + /// The chord as this OS sees it: off macOS the Control key is the primary modifier, so `Ctrl` + /// and `Cmd` are the same key. + pub fn effective(&self, p: Platform) -> Chord { + let mut c = self.clone(); + if !p.is_mac() && c.mods.ctrl { + c.mods.ctrl = false; + c.mods.cmd = true; + } + c + } + + /// Human text: `⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere. + pub fn display(&self, p: Platform) -> String { + if p.is_mac() { + let mut s = String::new(); + for (on, g) in [(self.mods.ctrl, "⌃"), (self.mods.alt, "⌥"), (self.mods.shift, "⇧"), (self.mods.cmd, "⌘")] { + if on { + s.push_str(g); + } + } + let k = match self.key { + "Backspace" => "⌫", + "Delete" => "⌦", + "Enter" => "↩", + "Escape" => "⎋", + "Tab" => "⇥", + "Left" => "←", + "Right" => "→", + "Up" => "↑", + "Down" => "↓", + "PageUp" => "Page Up", + "PageDown" => "Page Down", + k => k, + }; + s.push_str(k); + s + } else { + let e = self.effective(p); + let mut s = String::new(); + for (on, n) in [(e.mods.cmd, "Ctrl+"), (e.mods.alt, "Alt+"), (e.mods.shift, "Shift+")] { + if on { + s.push_str(n); + } + } + s.push_str(match self.key { + "PageUp" => "Page Up", + "PageDown" => "Page Down", + k => k, + }); + s + } + } +} + +/// Canonical spelling of a chord (`opt+cmd+k` → `Cmd+Alt+K`). +pub fn normalize(s: &str) -> std::result::Result { + Chord::parse(s).map(|c| c.canonical()) +} + +/// One key binding. `panel: None` = application-wide. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Binding { + pub command: String, + pub keys: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub panel: Option, +} + +impl Binding { + fn new(command: &str, keys: &str, panel: Option<&str>) -> Self { + Binding { command: command.to_string(), keys: normalize(keys).unwrap_or_else(|_| keys.to_string()), panel: panel.map(str::to_string) } + } + fn chord(&self) -> Option { + Chord::parse(&self.keys).ok() + } + fn context(&self) -> &str { + self.panel.as_deref().unwrap_or(APPLICATION) + } +} + +/// A command that can be bound (engine command or one registered by the frontend). +#[derive(Clone, Debug, Serialize)] +pub struct CommandInfo { + pub id: String, + pub label: String, + pub category: String, + pub menu: Vec, + /// Default bindings: (keys, panel). + #[serde(skip)] + pub defaults: Vec<(String, Option)>, +} + +impl CommandInfo { + pub fn new(id: &str, label: &str, menu: &[&str], default: Option<&str>) -> Self { + CommandInfo { + id: id.into(), + label: label.into(), + category: category_of(id, menu), + menu: menu.iter().map(|s| s.to_string()).collect(), + defaults: default.map(|k| vec![(k.to_string(), None)]).unwrap_or_default(), + } + } +} + +fn category_of(id: &str, menu: &[&str]) -> String { + let prefix = id.split('.').next().unwrap_or(""); + match prefix { + "tool" => "Tools".into(), + "playback" => "Playback".into(), + "playhead" => "Navigation".into(), + "trim" => "Trimming".into(), + "window" => "Window".into(), + "view" => "View".into(), + "app" | "help" | "mode" => "Application".into(), + "captions" => "Captions".into(), + _ => menu.first().map(|s| s.to_string()).unwrap_or_else(|| { + let mut c = prefix.chars(); + c.next().map(|f| f.to_uppercase().collect::() + c.as_str()).unwrap_or_default() + }), + } +} + +/// Engine commands worth binding: undoable actions that need no required parameters. +fn bindable(c: &CommandSpec) -> bool { + if !c.journal || c.id.starts_with("shortcuts.") || c.id.starts_with("prefs.") || c.id == "trim.tick" { + return false; + } + if c.shortcut.is_some() { + return true; + } + let body = c.params.trim().trim_start_matches('{').trim_end_matches('}'); + if body.trim().is_empty() { + return true; + } + let mut depth = 0; + let mut fields = vec![String::new()]; + for ch in body.chars() { + match ch { + '[' | '{' => depth += 1, + ']' | '}' => depth -= 1, + ',' if depth == 0 => { + fields.push(String::new()); + continue; + } + _ => {} + } + fields.last_mut().expect("field").push(ch); + } + fields.iter().all(|f| f.contains('?') || f.contains('=')) +} + +/// The preset file / `active.json` format. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PresetFile { + pub format: String, + pub version: u32, + pub name: String, + /// The OS the key names were written for; None = canonical (FilmCraft) notation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub platform: Option, + /// `active.json` only: the active set differs from the preset it started from. + #[serde(default)] + pub modified: bool, + pub bindings: Vec, +} + +#[derive(Clone, Debug)] +struct Snapshot { + bindings: Vec, + preset: String, + modified: bool, +} + +/// The active key bindings and their editing history. +#[derive(Clone, Debug)] +pub struct Shortcuts { + pub bindings: Vec, + /// Preset the active set came from (or was saved as). + pub preset: String, + /// Edited since the preset was loaded. + pub modified: bool, + /// Bumped on every change (frontends rebuild key maps and menus). + pub revision: u64, + undo: Vec, + redo: Vec, + external: Vec, + dir: Option, + /// The editor dialog's starting point (Cancel restores it). + session_start: Option, +} + +impl Default for Shortcuts { + fn default() -> Self { + Self::new() + } +} + +impl Shortcuts { + pub fn new() -> Self { + let mut s = Shortcuts { + bindings: Vec::new(), + preset: DEFAULT_PRESET.into(), + modified: false, + revision: 1, + undo: Vec::new(), + redo: Vec::new(), + external: Vec::new(), + dir: None, + session_start: None, + }; + s.bindings = s.builtin(DEFAULT_PRESET).expect("default preset"); + s + } + + // ---------------------------------------------------------------- commands + + /// Every bindable command (engine + frontend), in registry order. + pub fn commands(&self) -> Vec { + let mut v: Vec = + crate::commands::command_specs().iter().filter(|c| bindable(c)).map(|c| CommandInfo::new(c.id, c.label, c.menu, c.shortcut)).collect(); + for e in &self.external { + if !v.iter().any(|c| c.id == e.id) { + v.push(e.clone()); + } + } + v + } + + pub fn command(&self, id: &str) -> Option { + self.commands().into_iter().find(|c| c.id == id) + } + + fn known(&self, id: &str) -> bool { + crate::commands::find(id).is_some() || self.external.iter().any(|c| c.id == id) + } + + /// Commands owned by the frontend (tools, transport, panels…). Rebuilds the active set when it + /// is an unmodified built-in preset, so their defaults appear. + pub fn register_external(&mut self, cmds: Vec) { + self.external = cmds; + if !self.modified + && let Some(b) = self.builtin(&self.preset.clone()) + { + self.bindings = b; + self.revision += 1; + } + } + + // ---------------------------------------------------------------- presets + + /// Default bindings of every known command, plus FilmCraft's panel shortcuts. + fn base_defaults(&self) -> Vec { + let mut out = Vec::new(); + for c in self.commands() { + for (k, p) in &c.defaults { + out.push(Binding::new(&c.id, k, p.as_deref())); + } + } + for (c, k, p) in presets::FILMCRAFT_PANEL { + if self.known(c) { + out.push(Binding::new(c, k, panel_opt(p))); + } + } + out + } + + /// Premiere-default entries FilmCraft Default adopts: commands with no shortcut of their own + /// whose Premiere key is free in that context. + pub fn audit(&self) -> Vec { + let base = self.base_defaults(); + let mut added: Vec = Vec::new(); + for (c, k, p) in presets::PREMIERE { + if !self.known(c) || base.iter().any(|b| b.command == *c) { + continue; + } + let b = Binding::new(c, k, panel_opt(p)); + let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord() == b.chord()); + if !taken { + added.push(b); + } + } + added + } + + /// The bindings of a built-in preset. + pub fn builtin(&self, name: &str) -> Option> { + let mut b = self.base_defaults(); + b.extend(self.audit()); + let table: &[Entry] = match name { + DEFAULT_PRESET => &[], + PREMIERE_PRESET => presets::PREMIERE, + FCP_PRESET => presets::FINAL_CUT, + AVID_PRESET => presets::AVID, + _ => return None, + }; + self.apply_table(&mut b, table); + Some(b) + } + + /// Apply a preset table: listed commands get exactly the listed keys, and a listed key is taken + /// away from whatever else had it in that context. + fn apply_table(&self, b: &mut Vec, table: &[Entry]) { + let listed: Vec<&str> = table.iter().map(|e| e.0).filter(|c| self.known(c)).collect(); + b.retain(|x| !listed.contains(&x.command.as_str())); + for (c, k, p) in table { + if !self.known(c) { + continue; + } + let nb = Binding::new(c, k, panel_opt(p)); + b.retain(|o| !(o.context() == nb.context() && o.chord() == nb.chord())); + b.push(nb); + } + } + + fn presets_dir(&self) -> Option { + self.dir.as_ref().map(|d| d.join(DIR_NAME)) + } + + /// Custom preset names (files in the presets directory). + pub fn user_presets(&self) -> Vec { + let Some(dir) = self.presets_dir() else { return Vec::new() }; + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut v: Vec = rd + .flatten() + .filter(|e| e.file_name() != ACTIVE_FILE) + .filter_map(|e| std::fs::read(e.path()).ok().and_then(|b| serde_json::from_slice::(&b).ok()).map(|p| p.name)) + .filter(|n| !BUILTIN_PRESETS.contains(&n.as_str())) + .collect(); + v.sort(); + v.dedup(); + v + } + + fn preset_path(&self, name: &str) -> Option { + let safe: String = name.chars().map(|c| if c.is_alphanumeric() || " -_().".contains(c) { c } else { '_' }).collect(); + self.presets_dir().map(|d| d.join(format!("{}.json", safe.trim()))) + } + + /// Use the per-user data directory: loads the active set saved there (if any). + pub fn set_dir(&mut self, data_dir: &Path) { + self.dir = Some(data_dir.to_path_buf()); + let Some(path) = self.presets_dir().map(|d| d.join(ACTIVE_FILE)) else { return }; + let Ok(bytes) = std::fs::read(&path) else { return }; + match serde_json::from_slice::(&bytes) { + Ok(f) => { + self.preset = f.name.clone(); + if !f.modified + && let Some(b) = self.builtin(&f.name) + { + self.bindings = b; + self.modified = false; + } else { + self.bindings = convert(f.bindings, f.platform); + self.modified = f.modified; + } + self.revision += 1; + } + Err(e) => log::warn!("{}: {e}", path.display()), + } + } + + fn file(&self, name: &str, modified: bool) -> PresetFile { + PresetFile { format: FILE_FORMAT.into(), version: 1, name: name.into(), platform: None, modified, bindings: self.bindings.clone() } + } + + fn persist(&self) { + let Some(dir) = self.presets_dir() else { return }; + let f = self.file(&self.preset, self.modified); + if let Err(e) = std::fs::create_dir_all(&dir) + .and_then(|_| filmcraft_format::atomic_write(&dir.join(ACTIVE_FILE), &serde_json::to_vec_pretty(&f).unwrap_or_default())) + { + log::warn!("saving keyboard shortcuts: {e}"); + } + } + + // ---------------------------------------------------------------- editing + + fn snapshot(&self) -> Snapshot { + Snapshot { bindings: self.bindings.clone(), preset: self.preset.clone(), modified: self.modified } + } + + fn restore(&mut self, s: Snapshot) { + self.bindings = s.bindings; + self.preset = s.preset; + self.modified = s.modified; + } + + fn changed(&mut self, before: Snapshot) { + self.undo.push(before); + if self.undo.len() > 500 { + self.undo.remove(0); + } + self.redo.clear(); + self.revision += 1; + self.persist(); + } + + /// Assign `keys` to `command`. Unless `add`, the command's other shortcuts in that context are + /// replaced. Unless `keep_conflicts`, the key is taken away from any other command bound to it + /// in the same context (returned as `reassigned`). + pub fn set(&mut self, command: &str, keys: &str, panel: Option<&str>, add: bool, keep_conflicts: bool) -> std::result::Result, String> { + if !self.known(command) { + return Err(format!("unknown command `{command}`")); + } + let panel = check_panel(panel)?; + let chord = Chord::parse(keys)?; + let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; + let before = self.snapshot(); + if !add { + self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + } + let mut reassigned = Vec::new(); + if !keep_conflicts { + let p = Platform::current(); + let eff = chord.effective(p); + self.bindings.retain(|b| { + let clash = b.command != command && b.context() == nb.context() && b.chord().is_some_and(|c| c.effective(p) == eff); + if clash { + reassigned.push(b.clone()); + } + !clash + }); + } + if !self.bindings.contains(&nb) { + self.bindings.push(nb); + } + self.modified = true; + self.changed(before); + Ok(reassigned) + } + + /// Remove shortcuts of `command` (one key, or all; in one context, or all). + pub fn clear(&mut self, command: &str, keys: Option<&str>, panel: Option<&str>) -> std::result::Result { + let chord = keys.map(Chord::parse).transpose()?; + let panel = match panel { + Some(p) => Some(check_panel(Some(p))?.map(str::to_string)), + None => None, + }; + let before = self.snapshot(); + let n0 = self.bindings.len(); + self.bindings.retain(|b| { + let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); + !hit + }); + let n = n0 - self.bindings.len(); + if n > 0 { + self.modified = true; + self.changed(before); + } + Ok(n) + } + + pub fn undo(&mut self) -> bool { + let Some(s) = self.undo.pop() else { return false }; + let cur = self.snapshot(); + self.redo.push(cur); + self.restore(s); + self.revision += 1; + self.persist(); + true + } + + pub fn redo(&mut self) -> bool { + let Some(s) = self.redo.pop() else { return false }; + let cur = self.snapshot(); + self.undo.push(cur); + self.restore(s); + self.revision += 1; + self.persist(); + true + } + + pub fn can_undo(&self) -> bool { + !self.undo.is_empty() + } + pub fn can_redo(&self) -> bool { + !self.redo.is_empty() + } + + /// The shortcut editor opened: remember where Cancel goes back to. + pub fn begin_editing(&mut self) { + self.session_start = Some(self.snapshot()); + self.undo.clear(); + self.redo.clear(); + } + + /// OK: keep the edits. + pub fn end_editing(&mut self) { + self.session_start = None; + } + + /// Cancel: restore the set the editor opened with. + pub fn cancel_editing(&mut self) { + if let Some(s) = self.session_start.take() { + self.restore(s); + self.undo.clear(); + self.redo.clear(); + self.revision += 1; + self.persist(); + } + } + + pub fn load_preset(&mut self, name: &str) -> std::result::Result<(), String> { + let bindings = match self.builtin(name) { + Some(b) => b, + None => { + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + let f = read_file(&path)?; + convert(f.bindings, f.platform) + } + }; + let before = self.snapshot(); + self.bindings = bindings; + self.preset = name.into(); + self.modified = false; + self.changed(before); + Ok(()) + } + + pub fn save_preset(&mut self, name: &str) -> std::result::Result { + let name = name.trim(); + if name.is_empty() { + return Err("a preset needs a name".into()); + } + if BUILTIN_PRESETS.iter().any(|b| b.eq_ignore_ascii_case(name)) { + return Err(format!("`{name}` is a built-in preset; choose another name")); + } + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + write_file(&path, &self.file(name, false))?; + let before = self.snapshot(); + self.preset = name.into(); + self.modified = false; + self.changed(before); + Ok(path) + } + + pub fn delete_preset(&mut self, name: &str) -> std::result::Result<(), String> { + if BUILTIN_PRESETS.contains(&name) { + return Err("built-in presets cannot be deleted".into()); + } + let path = self.preset_path(name).ok_or("no data directory for custom presets")?; + std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?; + if self.preset == name { + self.modified = true; + self.revision += 1; + self.persist(); + } + Ok(()) + } + + pub fn export(&self, path: &Path) -> std::result::Result<(), String> { + write_file(path, &self.file(&self.preset, false)) + } + + /// Import a preset file: saved among the custom presets (when there is a data directory) and, + /// when `activate`, made the active set. + pub fn import(&mut self, path: &Path, activate: bool) -> std::result::Result { + let f = read_file(path)?; + let name = if f.name.trim().is_empty() || BUILTIN_PRESETS.contains(&f.name.as_str()) { + path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Imported".into()) + } else { + f.name.clone() + }; + let bindings: Vec = convert(f.bindings, f.platform).into_iter().filter(|b| self.known(&b.command)).collect(); + if let Some(p) = self.preset_path(&name) { + let file = PresetFile { format: FILE_FORMAT.into(), version: 1, name: name.clone(), platform: None, modified: false, bindings: bindings.clone() }; + write_file(&p, &file)?; + } + if activate { + let before = self.snapshot(); + self.bindings = bindings; + self.preset = name.clone(); + self.modified = false; + self.changed(before); + } + Ok(name) + } + + // ---------------------------------------------------------------- queries + + pub fn for_command(&self, id: &str) -> Vec<&Binding> { + self.bindings.iter().filter(|b| b.command == id).collect() + } + + /// The shortcut menus show: the first application-wide one. + pub fn primary(&self, id: &str) -> Option { + self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + } + + /// The command a chord runs with `panel` focused (panel shortcuts first). + pub fn resolve(&self, chord: &Chord, panel: Option<&str>, p: Platform) -> Option<&Binding> { + let eff = chord.effective(p); + let m = |b: &&Binding| b.chord().is_some_and(|c| c.effective(p) == eff); + if let Some(pn) = panel + && let Some(b) = self.bindings.iter().filter(|b| b.panel.as_deref() == Some(pn)).find(m) + { + return Some(b); + } + self.bindings.iter().filter(|b| b.panel.is_none()).find(m) + } + + /// Groups of bindings that share a key in one context on platform `p`. + pub fn conflicts(&self, p: Platform) -> Vec<(String, String, Vec)> { + let mut map: BTreeMap<(String, String), Vec> = BTreeMap::new(); + for b in &self.bindings { + let Some(c) = b.chord() else { continue }; + let e = map.entry((b.context().to_string(), c.effective(p).canonical())).or_default(); + if !e.contains(&b.command) { + e.push(b.command.clone()); + } + } + map.into_iter().filter(|(_, v)| v.len() > 1).map(|((ctx, k), v)| (ctx, k, v)).collect() + } + + /// Panel shortcuts that shadow an application shortcut while their panel has focus. + pub fn overrides(&self, p: Platform) -> Vec<(String, String, String, String)> { + let mut out = Vec::new(); + for b in self.bindings.iter().filter(|b| b.panel.is_some()) { + let Some(c) = b.chord() else { continue }; + if let Some(a) = self.resolve(&c, None, p) + && a.command != b.command + { + out.push((b.panel.clone().unwrap_or_default(), b.keys.clone(), b.command.clone(), a.command.clone())); + } + } + out + } + + /// Bindings on one key (any modifiers): the shortcut editor's "Key:" list. + pub fn for_key(&self, key: &str) -> Vec<&Binding> { + let Some(k) = canon_key(key) else { return Vec::new() }; + let mut v: Vec<&Binding> = self.bindings.iter().filter(|b| b.chord().is_some_and(|c| c.key == k)).collect(); + v.sort_by_key(|b| b.chord().map(|c| (c.mods.count(), c.mods))); + v + } + + /// Commands assigned with exactly `mods` held, per key: (application command, panel command). + pub fn keyboard(&self, mods: Mods, panel: Option<&str>) -> BTreeMap<&'static str, (Option, Option)> { + let mut m: BTreeMap<&'static str, (Option, Option)> = BTreeMap::new(); + for b in &self.bindings { + let Some(c) = b.chord() else { continue }; + if c.mods != mods { + continue; + } + let e = m.entry(c.key).or_default(); + match (&b.panel, panel) { + (None, _) => { + e.0.get_or_insert(b.command.clone()); + } + (Some(bp), Some(p)) if bp == p => { + e.1.get_or_insert(b.command.clone()); + } + (Some(_), None) => { + e.1.get_or_insert(b.command.clone()); + } + _ => {} + } + } + m + } + + fn binding_json(&self, b: &Binding, p: Platform) -> Value { + json!({"keys": b.keys, "panel": b.panel, "display": b.chord().map(|c| c.display(p)).unwrap_or_else(|| b.keys.clone())}) + } +} + +fn panel_opt(p: &str) -> Option<&str> { + if p.is_empty() { None } else { Some(p) } +} + +fn check_panel(panel: Option<&str>) -> std::result::Result, String> { + match panel { + None => Ok(None), + Some(p) if p.is_empty() || p.eq_ignore_ascii_case(APPLICATION) => Ok(None), + Some(p) => { + let n = p.to_ascii_lowercase().replace([' ', '_', '-'], ""); + let alias = match n.as_str() { + "programmonitor" => "program", + "sourcemonitor" => "source", + "projects" => "project", + "timelines" => "timeline", + _ => n.as_str(), + }; + PANELS + .iter() + .copied() + .find(|x| x.to_ascii_lowercase().replace(' ', "") == alias) + .map(Some) + .ok_or_else(|| format!("unknown panel `{p}` (one of {})", PANELS.join(", "))) + } + } +} + +/// Normalise bindings read from a file written for `platform`. +fn convert(bindings: Vec, platform: Option) -> Vec { + let p = platform.unwrap_or(Platform::Mac); + bindings + .into_iter() + .filter_map(|b| { + let c = Chord::parse_for(&b.keys, p).ok()?; + let panel = check_panel(b.panel.as_deref()).ok()?.map(str::to_string); + Some(Binding { command: b.command, keys: c.canonical(), panel }) + }) + .collect() +} + +fn read_file(path: &Path) -> std::result::Result { + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let f: PresetFile = serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if f.format != FILE_FORMAT { + return Err(format!("{}: not a FilmCraft keyboard shortcuts file", path.display())); + } + Ok(f) +} + +fn write_file(path: &Path, f: &PresetFile) -> std::result::Result<(), String> { + if let Some(d) = path.parent() { + std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?; + } + filmcraft_format::atomic_write(path, &serde_json::to_vec_pretty(f).unwrap_or_default()).map_err(|e| format!("{}: {e}", path.display())) +} + +// ------------------------------------------------------------------ commands + +fn platform_p(p: &Value) -> Platform { + str_p(p, "platform").and_then(Platform::from_name).unwrap_or_else(Platform::current) +} + +fn err(cmd: &str) -> impl Fn(String) -> EngineError + '_ { + move |m| bad(cmd, m) +} + +fn presets_json(s: &Session) -> Value { + let sc = &s.shortcuts; + json!({"active": sc.preset, "modified": sc.modified, "builtin": BUILTIN_PRESETS, "custom": sc.user_presets(), "canUndo": sc.can_undo(), "canRedo": sc.can_redo()}) +} + +fn list(s: &Session, p: &Value) -> Value { + let sc = &s.shortcuts; + let plat = platform_p(p); + let q = str_p(p, "query").map(str::to_ascii_lowercase); + let assigned_only = bool_p(p, "assigned").unwrap_or(false); + let panel = str_p(p, "panel").and_then(|x| check_panel(Some(x)).ok().flatten()); + let rows: Vec = sc + .commands() + .into_iter() + .filter_map(|c| { + let bs: Vec<&Binding> = sc.for_command(&c.id).into_iter().filter(|b| panel.is_none() || b.panel.as_deref() == panel).collect(); + if assigned_only && bs.is_empty() { + return None; + } + if let Some(q) = &q { + let hit = c.label.to_ascii_lowercase().contains(q.as_str()) + || c.id.to_ascii_lowercase().contains(q.as_str()) + || bs.iter().any(|b| b.keys.to_ascii_lowercase().contains(q.as_str()) || b.chord().is_some_and(|ch| ch.display(plat).to_ascii_lowercase().contains(q.as_str()))); + if !hit { + return None; + } + } + Some(json!({"id": c.id, "label": c.label, "category": c.category, "menu": c.menu, "shortcuts": bs.iter().map(|b| sc.binding_json(b, plat)).collect::>()})) + }) + .collect(); + json!(rows) +} + +pub fn commands() -> Vec { + macro_rules! sc { + ($id:literal, $label:literal, $params:literal, $journal:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[], shortcut: None, params: $params, enabled: always, run: $run, journal: $journal } + }; + } + vec![ + sc!("shortcuts.list", "List Keyboard Shortcuts", r#"{"query":str?,"panel":str?,"assigned":bool?,"platform":"mac|windows|linux"?}"#, false, |s, p| Ok( + list(s, p) + )), + sc!("shortcuts.get", "Get Shortcuts of a Command", r#"{"command":id,"platform":str?}"#, false, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.get", "need `command`"))?; + let c = s.shortcuts.command(id).ok_or_else(|| bad("shortcuts.get", format!("unknown command `{id}`")))?; + let plat = platform_p(p); + let bs: Vec = s.shortcuts.for_command(id).into_iter().map(|b| s.shortcuts.binding_json(b, plat)).collect(); + Ok(json!({"id": c.id, "label": c.label, "category": c.category, "shortcuts": bs})) + }), + sc!("shortcuts.set", "Assign Shortcut", r#"{"command":id,"keys":"Cmd+Shift+K","panel":str?,"add":bool?,"keepConflicts":bool?}"#, true, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.set", "need `command`"))?; + let keys = str_p(p, "keys").ok_or_else(|| bad("shortcuts.set", "need `keys`"))?; + let r = s + .shortcuts + .set(id, keys, str_p(p, "panel"), bool_p(p, "add").unwrap_or(false), bool_p(p, "keepConflicts").unwrap_or(false)) + .map_err(err("shortcuts.set"))?; + let plat = Platform::current(); + let conflicts: Vec = s.shortcuts.conflicts(plat).into_iter().map(|(c, k, v)| json!({"context": c, "keys": k, "commands": v})).collect(); + Ok(json!({"keys": normalize(keys).unwrap_or_default(), "reassigned": r, "conflicts": conflicts})) + }), + sc!("shortcuts.clear", "Clear Shortcut", r#"{"command":id,"keys":str?,"panel":str?}"#, true, |s, p| { + let id = str_p(p, "command").ok_or_else(|| bad("shortcuts.clear", "need `command`"))?; + let n = s.shortcuts.clear(id, str_p(p, "keys"), str_p(p, "panel")).map_err(err("shortcuts.clear"))?; + Ok(json!({"removed": n})) + }), + sc!("shortcuts.undo", "Undo Shortcut Change", "{}", true, |s, _| Ok(json!({"undone": s.shortcuts.undo()}))), + sc!("shortcuts.redo", "Redo Shortcut Change", "{}", true, |s, _| Ok(json!({"redone": s.shortcuts.redo()}))), + sc!("shortcuts.conflicts", "Shortcut Conflicts", r#"{"platform":"mac|windows|linux"?}"#, false, |s, p| { + let plat = platform_p(p); + let c: Vec = s.shortcuts.conflicts(plat).into_iter().map(|(c, k, v)| json!({"context": c, "keys": k, "commands": v})).collect(); + let o: Vec = + s.shortcuts.overrides(plat).into_iter().map(|(pn, k, cmd, app)| json!({"panel": pn, "keys": k, "command": cmd, "overrides": app})).collect(); + Ok(json!({"conflicts": c, "panelOverrides": o})) + }), + sc!("shortcuts.forKey", "Shortcuts on a Key", r#"{"key":"K","platform":str?}"#, false, |s, p| { + let k = str_p(p, "key").ok_or_else(|| bad("shortcuts.forKey", "need `key`"))?; + let plat = platform_p(p); + Ok(json!( + s.shortcuts + .for_key(k) + .into_iter() + .map(|b| json!({"keys": b.keys, "display": b.chord().map(|c| c.display(plat)), "command": b.command, "panel": b.panel})) + .collect::>() + )) + }), + sc!("shortcuts.resolve", "Resolve Shortcut", r#"{"keys":str,"panel":str?,"platform":str?}"#, false, |s, p| { + let keys = str_p(p, "keys").ok_or_else(|| bad("shortcuts.resolve", "need `keys`"))?; + let c = Chord::parse(keys).map_err(err("shortcuts.resolve"))?; + let panel = check_panel(str_p(p, "panel")).map_err(err("shortcuts.resolve"))?; + Ok(s.shortcuts.resolve(&c, panel, platform_p(p)).map(|b| json!({"command": b.command, "panel": b.panel})).unwrap_or(Value::Null)) + }), + sc!("shortcuts.presets", "Keyboard Shortcut Presets", "{}", false, |s, _| Ok(presets_json(s))), + sc!("shortcuts.loadPreset", "Load Shortcut Preset", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.loadPreset", "need `name`"))?; + s.shortcuts.load_preset(n).map_err(err("shortcuts.loadPreset"))?; + Ok(presets_json(s)) + }), + sc!("shortcuts.savePreset", "Save Shortcut Preset As", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.savePreset", "need `name`"))?; + let path = s.shortcuts.save_preset(n).map_err(err("shortcuts.savePreset"))?; + Ok(json!({"path": path, "presets": presets_json(s)})) + }), + sc!("shortcuts.deletePreset", "Delete Shortcut Preset", r#"{"name":str}"#, true, |s, p| { + let n = str_p(p, "name").ok_or_else(|| bad("shortcuts.deletePreset", "need `name`"))?; + s.shortcuts.delete_preset(n).map_err(err("shortcuts.deletePreset"))?; + Ok(presets_json(s)) + }), + sc!("shortcuts.export", "Export Keyboard Shortcuts", r#"{"path":str}"#, true, |s, p| { + let path = str_p(p, "path").ok_or_else(|| bad("shortcuts.export", "need `path`"))?; + s.shortcuts.export(Path::new(path)).map_err(err("shortcuts.export"))?; + Ok(json!({"path": path, "bindings": s.shortcuts.bindings.len()})) + }), + sc!("shortcuts.import", "Import Keyboard Shortcuts", r#"{"path":str,"activate":bool=true}"#, true, |s, p| { + let path = str_p(p, "path").ok_or_else(|| bad("shortcuts.import", "need `path`"))?; + let name = s.shortcuts.import(Path::new(path), bool_p(p, "activate").unwrap_or(true)).map_err(err("shortcuts.import"))?; + Ok(json!({"name": name, "presets": presets_json(s)})) + }), + sc!("shortcuts.audit", "Shortcut Audit", "{}", false, |s, _| { + let a = s.shortcuts.audit(); + Ok(json!({"count": a.len(), "assigned": a})) + }), + ] +} diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs new file mode 100644 index 00000000..23dc6dec --- /dev/null +++ b/crates/engine/src/shortcuts_tests.rs @@ -0,0 +1,215 @@ +//! Keyboard shortcuts: chords, presets, conflicts, panel scope, undo, files. + +use super::*; +use crate::shortcuts::{Binding, Chord, CommandInfo, Platform, Shortcuts}; +use serde_json::json; + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("fc-shortcuts-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The frontend's commands, as the egui UI registers them (a few are enough here). +fn with_ui_commands(s: &mut Session) { + s.shortcuts.register_external(vec![ + CommandInfo::new("playback.toggle", "Play/Stop", &[], Some("Space")), + CommandInfo::new("playback.forward", "Shuttle Right", &[], Some("L")), + CommandInfo::new("playback.slowForward", "Shuttle Slow Right", &[], None), + CommandInfo::new("playback.playAround", "Play Around", &[], None), + CommandInfo::new("tool.razor", "Razor Tool", &[], Some("C")), + CommandInfo::new("app.keyboardShortcuts", "Keyboard Shortcuts…", &["Edit"], Some("Cmd+Alt+K")), + ]); +} + +#[test] +fn chords_parse_normalise_and_display_per_os() { + let c = Chord::parse("⌥⇧⌘K").unwrap(); + assert_eq!(c.canonical(), "Cmd+Alt+Shift+K"); + assert_eq!(Chord::parse("shift+opt+command+k").unwrap(), c); + assert_eq!(c.display(Platform::Mac), "⌥⇧⌘K"); + assert_eq!(c.display(Platform::Windows), "Ctrl+Alt+Shift+K"); + assert_eq!(Chord::parse("Return").unwrap().canonical(), "Enter"); + assert_eq!(Chord::parse("Forward Delete").unwrap().canonical(), "Delete"); + assert_eq!(Chord::parse("Shift+Left").unwrap().display(Platform::Mac), "⇧←"); + // Windows text: Ctrl is the primary modifier + assert_eq!(Chord::parse_for("Ctrl+Shift+Z", Platform::Windows).unwrap().canonical(), "Cmd+Shift+Z"); + assert_eq!(Chord::parse_for("Win+T", Platform::Windows).unwrap().canonical(), "Ctrl+T"); + // macOS Control folds into Ctrl elsewhere + assert_eq!(Chord::parse("Ctrl+T").unwrap().effective(Platform::Windows), Chord::parse("Cmd+T").unwrap()); + assert!(Chord::parse("Cmd+").is_err()); + assert!(Chord::parse("Hyper+K").is_err()); +} + +#[test] +fn defaults_follow_the_registry_and_the_premiere_audit() { + let mut s = Session::default(); + with_ui_commands(&mut s); + let sc = &s.shortcuts; + assert_eq!(sc.preset, "FilmCraft Default"); + assert_eq!(sc.primary("sequence.addEdit").as_deref(), Some("Cmd+K")); + assert_eq!(sc.primary("playback.toggle").as_deref(), Some("Space")); + // the audit adopts Premiere defaults for commands that had none + let audit = sc.audit(); + let has = |c: &str, k: &str, p: Option<&str>| audit.iter().any(|b| b.command == c && b.keys == k && b.panel.as_deref() == p); + assert!(has("trim.applyDefaultTransition", "Shift+D", None), "{audit:?}"); + assert!(has("playback.slowForward", "Shift+L", None)); + assert!(has("playback.playAround", "Shift+K", None)); + assert!(has("source.open", "Shift+O", Some("Project"))); + assert_eq!(sc.primary("trim.applyDefaultTransition").as_deref(), Some("Shift+D")); + // no conflicts in any built-in preset (macOS) + for name in crate::shortcuts::BUILTIN_PRESETS { + let mut t = Session::default(); + with_ui_commands(&mut t); + t.shortcuts.load_preset(name).unwrap(); + assert!(t.shortcuts.conflicts(Platform::Mac).is_empty(), "{name}: {:?}", t.shortcuts.conflicts(Platform::Mac)); + } + // command.list reports the live bindings + let list = s.execute("command.list", json!({})).unwrap(); + let ae = list.as_array().unwrap().iter().find(|c| c["id"] == "sequence.addEdit").unwrap(); + assert_eq!(ae["shortcut"], json!("Cmd+K")); +} + +#[test] +fn assign_reassigns_conflicts_and_undoes() { + let mut s = Session::default(); + let r = s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Shift+K"})).unwrap(); + assert_eq!(r["reassigned"][0]["command"], json!("sequence.addEditAllTracks"), "{r}"); + assert!(s.shortcuts.primary("sequence.addEditAllTracks").is_none()); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K"), "replaces Cmd+Z"); + let hit = s.execute("shortcuts.resolve", json!({"keys": "⇧⌘K"})).unwrap(); + assert_eq!(hit["command"], json!("edit.undo")); + assert!(s.shortcuts.modified); + // add a second shortcut, keep a conflict on purpose + s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "Cmd+Z", "add": true})).unwrap(); + assert_eq!(s.shortcuts.for_command("edit.undo").iter().filter(|b| b.panel.is_none()).count(), 2); + let r = s.execute("shortcuts.set", json!({"command": "edit.redo", "keys": "M", "keepConflicts": true})).unwrap(); + assert_eq!(r["conflicts"].as_array().unwrap().len(), 1, "{r}"); + let c = s.execute("shortcuts.conflicts", json!({"platform": "mac"})).unwrap(); + assert_eq!(c["conflicts"][0]["keys"], json!("M")); + // undo / redo within the editor + s.execute("shortcuts.undo", json!({})).unwrap(); + assert!(s.execute("shortcuts.conflicts", json!({})).unwrap()["conflicts"].as_array().unwrap().is_empty()); + s.execute("shortcuts.undo", json!({})).unwrap(); + s.execute("shortcuts.undo", json!({})).unwrap(); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); + assert_eq!(s.shortcuts.primary("sequence.addEditAllTracks").as_deref(), Some("Cmd+Shift+K")); + s.execute("shortcuts.redo", json!({})).unwrap(); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Shift+K")); + // clear + let r = s.execute("shortcuts.clear", json!({"command": "edit.undo"})).unwrap(); + assert_eq!(r["removed"], json!(2), "Cmd+Shift+K and History ▸ Left"); + assert!(s.shortcuts.for_command("edit.undo").is_empty()); + assert!(s.execute("shortcuts.set", json!({"command": "no.such", "keys": "K"})).is_err()); + assert!(s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "K", "panel": "Nowhere"})).is_err()); + // editor Cancel restores the set it opened with + s.shortcuts.begin_editing(); + s.execute("shortcuts.set", json!({"command": "edit.undo", "keys": "U"})).unwrap(); + s.shortcuts.cancel_editing(); + assert!(s.shortcuts.for_command("edit.undo").is_empty()); +} + +#[test] +fn panel_shortcuts_override_application_ones_with_focus() { + let s = Session::default(); + let left = Chord::parse("Left").unwrap(); + let p = Platform::Mac; + assert_eq!(s.shortcuts.resolve(&left, None, p).unwrap().command, "playhead.stepBack"); + assert_eq!(s.shortcuts.resolve(&left, Some("Timeline"), p).unwrap().command, "playhead.stepBack"); + assert_eq!(s.shortcuts.resolve(&left, Some("History"), p).unwrap().command, "edit.undo"); + let bs = Chord::parse("Backspace").unwrap(); + assert_eq!(s.shortcuts.resolve(&bs, Some("Project"), p).unwrap().command, "project.delete"); + assert_eq!(s.shortcuts.resolve(&bs, None, p).unwrap().command, "edit.clear"); + let ov = s.shortcuts.overrides(p); + assert!(ov.iter().any(|(panel, k, cmd, app)| panel == "History" && k == "Left" && cmd == "edit.undo" && app == "playhead.stepBack"), "{ov:?}"); + // Windows: macOS-Control shortcuts fold into Ctrl and can clash + let mut t = Session::default(); + t.execute("shortcuts.set", json!({"command": "edit.copy", "keys": "Ctrl+C", "add": true, "keepConflicts": true})).unwrap(); + assert!(t.shortcuts.conflicts(Platform::Mac).is_empty()); + assert!(!t.shortcuts.conflicts(Platform::Windows).is_empty() || t.shortcuts.for_command("edit.copy").len() == 2); + let k = t.execute("shortcuts.forKey", json!({"key": "C"})).unwrap(); + assert!(k.as_array().unwrap().iter().any(|b| b["command"] == "edit.copy" && b["keys"] == "Ctrl+C"), "{k}"); +} + +#[test] +fn compat_presets_map_other_editors() { + let mut s = Session::default(); + with_ui_commands(&mut s); + s.execute("shortcuts.loadPreset", json!({"name": "Premiere Pro Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("markers.clearIn").as_deref(), Some("Alt+I")); + assert_eq!(s.shortcuts.primary("edit.clear").as_deref(), Some("Delete")); + assert!(s.shortcuts.for_command("edit.clear").iter().any(|b| b.panel.as_deref() == Some("Timeline") && b.keys == "Backspace")); + s.execute("shortcuts.loadPreset", json!({"name": "Final Cut Pro Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some("W")); + assert_eq!(s.shortcuts.primary("tool.razor").as_deref(), Some("B")); + assert!(s.shortcuts.primary("trim.rippleNext").is_none(), "W was taken by Insert"); + s.execute("shortcuts.loadPreset", json!({"name": "Avid Media Composer Compatible"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.lift").as_deref(), Some("Z")); + assert_eq!(s.shortcuts.for_command("markers.markIn").len(), 2, "I and E"); + assert!(!s.shortcuts.modified); + assert!(s.execute("shortcuts.loadPreset", json!({"name": "Nope"})).is_err()); + s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap(); + assert_eq!(s.shortcuts.primary("source.insert").as_deref(), Some(",")); +} + +#[test] +fn custom_presets_persist_export_and_import() { + let dir = temp_dir("presets"); + let mut s = Session::default(); + s.shortcuts.set_dir(&dir); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+Shift+B"})).unwrap(); + let r = s.execute("shortcuts.savePreset", json!({"name": "My Keys"})).unwrap(); + assert!(std::path::Path::new(r["path"].as_str().unwrap()).exists()); + let p = s.execute("shortcuts.presets", json!({})).unwrap(); + assert_eq!(p["active"], json!("My Keys")); + assert_eq!(p["custom"], json!(["My Keys"])); + assert!(s.execute("shortcuts.savePreset", json!({"name": "FilmCraft Default"})).is_err()); + // the active set survives a restart + let mut t = Shortcuts::new(); + t.set_dir(&dir); + assert_eq!(t.preset, "My Keys"); + assert_eq!(t.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B")); + // switch away and back + s.execute("shortcuts.loadPreset", json!({"name": "FilmCraft Default"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+K")); + s.execute("shortcuts.loadPreset", json!({"name": "My Keys"})).unwrap(); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Shift+B")); + // export, then import a Windows-notation file + let out = dir.join("export.json"); + s.execute("shortcuts.export", json!({"path": out.to_string_lossy()})).unwrap(); + let f: crate::shortcuts::PresetFile = serde_json::from_slice(&std::fs::read(&out).unwrap()).unwrap(); + assert!(f.bindings.contains(&Binding { command: "sequence.addEdit".into(), keys: "Cmd+Shift+B".into(), panel: None })); + let win = dir.join("win.json"); + std::fs::write( + &win, + serde_json::to_vec(&json!({"format": "filmcraft-keyboard-shortcuts", "version": 1, "name": "From Windows", "platform": "windows", + "bindings": [{"command": "edit.undo", "keys": "Ctrl+Z"}, {"command": "sequence.addEdit", "keys": "Ctrl+Alt+K"}, {"command": "no.such", "keys": "K"}]})) + .unwrap(), + ) + .unwrap(); + let r = s.execute("shortcuts.import", json!({"path": win.to_string_lossy()})).unwrap(); + assert_eq!(r["name"], json!("From Windows")); + assert_eq!(s.shortcuts.primary("edit.undo").as_deref(), Some("Cmd+Z")); + assert_eq!(s.shortcuts.primary("sequence.addEdit").as_deref(), Some("Cmd+Alt+K")); + assert_eq!(s.shortcuts.bindings.len(), 2, "unknown commands dropped"); + s.execute("shortcuts.deletePreset", json!({"name": "My Keys"})).unwrap(); + assert!(s.execute("shortcuts.deletePreset", json!({"name": "FilmCraft Default"})).is_err()); + assert_eq!(s.execute("shortcuts.presets", json!({})).unwrap()["custom"], json!(["From Windows"])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn list_searches_labels_and_keys() { + let s = Session::default(); + let r = s.shortcuts.commands(); + assert!(r.iter().any(|c| c.id == "trim.forward")); + assert!(!r.iter().any(|c| c.id == "timeline.move"), "commands with required params are not bindable"); + let mut s = s; + let v = s.execute("shortcuts.list", json!({"query": "ripple delete"})).unwrap(); + assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "edit.rippleDelete"), "{v}"); + let v = s.execute("shortcuts.list", json!({"query": "cmd+k", "platform": "windows"})).unwrap(); + assert!(v.as_array().unwrap().iter().any(|c| c["id"] == "sequence.addEdit"), "{v}"); + let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap(); + assert_eq!(v.as_array().unwrap().len(), 2, "{v}"); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 5dc96855..c8d871d5 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -100,10 +100,10 @@ impl PanelKind { PanelKind::Timeline => Some("Shift+3"), PanelKind::Program => Some("Shift+4"), PanelKind::EffectControls => Some("Shift+5"), - PanelKind::AudioClipMixer => Some("Shift+6"), + PanelKind::AudioClipMixer => Some("Shift+9"), PanelKind::Effects => Some("Shift+7"), PanelKind::MediaBrowser => Some("Shift+8"), - PanelKind::AudioTrackMixer => Some("Shift+9"), + PanelKind::AudioTrackMixer => Some("Shift+6"), _ => None, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66ac3d41..a68ff6cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,10 @@ pub struct HostHooks { pub pick_open_project: Option Option>>, /// Save dialog with a filter: (filter name, extensions, suggested file name) → path. pub pick_save_as: Option Option>>, + /// The active keyboard shortcuts changed: update native menu key equivalents. + pub shortcuts_changed: Option>, + /// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path. + pub pick_open_file: Option Option>>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -112,7 +116,11 @@ pub struct FilmcraftApp { pub last_timeline_width: f32, pub fps: f32, last_time: f64, - bindings: Vec<(egui::Modifiers, egui::Key, String)>, + bindings: Vec, + /// Shortcut-set revision `bindings` (and the native menu) were built from. + bindings_rev: u64, + /// Keyboard Shortcuts dialog state. + pub shortcut_editor: panels::shortcuts_dialog::EditorState, pub toast: Option<(String, f64)>, pub tl: panels::timeline::TlState, /// Commands from outside the UI (native menu bar), invoked on the UI thread. @@ -168,7 +176,8 @@ impl FilmcraftApp { } impl FilmcraftApp { - pub fn new(session: Session) -> Self { + pub fn new(mut session: Session) -> Self { + session.shortcuts.register_external(menus::external_commands()); let recovery = !session.recovery_candidates().is_empty(); let frames = Arc::new(FrameServer::new( session.media.clone(), @@ -205,7 +214,9 @@ impl FilmcraftApp { last_timeline_width: 1000.0, fps: 60.0, last_time: 0.0, - bindings: menus::bindings(), + bindings: Vec::new(), + bindings_rev: 0, + shortcut_editor: Default::default(), toast: None, tl: Default::default(), command_inbox: None, @@ -521,16 +532,28 @@ impl FilmcraftApp { // ---------------------------------------------------------------- input fn handle_shortcuts(&mut self, ctx: &egui::Context) { - if ctx.egui_wants_keyboard_input() { + if self.bindings_rev != self.session.shortcuts.revision { + self.bindings = menus::bindings(self); + self.bindings_rev = self.session.shortcuts.revision; + if let Some(hook) = self.hooks.shortcuts_changed.as_mut() { + let items = menus::menu_items_for(&self.session); + hook(&items); + } + } + if ctx.egui_wants_keyboard_input() || self.dialog == Some(Dialog::Shortcuts) { return; } // Esc cancels a dynamic trim in progress if self.session.trim_play.dynamic.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { let _ = self.session.execute("trim.cancelDynamic", json!({})); } + // Panel shortcuts of the focused panel first: they override application shortcuts. + let focused = self.ui.focused.title(); let mut fire = Vec::new(); ctx.input_mut(|i| { - for (m, k, id) in &self.bindings { + let panel = self.bindings.iter().filter(|b| b.3.as_deref() == Some(focused)); + let app_wide = self.bindings.iter().filter(|b| b.3.is_none()); + for (m, k, id, _) in panel.chain(app_wide) { if i.consume_key(*m, *k) { fire.push(id.clone()); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6b39a7cb..8defba45 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,9 +26,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.forward", "Shuttle Right", [], Some("L")), uic!("playback.stop", "Shuttle Stop", [], Some("K")), uic!("playback.reverse", "Shuttle Left", [], Some("J")), - uic!("playback.slowForward", "Shuttle Slow Right", [], Some("Shift+L")), - uic!("playback.slowReverse", "Shuttle Slow Left", [], Some("Shift+J")), - uic!("playback.playAround", "Play Around", [], Some("Shift+K")), + uic!("playback.slowForward", "Shuttle Slow Right", [], None), + uic!("playback.slowReverse", "Shuttle Slow Left", [], None), + uic!("playback.playAround", "Play Around", [], None), uic!("playback.inToOut", "Play In to Out", [], Some("Shift+Space")), uic!("playback.loop", "Loop", [], None), uic!("view.zoomIn", "Zoom In", ["View"], Some("=")), @@ -67,7 +67,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("mode.edit", "Edit", [], None), uic!("mode.export", "Export", ["File", "Export"], Some("Cmd+M")), uic!("app.about", "About FilmCraft", ["Help"], None), - uic!("help.shortcuts", "Keyboard Shortcuts", ["Help"], None), + uic!("app.keyboardShortcuts", "Keyboard Shortcuts…", ["Edit"], Some("Cmd+Alt+K")), uic!("app.preferences.autoSave", "Auto Save…", ["Edit", "Preferences"], Some("Cmd+,")), ]; @@ -164,8 +164,8 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Val app.dialog = Some(crate::Dialog::About); return Ok(Value::Null); } - "help.shortcuts" => { - app.dialog = Some(crate::Dialog::Shortcuts); + "help.shortcuts" | "app.keyboardShortcuts" => { + crate::panels::shortcuts_dialog::open(app); return Ok(Value::Null); } "app.preferences" | "app.preferences.autoSave" => { @@ -237,6 +237,11 @@ pub struct MenuItem { pub const MENUS: [&str; 9] = ["File", "Edit", "Clip", "Sequence", "Markers", "Graphics and Titles", "View", "Window", "Help"]; pub fn menu_items(app: &FilmcraftApp) -> Vec { + menu_items_for(&app.session) +} + +/// Menu entries with their live shortcuts and enablement. +pub fn menu_items_for(session: &filmcraft_engine::Session) -> Vec { let mut out = Vec::new(); for c in filmcraft_engine::command_specs() { if c.menu.is_empty() { @@ -246,8 +251,8 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(Into::into), - enabled: app.session.is_enabled(c.id), + shortcut: session.shortcuts.primary(c.id), + enabled: session.is_enabled(c.id), }); } for c in UI_COMMANDS { @@ -258,7 +263,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: c.id.into(), label: c.label.into(), path: c.menu.iter().map(|s| s.to_string()).collect(), - shortcut: c.shortcut.map(Into::into), + shortcut: session.shortcuts.primary(c.id), enabled: true, }); } @@ -267,20 +272,17 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { id: panel_command_id(p), label: p.title().into(), path: vec!["Window".into()], - shortcut: p.window_shortcut().map(Into::into), + shortcut: session.shortcuts.primary(&panel_command_id(p)), enabled: true, }); } out } -/// Pretty shortcut text for menus (macOS glyphs). +/// Shortcut text for menus in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere). pub fn shortcut_text(s: &str) -> String { - if cfg!(target_os = "macos") { - s.replace("Cmd+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃").replace("Backspace", "⌫").replace("Delete", "⌦") - } else { - s.replace("Cmd+", "Ctrl+") - } + use filmcraft_engine::shortcuts::{Chord, Platform}; + Chord::parse(s).map(|c| c.display(Platform::current())).unwrap_or_else(|_| s.to_string()) } /// Parse "Cmd+Shift+K" into modifiers + key. @@ -313,26 +315,26 @@ pub fn parse_shortcut(s: &str) -> Option<(egui::Modifiers, egui::Key)> { key.map(|k| (m, k)) } -/// All shortcut bindings: (modifiers, key, command id). -pub fn bindings() -> Vec<(egui::Modifiers, egui::Key, String)> { - let mut v = Vec::new(); - for c in UI_COMMANDS { - if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) { - v.push((m, k, c.id.to_string())); - } - } - for c in filmcraft_engine::command_specs() { - if let Some((m, k)) = c.shortcut.and_then(parse_shortcut) { - v.push((m, k, c.id.to_string())); - } - } +/// One active key binding for the input loop: (modifiers, key, command id, panel or None). +pub type KeyBinding = (egui::Modifiers, egui::Key, String, Option); + +/// The active key bindings (from the engine's shortcut set), most specific first so Shift+I +/// doesn't also fire I. +pub fn bindings(app: &FilmcraftApp) -> Vec { + let mut v: Vec = + app.session.shortcuts.bindings.iter().filter_map(|b| parse_shortcut(&b.keys).map(|(m, k)| (m, k, b.command.clone(), b.panel.clone()))).collect(); + v.sort_by_key(|(m, ..)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8)); + v +} + +/// Frontend-owned commands, registered with the engine's shortcut set so they can be listed, +/// rebound and resolved alongside engine commands (the `shortcuts.` commands). +pub fn external_commands() -> Vec { + use filmcraft_engine::shortcuts::CommandInfo; + let mut v: Vec = UI_COMMANDS.iter().map(|c| CommandInfo::new(c.id, c.label, c.menu, c.shortcut)).collect(); for p in PanelKind::ALL { - if let Some((m, k)) = p.window_shortcut().and_then(parse_shortcut) { - v.push((m, k, panel_command_id(p))); - } + v.push(CommandInfo::new(&panel_command_id(p), p.title(), &["Window"], p.window_shortcut())); } - // Most specific (more modifiers) first so Shift+I doesn't also fire I. - v.sort_by_key(|(m, _, _)| std::cmp::Reverse(m.command as u8 + m.shift as u8 + m.alt as u8 + m.ctrl as u8)); v } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 73436d25..0ce1fe04 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1,4 +1,5 @@ -//! Modal dialogs (About, Keyboard Shortcuts; Preferences/Recovery/Revert in `file_dialogs`). +//! Modal dialogs (About; Keyboard Shortcuts in `shortcuts_dialog`; Preferences/Recovery/Revert in +//! `file_dialogs`). use crate::{Dialog, FilmcraftApp}; @@ -21,22 +22,10 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { }); } Dialog::Shortcuts => { - egui::Window::new("Keyboard Shortcuts").open(&mut open).default_size([520.0, 520.0]).show(ctx, |ui| { - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("shortcuts").striped(true).show(ui, |ui| { - for c in filmcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) { - ui.label(c.label); - ui.monospace(crate::menus::shortcut_text(c.shortcut.unwrap_or(""))); - ui.end_row(); - } - for c in crate::menus::UI_COMMANDS.iter().filter(|c| c.shortcut.is_some()) { - ui.label(c.label); - ui.monospace(crate::menus::shortcut_text(c.shortcut.unwrap_or(""))); - ui.end_row(); - } - }); - }); - }); + if !crate::panels::shortcuts_dialog::show(app, ctx) && app.dialog == Some(d) { + app.dialog = None; + } + return; } Dialog::NewSequence | Dialog::Preferences | Dialog::Recovery | Dialog::RevertConfirm => {} } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a70b2d96..79ff44ce 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod meters; pub mod misc; pub mod monitor; pub mod project; +pub mod shortcuts_dialog; pub mod text; pub mod timeline; pub mod timeline_captions; diff --git a/crates/ui-egui/src/panels/shortcuts_dialog.rs b/crates/ui-egui/src/panels/shortcuts_dialog.rs new file mode 100644 index 00000000..262d830c --- /dev/null +++ b/crates/ui-egui/src/panels/shortcuts_dialog.rs @@ -0,0 +1,787 @@ +//! Edit ▸ Keyboard Shortcuts… (⌥⌘K): a keyboard drawn in code with every assigned key coloured +//! (application-wide / panel-specific / both), a searchable command list with a shortcut column +//! (click it and press keys to assign; "+" adds another shortcut), the "Key:" detail list, +//! conflict warnings, preset load / Save As / Delete / Export / Import, and Undo / Redo / Clear. +//! Edits apply live (menus update immediately); Cancel restores the set the dialog opened with. +//! +//! All state lives in the engine's shortcut set (`shortcuts.*` commands), so agents can do the +//! same over MCP. Automation ids: `shortcuts.preset`, `shortcuts.preset.`, `shortcuts.saveAs`, +//! `shortcuts.saveAs.name`, `shortcuts.saveAs.ok`, `shortcuts.delete`, `shortcuts.export`, +//! `shortcuts.import`, `shortcuts.context`, `shortcuts.context.`, `shortcuts.search`, +//! `shortcuts.key.`, `shortcuts.mod.`, `shortcuts.row.`, +//! `shortcuts.cell.` (click = record), `shortcuts.add.`, `shortcuts.assignKey`, +//! `shortcuts.undo`, `shortcuts.redo`, `shortcuts.clear`, `shortcuts.cancel`, `shortcuts.ok`. + +use egui::text::LayoutJob; +use egui::{Align2, Color32, CornerRadius, FontId, Rect, RichText, Sense, Stroke, StrokeKind, TextFormat, pos2, vec2}; +use filmcraft_engine::shortcuts::{APPLICATION, Chord, KEYS, Mods, PANELS, Platform}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::FilmcraftApp; +use crate::theme::Tokens; + +/// Key colours (our own palette): application-wide, panel-specific, unassigned, held modifier. +pub const APP_KEY: Color32 = Color32::from_rgb(0x74, 0x57, 0xc8); +pub const PANEL_KEY: Color32 = Color32::from_rgb(0x4a, 0xa6, 0x84); +pub const FREE_KEY: Color32 = Color32::from_rgb(0x5a, 0x5a, 0x5e); +const MOD_KEY: Color32 = Color32::from_rgb(0x2f, 0x62, 0xc0); +const WARN: Color32 = Color32::from_rgb(0xe8, 0xa0, 0x3c); + +#[derive(Clone, Debug, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorState { + pub search: String, + /// "Application" or a panel name (which panel's shortcuts the keyboard and assignments use). + pub context: String, + pub selected: Option, + /// Waiting for a key press: (command, add instead of replace). + pub recording: Option<(String, bool)>, + /// Modifier filter toggled on the drawn keyboard. + pub mods: Mods, + /// Key whose assignments the "Key:" list shows. + pub key: Option, + pub message: String, + /// Save As… name being typed (Some = the field is open). + pub save_name: Option, +} + +impl EditorState { + fn panel(&self) -> Option<&str> { + if self.context.is_empty() || self.context == APPLICATION { None } else { Some(self.context.as_str()) } + } +} + +/// Open the dialog (Edit ▸ Keyboard Shortcuts…). +pub fn open(app: &mut FilmcraftApp) { + app.session.shortcuts.begin_editing(); + app.shortcut_editor = EditorState { context: APPLICATION.into(), ..Default::default() }; + app.dialog = Some(crate::Dialog::Shortcuts); +} + +/// The egui key for a canonical key name. +fn egui_key(name: &str) -> Option { + crate::menus::parse_shortcut(name).map(|(_, k)| k) +} + +/// Canonical chord text from an egui key event. +pub fn chord_of(key: egui::Key, m: egui::Modifiers) -> Option { + let name = KEYS.iter().find(|k| egui_key(k) == Some(key))?; + let mods = Mods { cmd: m.command || m.mac_cmd, ctrl: m.ctrl && cfg!(target_os = "macos"), alt: m.alt, shift: m.shift }; + Some(format!("{}{}", mods.prefix(), name)) +} + +fn label_of(app: &FilmcraftApp, id: &str) -> String { + app.session + .shortcuts + .command(id) + .map(|c| c.label) + .or_else(|| filmcraft_engine::find_command(id).map(|c| c.label.to_string())) + .unwrap_or_else(|| id.to_string()) +} + +fn exec(app: &mut FilmcraftApp, cmd: &str, params: Value) -> Option { + match app.session.execute(cmd, params) { + Ok(v) => Some(v), + Err(e) => { + app.shortcut_editor.message = e.to_string(); + None + } + } +} + +/// Assign a recorded chord. +fn assign(app: &mut FilmcraftApp, command: &str, keys: &str, add: bool) { + let panel = app.shortcut_editor.panel().map(str::to_string); + let p = Platform::current(); + if let Some(r) = exec(app, "shortcuts.set", json!({"command": command, "keys": keys, "panel": panel, "add": add})) { + let disp = Chord::parse(keys).map(|c| c.display(p)).unwrap_or_else(|_| keys.into()); + let mut msg = format!("{disp} → {}", label_of(app, command)); + let moved: Vec = r["reassigned"].as_array().into_iter().flatten().filter_map(|b| b["command"].as_str().map(|c| label_of(app, c))).collect(); + if !moved.is_empty() { + msg.push_str(&format!(" (removed from {})", moved.join(", "))); + } + app.shortcut_editor.message = msg; + } +} + +/// Take the next key press while recording (Esc cancels). +fn record(app: &mut FilmcraftApp, ctx: &egui::Context) { + let Some((cmd, add)) = app.shortcut_editor.recording.clone() else { return }; + let got = ctx.input_mut(|i| { + let mut got = None; + i.events.retain(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if got.is_none() => { + got = Some((*key, *modifiers)); + false + } + egui::Event::Key { .. } | egui::Event::Text(_) => false, + _ => true, + }); + got + }); + let Some((key, m)) = got else { return }; + app.shortcut_editor.recording = None; + if key == egui::Key::Escape && !m.any() { + app.shortcut_editor.message = "Cancelled".into(); + return; + } + match chord_of(key, m) { + Some(k) => assign(app, &cmd, &k, add), + None => app.shortcut_editor.message = format!("{key:?} can't be used as a shortcut"), + } +} + +/// Show the dialog; false when it closed. +pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) -> bool { + record(app, ctx); + let t = app.tokens; + let screen = ctx.content_rect(); + let size = vec2((screen.width() - 40.0).clamp(760.0, 1320.0), (screen.height() - 60.0).clamp(560.0, 900.0)); + let mut close: Option = None; // Some(true) = OK, Some(false) = Cancel + let mut open = true; + egui::Window::new("Keyboard Shortcuts") + .id(egui::Id::new("keyboard-shortcuts")) + .open(&mut open) + .collapsible(false) + .resizable(false) + .fixed_size(size) + .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel_bg).inner_margin(14.0)) + .show(ctx, |ui| { + ui.set_width(size.x); + header(app, ui, &t); + ui.add_space(8.0); + let kb_h = (size.y * 0.42).clamp(220.0, 360.0); + let (kb, _) = ui.allocate_exact_size(vec2(size.x, kb_h), Sense::hover()); + keyboard(app, ui, kb, &t); + legend(ui, &t); + ui.add_space(6.0); + let rest_h = (size.y - kb_h - 160.0).max(160.0); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(size.x * 0.6); + command_list(app, ui, rest_h, &t); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.set_width(size.x * 0.4 - 14.0); + key_detail(app, ui, rest_h, &t); + }); + }); + ui.add_space(6.0); + close = footer(app, ui, &t); + }); + if !open { + close = Some(false); + } + match close { + Some(ok) => { + if ok { + app.session.shortcuts.end_editing(); + } else { + app.session.shortcuts.cancel_editing(); + } + app.shortcut_editor.recording = None; + false + } + None => true, + } +} + +fn small_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, id: &str, label: &str, enabled: bool) -> bool { + let r = ui.add_enabled(enabled, egui::Button::new(RichText::new(label).size(12.5)).min_size(vec2(0.0, 24.0))); + app.auto.add(id, r.rect, label); + r.clicked() +} + +fn header(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let presets = app.session.execute("shortcuts.presets", json!({})).unwrap_or_default(); + let active = presets["active"].as_str().unwrap_or("").to_string(); + let modified = presets["modified"].as_bool().unwrap_or(false); + let builtin: Vec = presets["builtin"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + let custom: Vec = presets["custom"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect(); + ui.horizontal(|ui| { + ui.label(RichText::new("Preset:").size(13.0).color(t.text_dim)); + let shown = if modified { format!("{active} (modified)") } else { active.clone() }; + let r = egui::ComboBox::from_id_salt("shortcuts-preset").width(280.0).selected_text(shown).show_ui(ui, |ui| { + for name in builtin.iter().chain(custom.iter()) { + let r = ui.selectable_label(*name == active && !modified, name); + app.auto.add(&format!("shortcuts.preset.{name}"), r.rect, name); + if r.clicked() { + exec(app, "shortcuts.loadPreset", json!({"name": name})); + app.shortcut_editor.message = format!("Loaded “{name}”"); + } + } + }); + app.auto.add("shortcuts.preset", r.response.rect, "Preset"); + ui.add_space(8.0); + if small_button(app, ui, "shortcuts.saveAs", "Save As…", true) { + app.shortcut_editor.save_name = Some(if builtin.contains(&active) { "My Shortcuts".into() } else { active.clone() }); + } + let can_delete = custom.contains(&active); + if small_button(app, ui, "shortcuts.delete", "Delete", can_delete) { + exec(app, "shortcuts.deletePreset", json!({"name": active})); + app.shortcut_editor.message = format!("Deleted preset “{active}”"); + } + if small_button(app, ui, "shortcuts.export", "Export…", true) { + let picked = app.hooks.pick_save_as.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"], &format!("{active}.json"))); + match picked { + Some(path) => { + if exec(app, "shortcuts.export", json!({"path": path})).is_some() { + app.shortcut_editor.message = format!("Exported to {path}"); + } + } + None => app.shortcut_editor.message = "Export: no file chosen (agents: shortcuts.export {path})".into(), + } + } + if small_button(app, ui, "shortcuts.import", "Import…", true) { + let picked = app.hooks.pick_open_file.as_mut().and_then(|f| f("Keyboard Shortcuts", &["json"])); + match picked { + Some(path) => { + if let Some(r) = exec(app, "shortcuts.import", json!({"path": path})) { + app.shortcut_editor.message = format!("Imported “{}”", r["name"].as_str().unwrap_or("")); + } + } + None => app.shortcut_editor.message = "Import: no file chosen (agents: shortcuts.import {path})".into(), + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(RichText::new(if cfg!(target_os = "macos") { "Keyboard: US (macOS)" } else { "Keyboard: US" }).size(12.0).color(t.text_dim)); + }); + }); + if let Some(mut name) = app.shortcut_editor.save_name.clone() { + ui.horizontal(|ui| { + ui.label(RichText::new("Save preset as:").size(13.0).color(t.text_dim)); + let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(260.0)); + app.auto.add("shortcuts.saveAs.name", r.rect, "Preset name"); + let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + if small_button(app, ui, "shortcuts.saveAs.ok", "Save", !name.trim().is_empty()) || enter { + if exec(app, "shortcuts.savePreset", json!({"name": name})).is_some() { + app.shortcut_editor.message = format!("Saved preset “{}”", name.trim()); + app.shortcut_editor.save_name = None; + } else { + app.shortcut_editor.save_name = Some(name.clone()); + } + } else if small_button(app, ui, "shortcuts.saveAs.cancel", "Cancel", true) { + app.shortcut_editor.save_name = None; + } else { + app.shortcut_editor.save_name = Some(name.clone()); + } + }); + } + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label(RichText::new("Commands:").size(13.0).color(t.text_dim)); + let cur = app.shortcut_editor.context.clone(); + let r = + egui::ComboBox::from_id_salt("shortcuts-context").width(280.0).selected_text(if cur.is_empty() { APPLICATION } else { &cur }).show_ui(ui, |ui| { + for c in std::iter::once(APPLICATION).chain(PANELS.iter().copied()) { + let label = if c == APPLICATION { c.to_string() } else { format!("{c} Panel") }; + let r = ui.selectable_label(cur == c, label); + app.auto.add(&format!("shortcuts.context.{c}"), r.rect, c); + if r.clicked() { + app.shortcut_editor.context = c.to_string(); + } + } + }); + app.auto.add("shortcuts.context", r.response.rect, "Commands"); + }); +} + +/// (key name, width in key units); "" = gap. +type KeyRow = &'static [(&'static str, f32)]; +const MAIN_ROWS: [KeyRow; 6] = [ + &[ + ("Escape", 1.0), + ("", 0.5), + ("F1", 1.0), + ("F2", 1.0), + ("F3", 1.0), + ("F4", 1.0), + ("", 0.25), + ("F5", 1.0), + ("F6", 1.0), + ("F7", 1.0), + ("F8", 1.0), + ("", 0.25), + ("F9", 1.0), + ("F10", 1.0), + ("F11", 1.0), + ("F12", 1.0), + ], + &[ + ("`", 1.0), + ("1", 1.0), + ("2", 1.0), + ("3", 1.0), + ("4", 1.0), + ("5", 1.0), + ("6", 1.0), + ("7", 1.0), + ("8", 1.0), + ("9", 1.0), + ("0", 1.0), + ("-", 1.0), + ("=", 1.0), + ("Backspace", 2.0), + ], + &[ + ("Tab", 1.5), + ("Q", 1.0), + ("W", 1.0), + ("E", 1.0), + ("R", 1.0), + ("T", 1.0), + ("Y", 1.0), + ("U", 1.0), + ("I", 1.0), + ("O", 1.0), + ("P", 1.0), + ("[", 1.0), + ("]", 1.0), + ("\\", 1.5), + ], + &[ + ("#Caps", 1.75), + ("A", 1.0), + ("S", 1.0), + ("D", 1.0), + ("F", 1.0), + ("G", 1.0), + ("H", 1.0), + ("J", 1.0), + ("K", 1.0), + ("L", 1.0), + (";", 1.0), + ("'", 1.0), + ("Enter", 2.25), + ], + &[ + ("#Shift", 2.25), + ("Z", 1.0), + ("X", 1.0), + ("C", 1.0), + ("V", 1.0), + ("B", 1.0), + ("N", 1.0), + ("M", 1.0), + (",", 1.0), + (".", 1.0), + ("/", 1.0), + ("#Shift", 2.75), + ], + &[("#Ctrl", 1.5), ("#Alt", 1.25), ("#Cmd", 1.5), ("Space", 6.5), ("#Cmd", 1.5), ("#Alt", 1.25), ("#Ctrl", 1.5)], +]; +/// Navigation cluster rows (x offset in units within the cluster). +const NAV_ROWS: [&[(&str, f32)]; 6] = [ + &[], + &[("Insert", 0.0), ("Home", 1.0), ("PageUp", 2.0)], + &[("Delete", 0.0), ("End", 1.0), ("PageDown", 2.0)], + &[], + &[("Up", 1.0)], + &[("Left", 0.0), ("Down", 1.0), ("Right", 2.0)], +]; + +fn key_legend(name: &str, mac: bool) -> String { + match name { + "#Caps" => "Caps Lock".into(), + "#Shift" => "⇧ Shift".into(), + "#Ctrl" => { + if mac { + "⌃ Control".into() + } else { + "Win".into() + } + } + "#Alt" => { + if mac { + "⌥ Option".into() + } else { + "Alt".into() + } + } + "#Cmd" => { + if mac { + "⌘ Command".into() + } else { + "Ctrl".into() + } + } + "Backspace" => { + if mac { + "⌫ Delete".into() + } else { + "Backspace".into() + } + } + "Delete" => { + if mac { + "⌦ Fwd Del".into() + } else { + "Delete".into() + } + } + "Enter" => "↩ Return".into(), + "Escape" => "Esc".into(), + "PageUp" => "Page Up".into(), + "PageDown" => "Page Down".into(), + "Up" => "↑".into(), + "Down" => "↓".into(), + "Left" => "←".into(), + "Right" => "→".into(), + k => k.into(), + } +} + +fn keyboard(app: &mut FilmcraftApp, ui: &mut egui::Ui, area: Rect, t: &Tokens) { + let mac = cfg!(target_os = "macos"); + // held modifiers count as the filter too (outside recording) + let held = ui.input(|i| i.modifiers); + let mut mods = app.shortcut_editor.mods; + if app.shortcut_editor.recording.is_none() { + mods.cmd |= held.command; + mods.ctrl |= held.ctrl && mac; + mods.alt |= held.alt; + mods.shift |= held.shift; + } + let panel = app.shortcut_editor.panel().map(str::to_string); + let map = app.session.shortcuts.keyboard(mods, panel.as_deref()); + let units_w = 15.0 + 0.5 + 3.0; + let gap = 3.0; + let u = (area.width() / units_w).min(area.height() / 6.2); + let row_h = u * 0.98; + let origin = area.min; + let mut clicked_key: Option = None; + let mut toggled: Option<&str> = None; + let mut draw = |ui: &mut egui::Ui, r: Rect, name: &str| { + let r = r.shrink(gap / 2.0); + let is_mod = name.starts_with('#'); + let (app_cmd, panel_cmd) = map.get(name).cloned().unwrap_or_default(); + let mod_on = match name { + "#Shift" => mods.shift, + "#Alt" => mods.alt, + "#Cmd" => mods.cmd, + "#Ctrl" => mods.ctrl, + _ => false, + }; + // modifier keys appear twice: the right-hand ones get a ".right" suffix + let right = is_mod && r.center().x > area.center().x; + let id = match (is_mod, right) { + (true, false) => format!("shortcuts.mod.{}", &name[1..]), + (true, true) => format!("shortcuts.mod.{}.right", &name[1..]), + _ => format!("shortcuts.key.{name}"), + }; + let resp = ui.interact(r, egui::Id::new(&id), Sense::click()); + let p = ui.painter(); + let base = if is_mod { + if mod_on { MOD_KEY } else { FREE_KEY.gamma_multiply(0.8) } + } else if app_cmd.is_some() { + APP_KEY + } else if panel_cmd.is_some() { + PANEL_KEY + } else { + FREE_KEY + }; + p.rect_filled(r, CornerRadius::same(3), base); + if app_cmd.is_some() && panel_cmd.is_some() { + // both: lower-right triangle in the panel colour + p.add(egui::Shape::convex_polygon(vec![r.right_top(), r.right_bottom(), r.left_bottom()], PANEL_KEY, Stroke::NONE)); + } + if resp.hovered() { + p.rect_stroke(r, CornerRadius::same(3), Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + } + if app.shortcut_editor.key.as_deref() == Some(name) { + p.rect_stroke(r, CornerRadius::same(3), Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + let fs = (u * 0.2).clamp(8.5, 12.0); + if let Some(cmd) = app_cmd.as_ref().or(panel_cmd.as_ref()) { + let label = label_of(app, cmd); + let mut job = LayoutJob::single_section(label, TextFormat::simple(FontId::proportional(fs), Color32::WHITE)); + job.wrap.max_width = r.width() - 8.0; + job.wrap.max_rows = if r.height() > 3.5 * fs { 2 } else { 1 }; + job.wrap.break_anywhere = false; + job.wrap.overflow_character = Some('…'); + let g = ui.fonts_mut(|f| f.layout_job(job)); + ui.painter().galley(r.min + vec2(4.0, 3.0), g, Color32::WHITE); + } + ui.painter().text( + r.left_bottom() + vec2(4.0, -3.0), + Align2::LEFT_BOTTOM, + key_legend(name, mac), + FontId::proportional(fs), + Color32::from_white_alpha(220), + ); + let tip = match (&app_cmd, &panel_cmd) { + (Some(a), Some(b)) => format!("{} / {} ({})", label_of(app, a), label_of(app, b), panel.clone().unwrap_or_default()), + (Some(a), None) => label_of(app, a), + (None, Some(b)) => label_of(app, b), + _ => key_legend(name, mac), + }; + app.auto.add(&id, r, &tip); + if resp.clicked() { + if is_mod { + toggled = Some(match name { + "#Shift" => "Shift", + "#Alt" => "Alt", + "#Cmd" => "Cmd", + _ => "Ctrl", + }); + } else if name != "#Caps" { + clicked_key = Some(name.to_string()); + } + } + }; + for (ri, row) in MAIN_ROWS.iter().enumerate() { + let mut x = origin.x; + let y = origin.y + ri as f32 * row_h + if ri > 0 { u * 0.15 } else { 0.0 }; + for (name, w) in row.iter() { + let r = Rect::from_min_size(pos2(x, y), vec2(w * u, row_h)); + if !name.is_empty() { + draw(ui, r, name); + } + x += w * u; + } + } + let nav_x = origin.x + 15.5 * u; + for (ri, row) in NAV_ROWS.iter().enumerate() { + let y = origin.y + ri as f32 * row_h + if ri > 0 { u * 0.15 } else { 0.0 }; + for (name, off) in row.iter() { + draw(ui, Rect::from_min_size(pos2(nav_x + off * u, y), vec2(u, row_h)), name); + } + } + let ed = &mut app.shortcut_editor; + if let Some(m) = toggled { + match m { + "Shift" => ed.mods.shift = !ed.mods.shift, + "Alt" => ed.mods.alt = !ed.mods.alt, + "Cmd" => ed.mods.cmd = !ed.mods.cmd, + _ => ed.mods.ctrl = !ed.mods.ctrl, + } + } + if let Some(k) = clicked_key { + ed.key = Some(k); + } +} + +fn legend(ui: &mut egui::Ui, t: &Tokens) { + ui.horizontal(|ui| { + for (c, text) in [ + (APP_KEY, "Application shortcuts work whatever panel has focus"), + (PANEL_KEY, "Panel shortcuts override application shortcuts while that panel has focus"), + (MOD_KEY, "Modifier held / selected"), + ] { + let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); + ui.painter().rect_filled(r, 2.0, c); + ui.label(RichText::new(text).size(12.0).color(t.text_dim)); + ui.add_space(14.0); + } + }); +} + +fn command_list(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { + let plat = Platform::current(); + ui.horizontal(|ui| { + let mut q = app.shortcut_editor.search.clone(); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands or keys").desired_width(320.0)); + app.auto.add("shortcuts.search", r.rect, "Search"); + app.shortcut_editor.search = q; + if let Some(sel) = app.shortcut_editor.selected.clone() { + ui.label(RichText::new(format!("Selected: {}", label_of(app, &sel))).size(12.0).color(t.text_dim)); + } + }); + ui.add_space(4.0); + let rows = app.session.execute("shortcuts.list", json!({"query": app.shortcut_editor.search})).unwrap_or_default(); + let rows = rows.as_array().cloned().unwrap_or_default(); + let w = ui.available_width(); + // column header + let (hr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(pos2(hr.min.x + w * 0.58, hr.center().y), Align2::LEFT_CENTER, "Shortcut", Tokens::semibold(12.5), t.text_dim); + egui::ScrollArea::vertical().id_salt("shortcuts-list").max_height(height - 50.0).auto_shrink([false, false]).show(ui, |ui| { + let mut last_cat = String::new(); + for (i, row) in rows.iter().enumerate() { + let id = row["id"].as_str().unwrap_or("").to_string(); + let cat = row["category"].as_str().unwrap_or("").to_string(); + if cat != last_cat { + let (cr, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); + let c = cr.left_center() + vec2(9.0, 0.0); + ui.painter().add(egui::Shape::convex_polygon(vec![c + vec2(-4.0, -2.5), c + vec2(4.0, -2.5), c + vec2(0.0, 3.0)], t.text_dim, Stroke::NONE)); + ui.painter().text(cr.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, &cat, Tokens::semibold(12.0), t.text); + last_cat = cat; + } + let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + let selected = app.shortcut_editor.selected.as_deref() == Some(id.as_str()); + let bg = if selected { + t.row_selected + } else if i % 2 == 0 { + t.app_bg + } else { + t.row_alt + }; + ui.painter().rect_filled(r, 0.0, bg); + ui.painter().text(r.left_center() + vec2(22.0, 0.0), Align2::LEFT_CENTER, row["label"].as_str().unwrap_or(""), Tokens::ui(12.5), t.text); + app.auto.add(&format!("shortcuts.row.{id}"), r, row["label"].as_str().unwrap_or("")); + if resp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + } + // shortcut cell: click to record (replace); "+" adds another + let cell = Rect::from_min_max(pos2(r.min.x + w * 0.58, r.min.y + 2.0), pos2(r.max.x - 34.0, r.max.y - 2.0)); + let recording = app.shortcut_editor.recording.as_ref().is_some_and(|(c, _)| *c == id); + let text = if recording { + "Type a shortcut… (Esc cancels)".to_string() + } else { + row["shortcuts"] + .as_array() + .into_iter() + .flatten() + .map(|b| { + let d = b["display"].as_str().unwrap_or("").to_string(); + match b["panel"].as_str() { + Some(pn) => format!("{d} ({pn})"), + None => d, + } + }) + .collect::>() + .join(", ") + }; + let cresp = ui.interact(cell, egui::Id::new(("shortcut-cell", &id)), Sense::click()); + if recording { + ui.painter().rect(cell, 3.0, t.field_bg, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } else if cresp.hovered() { + ui.painter().rect_stroke(cell, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + ui.painter().text(cell.left_center() + vec2(6.0, 0.0), Align2::LEFT_CENTER, &text, Tokens::ui(12.5), if recording { t.accent } else { t.hot_text }); + app.auto.add(&format!("shortcuts.cell.{id}"), cell, &text); + if cresp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + app.shortcut_editor.recording = Some((id.clone(), false)); + } + let plus = Rect::from_min_size(pos2(r.max.x - 30.0, r.min.y + 3.0), vec2(20.0, 20.0)); + let presp = ui.interact(plus, egui::Id::new(("shortcut-add", &id)), Sense::click()).on_hover_text("Add another shortcut"); + ui.painter().text( + plus.center(), + Align2::CENTER_CENTER, + "+", + Tokens::semibold(14.0), + if presp.hovered() { t.tab_text_active } else { t.text_faint }, + ); + app.auto.add(&format!("shortcuts.add.{id}"), plus, "Add shortcut"); + if presp.clicked() { + app.shortcut_editor.selected = Some(id.clone()); + app.shortcut_editor.recording = Some((id.clone(), true)); + } + } + if rows.is_empty() { + ui.label(RichText::new("No matching commands").color(t.text_dim)); + } + }); + let _ = plat; +} + +fn key_detail(app: &mut FilmcraftApp, ui: &mut egui::Ui, height: f32, t: &Tokens) { + let plat = Platform::current(); + let key = app.shortcut_editor.key.clone(); + ui.horizontal(|ui| { + ui.label(RichText::new("Key:").size(13.0).strong().color(t.text)); + if let Some(k) = &key { + ui.label(RichText::new(key_legend(k, plat.is_mac())).size(13.0).color(t.hot_text)); + } + }); + let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + ui.painter().text(hr.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, "Modifiers", Tokens::semibold(12.5), t.text_dim); + ui.painter().text(hr.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, "Command", Tokens::semibold(12.5), t.text_dim); + let list = key.as_ref().and_then(|k| app.session.execute("shortcuts.forKey", json!({"key": k})).ok()).unwrap_or_default(); + egui::ScrollArea::vertical().id_salt("shortcuts-key").max_height(height - 90.0).auto_shrink([false, false]).show(ui, |ui| { + let w = ui.available_width(); + for (i, b) in list.as_array().into_iter().flatten().enumerate() { + let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, if i % 2 == 0 { t.app_bg } else { t.row_alt }); + let keys = b["keys"].as_str().unwrap_or(""); + let mods = Chord::parse(keys).map(|c| { + let k = Chord { mods: c.mods, key: c.key }; + let d = k.display(plat); + let legend = key_legend(c.key, plat.is_mac()); + let m = d.trim_end_matches(&legend).trim_end_matches(c.key).trim_end_matches('+').to_string(); + if m.is_empty() { "None".to_string() } else { m } + }); + ui.painter().text(r.left_center() + vec2(8.0, 0.0), Align2::LEFT_CENTER, mods.unwrap_or_default(), Tokens::ui(12.5), t.text); + let cmd = b["command"].as_str().unwrap_or(""); + let label = match b["panel"].as_str() { + Some(p) => format!("{} ({p})", label_of(app, cmd)), + None => label_of(app, cmd), + }; + ui.painter().text(r.left_center() + vec2(110.0, 0.0), Align2::LEFT_CENTER, label, Tokens::ui(12.5), t.text); + } + if key.is_some() && list.as_array().is_none_or(|a| a.is_empty()) { + ui.label(RichText::new("Nothing is assigned to this key").color(t.text_dim)); + } + if key.is_none() { + ui.label(RichText::new("Click a key on the keyboard to see its shortcuts").color(t.text_dim)); + } + }); + // assign the selected command to the clicked key with the selected modifiers + if let (Some(k), Some(cmd)) = (key, app.shortcut_editor.selected.clone()) { + let keys = format!("{}{}", app.shortcut_editor.mods.prefix(), k); + let disp = Chord::parse(&keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); + let label = format!("Assign {disp} to {}", label_of(app, &cmd)); + if small_button(app, ui, "shortcuts.assignKey", &label, true) { + assign(app, &cmd, &keys, false); + } + } +} + +/// Bottom row: messages / conflicts, Undo / Redo / Clear, Cancel / OK. Returns Some(ok) to close. +fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, t: &Tokens) -> Option { + let plat = Platform::current(); + let conflicts = app.session.shortcuts.conflicts(plat); + let mut close = None; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 420.0); + for (ctx, keys, cmds) in conflicts.iter().take(2) { + let disp = Chord::parse(keys).map(|c| c.display(plat)).unwrap_or(keys.clone()); + let names: Vec = cmds.iter().map(|c| label_of(app, c)).collect(); + let r = ui.label(RichText::new(format!("Conflict: {disp} is assigned to {} ({ctx})", names.join(" and "))).size(12.0).color(WARN)); + app.auto.add("shortcuts.conflict", r.rect, "conflict"); + } + let msg = if app.shortcut_editor.message.is_empty() { + "Click a command's Shortcut cell and press keys to assign it; + adds another shortcut.".to_string() + } else { + app.shortcut_editor.message.clone() + }; + let r = ui.label(RichText::new(msg.clone()).size(12.0).color(t.text_dim)); + app.auto.add("shortcuts.message", r.rect, &msg); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let ok = + ui.add(egui::Button::new(RichText::new("OK").size(13.0).color(Color32::WHITE)).fill(t.accent).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + app.auto.add("shortcuts.ok", ok.rect, "OK"); + if ok.clicked() { + close = Some(true); + } + let cancel = ui.add(egui::Button::new(RichText::new("Cancel").size(13.0)).min_size(vec2(72.0, 28.0)).corner_radius(14.0)); + app.auto.add("shortcuts.cancel", cancel.rect, "Cancel"); + if cancel.clicked() { + close = Some(false); + } + ui.add_space(16.0); + let sel = app.shortcut_editor.selected.clone(); + let panel = app.shortcut_editor.panel().map(str::to_string); + if small_button(app, ui, "shortcuts.clear", "Clear", sel.is_some()) + && let Some(c) = sel + && let Some(r) = exec(app, "shortcuts.clear", json!({"command": c, "panel": panel.clone().unwrap_or_else(|| APPLICATION.into())})) + { + app.shortcut_editor.message = format!("Cleared {} shortcut(s) of {}", r["removed"], label_of(app, &c)); + } + let (cu, cr) = (app.session.shortcuts.can_undo(), app.session.shortcuts.can_redo()); + if small_button(app, ui, "shortcuts.redo", "Redo", cr) { + exec(app, "shortcuts.redo", json!({})); + } + if small_button(app, ui, "shortcuts.undo", "Undo", cu) { + exec(app, "shortcuts.undo", json!({})); + } + }); + }); + close +} diff --git a/crates/ui-egui/tests/scripted.rs b/crates/ui-egui/tests/scripted.rs index 98e5f7f5..86d9cc4c 100644 --- a/crates/ui-egui/tests/scripted.rs +++ b/crates/ui-egui/tests/scripted.rs @@ -336,4 +336,109 @@ fn screenshots() { d.screenshot("trim-monitor-dynamic-roll"); d.ok("ui.key", json!({"key": "K"})); d.frames(2); + d.exec("trim.clear", json!({})); + // Keyboard Shortcuts dialog: Premiere-compatible preset, Timeline panel context, key K selected + d.ok("ui.key", json!({"key": "Cmd+Alt+K"})); + d.frames(2); + d.screenshot("shortcuts-dialog"); + d.ok("ui.click", json!({"id": "shortcuts.context"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.context.Timeline"})); + d.ok("ui.click", json!({"id": "shortcuts.key.K"})); + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "trim"})); + d.frames(2); + d.screenshot("shortcuts-dialog-timeline-trim"); + d.ok("ui.click", json!({"id": "shortcuts.mod.Cmd"})); + d.frames(2); + d.screenshot("shortcuts-dialog-cmd"); +} + +#[test] +fn keyboard_shortcuts_dialog_assigns_live_and_cancel_restores() { + let mut d = Driver::demo(); + let menu_shortcut = |d: &mut Driver, id: &str| -> Value { + let items = d.ok("ui.menu.list", json!({})); + items.as_array().unwrap().iter().find(|i| i["id"] == id).map(|i| i["shortcut"].clone()).unwrap_or(Value::Null) + }; + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+K")); + assert_eq!(menu_shortcut(&mut d, "app.keyboardShortcuts"), json!("Cmd+Alt+K")); + // Edit ▸ Keyboard Shortcuts… by its shortcut + d.ok("ui.key", json!({"key": "Cmd+Alt+K"})); + d.frames(2); + assert_eq!(d.inspect()["dialog"], json!("Shortcuts")); + let keys = d.element_ids("shortcuts.key."); + assert!(keys.len() > 60, "drawn keyboard: {}", keys.len()); + // search, then click the Shortcut cell and press new keys + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "add edit"})); + d.frames(2); + assert!(d.element_ids("shortcuts.row.").iter().any(|x| x == "shortcuts.row.sequence.addEdit")); + d.ok("ui.click", json!({"id": "shortcuts.cell.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+J"), "the menu shows the new key live"); + let cmds = d.ok("engine.commands", json!({})); + let ae = cmds.as_array().unwrap().iter().find(|c| c["id"] == "sequence.addEdit").unwrap().clone(); + assert_eq!(ae["shortcut"], json!("Cmd+Shift+J"), "command list too: {ae}"); + // reassigning a used key takes it from the other command (and says so) + d.ok("ui.click", json!({"id": "shortcuts.cell.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+K"})); + d.frames(2); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), Value::Null); + let msg = d.element_ids("shortcuts.message"); + assert_eq!(msg.len(), 1); + // Undo / Redo inside the dialog + d.ok("ui.click", json!({"id": "shortcuts.undo"})); + d.frames(1); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), json!("Cmd+Shift+K")); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+J")); + d.ok("ui.click", json!({"id": "shortcuts.redo"})); + d.frames(1); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+Shift+K")); + // Cancel restores everything + d.ok("ui.click", json!({"id": "shortcuts.cancel"})); + d.frames(2); + assert!(d.inspect()["dialog"].is_null()); + assert_eq!(menu_shortcut(&mut d, "sequence.addEdit"), json!("Cmd+K")); + assert_eq!(menu_shortcut(&mut d, "sequence.addEditAllTracks"), json!("Cmd+Shift+K")); + + // Assign + OK, then the new key really runs the command + d.ok("ui.menu.invoke", json!({"id": "app.keyboardShortcuts"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.search"})); + d.ok("ui.type", json!({"text": "add edit"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.add.sequence.addEdit"})); + d.frames(1); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + d.ok("ui.click", json!({"id": "shortcuts.ok"})); + d.frames(2); + let n0 = track_clips(&d.sequence(), 0).len(); + d.exec("playhead.set", json!({"seconds": 2.0})); + d.ok("ui.set", json!({"focused": "Timeline"})); + d.ok("ui.key", json!({"key": "Cmd+Shift+J"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1, "Cmd+Shift+J adds an edit"); + d.ok("ui.key", json!({"key": "Cmd+K"})); + d.frames(2); + d.exec("playhead.set", json!({"seconds": 3.0})); + d.ok("ui.key", json!({"key": "Cmd+K"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 2, "the original Cmd+K was kept (added, not replaced)"); + + // Panel-specific: Left in the History panel is Undo; in the Timeline it steps back + d.ok("ui.set", json!({"focused": "History"})); + d.ok("ui.key", json!({"key": "Left"})); + d.frames(2); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1, "History ▸ Left = Step Backward (undo)"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let ph = d.sequence()["playhead"].as_i64().unwrap(); + d.ok("ui.key", json!({"key": "Left"})); + d.frames(2); + assert!(d.sequence()["playhead"].as_i64().unwrap() < ph, "Timeline ▸ Left steps back"); + assert_eq!(track_clips(&d.sequence(), 0).len(), n0 + 1); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ecf20135..7a476a73 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -34,6 +34,20 @@ Element ids are stable, e.g. `timeline.clip.`, `timeline.track.V1.lock`, `to Project files, auto-save, crash recovery and preferences commands (`file.recover`, `prefs.set`, …) and their automation ids are listed in [project-files.md](project-files.md). +**Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim +mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what +it shows. Dynamic trimming takes an explicit clock (seconds) so it is deterministic: `trim.shuttle +{direction, slow?, clock}` (J/L), `trim.tick {clock}`, `trim.shuttleStop {clock?}` (K, one undo step), +`trim.cancelDynamic` (Esc), `trim.playAround {clock, loop?}` (Space / Shift+K). + +**Keyboard shortcuts** (`crates/engine/src/shortcuts.rs`): `shortcuts.list {query?, panel?}`, +`shortcuts.get`, `shortcuts.set {command, keys, panel?, add?, keepConflicts?}`, `shortcuts.clear`, +`shortcuts.undo` / `shortcuts.redo`, `shortcuts.conflicts {platform?}`, `shortcuts.forKey {key}`, +`shortcuts.resolve {keys, panel?}`, `shortcuts.presets`, `shortcuts.loadPreset` / `savePreset` / +`deletePreset {name}`, `shortcuts.export` / `import {path}`, `shortcuts.audit`. Keys use `Cmd` (⌘ / +Ctrl), `Ctrl` (macOS ⌃), `Alt`, `Shift`. The dialog (Edit ▸ Keyboard Shortcuts…, ⌥⌘K) uses ids +`shortcuts.*` (`shortcuts.key.K`, `shortcuts.cell.`, `shortcuts.ok`, …). + ## MCP `filmcraft-cli mcp` serves MCP on stdio: headless (in-process session; `--demo` / `--project p.fcproj`) or `--bridge 127.0.0.1:9876` to drive the running app. Tools: `command_list`, `command_run`,