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:
parent
d28b28214d
commit
985a848c5e
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Reference in New Issue