Shortcuts: Cmd+Left / Cmd+Right Arrow step a frame

Previous / Next Frame also answer to Cmd+Left / Cmd+Right Arrow, and Back / Forward
10 Frames to Cmd+Shift+Left / Right, next to the Page keys (After Effects' second
default keys). A command spec can now list several default shortcuts
(`alt_shortcuts`, `cmd!(…, ["PageDown", "Cmd+ArrowRight"], …)`); menus and tooltips
keep showing the first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
pravdomilandClaude Opus 5.5 committed 2026-10-11 16:26:27 +02:00
1 parent 9a7d3e7699
commit 88bf89ea5d
8 files changed
+93 -9

No files matched your search

+1
View File
@@ -109,6 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
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,
@@ -405,6 +405,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Generate Fill Layer",
menu: &[],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: has_layers,
run: generate,
@@ -415,6 +416,7 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Content-Aware Fill Layer...",
menu: &["Layer", "New"],
shortcut: None,
alt_shortcuts: &[],
params: P,
enabled: always,
run: open_panel,
+18 -1
View File
@@ -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,
+2 -2
View File
@@ -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 }
};
}
+4 -4
View File
@@ -169,10 +169,10 @@ fn with_to(p: &Value, to: &str) -> Value {
pub fn specs() -> Vec<CommandSpec> {
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"}))),
+4 -2
View File
@@ -88,7 +88,9 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
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<Bindable> {
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(),
+18
View File
@@ -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());
+44
View File
@@ -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);
}