diff --git a/crates/engine/src/commands/batch.rs b/crates/engine/src/commands/batch.rs index 6ed3a16d..4bfd2b2b 100644 --- a/crates/engine/src/commands/batch.rs +++ b/crates/engine/src/commands/batch.rs @@ -109,6 +109,7 @@ pub fn specs() -> Vec { label: "Run Commands (Batch)", menu: &[], shortcut: None, + alt_shortcuts: &[], params: "{steps: [{command, params?}], label? (undo step name, default Batch), atomic?: bool (default true: a failing step rolls the whole batch back)} → {steps, results: [each step's result]}; a string param \"$N\" or \"$N.key.0\" is step N's result (1-based), e.g. {\"layer\": \"$1.layer\"}", enabled: always, run: batch, diff --git a/crates/engine/src/commands/content_fill.rs b/crates/engine/src/commands/content_fill.rs index 9ff0e157..650df5e7 100644 --- a/crates/engine/src/commands/content_fill.rs +++ b/crates/engine/src/commands/content_fill.rs @@ -405,6 +405,7 @@ pub fn specs() -> Vec { label: "Generate Fill Layer", menu: &[], shortcut: None, + alt_shortcuts: &[], params: P, enabled: has_layers, run: generate, @@ -415,6 +416,7 @@ pub fn specs() -> Vec { label: "Content-Aware Fill Layer...", menu: &["Layer", "New"], shortcut: None, + alt_shortcuts: &[], params: P, enabled: always, run: open_panel, diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 98fe0cb4..2218eff4 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -98,6 +98,8 @@ pub struct CommandSpec { pub menu: &'static [&'static str], /// Default shortcut (egui-style names, `Cmd` = ⌘ on macOS / Ctrl elsewhere). pub shortcut: Option<&'static str>, + /// Further default shortcuts (the menus and tooltips show `shortcut`). + pub alt_shortcuts: &'static [&'static str], /// Parameter description for agents and the MCP schema. pub params: &'static str, pub enabled: Enabled, @@ -108,8 +110,22 @@ pub struct CommandSpec { #[macro_export] macro_rules! cmd { + // Several default shortcuts: `["PageDown", "Cmd+ArrowRight"]`. + ($id:literal, $label:literal, [$($m:literal),*], [$sc:literal, $($alt:literal),+], $params:literal, $en:expr, $run:expr) => { + $crate::commands::CommandSpec { alt_shortcuts: &[$($alt),+], ..$crate::cmd!($id, $label, [$($m),*], Some($sc), $params, $en, $run) } + }; ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { - $crate::commands::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } + $crate::commands::CommandSpec { + id: $id, + label: $label, + menu: &[$($m),*], + shortcut: $sc, + alt_shortcuts: &[], + params: $params, + enabled: $en, + run: $run, + journal: true, + } }; } #[macro_export] @@ -120,6 +136,7 @@ macro_rules! query { label: $label, menu: &[], shortcut: None, + alt_shortcuts: &[], params: $params, enabled: $crate::commands::always, run: $run, diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index b868828d..cef0abd0 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -10,10 +10,10 @@ use super::{CommandSpec, not_yet, not_yet_run}; #[allow(unused_macros)] macro_rules! stub { ($id:literal, $label:literal, [$($m:literal),*], $params:literal) => { - CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: not_yet, run: not_yet_run, journal: true } + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true } }; ($id:literal, $label:literal, [$($m:literal),*], $sc:literal, $params:literal) => { - CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), params: $params, enabled: not_yet, run: not_yet_run, journal: true } + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: Some($sc), alt_shortcuts: &[], params: $params, enabled: not_yet, run: not_yet_run, journal: true } }; } diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index f98ea5ed..b1a5b018 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -169,10 +169,10 @@ fn with_to(p: &Value, to: &str) -> Value { pub fn specs() -> Vec { vec![ cmd!("time.set", "Go to Time...", ["View"], Some("Alt+Shift+J"), "{time? (s) | frame? | timecode?}", has_comp, set), - cmd!("time.nextFrame", "Next Frame", [], Some("PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))), - cmd!("time.previousFrame", "Previous Frame", [], Some("PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))), - cmd!("time.forward10", "Forward 10 Frames", [], Some("Shift+PageDown"), "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), - cmd!("time.back10", "Back 10 Frames", [], Some("Shift+PageUp"), "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), + cmd!("time.nextFrame", "Next Frame", [], ["PageDown", "Cmd+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 1}))), + cmd!("time.previousFrame", "Previous Frame", [], ["PageUp", "Cmd+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -1}))), + cmd!("time.forward10", "Forward 10 Frames", [], ["Shift+PageDown", "Cmd+Shift+ArrowRight"], "{}", has_comp, |s, _| step(s, &json!({"frames": 10}))), + cmd!("time.back10", "Back 10 Frames", [], ["Shift+PageUp", "Cmd+Shift+ArrowLeft"], "{}", has_comp, |s, _| step(s, &json!({"frames": -10}))), cmd!("time.step", "Step Frames", [], None, "{frames}", has_comp, step), cmd!("time.start", "Go to Start", [], Some("Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))), cmd!("time.end", "Go to End", [], Some("End"), "{}", has_comp, |s, _| go(s, &json!({"to": "end"}))), diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index cd628b9f..942184e3 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { let entries = crate::menus::entries(); for (path, e) in &entries { let key = binding_key(&e.command, &e.params); - let defaults = e.shortcut.iter().filter_map(|s| normalize(s)).collect(); + // A plain entry also gets its command's further default keys. + let alt = if e.params.is_null() { crate::commands::find(&e.command).map_or(&[][..], |c| c.alt_shortcuts) } else { &[] }; + let defaults = e.shortcut.as_deref().into_iter().chain(alt.iter().copied()).filter_map(normalize).collect(); push( Bindable { key, @@ -106,7 +108,7 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) { continue; } - let defaults = c.shortcut.and_then(normalize).into_iter().collect(); + let defaults = c.shortcut.into_iter().chain(c.alt_shortcuts.iter().copied()).filter_map(normalize).collect(); push( Bindable { key: c.id.to_string(), diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index 1a445205..630785f0 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -333,6 +333,24 @@ fn defaults_have_no_conflicts_and_match_the_menus() { assert!(t.bindables.len() > 500); } +/// Cmd+Left / Cmd+Right Arrow step a frame like Page Up / Page Down (Shift: 10 frames). +#[test] +fn cmd_arrows_step_frames_like_page_keys() { + let s = Session::default(); + let t = s.shortcuts(); + for (id, keys) in [ + ("time.nextFrame", ["PageDown", "Cmd+ArrowRight"]), + ("time.previousFrame", ["PageUp", "Cmd+ArrowLeft"]), + ("time.forward10", ["Shift+PageDown", "Cmd+Shift+ArrowRight"]), + ("time.back10", ["Shift+PageUp", "Cmd+Shift+ArrowLeft"]), + ] { + assert_eq!(t.keys.get(id).map(Vec::as_slice), Some(keys.map(String::from).as_slice()), "{id}"); + // The menus and tooltips keep showing the Page key. + assert_eq!(t.shortcut_of(id, &json!(null)), Some(keys[0])); + } + assert!(t.all_conflicts().is_empty(), "{:?}", t.all_conflicts()); +} + #[test] fn shortcut_edits_detect_conflicts_and_create_a_custom_preset() { let store = Arc::new(MemoryConfig::default()); diff --git a/crates/ui-egui/tests/ui_frame_step.rs b/crates/ui-egui/tests/ui_frame_step.rs new file mode 100644 index 00000000..cd3ac329 --- /dev/null +++ b/crates/ui-egui/tests/ui_frame_step.rs @@ -0,0 +1,44 @@ +//! Stepping the current time from the keyboard: Page Down / Page Up and Cmd+Right / Cmd+Left +//! Arrow move one frame, with Shift ten. + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Key, Modifiers}; +use egui_kittest::Harness; +use serde_json::json; + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.event(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.step(); + h.event(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); +} + +#[test] +fn cmd_arrows_step_the_current_frame() { + let mut session = Session::default(); + session.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30.0, "duration": 4.0})).unwrap(); + let cid = session.state.active_comp.unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(2); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + }; + assert_eq!(frame(&h), 0); + let cmd = Modifiers::COMMAND; + let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT; + key(&mut h, Key::ArrowRight, cmd); + assert_eq!(frame(&h), 1); + key(&mut h, Key::PageDown, Modifiers::NONE); + assert_eq!(frame(&h), 2); + key(&mut h, Key::ArrowRight, cmd_shift); + assert_eq!(frame(&h), 12); + key(&mut h, Key::ArrowLeft, cmd); + assert_eq!(frame(&h), 11); + key(&mut h, Key::ArrowLeft, cmd_shift); + assert_eq!(frame(&h), 1); + // The plain arrows still nudge layers, not the time. + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(frame(&h), 1); +}