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] 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}"); }