simple_editor/
hotkeys.rs

1//! Keyboard shortcuts: a fixed list of actions, default bindings, user overrides (stored in Settings),
2//! and per-frame polling. The bare letters V / T / S / D / M belong to the tool strip (ui::tools), which
3//! consumes them before this table is polled, so actions that used them sit on Shift+<letter>. Mouse modifiers (Ctrl+Scroll zoom, Alt+Scroll track height, Shift+Scroll pan)
4//! are fixed and not part of this table.
5
6use crate::settings::Settings;
7use eframe::egui::{self, Key, KeyboardShortcut, Modifiers};
8use std::collections::HashMap;
9
10macro_rules! actions {
11    ($($v:ident => $id:literal, $label:literal, $sc:expr;)*) => {
12        #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
13        pub enum Action { $($v),* }
14        impl Action {
15            pub const ALL: &'static [Action] = &[$(Action::$v),*];
16            /// Stable id used in settings.json.
17            pub fn id(self) -> &'static str { match self { $(Action::$v => $id),* } }
18            pub fn label(self) -> &'static str { match self { $(Action::$v => $label),* } }
19            pub fn default_shortcut(self) -> Option<KeyboardShortcut> { match self { $(Action::$v => $sc),* } }
20            pub fn from_id(s: &str) -> Option<Action> { Self::ALL.iter().copied().find(|a| a.id() == s) }
21        }
22    };
23}
24
25const fn sc(m: Modifiers, k: Key) -> Option<KeyboardShortcut> {
26    Some(KeyboardShortcut::new(m, k))
27}
28const NONE: Modifiers = Modifiers::NONE;
29const CTRL: Modifiers = Modifiers::CTRL;
30const SHIFT: Modifiers = Modifiers::SHIFT;
31const ALT: Modifiers = Modifiers::ALT;
32const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false };
33const CTRL_ALT: Modifiers = Modifiers { alt: true, ctrl: true, shift: false, mac_cmd: false, command: false };
34
35/// Canonical form so `Ctrl` and `Command` (egui sets both on Windows) compare equal.
36fn canon(ks: &KeyboardShortcut) -> (bool, bool, bool, Key) {
37    (ks.modifiers.ctrl || ks.modifiers.command, ks.modifiers.shift, ks.modifiers.alt, ks.logical_key)
38}
39fn same(a: Option<KeyboardShortcut>, b: Option<KeyboardShortcut>) -> bool {
40    match (a, b) {
41        (None, None) => true,
42        (Some(a), Some(b)) => canon(&a) == canon(&b),
43        _ => false,
44    }
45}
46
47actions! {
48    NewProject => "new_project", "New Project", sc(CTRL, Key::N);
49    OpenFile => "open", "Open Video / Media…", sc(CTRL, Key::O);
50    OpenProject => "open_project", "Open Project…", sc(CTRL_SHIFT, Key::O);
51    Save => "save", "Save (project, or overwrite opened video)", sc(CTRL, Key::S);
52    SaveProjectAs => "save_project_as", "Save Project As…", sc(CTRL_SHIFT, Key::S);
53    ExportVideo => "export", "Export Video As…", sc(CTRL, Key::E);
54    ExportLossless => "export_lossless", "Fast Lossless Cut…", sc(CTRL_ALT, Key::E);
55    ExportXml => "export_xml", "Export Premiere / Resolve XML…", sc(CTRL_SHIFT, Key::E);
56    ImportMedia => "import", "Import Media…", sc(CTRL, Key::I);
57    Settings => "settings", "Settings…", sc(CTRL, Key::Comma);
58    Undo => "undo", "Undo", sc(CTRL, Key::Z);
59    Redo => "redo", "Redo", sc(CTRL_SHIFT, Key::Z);
60    PlayPause => "play_pause", "Play / Pause", sc(NONE, Key::Space);
61    Stop => "stop", "Stop", sc(NONE, Key::K);
62    StepBack => "step_back", "Step Back 1 Frame", sc(NONE, Key::ArrowLeft);
63    StepForward => "step_fwd", "Step Forward 1 Frame", sc(NONE, Key::ArrowRight);
64    GoStart => "go_start", "Go to Start", sc(NONE, Key::Home);
65    GoEnd => "go_end", "Go to End", sc(NONE, Key::End);
66    PrevCut => "prev_cut", "Previous Cut", sc(NONE, Key::ArrowUp);
67    NextCut => "next_cut", "Next Cut", sc(NONE, Key::ArrowDown);
68    Split => "split", "Split at Playhead", sc(CTRL, Key::B);
69    Delete => "delete", "Delete", sc(NONE, Key::Delete);
70    RippleDelete => "ripple_delete", "Ripple Delete", sc(SHIFT, Key::Delete);
71    SelectAll => "select_all", "Select All", sc(CTRL, Key::A);
72    Deselect => "deselect", "Deselect All", sc(CTRL_SHIFT, Key::A);
73    MarkIn => "mark_in", "Mark In", sc(NONE, Key::I);
74    MarkOut => "mark_out", "Mark Out", sc(NONE, Key::O);
75    ClearInOut => "clear_in_out", "Clear In / Out", sc(ALT, Key::X);
76    TrimToInOut => "trim_in_out", "Trim to In / Out", sc(CTRL_SHIFT, Key::I);
77    RippleDeleteInOut => "ripple_in_out", "Ripple Delete In / Out", sc(CTRL_SHIFT, Key::Delete);
78    AddText => "add_text", "Add Text Clip", sc(SHIFT, Key::T);
79    ZoomIn => "zoom_in", "Zoom In", sc(CTRL, Key::Equals);
80    ZoomOut => "zoom_out", "Zoom Out", sc(CTRL, Key::Minus);
81    ZoomFit => "zoom_fit", "Zoom to Fit", sc(SHIFT, Key::Z);
82    LinkToggle => "link", "Link / Unlink", sc(CTRL, Key::L);
83    ToggleEnabled => "toggle_enabled", "Enable / Disable Clip", sc(SHIFT, Key::D);
84    NudgeLeft => "nudge_left", "Nudge Left 1 Frame", sc(NONE, Key::Comma);
85    NudgeRight => "nudge_right", "Nudge Right 1 Frame", sc(NONE, Key::Period);
86    ToggleSnap => "snap", "Toggle Snapping", sc(NONE, Key::N);
87    AddVideoTrack => "add_video_track", "Add Video Track", None;
88    AddAudioTrack => "add_audio_track", "Add Audio Track", None;
89    ToggleLibrary => "toggle_library", "Show / Hide Library", sc(CTRL, Key::Num1);
90    ToggleInspector => "toggle_inspector", "Show / Hide Inspector", sc(CTRL, Key::Num2);
91    ToggleEffects => "toggle_effects", "Show / Hide Effects", sc(CTRL, Key::Num3);
92    ToggleTransitions => "toggle_transitions", "Show / Hide Transitions", sc(CTRL, Key::Num4);
93    ToggleCurves => "toggle_curves", "Show / Hide Curve Editor", sc(CTRL, Key::Num5);
94    ToggleSubtitles => "toggle_subtitles", "Show / Hide Subtitles", sc(CTRL, Key::Num6);
95    Retime => "retime", "Speed / Retime…", sc(CTRL, Key::R);
96    FreezeFrame => "freeze", "Freeze Frame at Playhead", sc(SHIFT, Key::R);
97    Fullscreen => "fullscreen", "Fullscreen Playback", sc(NONE, Key::F11);
98    AddTransition => "add_transition", "Add Transition at Selected Cut", sc(CTRL_SHIFT, Key::T);
99    AddSubtitle => "add_subtitle", "Add Subtitle at Playhead", sc(ALT, Key::S);
100    AddTransitionEnd => "add_transition_end", "Add Transition at Clip End", None;
101    TogglePlanner => "toggle_planner", "Show / Hide Planner", sc(CTRL, Key::Num7);
102    AutoCut => "auto_cut", "Auto-cut (silence) Panel", sc(CTRL_ALT, Key::A);
103    NestSequence => "nest", "Nest Selection into a New Sequence", sc(ALT, Key::N);
104    OpenParentSequence => "parent_sequence", "Back to Parent Timeline", sc(ALT, Key::ArrowUp);
105    SaveTemplate => "save_template", "Save Selection as Template…", None;
106    ApplyFlow => "flow", "Flow Motion Between Selected Clips", None;
107    // ---- round 3 ----
108    AddLastTransition => "add_last_transition", "Add Last Used Transition", sc(CTRL, Key::T);
109    CopyAttributes => "copy_attrs", "Copy Attributes", sc(CTRL_ALT, Key::C);
110    PasteAttributes => "paste_attrs", "Paste Attributes…", sc(CTRL_ALT, Key::V);
111    AddMarker => "add_marker", "Add Marker at Playhead", sc(SHIFT, Key::M);
112    ToggleMarkers => "toggle_markers", "Show / Hide Markers", sc(CTRL, Key::Num8);
113    ToggleNodes => "toggle_nodes", "Show / Hide Node Editor", sc(CTRL, Key::Num9);
114    ToggleMixer => "toggle_mixer", "Show / Hide Mixer", sc(CTRL, Key::Num0);
115    ToggleTools => "toggle_tools", "Show / Hide Tools", None;
116    AddShape => "add_shape", "Add Shape", sc(SHIFT, Key::S);
117    AddAdjustment => "add_adjustment", "Add Adjustment Layer", sc(CTRL_ALT, Key::L);
118    AddMask => "add_mask", "Add Mask to Selection", sc(CTRL_SHIFT, Key::M);
119    ExportFrame => "export_frame", "Export Frame…", sc(CTRL_SHIFT, Key::F);
120    ScreenCapture => "screen_capture", "Screen Recording…", None;
121    Voiceover => "voiceover", "Record Voiceover…", sc(CTRL_ALT, Key::R);
122    ImportTimeline => "import_timeline", "Import Timeline (Premiere / Resolve XML, EDL)…", None;
123    MovieMode => "movie_mode", "Movie Mode (pre-render)", None;
124    // ---- clip clipboard (Copy/Paste *Attributes* above is a different feature) ----
125    CopyClips => "copy_clips", "Copy Clips", sc(CTRL, Key::C);
126    CutClips => "cut_clips", "Cut Clips", sc(CTRL, Key::X);
127    PasteClips => "paste_clips", "Paste Clips at Playhead", sc(CTRL, Key::V);
128    PasteInPlace => "paste_in_place", "Paste Clips on the First Free Track", sc(CTRL_SHIFT, Key::V);
129    PasteInsert => "paste_insert", "Paste Insert (ripple the rest right)", sc(NONE, Key::F10);
130    PasteAtTop => "paste_at_top", "Paste on a New Track at the Top", sc(NONE, Key::F9);
131    // ---- container clips ----
132    AddContainer => "add_container", "Add Container Clip at Playhead", None;
133    ReplaceContainerMedia => "replace_container", "Replace Container Media…", None;
134    MakeContainer => "make_container", "Convert to Container", None;
135    UnmakeContainer => "unmake_container", "Remove Container", None;
136}
137
138pub struct Hotkeys {
139    map: HashMap<Action, Option<KeyboardShortcut>>,
140}
141
142impl Hotkeys {
143    pub fn defaults() -> Self {
144        Self { map: Action::ALL.iter().map(|&a| (a, a.default_shortcut())).collect() }
145    }
146    pub fn from_settings(s: &Settings) -> Self {
147        let mut h = Self::defaults();
148        for (id, text) in &s.hotkeys {
149            if let Some(a) = Action::from_id(id) {
150                h.map.insert(a, Self::parse(text));
151            }
152        }
153        h
154    }
155    /// Store only bindings that differ from the defaults.
156    pub fn to_settings(&self, s: &mut Settings) {
157        s.hotkeys.clear();
158        for &a in Action::ALL {
159            let cur = self.get(a);
160            if !same(cur, a.default_shortcut()) {
161                s.hotkeys.insert(a.id().to_string(), cur.map(|k| Self::format(&k)).unwrap_or_default());
162            }
163        }
164    }
165    pub fn get(&self, a: Action) -> Option<KeyboardShortcut> {
166        self.map.get(&a).copied().flatten()
167    }
168    /// Bind `a` to `ks` (None = unbound). Any other action using the same shortcut is unbound.
169    pub fn set(&mut self, a: Action, ks: Option<KeyboardShortcut>) {
170        if let Some(k) = ks {
171            for (_, v) in self.map.iter_mut() {
172                if same(*v, Some(k)) {
173                    *v = None;
174                }
175            }
176        }
177        self.map.insert(a, ks);
178    }
179    pub fn reset(&mut self, a: Action) {
180        self.set(a, a.default_shortcut());
181    }
182    pub fn reset_all(&mut self) {
183        *self = Self::defaults();
184    }
185    /// Which action (if any) already uses this shortcut.
186    pub fn conflict(&self, ks: KeyboardShortcut) -> Option<Action> {
187        Action::ALL.iter().copied().find(|&a| same(self.get(a), Some(ks)))
188    }
189    /// Display text for menus ("Ctrl+B" or "").
190    pub fn text(&self, a: Action) -> String {
191        self.get(a).map(|k| Self::format(&k)).unwrap_or_default()
192    }
193    pub fn format(ks: &KeyboardShortcut) -> String {
194        let mut s = String::new();
195        if ks.modifiers.ctrl || ks.modifiers.command {
196            s.push_str("Ctrl+");
197        }
198        if ks.modifiers.shift {
199            s.push_str("Shift+");
200        }
201        if ks.modifiers.alt {
202            s.push_str("Alt+");
203        }
204        s.push_str(ks.logical_key.name());
205        s
206    }
207    pub fn parse(s: &str) -> Option<KeyboardShortcut> {
208        let s = s.trim();
209        if s.is_empty() {
210            return None;
211        }
212        let mut m = Modifiers::NONE;
213        let mut key = None;
214        for part in s.split('+') {
215            match part.trim().to_ascii_lowercase().as_str() {
216                "ctrl" | "control" | "cmd" | "command" => m = m.plus(Modifiers::CTRL),
217                "shift" => m = m.plus(Modifiers::SHIFT),
218                "alt" => m = m.plus(Modifiers::ALT),
219                other => key = Key::from_name(other).or_else(|| Key::from_name(part.trim())),
220            }
221        }
222        key.map(|k| KeyboardShortcut::new(m, k))
223    }
224    /// Consume matching shortcuts this frame. Nothing fires while a text field has focus.
225    pub fn poll(&self, ctx: &egui::Context) -> Vec<Action> {
226        self.poll_pass(ctx, false)
227    }
228    /// Second pass, run *after* the panes are drawn: the clip clipboard keys, which the curve and node
229    /// editors also claim while the pointer is over them (whoever is hovered wins, the timeline is the
230    /// fallback).
231    pub fn poll_late(&self, ctx: &egui::Context) -> Vec<Action> {
232        self.poll_pass(ctx, true)
233    }
234    fn poll_pass(&self, ctx: &egui::Context, late: bool) -> Vec<Action> {
235        if ctx.wants_keyboard_input() {
236            return Vec::new();
237        }
238        if !late {
239            restore_clipboard_keys(ctx);
240        }
241        // consume_shortcut ignores *extra* shift/alt, so walking the table in declaration order would
242        // let Ctrl+Shift+Z fire plain Undo: try the most specific binding first.
243        let mut order: Vec<Action> = Action::ALL.iter().copied().filter(|&a| is_late(a) == late).collect();
244        order.sort_by_key(|&a| {
245            std::cmp::Reverse(self.get(a).map_or(0u8, |k| k.modifiers.shift as u8 + k.modifiers.alt as u8))
246        });
247        let mut out = Vec::new();
248        ctx.input_mut(|i| {
249            for a in order {
250                if let Some(ks) = self.get(a) {
251                    if i.consume_shortcut(&ks) {
252                        out.push(a);
253                    }
254                }
255            }
256        });
257        out
258    }
259}
260
261/// Actions the curve and node editors also claim while the pointer is over them, so the timeline only
262/// gets them if no pane wanted them. Delete is here for the same reason the clipboard keys are: the early
263/// pass runs BEFORE any pane is drawn, so a global Delete would eat the key and remove the selected CLIPS
264/// while the user was deleting keyframes or nodes.
265fn is_late(a: Action) -> bool {
266    matches!(a, Action::CopyClips | Action::CutClips | Action::PasteClips | Action::PasteInPlace | Action::Delete)
267}
268
269/// egui-winit swallows the clipboard keys: Ctrl+C / Ctrl+X / Ctrl+V (and Ctrl+Alt+C/V, Shift+Delete,
270/// Ctrl+Insert) arrive as `Event::Copy` / `Cut` / `Paste` with the key event *dropped*, so no shortcut
271/// on those keys can ever match. Put the key events back. The clipboard event no longer says which key
272/// produced it, so the modifiers decide — with Shift down a Cut is Windows' Shift+Delete (Ctrl+Shift+X
273/// is nobody's shortcut). Callers skip this while a text field has focus, so text copy/paste is untouched.
274fn restore_clipboard_keys(ctx: &egui::Context) {
275    ctx.input_mut(|i| {
276        let m = i.modifiers;
277        let cmd = m.ctrl || m.command;
278        let mut keys: Vec<Key> = Vec::new();
279        for e in &i.events {
280            match e {
281                egui::Event::Cut if m.shift => keys.push(Key::Delete),
282                egui::Event::Cut if cmd => keys.push(Key::X),
283                egui::Event::Copy if cmd => keys.push(Key::C),
284                egui::Event::Paste(_) if cmd => keys.push(Key::V),
285                _ => {}
286            }
287        }
288        for key in keys {
289            i.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m });
290        }
291    });
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    #[test]
298    fn parse_format_roundtrip() {
299        for &a in Action::ALL {
300            if let Some(k) = a.default_shortcut() {
301                let txt = Hotkeys::format(&k);
302                assert!(same(Hotkeys::parse(&txt), Some(k)), "{txt}");
303            }
304        }
305    }
306    /// Feed the events egui-winit *actually* delivers for these chords (the raw key event is gone) and
307    /// check the actions still fire — and in the late pass for the clip clipboard, so a hovered curve /
308    /// node editor gets first refusal.
309    #[test]
310    fn clipboard_chords_survive_winit_translation() {
311        let h = Hotkeys::defaults();
312        let run = |ev: egui::Event, m: Modifiers| {
313            let ctx = egui::Context::default();
314            let raw = egui::RawInput { modifiers: m, events: vec![ev], ..Default::default() };
315            let (mut early, mut late) = (Vec::new(), Vec::new());
316            ctx.run(raw, |ctx| {
317                early = h.poll(ctx);
318                late = h.poll_late(ctx);
319            });
320            (early, late)
321        };
322        let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
323        let ctrl_shift = Modifiers { shift: true, ..ctrl };
324        for (ev, m, want) in [
325            (egui::Event::Copy, ctrl, Action::CopyClips),
326            (egui::Event::Cut, ctrl, Action::CutClips),
327            (egui::Event::Paste("x".into()), ctrl, Action::PasteClips),
328            (egui::Event::Paste("x".into()), ctrl_shift, Action::PasteInPlace),
329        ] {
330            let (early, late) = run(ev, m);
331            assert!(early.is_empty(), "{want:?} must wait for the late pass, got {early:?}");
332            assert_eq!(late, vec![want]);
333        }
334        // Windows folds Shift+Delete into Cut too — that one is a normal (early) action
335        let (early, _) = run(egui::Event::Cut, Modifiers::SHIFT);
336        assert_eq!(early, vec![Action::RippleDelete]);
337    }
338
339    #[test]
340    fn no_duplicate_defaults() {
341        let mut seen = std::collections::HashSet::new();
342        for &a in Action::ALL {
343            if let Some(k) = a.default_shortcut() {
344                assert!(seen.insert(Hotkeys::format(&k)), "duplicate default {}", Hotkeys::format(&k));
345            }
346        }
347    }
348}