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 <noreply@anthropic.com>
This commit is contained in:
pravdomil
2026-10-12 20:57:21 +02:00
co-authored by Claude Opus 5.5
parent a644bb07c9
commit d444eceb90
5 changed files with 147 additions and 0 deletions
+2
View File
@@ -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());
+88
View File
@@ -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<Value> {
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<ItemId> {
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<Value> {
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<CommandSpec> {
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),
]
}
+9
View File
@@ -200,6 +200,9 @@ pub struct EditorState {
pub active_comp: Option<ItemId>,
/// Open compositions (viewer/timeline tabs), in tab order.
pub open_comps: Vec<ItemId>,
/// Compositions that were active before the current one, most recent first (Shift+Esc).
#[serde(default)]
pub recent_comps: Vec<ItemId>,
/// Current time per comp (the CTI).
pub times: std::collections::BTreeMap<ItemId, Tick>,
pub selected_layers: Vec<LayerId>,
@@ -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();
+46
View File
@@ -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));
}
+2
View File
@@ -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}");
}