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 <data dir>/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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 01:56:47 -04:00
parent d28b28214d
commit 985a848c5e
15 changed files with 2627 additions and 64 deletions

View File

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

View File

@ -19,8 +19,12 @@ fn accel(s: &str) -> Option<Accelerator> {
mapped.parse().ok()
}
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> Receiver<String> {
/// Updates native key equivalents when the active keyboard shortcuts change.
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, 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<String> {
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<String> {
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)
}

View File

@ -916,7 +916,7 @@ fn build() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
)),
];
v.extend(crate::captions::commands());
v.extend(crate::shortcuts::commands());
// Labels as individual commands (Edit ▸ Label ▸ <name>)
for l in Label::ALL {
let _ = l;

View File

@ -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<previews::PreviewStore>,
/// 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;

View File

@ -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", ""),
];

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -50,6 +50,10 @@ pub struct HostHooks {
pub pick_open_project: Option<Box<dyn FnMut() -> Option<String>>>,
/// Save dialog with a filter: (filter name, extensions, suggested file name) → path.
pub pick_save_as: Option<Box<dyn FnMut(&str, &[&str], &str) -> Option<String>>>,
/// The active keyboard shortcuts changed: update native menu key equivalents.
pub shortcuts_changed: Option<Box<dyn FnMut(&[menus::MenuItem])>>,
/// Open dialog for a JSON file (shortcut preset import): filter name, extensions → path.
pub pick_open_file: Option<Box<dyn FnMut(&str, &[&str]) -> Option<String>>>,
}
#[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<menus::KeyBinding>,
/// 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());
}

View File

@ -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<MenuItem> {
menu_items_for(&app.session)
}
/// Menu entries with their live shortcuts and enablement.
pub fn menu_items_for(session: &filmcraft_engine::Session) -> Vec<MenuItem> {
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<MenuItem> {
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<MenuItem> {
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<MenuItem> {
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<String>);
/// 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<KeyBinding> {
let mut v: Vec<KeyBinding> =
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<filmcraft_engine::shortcuts::CommandInfo> {
use filmcraft_engine::shortcuts::CommandInfo;
let mut v: Vec<CommandInfo> = 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
}

View File

@ -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 => {}
}

View File

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

View File

@ -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.<name>`, `shortcuts.saveAs`,
//! `shortcuts.saveAs.name`, `shortcuts.saveAs.ok`, `shortcuts.delete`, `shortcuts.export`,
//! `shortcuts.import`, `shortcuts.context`, `shortcuts.context.<panel>`, `shortcuts.search`,
//! `shortcuts.key.<Key>`, `shortcuts.mod.<Cmd|Ctrl|Alt|Shift>`, `shortcuts.row.<command>`,
//! `shortcuts.cell.<command>` (click = record), `shortcuts.add.<command>`, `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<String>,
/// 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<String>,
pub message: String,
/// Save As… name being typed (Some = the field is open).
pub save_name: Option<String>,
}
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<egui::Key> {
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<String> {
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<Value> {
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<String> = 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<bool> = 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<String> = presets["builtin"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
let custom: Vec<String> = 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<String> = 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::<Vec<_>>()
.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<bool> {
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<String> = 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
}

View File

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

View File

@ -34,6 +34,20 @@ Element ids are stable, e.g. `timeline.clip.<id>`, `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.<command>`, `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`,