Merge pull request #1054 from pravdomil/claude/shortcuts-keyboard-commands

# Conflicts:
#	crates/engine/src/commands/time.rs
#	crates/ui-egui/src/menus.rs
This commit is contained in:
Brandon Thomas 2026-10-13 04:28:51 -04:00
commit dc3131b339
12 changed files with 727 additions and 9 deletions

View File

@ -945,6 +945,14 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
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),
_ => {
@ -977,6 +985,20 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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
@ -1606,6 +1628,16 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@ -1633,17 +1665,29 @@ pub fn specs() -> Vec<CommandSpec> {
"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)

View File

@ -24,6 +24,11 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
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");

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;
@ -176,6 +177,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());

View File

@ -0,0 +1,147 @@
//! Keyboard-driven commands that have no menu entry.
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, 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
/// 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()}))
}
/// Shift+F2: no keyframe, property or property group is selected; the layers stay selected.
fn deselect_keys(s: &mut Session, _: &Value) -> Result<Value> {
s.state.selected_keys.clear();
s.state.selected_props.clear();
s.state.selected_vertices.clear();
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<Value> {
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<Value> {
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<u64> = 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<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),
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),
]
}

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", [], ["Home", "Cmd+Alt+ArrowLeft"], "{}", has_comp, |s, _| go(s, &json!({"to": "start"}))),
cmd!("time.end", "Go to End", [], ["End", "Cmd+Alt+ArrowRight"], "{}", 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

@ -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();
@ -1183,6 +1192,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

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

View File

@ -0,0 +1,218 @@
//! The keyboard-only commands: time navigation, the work area, selection and layer timing.
use effectcraft_time::Tick;
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()
}
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)]);
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);
}
#[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));
}
#[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);
}
#[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<u64> = 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));
}

View File

@ -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) {

View File

@ -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")),
@ -53,6 +56,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", [], Some("Shift+;")),
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")),
@ -108,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")),
@ -123,6 +130,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),
@ -374,6 +385,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),
@ -572,6 +593,62 @@ 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.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}));
}
// \\: 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<PanelKind> = 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];
@ -2133,6 +2210,131 @@ 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"),
("comp.workAreaToSelection", "Cmd+Alt+B"),
("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"),
("timeline.scrollToTime", "D"),
("timeline.scrollToLayer", "X"),
("timeline.parentColumn", "Shift+F4"),
("view.fitUpTo100", "Alt+/"),
("view.nextItem", "Shift+."),
("view.previousItem", "Shift+,"),
("window.toggleCompTimeline", "\\"),
("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}");
}
}
#[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 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 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 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());

View File

@ -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]);
@ -1346,6 +1354,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;
@ -1753,6 +1766,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
@ -4119,6 +4140,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]

View File

@ -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<ScrollTo>,
}
pub fn default_tl_columns() -> BTreeSet<String> {
@ -426,6 +438,7 @@ impl Default for TimelineState {
unlinked: BTreeSet::new(),
columns: default_tl_columns(),
source_name: false,
scroll_to: None,
}
}
}