From a644bb07c9ced4cb4d6d7d1797e2e32457cca5c1 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:53:49 +0200 Subject: [PATCH 1/8] 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 --- crates/engine/src/commands/time.rs | 19 ++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_shortcut_cmds.rs | 51 ++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 14 +++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/tests_shortcut_cmds.rs 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()); From d444eceb90ead846f6f172a5c9a18184cfa474fd Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:57:21 +0200 Subject: [PATCH 2/8] Shortcuts: work area from the selection, most recent comp comp.workAreaToSelection (Cmd+Alt+B) spans the selected layers, or the whole composition with none selected. comp.activateRecent (Shift+Esc) goes back to the composition that was active most recently within the active one's nesting hierarchy; EditorState keeps recent_comps for it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/nav_more.rs | 88 ++++++++++++++++++++++++ crates/engine/src/lib.rs | 9 +++ crates/engine/src/tests_shortcut_cmds.rs | 46 +++++++++++++ crates/ui-egui/src/menus.rs | 2 + 5 files changed, 147 insertions(+) create mode 100644 crates/engine/src/commands/nav_more.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642..ba223567 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -39,6 +39,7 @@ pub mod markers; mod mask; pub mod mask_interp; pub(crate) mod model3d; +mod nav_more; pub mod paint; pub mod panels_cmds; pub(crate) mod path_nulls; @@ -175,6 +176,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(paths::specs()); v.extend(effect::specs()); v.extend(time::specs()); + v.extend(nav_more::specs()); v.extend(render_queue::specs()); v.extend(help::specs()); v.extend(query::specs()); diff --git a/crates/engine/src/commands/nav_more.rs b/crates/engine/src/commands/nav_more.rs new file mode 100644 index 00000000..3617f9a0 --- /dev/null +++ b/crates/engine/src/commands/nav_more.rs @@ -0,0 +1,88 @@ +//! Keyboard-driven commands that have no menu entry. + +use std::collections::BTreeSet; + +use effectcraft_project::{ItemId, LayerSource, Project}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, comp_id, has_comp, layers_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Ctrl+Alt+B: the work area spans the selected layers (their earliest In to their latest Out +/// point), or the whole composition with none selected. +fn work_area_to_selection(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + s.edit("Work Area", None, |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let fd = c.frame_duration(); + let spans: Vec<(Tick, Tick)> = c.layers.iter().filter(|l| ids.contains(&l.id)).map(|l| (l.in_point, l.out_point)).collect(); + let (start, end) = match (spans.iter().map(|s| s.0).min(), spans.iter().map(|s| s.1).max()) { + (Some(a), Some(b)) => (a, b), + _ => (Tick::ZERO, c.duration), + }; + // min/max, not clamp: the bounds cross on a comp shorter than a frame. + let start = start.min(c.duration - fd).max(Tick::ZERO); + c.work_area = (start, end.min(c.duration).max(start + fd)); + Ok(()) + })?; + let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; + Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()})) +} + +/// The comps connected to `from` by nesting, in either direction (its network of nested +/// compositions), `from` included. +fn hierarchy(proj: &Project, from: ItemId) -> BTreeSet { + let mut edges: Vec<(ItemId, ItemId)> = Vec::new(); + for item in proj.items.values() { + let Some(c) = item.as_comp() else { continue }; + for l in &c.layers { + if let LayerSource::Comp { item: nested } = &l.source { + edges.push((item.id, *nested)); + } + } + } + let mut seen = BTreeSet::from([from]); + let mut todo = vec![from]; + while let Some(c) = todo.pop() { + for (a, b) in &edges { + let other = if *a == c { + *b + } else if *b == c { + *a + } else { + continue; + }; + if seen.insert(other) { + todo.push(other); + } + } + } + seen +} + +/// Shift+Esc: activate the composition that was active most recently among those in the active +/// composition's nesting hierarchy. +fn activate_recent(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let related = hierarchy(&s.project, cid); + let next = s.state.recent_comps.iter().copied().find(|c| *c != cid && related.contains(c) && s.project.comp(*c).is_some()); + let Some(next) = next else { return Ok(Value::Null) }; + s.open_comp(next); + Ok(json!({"comp": next.0})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "comp.workAreaToSelection", + "Set Work Area to Selected Layers", + [], + Some("Cmd+Alt+B"), + "{comp?, layers? (default the selected layers; the whole composition with none)}", + has_comp, + work_area_to_selection + ), + cmd!("comp.activateRecent", "Activate Most Recent Composition in Hierarchy", [], Some("Shift+Escape"), "{comp?}", has_comp, activate_recent), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5b354ec0..a92e4192 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -200,6 +200,9 @@ pub struct EditorState { pub active_comp: Option, /// Open compositions (viewer/timeline tabs), in tab order. pub open_comps: Vec, + /// Compositions that were active before the current one, most recent first (Shift+Esc). + #[serde(default)] + pub recent_comps: Vec, /// Current time per comp (the CTI). pub times: std::collections::BTreeMap, pub selected_layers: Vec, @@ -879,6 +882,12 @@ impl Session { self.state.open_comps.push(id); } if self.state.active_comp != Some(id) { + let previous = self.state.active_comp; + self.state.recent_comps.retain(|c| *c != id && Some(*c) != previous); + if let Some(previous) = previous { + self.state.recent_comps.insert(0, previous); + self.state.recent_comps.truncate(32); + } self.state.active_comp = Some(id); self.state.selected_layers.clear(); self.state.selected_props.clear(); diff --git a/crates/engine/src/tests_shortcut_cmds.rs b/crates/engine/src/tests_shortcut_cmds.rs index 88808fa1..30d2daa2 100644 --- a/crates/engine/src/tests_shortcut_cmds.rs +++ b/crates/engine/src/tests_shortcut_cmds.rs @@ -1,5 +1,6 @@ //! The keyboard-only commands: time navigation, the work area, selection and layer timing. +use effectcraft_time::Tick; use serde_json::json; use crate::Session; @@ -49,3 +50,48 @@ fn time_goes_to_the_work_area_and_to_in_and_out_points() { s.execute("time.nextInOut", json!({})).unwrap(); assert!((secs(&s) - (10.0 - frame)).abs() < 1e-9); } + +#[test] +fn work_area_follows_the_selected_layers() { + let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]); + s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap(); + s.execute("comp.workAreaToSelection", json!({})).unwrap(); + let wa = s.active_comp().unwrap().work_area; + assert_eq!((wa.0.seconds(), wa.1.seconds()), (1.0, 6.0)); + // With nothing selected: the whole composition. + s.execute("edit.deselectAll", json!({})).unwrap(); + s.execute("comp.workAreaToSelection", json!({})).unwrap(); + let wa = s.active_comp().unwrap().work_area; + assert_eq!((wa.0, wa.1.seconds()), (Tick::ZERO, 10.0)); + // One undo step each. + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().work_area.1.seconds(), 6.0); + // Layers reaching outside the comp don't take the work area with them. + s.execute("layer.timing", json!({"layers": [ids[0]], "delta": -5.0})).unwrap(); + s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap(); + s.execute("comp.workAreaToSelection", json!({})).unwrap(); + let wa = s.active_comp().unwrap().work_area; + assert!(wa.0 == Tick::ZERO && wa.1 > wa.0); +} + +#[test] +fn the_most_recent_comp_of_the_hierarchy_comes_back() { + let mut s = Session::default(); + let new = |s: &mut Session, name: &str| s.execute("comp.new", json!({"name": name, "duration": 4})).unwrap()["comp"].as_u64().unwrap(); + let main = new(&mut s, "Main"); + let nested = new(&mut s, "Nested"); + let other = new(&mut s, "Other"); + // Main contains Nested; Other stands alone. + s.execute("comp.open", json!({"comp": main})).unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + s.execute("comp.open", json!({"comp": nested})).unwrap(); + s.execute("comp.open", json!({"comp": other})).unwrap(); + // Other has no related comp to go back to. + assert!(s.execute("comp.activateRecent", json!({})).unwrap().is_null()); + s.execute("comp.open", json!({"comp": main})).unwrap(); + // From Main: Other was active last, but Nested is the one in Main's hierarchy. + s.execute("comp.activateRecent", json!({})).unwrap(); + assert_eq!(s.state.active_comp.map(|c| c.0), Some(nested)); + s.execute("comp.activateRecent", json!({})).unwrap(); + assert_eq!(s.state.active_comp.map(|c| c.0), Some(main)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c0dec8f8..041a7779 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2127,6 +2127,8 @@ mod tests { ("time.workAreaEnd", "Shift+End"), ("time.previousInOut", "Cmd+Alt+Shift+ArrowLeft"), ("time.nextInOut", "Cmd+Alt+Shift+ArrowRight"), + ("comp.workAreaToSelection", "Cmd+Alt+B"), + ("comp.activateRecent", "Shift+Escape"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } From cf912d50491b398d299ab72222768214b6a33a52 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:59:48 +0200 Subject: [PATCH 3/8] Shortcuts: deselect keyframes and properties keys.deselectAll (Shift+F2, Cmd+Alt+Shift+A) clears the selected keyframes, properties and groups and leaves the layers selected. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/nav_more.rs | 11 ++++++++++- crates/engine/src/tests_shortcut_cmds.rs | 12 ++++++++++++ crates/ui-egui/src/menus.rs | 2 ++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/nav_more.rs b/crates/engine/src/commands/nav_more.rs index 3617f9a0..ce7fc57e 100644 --- a/crates/engine/src/commands/nav_more.rs +++ b/crates/engine/src/commands/nav_more.rs @@ -6,7 +6,7 @@ use effectcraft_project::{ItemId, LayerSource, Project}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, comp_id, has_comp, layers_p}; +use super::{CommandSpec, always, comp_id, has_comp, layers_p}; use crate::{EngineError, Result, Session, cmd}; /// Ctrl+Alt+B: the work area spans the selected layers (their earliest In to their latest Out @@ -30,6 +30,14 @@ fn work_area_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()})) } +/// Shift+F2: no keyframe, property or property group is selected; the layers stay selected. +fn deselect_keys(s: &mut Session, _: &Value) -> Result { + s.state.selected_keys.clear(); + s.state.selected_props.clear(); + s.state.selected_vertices.clear(); + Ok(Value::Null) +} + /// The comps connected to `from` by nesting, in either direction (its network of nested /// compositions), `from` included. fn hierarchy(proj: &Project, from: ItemId) -> BTreeSet { @@ -84,5 +92,6 @@ pub fn specs() -> Vec { work_area_to_selection ), cmd!("comp.activateRecent", "Activate Most Recent Composition in Hierarchy", [], Some("Shift+Escape"), "{comp?}", has_comp, activate_recent), + cmd!("keys.deselectAll", "Deselect All Keyframes and Properties", [], ["Shift+F2", "Cmd+Alt+Shift+A"], "{}", always, deselect_keys), ] } diff --git a/crates/engine/src/tests_shortcut_cmds.rs b/crates/engine/src/tests_shortcut_cmds.rs index 30d2daa2..64bcb744 100644 --- a/crates/engine/src/tests_shortcut_cmds.rs +++ b/crates/engine/src/tests_shortcut_cmds.rs @@ -95,3 +95,15 @@ fn the_most_recent_comp_of_the_hierarchy_comes_back() { s.execute("comp.activateRecent", json!({})).unwrap(); assert_eq!(s.state.active_comp.map(|c| c.0), Some(main)); } + +#[test] +fn keyframes_and_properties_deselect_but_layers_stay() { + let (mut s, ids) = session([(0.0, 10.0); 3]); + s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap(); + s.execute("prop.addKey", json!({"layer": ids[0], "path": "transform/opacity", "time": 1.0, "value": 50.0})).unwrap(); + s.execute("keys.selectAll", json!({})).unwrap(); + assert!(!s.state.selected_keys.is_empty()); + s.execute("keys.deselectAll", json!({})).unwrap(); + assert!(s.state.selected_keys.is_empty() && s.state.selected_props.is_empty()); + assert_eq!(s.state.selected_layers.len(), 1); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 041a7779..cb2cb0b6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2129,6 +2129,8 @@ mod tests { ("time.nextInOut", "Cmd+Alt+Shift+ArrowRight"), ("comp.workAreaToSelection", "Cmd+Alt+B"), ("comp.activateRecent", "Shift+Escape"), + ("keys.deselectAll", "Shift+F2"), + ("keys.deselectAll", "Cmd+Alt+Shift+A"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } From bf53ae60949b27199b3612c5dd134fd62e781b41 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:02:49 +0200 Subject: [PATCH 4/8] Shortcuts: layer selection, timing, masks and Casts Shadows - layer.extendSelectionNext / Previous (Cmd+Shift+Down / Up). - layer.moveInToStart / moveOutToEnd (Alt+Home / End). - layer.moveEarlier / moveLater (Alt+PageUp / PageDown) and layer.moveEarlier10 / moveLater10 (with Shift). Alt+PageUp / PageDown used to slip the source one frame; layer.slipBack / slipForward keep working but have no default key. - layer.stretchInToTime / stretchOutToTime (Cmd+Shift+, / Cmd+Alt+,): layer.timeStretch takes to: in|out. - layer.mask.selectNext / selectPrevious (Alt+` / Alt+Shift+`). - layer.castsShadows (Alt+Shift+C) for the selected 3D layers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 50 ++++++++++- crates/engine/src/commands/layer_time.rs | 27 +++++- crates/engine/src/commands/nav_more.rs | 52 ++++++++++- crates/engine/src/tests_fidelity.rs | 7 +- crates/engine/src/tests_shortcut_cmds.rs | 109 +++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 9 ++ 6 files changed, 246 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index d4cf1d90..3968ce1b 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -919,6 +919,14 @@ fn timing(s: &mut Session, p: &Value) -> Result { let d = t - l.out_point; shift(l, d); } + "moveInToStart" => { + let d = Tick::ZERO - l.in_point; + shift(l, d); + } + "moveOutToEnd" => { + let d = comp_dur - l.out_point; + shift(l, d); + } "trimInToTime" => l.in_point = t.min(l.out_point - fd), "trimOutToTime" => l.out_point = t.max(l.in_point + fd), _ => { @@ -951,6 +959,20 @@ fn timing(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// `p` with `key` set. +fn with(p: &Value, key: &str, v: Value) -> Value { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p[key] = v; + p +} + +/// Move the layers `frames` frames in time (Alt+Page Up / Page Down, with Shift ten frames). +fn move_frames(s: &mut Session, p: &Value, frames: i64) -> Result { + let (cid, _) = layers_p(s, p)?; + let delta = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate.tick_of(frames).seconds(); + timing(s, &with(p, "delta", json!(delta))) +} + /// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like /// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive /// moves the source later. Clamped so the source still covers the in–out span. Keyframes and @@ -1580,6 +1602,16 @@ pub fn specs() -> Vec { cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select), cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next), cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev), + cmd!("layer.extendSelectionNext", "Extend Selection to Next Layer", [], Some("Cmd+Shift+ArrowDown"), "{}", has_comp, |s, _| select_step( + s, + &json!({"add": true}), + 1 + )), + cmd!("layer.extendSelectionPrevious", "Extend Selection to Previous Layer", [], Some("Cmd+Shift+ArrowUp"), "{}", has_comp, |s, _| select_step( + s, + &json!({"add": true}), + -1 + )), cmd!( "layer.setSwitch", "Layer Switch", @@ -1607,17 +1639,29 @@ pub fn specs() -> Vec { "Layer Timing", [], None, - "{layers?, op?: moveInToTime|moveOutToTime|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}", + "{layers?, op?: moveInToTime|moveOutToTime|moveInToStart|moveOutToEnd|trimInToTime|trimOutToTime, delta?, start?, in?, out?, inDelta?, outDelta? (seconds, per layer), merge?}", has_layers, timing ), + cmd!("layer.moveInToStart", "Move Layer In Point to Beginning of Comp", [], Some("Alt+Home"), "{layers?}", has_layers, |s, p| timing( + s, + &with(p, "op", json!("moveInToStart")) + )), + cmd!("layer.moveOutToEnd", "Move Layer Out Point to End of Comp", [], Some("Alt+End"), "{layers?}", has_layers, |s, p| timing( + s, + &with(p, "op", json!("moveOutToEnd")) + )), + cmd!("layer.moveEarlier", "Move Layers 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -1)), + cmd!("layer.moveLater", "Move Layers 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 1)), + cmd!("layer.moveEarlier10", "Move Layers 10 Frames Earlier", [], Some("Alt+Shift+PageUp"), "{layers?}", has_layers, |s, p| move_frames(s, p, -10)), + cmd!("layer.moveLater10", "Move Layers 10 Frames Later", [], Some("Alt+Shift+PageDown"), "{layers?}", has_layers, |s, p| move_frames(s, p, 10)), cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), - cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| { + cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], None, "{layers?}", has_layers, |s, p| { let mut p = p.clone(); p["frames"] = json!(-1); slip(s, &p) }), - cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| { + cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], None, "{layers?}", has_layers, |s, p| { let mut p = p.clone(); p["frames"] = json!(1); slip(s, &p) diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs index 335481f7..d98bfdea 100644 --- a/crates/engine/src/commands/layer_time.rs +++ b/crates/engine/src/commands/layer_time.rs @@ -24,6 +24,11 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { let pct = f_p(p, "percent"); let dur = f_p(p, "duration"); let hold = str_p(p, "hold").unwrap_or("in").to_string(); + // `to`: stretch so the In (or Out) point lands on the current time, the other point held. + let to = str_p(p, "to").map(str::to_string); + if to.as_deref().is_some_and(|t| t != "in" && t != "out") { + return Err(bad("layer.timeStretch", "to: in|out")); + } let cti = s.time(); let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; let fd = comp_rate.frame_duration(); @@ -39,6 +44,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { continue; } let span = (l.out_point - l.in_point).seconds(); + let (dur, hold) = match to.as_deref() { + Some("in") => (Some((l.out_point - cti).seconds()), "out"), + Some(_) => (Some((cti - l.in_point).seconds()), "in"), + None => (dur, hold.as_str()), + }; + // The current time is on the wrong side of the held point: nothing to stretch to. + if to.is_some() && dur.is_some_and(|d| d < fd.seconds()) { + continue; + } + let pct = if to.is_some() { None } else { pct }; let new = match (pct, dur) { (Some(v), _) => v, (None, Some(d)) if span > 0.0 => old * d / span, @@ -48,7 +63,7 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); } // The hold point keeps showing the same layer time. - let h = match hold.as_str() { + let h = match hold { "current" => cti, "out" => l.out_point, _ => l.in_point, @@ -220,6 +235,16 @@ pub fn specs() -> Vec { has_layers, time_stretch ), + cmd!("layer.stretchInToTime", "Stretch Layer In Point to Current Time", [], Some("Cmd+Shift+,"), "{layers?}", has_layers, |s, p| { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["to"] = json!("in"); + time_stretch(s, &p) + }), + cmd!("layer.stretchOutToTime", "Stretch Layer Out Point to Current Time", [], Some("Cmd+Alt+,"), "{layers?}", has_layers, |s, p| { + let mut p = if p.is_object() { p.clone() } else { json!({}) }; + p["to"] = json!("out"); + time_stretch(s, &p) + }), cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| { let mut p = p.clone(); p["op"] = json!("reverse"); diff --git a/crates/engine/src/commands/nav_more.rs b/crates/engine/src/commands/nav_more.rs index ce7fc57e..d008d1ef 100644 --- a/crates/engine/src/commands/nav_more.rs +++ b/crates/engine/src/commands/nav_more.rs @@ -2,11 +2,12 @@ use std::collections::BTreeSet; +use effectcraft_keyframe::Value as KV; use effectcraft_project::{ItemId, LayerSource, Project}; use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, always, comp_id, has_comp, layers_p}; +use super::{CommandSpec, always, bad, comp_id, has_comp, has_layers, layers_p}; use crate::{EngineError, Result, Session, cmd}; /// Ctrl+Alt+B: the work area spans the selected layers (their earliest In to their latest Out @@ -38,6 +39,52 @@ fn deselect_keys(s: &mut Session, _: &Value) -> Result { Ok(Value::Null) } +/// Alt+` / Alt+Shift+`: select the next (previous) mask of the layer, wrapping around; the first +/// (last) one when none is selected. +fn step_mask(s: &mut Session, p: &Value, dir: i64) -> Result { + let cmd = if dir > 0 { "layer.mask.selectNext" } else { "layer.mask.selectPrevious" }; + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (lid, masks) = ids + .iter() + .filter_map(|id| comp.layer(*id)) + .find_map(|l| l.masks().filter(|m| m.groups().next().is_some()).map(|m| (l.id, m))) + .ok_or_else(|| bad(cmd, "the selected layers have no masks"))?; + let groups: Vec<_> = masks.groups().collect(); + let n = groups.len() as i64; + // The selected mask: its group, or a property inside it. + let selected = |g: &effectcraft_project::PropGroup| { + s.state.selected_props.iter().any(|(l, uid)| *l == lid && (g.find_group(*uid).is_some() || g.find(*uid).is_some())) + }; + let i = match groups.iter().position(|g| selected(g)) { + Some(i) => (i as i64 + dir).rem_euclid(n.max(1)), + None if dir > 0 => 0, + None => n - 1, + }; + let uid = groups.get(i as usize).map(|g| g.uid).ok_or_else(|| bad(cmd, "the selected layers have no masks"))?; + s.state.selected_props = vec![(lid, uid)]; + s.state.selected_keys.clear(); + s.state.selected_vertices.clear(); + Ok(json!({"layer": lid.0, "mask": uid, "index": i + 1})) +} + +/// Alt+Shift+C: Casts Shadows on or off for the selected 3D layers (all follow the first one). +fn casts_shadows(s: &mut Session, p: &Value) -> Result { + const PATH: &str = "materialOptions/castsShadows"; + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let with: Vec<_> = ids.iter().filter_map(|id| comp.layer(*id)).filter(|l| l.switches.three_d && l.props.prop(PATH).is_some()).collect(); + let first = with.first().ok_or_else(|| bad("layer.castsShadows", "select a 3D layer"))?; + let on = match p.get("value").and_then(Value::as_bool) { + Some(v) => v, + None => first.props.prop(PATH).is_none_or(|pr| pr.value == KV::Enum(0)), + }; + let layers: Vec = with.iter().map(|l| l.id.0).collect(); + let set = super::find("material.set").ok_or_else(|| bad("layer.castsShadows", "Material Options are not available"))?; + (set.run)(s, &json!({"comp": cid.0, "layers": layers, "castsShadows": if on { "on" } else { "off" }}))?; + Ok(json!({"castsShadows": on, "layers": layers})) +} + /// The comps connected to `from` by nesting, in either direction (its network of nested /// compositions), `from` included. fn hierarchy(proj: &Project, from: ItemId) -> BTreeSet { @@ -93,5 +140,8 @@ pub fn specs() -> Vec { ), cmd!("comp.activateRecent", "Activate Most Recent Composition in Hierarchy", [], Some("Shift+Escape"), "{comp?}", has_comp, activate_recent), cmd!("keys.deselectAll", "Deselect All Keyframes and Properties", [], ["Shift+F2", "Cmd+Alt+Shift+A"], "{}", always, deselect_keys), + cmd!("layer.mask.selectNext", "Select Next Mask", [], Some("Alt+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, 1)), + cmd!("layer.mask.selectPrevious", "Select Previous Mask", [], Some("Alt+Shift+`"), "{layer?}", has_layers, |s, p| step_mask(s, p, -1)), + cmd!("layer.castsShadows", "Casts Shadows", [], Some("Alt+Shift+C"), "{layers?, value?}", has_layers, casts_shadows), ] } diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests_fidelity.rs index e5e84c03..ebe37560 100644 --- a/crates/engine/src/tests_fidelity.rs +++ b/crates/engine/src/tests_fidelity.rs @@ -67,7 +67,7 @@ fn slip_moves_the_source_under_fixed_in_and_out() { let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap(); assert_eq!(get(&s).start_time.seconds(), 1.0); assert_eq!(r["slipped"][0]["delta"], json!(0.5)); - // Alt+PageUp / Alt+PageDown nudge by a frame. + // Slip by a frame. s.execute("layer.slipBack", json!({"layers": [id]})).unwrap(); assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9); s.execute("layer.slipForward", json!({"layers": [id]})).unwrap(); @@ -75,8 +75,9 @@ fn slip_moves_the_source_under_fixed_in_and_out() { // Undo walks back through the slips. assert!(s.undo() && s.undo() && s.undo() && s.undo()); assert_eq!(get(&s).start_time, Tick::ZERO); - assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp")); - assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown")); + // Alt+PageUp / Alt+PageDown move the layer; the one-frame slips have no default key. + assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), None); + assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), None); // Layers without a source duration can't slip. s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err()); diff --git a/crates/engine/src/tests_shortcut_cmds.rs b/crates/engine/src/tests_shortcut_cmds.rs index 64bcb744..dd306ef9 100644 --- a/crates/engine/src/tests_shortcut_cmds.rs +++ b/crates/engine/src/tests_shortcut_cmds.rs @@ -23,6 +23,12 @@ fn secs(s: &Session) -> f64 { s.time().seconds() } +fn span(s: &Session, id: u64) -> (f64, f64) { + let c = s.active_comp().unwrap(); + let l = c.layers.iter().find(|l| l.id.0 == id).unwrap(); + (l.in_point.seconds(), l.out_point.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)]); @@ -107,3 +113,106 @@ fn keyframes_and_properties_deselect_but_layers_stay() { assert!(s.state.selected_keys.is_empty() && s.state.selected_props.is_empty()); assert_eq!(s.state.selected_layers.len(), 1); } + +#[test] +fn selection_extends_through_the_stack() { + let (mut s, ids) = session([(0.0, 10.0); 3]); + s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap(); + s.execute("layer.extendSelectionNext", json!({})).unwrap(); + s.execute("layer.extendSelectionNext", json!({})).unwrap(); + let mut sel: Vec = s.state.selected_layers.iter().map(|l| l.0).collect(); + sel.sort(); + let mut all = ids.clone(); + all.sort(); + assert_eq!(sel, all); + // At the end of the stack the selection stays. + s.execute("layer.extendSelectionNext", json!({})).unwrap(); + assert_eq!(s.state.selected_layers.len(), 3); + s.execute("layer.select", json!({"layers": [ids[2]]})).unwrap(); + s.execute("layer.extendSelectionPrevious", json!({})).unwrap(); + assert_eq!(s.state.selected_layers.len(), 2); + assert!(s.state.selected_layers.iter().any(|l| l.0 == ids[1])); +} + +#[test] +fn layers_move_to_the_comp_ends_and_by_ten_frames() { + let (mut s, ids) = session([(1.0, 3.0), (2.0, 6.0), (0.0, 10.0)]); + s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap(); + s.execute("layer.moveInToStart", json!({})).unwrap(); + assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((0.0, 2.0), (0.0, 4.0))); + s.execute("layer.moveOutToEnd", json!({})).unwrap(); + assert_eq!((span(&s, ids[0]), span(&s, ids[1])), ((8.0, 10.0), (6.0, 10.0))); + s.execute("layer.moveEarlier10", json!({})).unwrap(); + let (a, b) = span(&s, ids[0]); + assert!((a - (8.0 - 10.0 / 30.0)).abs() < 1e-9 && (b - (10.0 - 10.0 / 30.0)).abs() < 1e-9); + s.execute("layer.moveLater10", json!({})).unwrap(); + assert_eq!(span(&s, ids[0]), (8.0, 10.0)); + s.execute("layer.moveLater", json!({})).unwrap(); + s.execute("layer.moveEarlier", json!({})).unwrap(); + assert_eq!(span(&s, ids[0]), (8.0, 10.0)); + // The unselected layer never moved. + assert_eq!(span(&s, ids[2]), (0.0, 10.0)); + for (id, keys) in [ + ("layer.moveEarlier", "Alt+PageUp"), + ("layer.moveLater", "Alt+PageDown"), + ("layer.moveEarlier10", "Alt+Shift+PageUp"), + ("layer.moveLater10", "Alt+Shift+PageDown"), + ] { + assert_eq!(crate::find_command(id).and_then(|c| c.shortcut), Some(keys)); + } +} + +#[test] +fn in_and_out_points_stretch_to_the_current_time() { + let (mut s, ids) = session([(2.0, 4.0), (0.0, 10.0), (0.0, 10.0)]); + let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == ids[0]).unwrap().stretch; + s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap(); + // In point to 1 s: the Out point holds, the layer is 3 s long at 150 %. + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("layer.stretchInToTime", json!({})).unwrap(); + assert_eq!(span(&s, ids[0]), (1.0, 4.0)); + assert!((stretch(&s) - 150.0).abs() < 1e-6); + // Out point to 7 s: the In point holds, 6 s at 300 %. + s.execute("time.set", json!({"time": 7.0})).unwrap(); + s.execute("layer.stretchOutToTime", json!({})).unwrap(); + assert_eq!(span(&s, ids[0]), (1.0, 7.0)); + assert!((stretch(&s) - 300.0).abs() < 1e-6); + // A current time before the In point can't be the Out point: the layer is left alone. + s.execute("time.set", json!({"time": 0.5})).unwrap(); + s.execute("layer.stretchOutToTime", json!({})).unwrap(); + assert_eq!(span(&s, ids[0]), (1.0, 7.0)); + assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err()); +} + +#[test] +fn masks_are_stepped_through_and_wrap() { + let (mut s, ids) = session([(0.0, 10.0); 3]); + s.execute("layer.select", json!({"layers": [ids[0]]})).unwrap(); + assert!(s.execute("layer.mask.selectNext", json!({})).is_err()); + for _ in 0..3 { + s.execute("layer.addMask", json!({"shape": "rect"})).unwrap(); + } + s.execute("keys.deselectAll", json!({})).unwrap(); + let step = |s: &mut Session, cmd: &str| s.execute(cmd, json!({})).unwrap()["index"].as_u64().unwrap(); + assert_eq!(step(&mut s, "layer.mask.selectNext"), 1); + assert_eq!(step(&mut s, "layer.mask.selectNext"), 2); + assert_eq!(step(&mut s, "layer.mask.selectNext"), 3); + assert_eq!(step(&mut s, "layer.mask.selectNext"), 1); + assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3); + assert_eq!(s.state.selected_props.len(), 1); + s.execute("keys.deselectAll", json!({})).unwrap(); + assert_eq!(step(&mut s, "layer.mask.selectPrevious"), 3); +} + +#[test] +fn casts_shadows_toggles_on_3d_layers_only() { + let (mut s, ids) = session([(0.0, 10.0); 3]); + s.execute("layer.select", json!({"layers": [ids[0], ids[1]]})).unwrap(); + assert!(s.execute("layer.castsShadows", json!({})).is_err()); + s.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "threeD", "value": true})).unwrap(); + let before = s.execute("layer.castsShadows", json!({})).unwrap(); + assert_eq!(before["layers"], json!([ids[0]])); + let after = s.execute("layer.castsShadows", json!({})).unwrap(); + assert_ne!(before["castsShadows"], after["castsShadows"]); + assert_eq!(s.execute("layer.castsShadows", json!({"value": true})).unwrap()["castsShadows"], json!(true)); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb2cb0b6..77750b24 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2131,6 +2131,15 @@ mod tests { ("comp.activateRecent", "Shift+Escape"), ("keys.deselectAll", "Shift+F2"), ("keys.deselectAll", "Cmd+Alt+Shift+A"), + ("layer.extendSelectionNext", "Cmd+Shift+ArrowDown"), + ("layer.moveInToStart", "Alt+Home"), + ("layer.moveEarlier", "Alt+PageUp"), + ("layer.moveLater10", "Alt+Shift+PageDown"), + ("layer.stretchInToTime", "Cmd+Shift+,"), + ("layer.stretchOutToTime", "Cmd+Alt+,"), + ("layer.mask.selectNext", "Alt+`"), + ("layer.mask.selectPrevious", "Alt+Shift+`"), + ("layer.castsShadows", "Alt+Shift+C"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } From f7b366197dc70d3fc95936a2fa304c75995f228e Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:04:50 +0200 Subject: [PATCH 5/8] Shortcuts: scroll the Timeline, Parent column timeline.scrollToTime (D) centres the current time in the time graph, timeline.scrollToLayer (X) puts the topmost selected layer at the top; both are done when the Timeline is next drawn. timeline.parentColumn (Shift+F4) shows or hides the Parent column. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 31 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 32 +++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 13 +++++++++++ 3 files changed, 76 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 77750b24..397543ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -53,6 +53,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("timeline.zoomIn", "Zoom In Time", [], Some("=")), uic!("timeline.zoomOut", "Zoom Out Time", [], Some("-")), uic!("timeline.zoomFit", "Zoom to Fit Comp", [], None), + uic!("timeline.scrollToTime", "Scroll to Current Time", [], Some("D")), + uic!("timeline.scrollToLayer", "Scroll Topmost Selected Layer to Top", [], Some("X")), + uic!("timeline.parentColumn", "Show/Hide Parent Column", [], Some("Shift+F4")), uic!("timeline.zoomFrameToggle", "Zoom In to Frame Level / Out to the Whole Comp", [], Some(";")), uic!("timeline.graphEditor", "Graph Editor", [], Some("Shift+F3")), uic!("timeline.switchesModes", "Toggle Switches / Modes", [], Some("F4")), @@ -565,6 +568,13 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V app.ui.viewer.pan = [0.0, 0.0]; return Ok(Value::Null); } + "timeline.scrollToTime" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Time), + "timeline.scrollToLayer" => app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Layer), + "timeline.parentColumn" => { + let on = !crate::panels::timeline::column_visible(&app.ui.timeline, "parent"); + crate::panels::timeline::set_column(&mut app.ui.timeline, "parent", on)?; + return Ok(json!({"parent": on})); + } "view.actualSize" if app.ui.focused == PanelKind::Layer => { app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.layer_pan = [0.0, 0.0]; @@ -2140,11 +2150,32 @@ mod tests { ("layer.mask.selectNext", "Alt+`"), ("layer.mask.selectPrevious", "Alt+Shift+`"), ("layer.castsShadows", "Alt+Shift+C"), + ("timeline.scrollToTime", "D"), + ("timeline.scrollToLayer", "X"), + ("timeline.parentColumn", "Shift+F4"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } } + #[test] + fn timeline_keys_scroll_and_toggle_the_parent_column() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap(); + // D and X leave a scroll for the Timeline to do when it is drawn. + run(&mut app, "timeline.scrollToTime"); + assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Time)); + run(&mut app, "timeline.scrollToLayer"); + assert_eq!(app.ui.timeline.scroll_to, Some(crate::state::ScrollTo::Layer)); + // Shift+F4 shows and hides the Parent column. + let parent = crate::panels::timeline::column_visible(&app.ui.timeline, "parent"); + run(&mut app, "timeline.parentColumn"); + assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), !parent); + run(&mut app, "timeline.parentColumn"); + assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), parent); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..e9a92698 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -230,6 +230,14 @@ fn page_to_cti(start: f64, span: f64, duration: f64, cti: f64) -> f64 { cti.min(duration - span).max(0.0) } +/// The visible span's start (seconds) that puts `cti` in its middle, kept inside the comp. +fn centered_on(span: f64, duration: f64, cti: f64) -> f64 { + if !(span.is_finite() && duration.is_finite() && cti.is_finite()) { + return 0.0; + } + (cti - span / 2.0).min(duration - span).max(0.0) +} + /// The text of an expression being edited when its pick whip was pressed, and the selection in /// it (characters): the pick whip's reference goes in there. type WhipInto = (String, [usize; 2]); @@ -1302,6 +1310,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); app.ui.timeline.start = page_to_cti(app.ui.timeline.start, span, comp.duration.seconds(), app.session.time().seconds()); } + let scroll_to = app.ui.timeline.scroll_to.take(); + if scroll_to == Some(crate::state::ScrollTo::Time) && app.ui.timeline.pps.is_some() { + let span = ((graph_x1 - graph_x0 - 6.0) as f64 / pps).max(0.0); + app.ui.timeline.start = centered_on(span, comp.duration.seconds(), app.session.time().seconds()); + } let tm = TMap { x0: graph_x0 + 6.0, start: app.ui.timeline.start, pps }; ctx.data_mut(|d| d.insert_temp(tl_map_id(), (tm.x0, tm.start, tm.pps))); let top = rect.min.y; @@ -1709,6 +1722,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rh = t.row_h; let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); + if scroll_to == Some(crate::state::ScrollTo::Layer) { + // The topmost selected layer, in stack order. + let sel = &app.session.state.selected_layers; + let top = comp.layers.iter().find(|l| sel.contains(&l.id)).map(|l| l.id); + if let Some(i) = rows.iter().position(|r| matches!(r.kind, RowKind::Layer) && Some(r.layer) == top) { + app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum(); + } + } if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll @@ -3984,6 +4005,17 @@ mod tests { assert!(app.ui.timeline.reveal_props.len() > n_keys, "UU reveals modified properties too"); } + #[test] + fn scroll_to_time_centres_the_current_time_inside_the_comp() { + assert_eq!(centered_on(4.0, 10.0, 5.0), 3.0); + // Near the ends the view stops at the comp's start and end. + assert_eq!(centered_on(4.0, 10.0, 1.0), 0.0); + assert_eq!(centered_on(4.0, 10.0, 9.5), 6.0); + // A span longer than the comp, and bad numbers, start at zero. + assert_eq!(centered_on(20.0, 10.0, 5.0), 0.0); + assert_eq!(centered_on(f64::NAN, 10.0, 5.0), 0.0); + } + /// U and the other reveal shortcuts act on the selected layers only: other layers keep their /// twirl state and their own reveal (#160). #[test] diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a23279..b0e901ee 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -317,6 +317,15 @@ impl TimelineState { } } +/// Where a Timeline shortcut scrolls to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollTo { + /// D: the current time, centred in the time graph. + Time, + /// X: the topmost selected layer, at the top of the layers. + Layer, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. @@ -385,6 +394,9 @@ pub struct TimelineState { /// The name column shows Source Name instead of Layer Name (click its header). #[serde(default)] pub source_name: bool, + /// A scroll asked for by a shortcut (D, X), done when the Timeline is next drawn. + #[serde(skip)] + pub scroll_to: Option, } pub fn default_tl_columns() -> BTreeSet { @@ -426,6 +438,7 @@ impl Default for TimelineState { unlinked: BTreeSet::new(), columns: default_tl_columns(), source_name: false, + scroll_to: None, } } } From 00799d8c7706bd46d60af22147641f8263ee79c0 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:06:24 +0200 Subject: [PATCH 6/8] Shortcuts: fit up to 100 %, step through open comps view.fitUpTo100 (Alt+/) fits the viewer without magnifying past 100 %. view.previousItem / view.nextItem (Shift+, / Shift+.) activate the previous / next open composition, wrapping. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 48 +++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 397543ce..85d95cb6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -35,6 +35,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("playback.stop", "Stop", [], None), uic!("view.fit", "Fit", [], Some("Shift+/")), uic!("view.actualSize", "100%", [], Some("/")), + uic!("view.fitUpTo100", "Fit up to 100%", [], Some("Alt+/")), + uic!("view.nextItem", "Next Composition in Viewer", [], Some("Shift+.")), + uic!("view.previousItem", "Previous Composition in Viewer", [], Some("Shift+,")), uic!("viewer.nudge.up", "Move Selected Layers Up 1 Pixel at Current Magnification", [], Some("ArrowUp")), uic!("viewer.nudge.down", "Move Selected Layers Down 1 Pixel at Current Magnification", [], Some("ArrowDown")), uic!("viewer.nudge.left", "Move Selected Layers Left 1 Pixel at Current Magnification", [], Some("ArrowLeft")), @@ -575,6 +578,22 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::timeline::set_column(&mut app.ui.timeline, "parent", on)?; return Ok(json!({"parent": on})); } + "view.fitUpTo100" => { + app.ui.viewer.zoom = None; + app.ui.viewer.fit_up_to_100 = true; + app.ui.viewer.pan = [0.0, 0.0]; + return Ok(Value::Null); + } + // Shift+, / Shift+.: the previous / next open composition (the viewer's tabs). + "view.nextItem" | "view.previousItem" => { + let open = app.session.state.open_comps.clone(); + let Some(i) = app.session.state.active_comp.and_then(|c| open.iter().position(|o| *o == c)) else { return Ok(Value::Null) }; + let step = if id == "view.nextItem" { 1 } else { open.len().saturating_sub(1) }; + let Some(next) = open.get((i + step) % open.len().max(1)).copied().filter(|n| Some(*n) != app.session.state.active_comp) else { + return Ok(Value::Null); + }; + return run_engine(app, ctx, "comp.open", json!({"comp": next.0})); + } "view.actualSize" if app.ui.focused == PanelKind::Layer => { app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.layer_pan = [0.0, 0.0]; @@ -2153,6 +2172,9 @@ mod tests { ("timeline.scrollToTime", "D"), ("timeline.scrollToLayer", "X"), ("timeline.parentColumn", "Shift+F4"), + ("view.fitUpTo100", "Alt+/"), + ("view.nextItem", "Shift+."), + ("view.previousItem", "Shift+,"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } @@ -2176,6 +2198,32 @@ mod tests { assert_eq!(crate::panels::timeline::column_visible(&app.ui.timeline, "parent"), parent); } + #[test] + fn viewer_keys_fit_and_step_through_open_comps() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap(); + let new = |app: &mut EffectcraftApp, name: &str| app.session.execute("comp.new", json!({"name": name, "duration": 4})).unwrap()["comp"].as_u64(); + let active = |app: &EffectcraftApp| app.session.state.active_comp.map(|c| c.0); + // With one composition open there is nothing to step to. + let a = new(&mut app, "A"); + run(&mut app, "view.nextItem"); + assert_eq!(active(&app), a); + // Shift+, / Shift+.: through the open compositions, wrapping. + let b = new(&mut app, "B"); + assert_eq!(active(&app), b); + run(&mut app, "view.nextItem"); + assert_eq!(active(&app), a); + run(&mut app, "view.previousItem"); + assert_eq!(active(&app), b); + // Alt+/ fits the viewer without magnifying past 100 %; Shift+/ fits fully again. + app.ui.viewer.zoom = Some(3.0); + run(&mut app, "view.fitUpTo100"); + assert!(app.ui.viewer.zoom.is_none() && app.ui.viewer.fit_up_to_100); + run(&mut app, "view.fit"); + assert!(!app.ui.viewer.fit_up_to_100); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From 81521d9b0646cf523c1170b0cedf46e7b13f343e Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:08:43 +0200 Subject: [PATCH 7/8] Shortcuts: switch, cycle and close panels window.toggleCompTimeline (\) switches between the Composition viewer and the Timeline. window.previousPanel / nextPanel (Alt+Shift+, / .) step through the tabs of the focused panel's frame. window.closeAll (Cmd+Shift+W) closes every open composition when a viewer or the Timeline is active, otherwise the focused panel. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 8 +++++ crates/ui-egui/src/menus.rs | 71 +++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 80d68f8e..ae66eaeb 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -472,6 +472,14 @@ impl DockNode { self.panels(&mut v); v.contains(&p) } + /// The tabs of the group `p` is in, in order. + pub fn group_of(&self, p: PanelKind) -> Option<&[PanelKind]> { + match self { + DockNode::Split { a, b, .. } => a.group_of(p).or_else(|| b.group_of(p)), + DockNode::Tabs { panels, .. } => panels.contains(&p).then_some(panels.as_slice()), + DockNode::Stack { entries } => entries.iter().find(|e| e.contains(p)).map(|e| e.panels.as_slice()), + } + } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { if !crate::dock_shared::valid_tree(self) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 85d95cb6..4b2009d4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -129,6 +129,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), + uic!("window.toggleCompTimeline", "Switch Between Composition and Timeline Panel", [], Some("\\")), + uic!("window.nextPanel", "Next Panel in Frame", [], Some("Alt+Shift+.")), + uic!("window.previousPanel", "Previous Panel in Frame", [], Some("Alt+Shift+,")), + uic!("window.closeAll", "Close Panel or All Viewers", [], Some("Cmd+Shift+W")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), @@ -594,6 +598,39 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V }; return run_engine(app, ctx, "comp.open", json!({"comp": next.0})); } + // \\: between the Composition viewer and the Timeline. + "window.toggleCompTimeline" => { + let to = if app.ui.focused == PanelKind::Timeline { crate::panels::viewers::active_panel(app) } else { PanelKind::Timeline }; + app.show_panel(to); + return Ok(json!({"focused": to.id()})); + } + // Alt+Shift+, / Alt+Shift+.: the previous / next tab of the focused panel's group. + "window.nextPanel" | "window.previousPanel" => { + let focused = app.ui.focused; + let group: Vec = match app.ui.floating.iter().find(|f| f.panels.contains(&focused)) { + Some(f) => f.panels.clone(), + None => app.ui.dock.group_of(focused).map(<[PanelKind]>::to_vec).unwrap_or_default(), + }; + let Some(i) = group.iter().position(|p| *p == focused) else { return Ok(Value::Null) }; + let step = if id == "window.nextPanel" { 1 } else { group.len().saturating_sub(1) }; + if let Some(next) = group.get((i + step) % group.len().max(1)).copied() { + app.show_panel(next); + } + return Ok(json!({"focused": app.ui.focused.id()})); + } + // Ctrl+Shift+W: with a viewer or the Timeline active, every open composition closes; + // any other panel closes itself. + "window.closeAll" => { + let focused = app.ui.focused; + if matches!(focused, PanelKind::Composition | PanelKind::Timeline | PanelKind::Viewer(_)) { + for c in app.session.state.open_comps.clone() { + run_engine(app, ctx, "comp.close", json!({"comp": c.0}))?; + } + } else { + app.close_panel(focused); + } + return Ok(Value::Null); + } "view.actualSize" if app.ui.focused == PanelKind::Layer => { app.ui.layer_zoom = Some(1.0 / ctx.pixels_per_point()); app.ui.layer_pan = [0.0, 0.0]; @@ -2175,6 +2212,10 @@ mod tests { ("view.fitUpTo100", "Alt+/"), ("view.nextItem", "Shift+."), ("view.previousItem", "Shift+,"), + ("window.toggleCompTimeline", "\\"), + ("window.nextPanel", "Alt+Shift+."), + ("window.previousPanel", "Alt+Shift+,"), + ("window.closeAll", "Cmd+Shift+W"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } @@ -2224,6 +2265,36 @@ mod tests { assert!(!app.ui.viewer.fit_up_to_100); } + #[test] + fn panel_keys_switch_cycle_and_close() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let run = |app: &mut EffectcraftApp, id: &str| invoke(app, &ctx, id, json!({})).unwrap(); + for name in ["A", "B"] { + app.session.execute("comp.new", json!({"name": name, "duration": 4})).unwrap(); + } + // \\ switches between the Timeline and the Composition viewer. + app.ui.focused = PanelKind::Timeline; + run(&mut app, "window.toggleCompTimeline"); + assert_eq!(app.ui.focused, PanelKind::Composition); + run(&mut app, "window.toggleCompTimeline"); + assert_eq!(app.ui.focused, PanelKind::Timeline); + // Alt+Shift+. / Alt+Shift+,: the tabs of the focused panel's group, wrapping. + let group = app.ui.dock.group_of(PanelKind::Timeline).unwrap().to_vec(); + assert!(group.len() > 1, "{group:?}"); + run(&mut app, "window.nextPanel"); + assert_eq!(app.ui.focused, group[(group.iter().position(|p| *p == PanelKind::Timeline).unwrap() + 1) % group.len()]); + run(&mut app, "window.previousPanel"); + assert_eq!(app.ui.focused, PanelKind::Timeline); + // Ctrl+Shift+W with the Timeline active closes every composition. + run(&mut app, "window.closeAll"); + assert!(app.session.state.open_comps.is_empty()); + // With another panel active it closes that panel. + app.show_panel(PanelKind::Project); + run(&mut app, "window.closeAll"); + assert!(!app.ui.dock.contains(PanelKind::Project)); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); From af28bc4ddb49ed7c32e9c7b860494dc8b2d12ce2 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:09:49 +0200 Subject: [PATCH 8/8] Shortcuts: C cycles the camera tools tool.camera cycles Orbit, Pan and Dolly. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4b2009d4..cd16a692 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -114,6 +114,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("tool.orbit", "Orbit Tool", [], Some("1")), uic!("tool.panCamera", "Pan Camera Tool", [], Some("2")), uic!("tool.dolly", "Dolly Tool", [], Some("3")), + uic!("tool.camera", "Camera Tools", [], Some("C")), uic!("tool.rotate", "Rotation Tool", [], Some("W")), uic!("tool.panBehind", "Pan Behind Tool", [], Some("Y")), uic!("tool.shape", "Shape Tools", [], Some("Q")), @@ -377,6 +378,16 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V return frontend(app, ctx, "window.workspace", json!({"name": ws})); } if let Some(t) = id.strip_prefix("tool.") { + // C cycles through the camera tools. + if t == "camera" { + let tools = [Tool::Orbit, Tool::PanCamera, Tool::Dolly]; + let next = match tools.iter().position(|x| *x == app.ui.tool) { + Some(i) => tools[(i + 1) % tools.len()], + None => Tool::Orbit, + }; + app.ui.tool = next; + return Ok(json!({"tool": next})); + } // Slot shortcuts cycle through the slot's tools. let slot = match t { "shape" => Some(8), @@ -2216,6 +2227,7 @@ mod tests { ("window.nextPanel", "Alt+Shift+."), ("window.previousPanel", "Alt+Shift+,"), ("window.closeAll", "Cmd+Shift+W"), + ("tool.camera", "C"), ] { assert!(bindings(&app.session).into_iter().any(|x| x.2 == id && Some((x.0, x.1)) == parse_shortcut(keys)), "{id} on {keys}"); } @@ -2295,6 +2307,19 @@ mod tests { assert!(!app.ui.dock.contains(PanelKind::Project)); } + #[test] + fn c_cycles_the_camera_tools() { + let ctx = egui::Context::default(); + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + app.ui.tool = Tool::Selection; + let mut seen = vec![]; + for _ in 0..4 { + invoke(&mut app, &ctx, "tool.camera", json!({})).unwrap(); + seen.push(app.ui.tool); + } + assert_eq!(seen, [Tool::Orbit, Tool::PanCamera, Tool::Dolly, Tool::Orbit]); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());