Shortcuts: go to the work area ends and to In / Out points

time.workAreaStart / time.workAreaEnd (Shift+Home / End) and
time.previousInOut / time.nextInOut (Cmd+Alt+Shift+Left / Right): the In
and Out points of the selected layers, of every layer with none selected.
time.go takes nextInOut and prevInOut.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pravdomil 2026-10-12 20:53:49 +02:00
parent 9efe75330c
commit a644bb07c9
4 changed files with 84 additions and 2 deletions

View File

@ -100,6 +100,14 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
"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<Value> {
}
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<CommandSpec> {
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<CommandSpec> {
"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
),

View File

@ -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;

View File

@ -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<u64>) {
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<u64> = 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);
}

View File

@ -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());