diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index 1bc4638d..b9d81998 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -100,6 +100,14 @@ fn go(s: &mut Session, p: &Value) -> Result { "workEnd" => comp.work_area.1 - comp.frame_duration(), "layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t), "layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t), + "nextInOut" | "prevInOut" => { + // In and Out points (its last frame) of the selected layers, of every layer with none + // selected. + let sel = &s.state.selected_layers; + let times = + comp.layers.iter().filter(|l| sel.is_empty() || sel.contains(&l.id)).flat_map(|l| [l.in_point, l.out_point - comp.frame_duration()]).collect(); + step_to(times, t, comp.frame_duration(), to == "nextInOut") + } "nextKey" | "prevKey" if visible.is_some() => { // What the Timeline shows: keys of its revealed properties, layer and comp markers // and the work area (After Effects' "visible items"). @@ -148,7 +156,7 @@ fn go(s: &mut Session, p: &Value) -> Result { } step_to(times, t, comp.frame_duration(), to == "nextKey") } - _ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey")), + _ => return Err(super::bad("time.go", "to: start|end|workStart|workEnd|layerIn|layerOut|nextInOut|prevInOut|nextKey|prevKey")), }; s.set_time(target); Ok(report(s)) @@ -180,6 +188,13 @@ pub fn specs() -> Vec { 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"}))), + cmd!("time.workAreaStart", "Go to Work Area Start", [], Some("Shift+Home"), "{}", has_comp, |s, _| go(s, &json!({"to": "workStart"}))), + cmd!("time.workAreaEnd", "Go to Work Area End", [], Some("Shift+End"), "{}", has_comp, |s, _| go(s, &json!({"to": "workEnd"}))), + cmd!("time.previousInOut", "Go to Previous In or Out Point", [], Some("Cmd+Alt+Shift+ArrowLeft"), "{}", has_comp, |s, _| go( + s, + &json!({"to": "prevInOut"}) + )), + cmd!("time.nextInOut", "Go to Next In or Out Point", [], Some("Cmd+Alt+Shift+ArrowRight"), "{}", has_comp, |s, _| go(s, &json!({"to": "nextInOut"}))), cmd!("time.layerIn", "Go to Layer In Point", [], Some("I"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerIn"}))), cmd!("time.layerOut", "Go to Layer Out Point", [], Some("O"), "{}", has_comp, |s, _| go(s, &json!({"to": "layerOut"}))), cmd!( @@ -200,7 +215,7 @@ pub fn specs() -> Vec { "Go To", [], None, - "{to: start|end|workStart|workEnd|layerIn|layerOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}", + "{to: start|end|workStart|workEnd|layerIn|layerOut|nextInOut|prevInOut|nextKey|prevKey, prop?: uid, visible?: [{layer, prop}]}", has_comp, go ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44..5b354ec0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1164,6 +1164,8 @@ mod tests_sequence; #[cfg(test)] mod tests_settings; #[cfg(test)] +mod tests_shortcut_cmds; +#[cfg(test)] mod tests_stubs; #[cfg(test)] mod tests_styles; diff --git a/crates/engine/src/tests_shortcut_cmds.rs b/crates/engine/src/tests_shortcut_cmds.rs new file mode 100644 index 00000000..88808fa1 --- /dev/null +++ b/crates/engine/src/tests_shortcut_cmds.rs @@ -0,0 +1,51 @@ +//! The keyboard-only commands: time navigation, the work area, selection and layer timing. + +use serde_json::json; + +use crate::Session; + +/// A 10 s, 30 fps comp with three solids (#1 on top), each trimmed to `spans` (seconds). +fn session(spans: [(f64, f64); 3]) -> (Session, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap(); + for _ in 0..3 { + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + } + let ids: Vec = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect(); + for (id, (a, b)) in ids.iter().zip(spans) { + s.execute("layer.timing", json!({"layers": [id], "in": a, "out": b})).unwrap(); + } + (s, ids) +} + +fn secs(s: &Session) -> f64 { + s.time().seconds() +} + +#[test] +fn time_goes_to_the_work_area_and_to_in_and_out_points() { + let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]); + s.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap(); + s.execute("time.workAreaStart", json!({})).unwrap(); + assert_eq!(secs(&s), 2.0); + s.execute("time.workAreaEnd", json!({})).unwrap(); + let frame = 1.0 / 30.0; + assert!((secs(&s) - (4.0 - frame)).abs() < 1e-9); + // In and Out points of the selected layers (an Out point is the layer's last frame). + s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap(); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + let mut seen = vec![]; + for _ in 0..5 { + s.execute("time.nextInOut", json!({})).unwrap(); + seen.push(secs(&s)); + } + let want = [1.0, 2.0, 3.0 - frame, 6.0 - frame, 6.0 - frame]; + assert!(seen.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{seen:?}"); + s.execute("time.previousInOut", json!({})).unwrap(); + assert!((secs(&s) - (3.0 - frame)).abs() < 1e-9); + // With no layer selected: every layer's points. + s.execute("edit.deselectAll", json!({})).unwrap(); + s.execute("time.set", json!({"time": 8.0})).unwrap(); + s.execute("time.nextInOut", json!({})).unwrap(); + assert!((secs(&s) - (10.0 - frame)).abs() < 1e-9); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..c0dec8f8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2118,6 +2118,20 @@ mod tests { assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}"); } + /// The default keys of the keyboard-only commands parse and reach them. + #[test] + fn keyboard_only_commands_are_bound() { + let app = EffectcraftApp::new(effectcraft_engine::Session::default()); + for (id, keys) in [ + ("time.workAreaStart", "Shift+Home"), + ("time.workAreaEnd", "Shift+End"), + ("time.previousInOut", "Cmd+Alt+Shift+ArrowLeft"), + ("time.nextInOut", "Cmd+Alt+Shift+ArrowRight"), + ] { + assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); + } + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());