mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 19:18:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
a644bb07c9
commit
d444eceb90
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user