From 7643f803e0f4e3956847ebf21f785e78a0b8747f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:29 -0400 Subject: [PATCH 1/7] Keyframes: moving keys keeps the selection order keys.move (and nudging) rebuilt the key selection in layer and property order, so the order the keys were selected in was lost after a move. A Quick Offset that follows staggers layers in that order (#1012). The move now goes through move_key_refs, which maps each selected key to its new time and returns the selection in its original order, with the same collisions (a key landing on another replaces it), roving retiming and locked-layer handling as before. key_gesture is the drag-step logic (start again from the keys and project as they were before the drag) split out of shift_keys so other key drags can share it. Test: moving_keys_keeps_the_selection_order (fails before: the keys come back sorted by layer). --- crates/engine/src/commands/prop.rs | 115 +++++++++++------- crates/engine/src/tests_key_metadata_order.rs | 13 ++ 2 files changed, 81 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 64c166fd..903f9e13 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -862,17 +862,78 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im }) } +/// The keys a key drag's step applies to, the drag's total and the project as it was before the +/// drag. Steps of one drag (one `merge` key) start again from the keys as they were then, so a key +/// passed over survives; `d` adds to the drag's total, or is that total (`absolute`). +pub(crate) fn key_gesture( + s: &Session, + merge: Option<&str>, + d: Tick, + absolute: bool, +) -> (Vec, Tick, Option>) { + let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; + match (&s.key_move, s.history.undo.last()) { + (Some(g), Some((_, base))) if gesture => (g.from.clone(), if absolute { d } else { g.total + d }, Some(base.clone())), + _ => (s.state.selected_keys.clone(), d, None), + } +} + +/// Move keys `from` to the layer times `to` gives (from a key's layer and layer time). A key +/// landing on another key of its property replaces it, roving keys follow their neighbours and +/// keys of locked layers stay. Returns the keys' new references in `from`'s order: the selection +/// keeps the order it was made in. +pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef], to: impl Fn(&Layer, Tick) -> Result) -> Result> { + let mut remapped: std::collections::HashMap = Default::default(); + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in from { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + for ((lid, uid), times) in groups { + let Some(l) = comp.layer_mut(lid) else { continue }; + let at = |time| KeyRef { layer: lid, prop: uid, time }; + if l.switches.locked { + remapped.extend(times.iter().map(|t| (at(*t), at(*t)))); + continue; + } + let moves: Vec<(Tick, Tick)> = times.iter().map(|t| Ok((*t, to(l, *t)?))).collect::>()?; + let Some(pr) = l.props.find_mut(uid) else { continue }; + // Take the moving keys out, then put them back at their new times. + let mut moved = vec![]; + for (old, new) in &moves { + if let Some(i) = key_at(&pr.keys, *old) { + let mut k = pr.keys.remove(i); + k.time = *new; + moved.push((*old, k)); + } + } + // Target time -> the key's time before the move. + let mut targets: std::collections::HashMap = Default::default(); + for (old, k) in moved { + targets.insert(k.time, old); + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(j) => pr.keys[j] = k, + Err(j) => pr.keys.insert(j, k), + } + } + // Resolve stored indices before roving keys change their times. + let tracked: Vec<(Tick, usize)> = targets.into_iter().filter_map(|(t, old)| key_at(&pr.keys, t).map(|i| (old, i))).collect(); + effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial); + for (old, i) in tracked { + if let Some(key) = pr.keys.get(i) { + remapped.insert(at(old), at(key.time)); + } + } + } + let mut seen = std::collections::HashSet::new(); + Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect()) +} + /// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys /// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of /// locked layers stay. A key landing on another key of its property replaces it. pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - // A drag (steps with one merge key) starts again from the keys before it each step. - let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; - let (from, total, base) = match (&s.key_move, s.history.undo.last()) { - (Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())), - _ => (s.state.selected_keys.clone(), d, None), - }; + let (from, total, base) = key_gesture(s, merge, d, false); s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); s.edit(label, merge, |proj, st| { if let Some(b) = base { @@ -880,47 +941,7 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); - for k in &from { - groups.entry((k.layer, k.prop)).or_default().push(k.time); - } - let mut sel = vec![]; - for ((lid, uid), times) in groups { - let Some(l) = comp.layer_mut(lid) else { continue }; - let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total)); - let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect(); - if l.switches.locked { - sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t })); - continue; - } - let Some(pr) = l.props.find_mut(uid) else { continue }; - // Take the moving keys out, then put them back at their new times. - let mut moved = vec![]; - for (old, new) in &moves { - if let Some(i) = key_at(&pr.keys, *old) { - let mut k = pr.keys.remove(i); - k.time = *new; - moved.push(k); - } - } - for k in moved { - sel.push(KeyRef { layer: lid, prop: uid, time: k.time }); - match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { - Ok(j) => pr.keys[j] = k, - Err(j) => pr.keys.insert(j, k), - } - } - // Roving keys follow their neighbours: keep the selection on them. - let before: Vec = pr.keys.iter().map(|k| k.time).collect(); - if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { - for r in sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { - if let Some(i) = before.iter().position(|t| *t == r.time) { - r.time = pr.keys[i].time; - } - } - } - } - st.selected_keys = sel; + st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(l.layer_time(fr.snap_nearest(l.comp_time(t) + total))))?; Ok(json!(st.selected_keys.len())) }) } diff --git a/crates/engine/src/tests_key_metadata_order.rs b/crates/engine/src/tests_key_metadata_order.rs index fe2337c2..18d4a1a9 100644 --- a/crates/engine/src/tests_key_metadata_order.rs +++ b/crates/engine/src/tests_key_metadata_order.rs @@ -111,3 +111,16 @@ fn attribute_edit_deduplicates_legacy_refs_and_drops_stale_refs_without_snapping let opacity = s.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.find(prop).unwrap(); assert!(opacity.keys.iter().all(|k| k.out_interp == Interp::Linear)); } + +#[test] +fn moving_keys_keeps_the_selection_order() { + let (mut s, first) = setup(); + let second = s.execute_checked("layer.newSolid", json!({"name": "Other", "color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + for layer in [first, second] { + add(&mut s, layer, "transform/opacity", 0, json!(0)); + } + let (a, b) = (uid(&s, first, "transform/opacity"), uid(&s, second, "transform/opacity")); + s.execute_checked("keys.select", json!({"keys": [{"layer": second, "prop": b, "time": 0}, {"layer": first, "prop": a, "time": 0}]})).unwrap(); + s.execute_checked("keys.move", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!(s.state.selected_keys, vec![key(second, b, 2), key(first, a, 2)], "not sorted by layer"); +} From 28e9e5229ecb3069c5152a579ad6ceec57ca1034 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:31 -0400 Subject: [PATCH 2/7] Quick Offset: layer.quickOffset and keys.quickOffset commands (#1012) After Effects 25.4's Quick Offset staggers selected layers, or each layer's selected keyframes, in the order they were selected: the first stays, the last moves by the drag and the ones between by equal steps, landing on subframes. layer.quickOffset {delta, layers?} moves each layer's start, in and out point by total * i / (n - 1), worked out once in ticks with integer arithmetic, so 1 frame over 4 layers gives 0, 1/3, 2/3 and 1 frame exactly. keys.quickOffset {delta} does the same per layer for the selected keys, the layers in the order their first key was selected; every selected key of a layer, across its properties, moves by that layer's comp-time offset through its start and stretch (reversed too), keeping values, interpolation, easing and labels. Locked layers are skipped and at least two layers are needed. With merge and fromStart, delta is the whole drag and each step starts again from before it, so the drag is one undo step and a key passed over survives. A delta or a destination out of the range of times is an error, not a clamped move. Docs: agents.md; architecture.md notes Quick Offset as the exception to times snapping to frames. --- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/quick_offset.rs | 144 ++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_quick_offset.rs | 254 +++++++++++++++++++++ docs/agents.md | 6 +- docs/architecture.md | 4 +- 6 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/commands/quick_offset.rs create mode 100644 crates/engine/src/tests_quick_offset.rs diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642..fc027c7b 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -49,6 +49,7 @@ mod prop_groups; mod proxy; pub mod puppet; mod query; +mod quick_offset; mod relink; mod render_queue; pub(crate) mod rig3d; @@ -190,6 +191,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(focus::specs()); v.extend(key_labels::specs()); v.extend(key_transform::specs()); + v.extend(quick_offset::specs()); v.extend(file_more::specs()); v.extend(relink::specs()); v.extend(lottie::specs()); diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs new file mode 100644 index 00000000..aeda6c78 --- /dev/null +++ b/crates/engine/src/commands/quick_offset.rs @@ -0,0 +1,144 @@ +//! Quick Offset (After Effects 25.4): Ctrl/Cmd+Alt dragging selected layers or keyframes in the +//! Timeline staggers them in the order they were selected. The first stays, the last moves by the +//! whole drag and the ones between spread evenly, so they land on subframes; each layer's +//! selected keys move together. + +use std::collections::HashMap; + +use effectcraft_project::LayerId; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use super::prop::{key_gesture, move_key_refs}; +use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// `delta` (comp seconds) in ticks; out of range is an error, not a clamped drag. +fn delta_p(p: &Value, name: &str) -> Result { + let d = f_p(p, "delta").ok_or_else(|| bad(name, "missing `delta` (comp seconds)"))?; + let t = (d * TICKS_PER_SECOND as f64).round(); + if !t.is_finite() || t.abs() > Tick::MAX.0 as f64 { + return Err(bad(name, "`delta` is out of range")); + } + Ok(Tick(t as i64)) +} + +/// How far each of `n` layers moves, in order, when the last moves by `total`: `total × i / (n − 1)` +/// to the nearest tick, so the steps between them are equal. +fn shares(total: Tick, n: usize) -> Vec { + let last = n.saturating_sub(1).max(1) as i128; + (0..n) + .map(|i| { + let x = total.0 as i128 * i as i128; + Tick(((x + x.signum() * (last / 2)) / last) as i64) + }) + .collect() +} + +/// `t + d`, or an error (`what` names the time) when that leaves the range of times. +fn shifted(t: Tick, d: Tick, name: &str, what: &str) -> Result { + let v = t.0 as i128 + d.0 as i128; + if v < Tick::MIN.0 as i128 || v > Tick::MAX.0 as i128 { + return Err(bad(name, format!("{what} would move out of range"))); + } + Ok(Tick(v as i64)) +} + +/// The layers to stagger, in order: unlocked, each once. At least two. +fn order(name: &str, layers: impl IntoIterator, unlocked: impl Fn(LayerId) -> bool) -> Result> { + let mut out: Vec = vec![]; + for l in layers { + if unlocked(l) && !out.contains(&l) { + out.push(l); + } + } + if out.len() < 2 { + return Err(bad(name, "select at least two unlocked layers (or keys on two layers), in the order to offset them")); + } + Ok(out) +} + +/// `layer.quickOffset`: move the layers (`layers`, else the selection, in that order) by their +/// share of `delta`, the last layer's move. With `merge` and `fromStart`, `delta` is the whole drag +/// and every step applies to the layers as they were before it. +fn layers(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "layer.quickOffset"; + let d = delta_p(p, NAME)?; + let (cid, ids) = layers_p(s, p)?; + let merge = merge_p(p); + let gesture = b_p(p, "fromStart").unwrap_or(false) && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)); + let base = s.history.undo.last().filter(|_| gesture).map(|(_, b)| b.clone()); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, ids, |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let moves: Vec<(LayerId, Tick)> = order.iter().copied().zip(shares(d, order.len())).collect(); + s.edit("Quick Offset", merge, |proj, _| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &moves { + let l = comp.layer_mut(*id).ok_or(EngineError::NoComp)?; + let what = format!("layer \"{}\"", l.name); + let times = (shifted(l.start_time, *d, NAME, &what)?, shifted(l.in_point, *d, NAME, &what)?, shifted(l.out_point, *d, NAME, &what)?); + (l.start_time, l.in_point, l.out_point) = times; + } + Ok(json!({"layers": moves.iter().map(|(id, d)| json!({"layer": id.0, "offset": d.seconds()})).collect::>()})) + }) +} + +/// `keys.quickOffset`: the selected keys of each layer move together by the layer's share of +/// `delta` (comp seconds, the last layer's move), the layers in the order their first key was +/// selected. Like `keys.move`, steps sharing `merge` are one drag; with `fromStart`, `delta` is the +/// whole drag. +fn keys(s: &mut Session, p: &Value) -> Result { + const NAME: &str = "keys.quickOffset"; + let d = delta_p(p, NAME)?; + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let merge = merge_p(p); + let (from, total, base) = key_gesture(s, merge, d, b_p(p, "fromStart").unwrap_or(false)); + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; + let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; + let by_layer: HashMap = order.iter().copied().zip(shares(total, order.len())).collect(); + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + s.edit("Quick Offset Keyframes", merge, |proj, st| { + if let Some(b) = &base { + *proj = (**b).clone(); + } + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + st.selected_keys = move_key_refs(comp, &from, |l, t| { + let Some(d) = by_layer.get(&l.id) else { return Ok(t) }; + let what = format!("a key of \"{}\"", l.name); + let at = l.layer_time(shifted(l.comp_time(t), *d, NAME, &what)?); + if at < Tick::MIN || at > Tick::MAX { + return Err(bad(NAME, format!("{what} would move out of range"))); + } + Ok(at) + })?; + Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) + }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.quickOffset", + "Quick Offset", + [], + None, + "{delta (comp s: the last layer's move), layers? (in order; default the selection), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_layers, + layers + ), + cmd!( + "keys.quickOffset", + "Quick Offset Keyframes", + [], + None, + "{delta (comp s: the last layer's keys' move), merge?, fromStart?: bool (with merge: delta is the whole drag)}", + has_keys, + keys + ), + ] +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44..787df4f7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1158,6 +1158,8 @@ mod tests_project_items; #[cfg(test)] mod tests_proxy; #[cfg(test)] +mod tests_quick_offset; +#[cfg(test)] mod tests_rig3d; #[cfg(test)] mod tests_sequence; diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs new file mode 100644 index 00000000..7ccf75f1 --- /dev/null +++ b/crates/engine/src/tests_quick_offset.rs @@ -0,0 +1,254 @@ +//! Quick Offset (#1012): `layer.quickOffset` and `keys.quickOffset` stagger layers and each layer's +//! selected keys in selection order, onto subframes, as one undo step. + +use effectcraft_keyframe::Interp; +use effectcraft_project::{Layer, LayerId}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::{KeyRef, Session}; + +/// One frame at 30 fps. +const F: i64 = 8_467_200_000; + +fn setup(n: usize) -> (Session, Vec) { + let mut s = Session::default(); + s.execute_checked("comp.new", json!({"width": 64, "height": 64, "frameRate": 30, "duration": 10})).unwrap(); + let ids = + (0..n).map(|i| s.execute_checked("layer.newSolid", json!({"name": format!("L{i}"), "color": "#fff"})).unwrap()["layer"].as_u64().unwrap()).collect(); + (s, ids) +} + +fn layer(s: &Session, id: u64) -> &Layer { + s.active_comp().unwrap().layer(LayerId(id)).unwrap() +} + +/// (start, in, out) in ticks. +fn timing(s: &Session, id: u64) -> (i64, i64, i64) { + let l = layer(s, id); + (l.start_time.0, l.in_point.0, l.out_point.0) +} + +/// Click the first, Ctrl/Cmd-click the rest. +fn select(s: &mut Session, ids: &[u64]) { + for (i, id) in ids.iter().enumerate() { + s.execute_checked("layer.select", json!({"layers": [id], "add": i > 0})).unwrap(); + } +} + +fn add_key(s: &mut Session, id: u64, path: &str, seconds: f64, value: Value) { + s.execute_checked("prop.addKey", json!({"layer": id, "path": path, "time": seconds, "value": value})).unwrap(); +} + +fn select_keys(s: &mut Session, keys: &[(u64, &str, f64)]) { + for (i, (id, path, t)) in keys.iter().enumerate() { + s.execute_checked("keys.select", json!({"keys": [{"layer": id, "path": path, "time": t}], "add": i > 0})).unwrap(); + } +} + +/// The stored key times (ticks) of a property. +fn key_times(s: &Session, id: u64, path: &str) -> Vec { + layer(s, id).props.prop(path).unwrap().keys.iter().map(|k| k.time.0).collect() +} + +fn uid(s: &Session, id: u64, path: &str) -> u64 { + layer(s, id).props.prop(path).unwrap().uid +} + +#[test] +fn layers_stagger_in_selection_order_onto_subframes_as_one_undo() { + let (mut s, ids) = setup(4); + let order = [ids[2], ids[0], ids[3], ids[1]]; + select(&mut s, &order); + let before = s.project.clone(); + let reply = s.execute_checked("layer.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap(); + // The first selected stays, the last moves the whole frame, the ones between a third each. + for (i, id) in order.iter().enumerate() { + let d = F * i as i64 / 3; + let (st, inp, out) = timing(&s, *id); + assert_eq!((st, inp, out - inp), (d, d, 10 * 30 * F), "layer {i} in selection order"); + } + assert_eq!(reply["layers"].as_array().unwrap().len(), 4); + // Negative drags mirror it. + s.execute_checked("layer.quickOffset", json!({"delta": -2.0 / 30.0})).unwrap(); + for (i, id) in order.iter().enumerate() { + assert_eq!(timing(&s, *id).0, -F * i as i64 / 3, "layer {i} after dragging back two frames"); + } + s.execute_checked("edit.undo", json!({})).unwrap(); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "two offsets, two undo steps"); + s.execute_checked("edit.redo", json!({})).unwrap(); + assert_eq!(timing(&s, order[3]).0, F); +} + +#[test] +fn a_drag_restarts_from_the_layers_before_it_and_is_one_undo_step() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // An offset layer, a stretched one and a reversed one move their whole timing; their keys + // (in layer time) come along untouched. + s.execute_checked("layer.timing", json!({"layers": [ids[0]], "start": 1.0})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + add_key(&mut s, ids[2], "transform/opacity", 1.0, json!(50)); + let keys = key_times(&s, ids[2], "transform/opacity"); + let start: Vec<_> = ids.iter().map(|id| timing(&s, *id)).collect(); + let before = s.project.clone(); + for frames in [2.0, 5.0, -3.0, 2.0] { + s.execute_checked("layer.quickOffset", json!({"delta": frames / 30.0, "merge": "qo", "fromStart": true})).unwrap(); + } + for (i, id) in ids.iter().enumerate() { + let d = 2 * F * i as i64 / 2; + let (a, b, c) = start[i]; + assert_eq!(timing(&s, *id), (a + d, b + d, c + d), "layer {i}: only the last step's whole drag counts"); + } + assert_eq!(key_times(&s, ids[2], "transform/opacity"), keys); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before), "the drag is one undo step"); +} + +#[test] +fn locked_layers_bad_arguments_and_out_of_range_moves_change_nothing() { + let (mut s, ids) = setup(3); + select(&mut s, &ids); + // A locked layer is skipped: the other two are first and last. + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("layer.quickOffset", json!({"layers": ids, "delta": 0.1})).unwrap(); + assert_eq!((timing(&s, ids[0]).0, timing(&s, ids[1]).0), (0, 0)); + assert_eq!(timing(&s, ids[2]).0, Tick::from_seconds_f64(0.1).0); + let before = (s.project.clone(), s.revision); + for p in [ + json!({"layers": [ids[0]], "delta": 0.1}), + json!({"layers": [ids[0], ids[1]], "delta": 0.1}), + json!({"layers": ids}), + json!({"layers": ids, "delta": "far"}), + json!({"layers": ids, "delta": 1e12}), + json!({"layers": [ids[0], 999], "delta": 0.1}), + ] { + assert!(s.execute_checked("layer.quickOffset", p.clone()).is_err(), "{p}"); + } + assert!(s.project.same_content(&before.0) && s.revision == before.1, "refused calls change nothing"); + // The last layer's destination is past the end of time: nothing moves. + s.execute_checked("layer.timing", json!({"layers": [ids[2]], "start": 9.0e6})).unwrap(); + let before = s.project.clone(); + assert!(s.execute_checked("layer.quickOffset", json!({"layers": [ids[0], ids[2]], "delta": 1.0e5})).is_err()); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_stagger_per_layer_in_the_order_their_layers_were_selected() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + add_key(&mut s, ids[0], "transform/position", 0.5, json!([10, 20])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/opacity", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"interpolation": "hold"})).unwrap(); + // Layer 1's keys first, then layer 0's two properties, then layer 2's. + let picks = [ + (ids[1], "transform/opacity", 0.0), + (ids[0], "transform/opacity", 1.0), + (ids[1], "transform/opacity", 1.0), + (ids[0], "transform/position", 0.5), + (ids[2], "transform/opacity", 1.0), + ]; + select_keys(&mut s, &picks); + let before = s.project.clone(); + let reply = s.execute_checked("keys.quickOffset", json!({"delta": 3.0 / 30.0})).unwrap(); + assert_eq!(reply["keys"], 5); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[1], "transform/opacity"), vec![0, sec(1.0)], "the first layer stays"); + assert_eq!(key_times(&s, ids[0], "transform/opacity"), vec![0, sec(1.0) + 3 * F / 2], "the middle layer: 1.5 frames"); + assert_eq!(key_times(&s, ids[0], "transform/position"), vec![sec(0.5) + 3 * F / 2], "both its properties move as one group"); + assert_eq!(key_times(&s, ids[2], "transform/opacity"), vec![0, sec(1.0) + 3 * F], "the last layer: the whole drag"); + // The keys keep their values and interpolation, and the selection its order. + let moved = layer(&s, ids[0]).props.prop("transform/opacity").unwrap().keys[1].clone(); + assert_eq!((moved.value.components(), moved.out_interp), (vec![100.0], Interp::Hold)); + let order: Vec<(u64, u64)> = s.state.selected_keys.iter().map(|k| (k.layer.0, k.prop)).collect(); + let want: Vec<(u64, u64)> = picks.iter().map(|(id, path, _)| (*id, uid(&s, *id, path))).collect(); + assert_eq!(order, want); + s.execute_checked("edit.undo", json!({})).unwrap(); + assert!(s.project.same_content(&before)); +} + +#[test] +fn keys_of_stretched_and_reversed_layers_follow_the_comp_time_offset() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 1.0, json!(10)); + } + s.execute_checked("layer.timeStretch", json!({"layers": [ids[1]], "percent": 200})).unwrap(); + s.execute_checked("layer.timeStretch", json!({"layers": [ids[2]], "op": "reverse"})).unwrap(); + let comp_time = |s: &Session, id: u64| { + let l = layer(s, id); + l.comp_time(l.props.prop("transform/opacity").unwrap().keys[0].time).0 + }; + let before: Vec = ids.iter().map(|id| comp_time(&s, *id)).collect(); + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("keys.quickOffset", json!({"delta": 4.0 / 30.0})).unwrap(); + for (i, id) in ids.iter().enumerate() { + let moved = comp_time(&s, *id) - before[i]; + assert!((moved - 2 * F * i as i64).abs() <= 1, "layer {i}: moved {moved} ticks in comp time"); + } +} + +#[test] +fn a_key_drag_over_another_key_and_back_keeps_it() { + let (mut s, ids) = setup(2); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + } + add_key(&mut s, ids[1], "transform/opacity", 0.5, json!(50)); + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[1], "transform/opacity", 0.0)]); + let before = s.project.clone(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.5, "merge": "qk", "fromStart": true})).unwrap(); + assert_eq!(key_times(&s, ids[1], "transform/opacity").len(), 1, "the dragged key replaces the one it lands on"); + s.execute_checked("keys.quickOffset", json!({"delta": 0.0, "merge": "qk", "fromStart": true})).unwrap(); + assert!(s.project.same_content(&before), "dragged back, the key passed over is there again"); + assert_eq!(s.state.selected_keys.len(), 2); +} + +#[test] +fn keys_need_two_unlocked_layers() { + let (mut s, ids) = setup(3); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + add_key(&mut s, *id, "transform/opacity", 1.0, json!(100)); + } + select_keys(&mut s, &[(ids[0], "transform/opacity", 0.0), (ids[0], "transform/opacity", 1.0)]); + let before = s.project.clone(); + assert!(s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).is_err(), "keys on one layer"); + // Locked after selecting: that layer's keys stay; the other two still stagger. + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + s.execute_checked("layer.setSwitch", json!({"layers": [ids[1]], "switch": "locked", "value": true})).unwrap(); + s.execute_checked("keys.quickOffset", json!({"delta": 0.1})).unwrap(); + let sec = |t: f64| Tick::from_seconds_f64(t).0; + assert_eq!(key_times(&s, ids[0], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[1], "transform/opacity")[1], sec(1.0)); + assert_eq!(key_times(&s, ids[2], "transform/opacity")[1], sec(1.1)); + assert!(s.state.selected_keys.contains(&KeyRef { layer: LayerId(ids[1]), prop: uid(&s, ids[1], "transform/opacity"), time: Tick(sec(1.0)) })); + assert!(!before.same_content(&s.project)); +} + +#[test] +fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { + let (mut s, ids) = setup(2); + for (t, x) in [(0.0, 0), (1.0, 50), (2.0, 100)] { + add_key(&mut s, ids[0], "transform/position", t, json!([x, 0])); + } + add_key(&mut s, ids[1], "transform/position", 2.0, json!([0, 0])); + s.execute_checked("keys.select", json!({"keys": [{"layer": ids[0], "path": "transform/position", "time": 1.0}]})).unwrap(); + s.execute_checked("keys.interpolation", json!({"roving": true})).unwrap(); + // Only the last key of the roving layer is selected, with a key on the other layer first. + select_keys(&mut s, &[(ids[1], "transform/position", 2.0), (ids[0], "transform/position", 2.0)]); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0})).unwrap(); + let pos = layer(&s, ids[0]).props.prop("transform/position").unwrap(); + let times: Vec = pos.keys.iter().map(|k| k.time.0).collect(); + assert_eq!((times[0], times[2]), (0, Tick::from_seconds_f64(3.0).0)); + assert!(times[1] > 0 && times[1] < times[2], "the roving key retimed between its neighbours: {times:?}"); + for k in &s.state.selected_keys { + assert!(layer(&s, k.layer.0).props.find(k.prop).unwrap().keys.iter().any(|x| x.time == k.time), "{k:?} points at a key"); + } + assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), vec![ids[1], ids[0]]); +} diff --git a/docs/agents.md b/docs/agents.md index 01738edd..b63e69d7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -248,7 +248,11 @@ build real projects through these interfaces; they are worked examples of everyt / `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are one drag: each applies to the keys as they were before it, so a key passed over survives) and `keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag - started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` + started). **Quick Offset** (Ctrl/Cmd+Alt drag in the Timeline): `layer.quickOffset {delta, + layers?}` and `keys.quickOffset {delta}` stagger the selected layers, or each layer's selected + keys together, in selection order: the first stays, the last moves by `delta` (comp seconds) and + the ones between by equal steps, onto subframes; `merge` + `fromStart: true` make `delta` the whole + drag. Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste` at the current time (keys copied from several layers go to as many selected layers in order). `time.nextKey` / `time.previousKey` / `keys.selectAll` take `visible: [{layer, prop}]` to act only on those properties (the Timeline passes its revealed ones, as J / K and Ctrl+Alt+A do). diff --git a/docs/architecture.md b/docs/architecture.md index 72d8b734..e5564c22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,9 @@ All time is an integer `Tick`, 254 016 000 000 per second, the least common mult broadcast frame rate (×1001) and audio sample rate, so frame and sample positions are exact. Keyframe times are in **layer time**; `layer_time = (comp_time - start_time) / stretch`, and a Time Remap property overrides the source time. Commands snap layer times, keyframe times and -comp durations to whole frames, as After Effects does. Timecode is only a display. +comp durations to whole frames, as After Effects does, except Quick Offset (`layer.quickOffset`, +`keys.quickOffset`), which staggers layers and keys onto subframes, as After Effects 25.4 does. +Timecode is only a display. ## 3. Document model From 94b4d0ca34b1d1bedc3dde5b9ee86c9e92481691 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:37:34 -0400 Subject: [PATCH 3/7] Timeline: Quick Offset layers and keyframes in selection order (#1012) Ctrl/Cmd+Alt dragging a selected layer bar or a selected key now runs a Quick Offset (layer.quickOffset / keys.quickOffset with fromStart, one undo step). The mode is chosen when the drag starts and needs at least two unlocked layers; the last selected layer moves by whole frames with the pointer. A readout by the pointer shows the Total Offset and the offset Per Layer, translated. A plain drag still moves the selection together, Alt alone still scales a key group in time (it now leaves Ctrl/Cmd+Alt to Quick Offset), and Ctrl/Cmd+Alt+click still toggles Hold. The key drag's readout is drawn by a shared drag_tip. Keys also register timeline.keyAt.., exact where keys on subframes can share a frame. Tests (ui_quick_offset): staggering layer bars in selection order and keys per layer, one undo each; plain drags and Alt scaling unchanged; the readout while dragging. --- crates/ui-egui/src/i18n/ui.rs | 12 ++ crates/ui-egui/src/panels/timeline.rs | 105 +++++++++++++-- crates/ui-egui/tests/ui_quick_offset.rs | 162 ++++++++++++++++++++++++ docs/control-protocol.md | 3 +- 4 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/ui_quick_offset.rs diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f31d0951..ce2c9a43 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -525,6 +525,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+ドラッグでピンの動きを記録"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {}は次のコマンドで使用中:"), + ("Total Offset", "合計オフセット"), + ("Per Layer", "レイヤーごと"), ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ @@ -1060,6 +1062,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 项目"), ("Lottie animation", "Lottie 动画"), ("Project", "项目"), + ("Total Offset", "总偏移"), + ("Per Layer", "每个图层"), ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ @@ -1590,6 +1594,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("After Effects project", "After Effects 專案"), ("Lottie animation", "Lottie 動畫"), ("Project", "專案"), + ("Total Offset", "總偏移"), + ("Per Layer", "每個圖層"), ]; /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. @@ -2146,6 +2152,8 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("After Effects project", "Проєкт After Effects"), ("Lottie animation", "Анімація Lottie"), ("Project", "Проєкт"), + ("Total Offset", "Загальний зсув"), + ("Per Layer", "На шар"), ]; // Brazilian Portuguese. @@ -2580,6 +2588,8 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("⌘/Ctrl-drag a pin to record its motion", "⌘/Ctrl+arraste um pino para gravar seu movimento"), ("▣ {}", "▣ {}"), ("⚠ {} is used by:", "⚠ {} é usado por:"), + ("Total Offset", "Deslocamento total"), + ("Per Layer", "Por camada"), ]; /// Czech (cs): original translations covering every row in the Chinese panel catalogs. @@ -3115,6 +3125,8 @@ const CZECH: &[(&str, &str)] = &[ "Tyto sousední klíčové snímky nelze interpolovat, protože mají různý počet bodů přechodu.", ), ("Variable fonts only", "Jen proměnná písma"), + ("Total Offset", "Celkový posun"), + ("Per Layer", "Na vrstvu"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..802913c4 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1127,18 +1127,61 @@ fn layer_drag_id() -> egui::Id { } /// A keyframe drag in progress: where the pointer started, the grabbed key's comp time -/// (seconds) and how many frames the selected keys have moved so far. +/// (seconds), how many frames the selected keys have moved so far and, for a Quick Offset +/// (Ctrl/Cmd+Alt), how many layers it staggers. #[derive(Clone, Copy, Debug)] struct KeyDrag { origin_x: f32, anchor: f64, applied: i64, + quick: Option, } fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } +/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started, +/// how many layers it staggers and how many frames the last one has moved so far. +#[derive(Clone, Copy, Debug)] +struct QuickBar { + origin_x: f32, + layers: usize, + applied: i64, +} + +fn quick_bar_id(layer: LayerId) -> egui::Id { + egui::Id::new(("tl-quick-bar", layer.0)) +} + +/// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if +/// there are at least two. +fn quick_offset_layers(comp: &Comp, layers: impl IntoIterator) -> Option { + let mut seen: Vec = vec![]; + for id in layers { + if !seen.contains(&id) && comp.layer(id).is_some_and(|l| !l.switches.locked) { + seen.push(id); + } + } + (seen.len() >= 2).then_some(seen.len()) +} + +/// A Quick Offset's feedback: the last layer's move and the step from one layer to the next. +fn quick_offset_note(frames: i64, layers: usize) -> String { + let per = frames as f64 / layers.saturating_sub(1).max(1) as f64; + format!("{}: {:+} f {}: {:+.2} f", tr("Total Offset"), frames, tr("Per Layer"), per) +} + +/// A Timeline drag's readout by the pointer (After Effects shows it in the Info panel). +fn drag_tip(ctx: &egui::Context, t: &Tokens, ptr: Pos2, note: String) { + let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip"))); + let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text); + let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0)); + fg.rect_filled(r, 3.0, t.panel_bg); + fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + fg.galley(r.min + vec2(5.0, 3.0), g, t.text); +} + /// A switch pressed and dragged over other rows: the switch, the state the press gave it, and /// the undo step the drag folds into. #[derive(Clone, Debug)] @@ -2126,7 +2169,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); + // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the + // drag starts. The last selected layer moves by whole frames with the pointer. + let mods = ui.input(|i| i.modifiers); + if body.drag_started() + && is_sel + && mods.command + && mods.alt + && let Some(n) = quick_offset_layers(&comp, selected.iter().copied()) + && let Some(p) = ctx.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 })); + } + let quick = ctx.data(|d| d.get_temp::(quick_bar_id(layer.id))); + if let Some(mut q) = quick { + if body.dragged() + && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()) + { + let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; + if frames != q.applied { + q.applied = frames; + ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q)); + let merge = format!("bar-quick-{}", layer.id.0); + actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); + } + drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); + } + if body.drag_stopped() { + ctx.data_mut(|d| d.remove::(quick_bar_id(layer.id))); + ui_actions.push(UiAct::EndMerge); + } + } for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { + if kind == "move" && quick.is_some() { + continue; + } if resp.dragged() { let acc_id = egui::Id::new(("bar-acc", layer.id.0, kind)); let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); @@ -2564,6 +2641,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let kr = Rect::from_center_size(c, vec2(12.0, 14.0)); let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag()); app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name)); + // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame. + app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name)); let mods = ui.input(|i| i.modifiers); let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true}); if kresp.clicked() && mods.command { @@ -2658,7 +2737,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }); // Alt-drag the first or last key of a selected group: scale the group in time. if kresp.drag_started() - && ui.input(|i| i.modifiers.alt) + && mods.alt + && !mods.command && let Some(c) = app.session.active_comp_arc() { super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); @@ -2671,7 +2751,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && !super::graph_tools::alt_scale_active(&ctx) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 })); + // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012). + let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten(); + ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick })); } } } @@ -2875,7 +2957,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let total = ((target - kd.anchor) / fd).round() as i64; if total != kd.applied { - actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"}))); + actions.push(match kd.quick { + Some(_) => ("keys.quickOffset".into(), json!({"delta": total as f64 * fd, "merge": "key-quick-offset", "fromStart": true})), + None => ("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})), + }); kd.applied = total; ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd)); } @@ -2883,13 +2968,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Where the grabbed key is and how far the keys moved (After Effects shows this // in the Info panel). let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd); - let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied); - let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip"))); - let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text); - let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0)); - fg.rect_filled(r, 3.0, t.panel_bg); - fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - fg.galley(r.min + vec2(5.0, 3.0), g, t.text); + let note = match kd.quick { + Some(n) => quick_offset_note(kd.applied, n), + None => format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied), + }; + drag_tip(&ctx, &t, ptr, note); app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at)); } None => { diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs new file mode 100644 index 00000000..d8e16433 --- /dev/null +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -0,0 +1,162 @@ +//! Quick Offset in the Timeline (#1012): Ctrl/Cmd+Alt dragging selected layer bars or keys +//! staggers the layers in selection order, onto subframes, as one undo step; a plain drag still +//! moves them together and Alt alone still scales a key group. + +use effectcraft_engine::Session; +use effectcraft_engine::project::LayerId; +use effectcraft_engine::time::Tick; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// One frame at 30 fps. +const F: i64 = 8_467_200_000; + +/// A 4 s, 30 fps comp with three solids, each with Opacity keys at 0 s and 1 s, revealed. +fn harness() -> (Harness<'static, EffectcraftApp>, Vec) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Stagger", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let mut ids = vec![]; + for i in 0..3 { + let id = + s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": id, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + ids.push(id); + } + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + (h, ids) +} + +fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) +} + +/// Press at `from`, move to `to` in small steps with `modifiers` held (a real drag), release. +fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + for i in 1..=24 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.run_steps(2); +} + +fn ctrl_alt() -> Modifiers { + Modifiers { alt: true, ..Modifiers::COMMAND } +} + +fn start(h: &Harness<'_, EffectcraftApp>, id: u64) -> i64 { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().start_time.0 +} + +/// The comp times (ticks) of a layer's Opacity keys. +fn key_times(h: &Harness<'_, EffectcraftApp>, id: u64) -> Vec { + let l = h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone(); + l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| l.comp_time(k.time).0).collect() +} + +fn opacity_uid(h: &Harness<'_, EffectcraftApp>, id: u64) -> u64 { + h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().props.prop("transform/opacity").unwrap().uid +} + +#[test] +fn ctrl_alt_dragging_layer_bars_staggers_them_in_selection_order() { + let (mut h, ids) = harness(); + let order = [ids[2], ids[0], ids[1]]; + for (i, id) in order.iter().enumerate() { + h.state_mut().session.execute("layer.select", json!({"layers": [id], "add": i > 0})).unwrap(); + } + h.run_steps(2); + let before = h.state().session.project.clone(); + // Drag the middle layer's bar: the drag moves the last selected layer. + let from = center(&h, &format!("timeline.layer.{}.bar", ids[0])); + drag(&mut h, from, from + vec2(160.0, 0.0), ctrl_alt()); + let d = start(&h, order[2]); + assert!(d > 0 && d % F == 0, "the last selected layer moves by whole frames: {d}"); + assert_eq!([start(&h, order[0]), start(&h, order[1])], [0, d / 2], "the first stays, the middle one moves half"); + assert_eq!(key_times(&h, order[1]), vec![d / 2, Tick::from_seconds_f64(1.0).0 + d / 2], "its keys come along"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.project.same_content(&before), "one undo step"); + // A plain drag still moves every selected layer together. + h.run_steps(2); + drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default()); + let moved: Vec = order.iter().map(|id| start(&h, *id)).collect(); + assert!(moved[0] > 0 && moved.iter().all(|m| *m == moved[0]), "{moved:?}"); +} + +#[test] +fn ctrl_alt_dragging_keys_staggers_each_layers_keys_and_alt_still_scales() { + let (mut h, ids) = harness(); + // Layer 1's keys first, then layer 0's, then layer 2's. + let mut picks = vec![]; + for id in [ids[1], ids[0], ids[2]] { + for t in [0.0, 1.0] { + picks.push(json!({"layer": id, "path": "transform/opacity", "time": t})); + } + } + for (i, k) in picks.iter().enumerate() { + h.state_mut().session.execute("keys.select", json!({"keys": [k], "add": i > 0})).unwrap(); + } + h.run_steps(2); + let before = h.state().session.project.clone(); + let one = Tick::from_seconds_f64(1.0).0; + let grab = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0]))); + drag(&mut h, grab, grab + vec2(160.0, 0.0), ctrl_alt()); + let d = key_times(&h, ids[2])[0]; + assert!(d > 0 && d % F == 0, "the last layer's keys move by whole frames: {d}"); + assert_eq!(key_times(&h, ids[1]), vec![0, one], "the first layer's keys stay"); + assert_eq!(key_times(&h, ids[0]), vec![d / 2, one + d / 2], "the middle layer's keys move half, together"); + assert_eq!(key_times(&h, ids[2]), vec![d, one + d]); + let layers: Vec = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert_eq!(layers, vec![ids[1], ids[1], ids[0], ids[0], ids[2], ids[2]], "the selection keeps its order"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert!(h.state().session.project.same_content(&before), "one undo step"); + // Alt alone still scales a key group in time: the first key stays, the last follows. + h.state_mut().session.execute("keys.select", json!({"keys": [picks[2], picks[3]]})).unwrap(); + h.run_steps(2); + let last = center(&h, &format!("timeline.keyAt.{}.{one}", opacity_uid(&h, ids[0]))); + drag(&mut h, last, last + vec2(160.0, 0.0), Modifiers { alt: true, ..Default::default() }); + let scaled = key_times(&h, ids[0]); + assert!(scaled[0] == 0 && scaled[1] > one, "Alt scales: {scaled:?}"); + assert_eq!(key_times(&h, ids[1]), vec![0, one], "and staggers nothing"); +} + +#[test] +fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() { + let (mut h, ids) = harness(); + let from = center(&h, &format!("timeline.layer.{}.bar", ids[1])); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt())); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + let shapes = h.output().shapes.clone(); + let text: Vec = shapes + .iter() + .filter_map(|c| match &c.shape { + egui::Shape::Text(t) => Some(t.galley.text().to_string()), + _ => None, + }) + .collect(); + assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f5487981..61f01443 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -118,7 +118,8 @@ one), `panel.tab.Viewer()` and `viewers.` (View ▸ New Viewer's other Com renames it), `viewer.comp`, `viewer.handle..`, `timeline.layer..bar`, `timeline.layer..nestedMarker.` (a precomp's comp markers), `timeline.layer..twirl`, `timeline.layer..row` (click selects, Enter or a double-click renames, drag reorders), `timeline.prop..stopwatch`, -`timeline.key..`, `timeline.cti`, `effectControls.prop..value` (an angle's +`timeline.key..` (and `timeline.keyAt..`, exact in layer-time ticks, for +keys on subframes), `timeline.cti`, `effectControls.prop..value` (an angle's revolutions are `timeline.prop..revolutions`, `effectControls.prop..revolutions` and `properties.prop..revolutions`) and `effects.item.`. The Composition viewer adds `viewer.magnification`, `viewer.resolution`, `viewer.roi`, `viewer.grid`, `viewer.channel`, `viewer.exposure`, `viewer.snapshot`, From e2d50ec926c1ac55ac859e3b2587aac5b3d60ef9 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 17:45:30 -0400 Subject: [PATCH 4/7] Timeline: Shift ranges and key boxes stagger in comp order (#1012) In After Effects a marquee selection staggers in the comp's layer order, not in the direction the box was drawn. A Shift-click range of layer bars and a box of keys already come out top layer first; this test makes both with the pointer (the range from the bottom layer up, the box from the bottom right to the top left) and checks the Quick Offset that follows: the top layer stays and the bottom one moves the whole delta. --- crates/ui-egui/tests/ui_quick_offset.rs | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs index d8e16433..31161517 100644 --- a/crates/ui-egui/tests/ui_quick_offset.rs +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -160,3 +160,42 @@ fn a_quick_offset_drag_shows_the_total_and_per_layer_offset() { .collect(); assert!(text.iter().any(|t| t.starts_with("Total Offset: +") && t.contains("Per Layer: +")), "{text:?}"); } + +fn click(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, modifiers: Modifiers) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.run_steps(2); +} + +/// A Shift-click range of layers and a box of keys, drawn from the bottom right up to the top left, +/// stagger in the comp's layer order (top first), whichever way they were made (#1012). +#[test] +fn shift_ranges_and_key_boxes_stagger_in_comp_order() { + let (mut h, ids) = harness(); + // ids[2] is the top layer, ids[0] the bottom one. Click the bottom bar, Shift-click the top. + let bar = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.layer.{id}.bar")); + let (bottom, top) = (bar(&h, ids[0]), bar(&h, ids[2])); + click(&mut h, bottom, Modifiers::default()); + click(&mut h, top, Modifiers::SHIFT); + let selected: Vec = h.state().session.state.selected_layers.iter().map(|l| l.0).collect(); + assert_eq!(selected, vec![ids[2], ids[1], ids[0]], "a Shift range is in comp order"); + h.state_mut().session.execute("layer.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!([start(&h, ids[2]), start(&h, ids[1]), start(&h, ids[0])], [0, F, 2 * F]); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + h.run_steps(2); + // A box around the 1 s keys, drawn upwards and to the left. + let one = Tick::from_seconds_f64(1.0).0; + let key = |h: &Harness<'_, EffectcraftApp>, id: u64| center(h, &format!("timeline.keyAt.{}.{one}", opacity_uid(h, id))); + let (low, high) = (key(&h, ids[0]), key(&h, ids[2])); + drag(&mut h, low + vec2(30.0, 5.0), high - vec2(30.0, 5.0), Modifiers::default()); + let order: Vec = h.state().session.state.selected_keys.iter().map(|k| k.layer.0).collect(); + assert_eq!(order, vec![ids[2], ids[1], ids[0]], "a key box is in comp order"); + h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); + assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]); +} From f72b8993ef4bf57c97859aa5524422b191539230 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:34 -0400 Subject: [PATCH 5/7] Keyframes: moving keys keeps subframe spacing (#1012) keys.move and the nudges snapped each key's destination to the nearest frame, so moving keys a Quick Offset had staggered onto subframes put them back on frames: four keys at 0, 1/3, 2/3 and 1 frame, moved one frame, came out at 1, 1, 2 and 2. Layer bars kept theirs, because layer.timing snaps the move, not the times. The move is now rounded to whole frames and every key translated by it. layer_shift turns a comp-time move into a layer-time move through the layer's start and stretch (reversed too) without a round trip through comp time; keys.quickOffset uses it as well. Test: moving_staggered_keys_keeps_their_subframe_spacing (Quick Offset, then keys.move and keys.nudgeForward; fails before: 1/3 frame lands on a frame). --- crates/engine/src/commands/prop.rs | 17 ++++++++++++----- crates/engine/src/commands/quick_offset.rs | 9 +++------ crates/engine/src/tests_quick_offset.rs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 903f9e13..57ea8deb 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -928,9 +928,16 @@ pub(crate) fn move_key_refs(comp: &mut effectcraft_project::Comp, from: &[KeyRef Ok(from.iter().filter_map(|k| remapped.get(k).copied()).filter(|k| seen.insert(*k)).collect()) } -/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys -/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of -/// locked layers stay. A key landing on another key of its property replaces it. +/// A move of `d` in comp time as the move of `l`'s layer times (through its stretch, reversed +/// too), without a round trip through comp time. +pub(crate) fn layer_shift(l: &Layer, d: Tick) -> Tick { + l.layer_time(l.start_time + d) +} + +/// Move the selected keys by `d` comp seconds, rounded to whole frames: the keys keep their +/// spacing, subframe offsets (Quick Offset) included, and keys of time-stretched layers move with +/// the pointer, not by the layer-time distance. Keys of locked layers stay. A key landing on +/// another key of its property replaces it. pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let (from, total, base) = key_gesture(s, merge, d, false); @@ -940,8 +947,8 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T *proj = (*b).clone(); } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let fr = comp.frame_rate; - st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(l.layer_time(fr.snap_nearest(l.comp_time(t) + total))))?; + let d = comp.frame_rate.snap_nearest(total); + st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(t + layer_shift(l, d)))?; Ok(json!(st.selected_keys.len())) }) } diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs index aeda6c78..c3c377f7 100644 --- a/crates/engine/src/commands/quick_offset.rs +++ b/crates/engine/src/commands/quick_offset.rs @@ -9,7 +9,7 @@ use effectcraft_project::LayerId; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde_json::{Value, json}; -use super::prop::{key_gesture, move_key_refs}; +use super::prop::{key_gesture, layer_shift, move_key_refs}; use super::{CommandSpec, b_p, bad, f_p, has_keys, has_layers, layers_p, merge_p}; use crate::{EngineError, Result, Session, cmd}; @@ -110,11 +110,8 @@ fn keys(s: &mut Session, p: &Value) -> Result { st.selected_keys = move_key_refs(comp, &from, |l, t| { let Some(d) = by_layer.get(&l.id) else { return Ok(t) }; let what = format!("a key of \"{}\"", l.name); - let at = l.layer_time(shifted(l.comp_time(t), *d, NAME, &what)?); - if at < Tick::MIN || at > Tick::MAX { - return Err(bad(NAME, format!("{what} would move out of range"))); - } - Ok(at) + shifted(l.start_time, *d, NAME, &what)?; + shifted(t, layer_shift(l, *d), NAME, &what) })?; Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) }) diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs index 7ccf75f1..aeff851a 100644 --- a/crates/engine/src/tests_quick_offset.rs +++ b/crates/engine/src/tests_quick_offset.rs @@ -252,3 +252,19 @@ fn roving_keys_follow_and_the_selection_stays_on_the_moved_keys() { } assert_eq!(s.state.selected_keys.iter().map(|k| k.layer.0).collect::>(), vec![ids[1], ids[0]]); } + +#[test] +fn moving_staggered_keys_keeps_their_subframe_spacing() { + let (mut s, ids) = setup(4); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 0.0, json!(0)); + } + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 0.0)).collect::>()); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0 / 30.0})).unwrap(); + // A plain move (and a nudge) translates them: it used to put each key on its nearest frame. + s.execute_checked("keys.move", json!({"delta": 1.0 / 30.0})).unwrap(); + s.execute_checked("keys.nudgeForward", json!({})).unwrap(); + for (i, id) in ids.iter().enumerate() { + assert_eq!(key_times(&s, *id, "transform/opacity"), vec![2 * F + F * i as i64 / 3], "layer {i}"); + } +} From e1c9c33fb150d38777e957913fa9baafcda7ad8e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:48 -0400 Subject: [PATCH 6/7] Keyframes: a refused key drag step doesn't count towards the drag (#1012) keys.quickOffset and keys.move stored the drag's new total before the edit ran. When the edit refused the step (keys.quickOffset with a key landing past the end of time), the project stayed as it was but the total kept the refused delta, and with incremental steps (merge without fromStart) the next step moved by it too. The drag state is now stored only after the edit applies. Test: a_refused_step_does_not_count_towards_the_drag (+1 s, a refused step, -1 s: the keys end where they started; fails before: the last key ends near the end of time). --- crates/engine/src/commands/prop.rs | 9 ++++++--- crates/engine/src/commands/quick_offset.rs | 9 ++++++--- crates/engine/src/tests_quick_offset.rs | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index 57ea8deb..5248949a 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -941,8 +941,8 @@ pub(crate) fn layer_shift(l: &Layer, d: Tick) -> Tick { pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let (from, total, base) = key_gesture(s, merge, d, false); - s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); - s.edit(label, merge, |proj, st| { + let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + let moved = s.edit(label, merge, |proj, st| { if let Some(b) = base { *proj = (*b).clone(); } @@ -950,7 +950,10 @@ pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: T let d = comp.frame_rate.snap_nearest(total); st.selected_keys = move_key_refs(comp, &from, |l, t| Ok(t + layer_shift(l, d)))?; Ok(json!(st.selected_keys.len())) - }) + })?; + // Only a step that applied counts towards the drag. + s.key_move = next; + Ok(moved) } fn move_keys(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/commands/quick_offset.rs b/crates/engine/src/commands/quick_offset.rs index c3c377f7..31201345 100644 --- a/crates/engine/src/commands/quick_offset.rs +++ b/crates/engine/src/commands/quick_offset.rs @@ -101,8 +101,8 @@ fn keys(s: &mut Session, p: &Value) -> Result { let comp = start.comp(cid).ok_or(EngineError::NoComp)?; let order = order(NAME, from.iter().map(|k| k.layer), |id| comp.layer(id).is_some_and(|l| !l.switches.locked))?; let by_layer: HashMap = order.iter().copied().zip(shares(total, order.len())).collect(); - s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); - s.edit("Quick Offset Keyframes", merge, |proj, st| { + let next = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total }); + let moved = s.edit("Quick Offset Keyframes", merge, |proj, st| { if let Some(b) = &base { *proj = (**b).clone(); } @@ -114,7 +114,10 @@ fn keys(s: &mut Session, p: &Value) -> Result { shifted(t, layer_shift(l, *d), NAME, &what) })?; Ok(json!({"keys": st.selected_keys.len(), "layers": order.iter().map(|id| json!({"layer": id.0, "offset": by_layer.get(id).copied().unwrap_or_default().seconds()})).collect::>()})) - }) + })?; + // Only a step that applied counts towards the drag (a refused one is as if never sent). + s.key_move = next; + Ok(moved) } pub fn specs() -> Vec { diff --git a/crates/engine/src/tests_quick_offset.rs b/crates/engine/src/tests_quick_offset.rs index aeff851a..164bf875 100644 --- a/crates/engine/src/tests_quick_offset.rs +++ b/crates/engine/src/tests_quick_offset.rs @@ -268,3 +268,20 @@ fn moving_staggered_keys_keeps_their_subframe_spacing() { assert_eq!(key_times(&s, *id, "transform/opacity"), vec![2 * F + F * i as i64 / 3], "layer {i}"); } } + +#[test] +fn a_refused_step_does_not_count_towards_the_drag() { + let (mut s, ids) = setup(2); + for id in &ids { + add_key(&mut s, *id, "transform/opacity", 1.0, json!(0)); + } + select_keys(&mut s, &ids.iter().map(|id| (*id, "transform/opacity", 1.0)).collect::>()); + let before = s.project.clone(); + // The second step's last key would land past the end of time (one second short of it, its + // own delta isn't); with it counted, the third would still move the key that far. + let far = (Tick::MAX.seconds() - 1.0).floor(); + s.execute_checked("keys.quickOffset", json!({"delta": 1.0, "merge": "steps"})).unwrap(); + assert!(s.execute_checked("keys.quickOffset", json!({"delta": far, "merge": "steps"})).is_err()); + s.execute_checked("keys.quickOffset", json!({"delta": -1.0, "merge": "steps"})).unwrap(); + assert!(s.project.same_content(&before), "only the steps that applied count: {:?}", key_times(&s, ids[1], "transform/opacity")); +} From bfb7507565152ae2c95f135fd5404d2bca66990a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 12 Oct 2026 18:49:51 -0400 Subject: [PATCH 7/7] Timeline: a Quick Offset of layer bars ends on release off screen (#1012) The layer bar Quick Offset was followed and ended in its row's code, and rows out of view aren't drawn. Released with its row scrolled away, it stayed active: the next drag of that bar, even a plain one, ran as a Quick Offset from the old origin, into the old undo step. Like the key drag, it now follows the pointer below the rows and ends when the button is released, wherever the bar's row is. Test: a_quick_offset_released_off_screen_ends (release with the row scrolled away; a plain drag after it moves both layers together as its own undo step; fails before). --- crates/ui-egui/src/panels/timeline.rs | 56 +++++++++++++------------ crates/ui-egui/tests/ui_quick_offset.rs | 42 +++++++++++++++++++ 2 files changed, 72 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 802913c4..5b422652 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1141,17 +1141,18 @@ fn key_drag_id() -> egui::Id { egui::Id::new("tl-key-drag") } -/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): where the pointer started, -/// how many layers it staggers and how many frames the last one has moved so far. +/// A Quick Offset drag of selected layer bars (Ctrl/Cmd+Alt, #1012): the bar it started on, where +/// the pointer started, how many layers it staggers and how many frames the last one has moved. #[derive(Clone, Copy, Debug)] struct QuickBar { + layer: LayerId, origin_x: f32, layers: usize, applied: i64, } -fn quick_bar_id(layer: LayerId) -> egui::Id { - egui::Id::new(("tl-quick-bar", layer.0)) +fn quick_bar_id() -> egui::Id { + egui::Id::new("tl-quick-bar") } /// How many layers a Quick Offset of `layers` staggers (#1012): the unlocked ones, each once, if @@ -2170,7 +2171,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { layer_context_menu(&body, &comp, layer, &selected, vector_footage_layer(&app.session.project, layer), &mut actions); let drag_key = format!("bar-{}", layer.id.0); // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the - // drag starts. The last selected layer moves by whole frames with the pointer. + // drag starts and followed below the rows until the button is released. let mods = ui.input(|i| i.modifiers); if body.drag_started() && is_sel @@ -2179,29 +2180,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { && let Some(n) = quick_offset_layers(&comp, selected.iter().copied()) && let Some(p) = ctx.input(|i| i.pointer.press_origin()) { - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), QuickBar { origin_x: p.x, layers: n, applied: 0 })); - } - let quick = ctx.data(|d| d.get_temp::(quick_bar_id(layer.id))); - if let Some(mut q) = quick { - if body.dragged() - && let Some(ptr) = ctx.input(|i| i.pointer.latest_pos()) - { - let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; - if frames != q.applied { - q.applied = frames; - ctx.data_mut(|d| d.insert_temp(quick_bar_id(layer.id), q)); - let merge = format!("bar-quick-{}", layer.id.0); - actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); - } - drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); - } - if body.drag_stopped() { - ctx.data_mut(|d| d.remove::(quick_bar_id(layer.id))); - ui_actions.push(UiAct::EndMerge); - } + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 })); } + let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id); for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] { - if kind == "move" && quick.is_some() { + if kind == "move" && quick { continue; } if resp.dragged() { @@ -2937,6 +2920,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.remove::>(layer_drag_id())); } } + // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer + // until the button is released, wherever the bar's row has scrolled to. + if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) { + let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + match ptr.filter(|_| down) { + Some(ptr) => { + let frames = (((ptr.x - q.origin_x) as f64 / pps) / fd).round() as i64; + if frames != q.applied { + q.applied = frames; + ctx.data_mut(|d| d.insert_temp(quick_bar_id(), q)); + let merge = format!("bar-quick-{}", q.layer.0); + actions.push(("layer.quickOffset".into(), json!({"delta": frames as f64 * fd, "merge": merge, "fromStart": true}))); + } + drag_tip(&ctx, &t, ptr, quick_offset_note(q.applied, q.layers)); + } + None => { + ctx.data_mut(|d| d.remove::(quick_bar_id())); + ui_actions.push(UiAct::EndMerge); + } + } + } // Keyframe drag: the selected keys move by whole frames with the pointer until it is // released (one undo step); Shift snaps the grabbed key to the current time, the work area // and composition markers. diff --git a/crates/ui-egui/tests/ui_quick_offset.rs b/crates/ui-egui/tests/ui_quick_offset.rs index 31161517..0587e85f 100644 --- a/crates/ui-egui/tests/ui_quick_offset.rs +++ b/crates/ui-egui/tests/ui_quick_offset.rs @@ -199,3 +199,45 @@ fn shift_ranges_and_key_boxes_stagger_in_comp_order() { h.state_mut().session.execute("keys.quickOffset", json!({"delta": 2.0 / 30.0})).unwrap(); assert_eq!([key_times(&h, ids[2])[1], key_times(&h, ids[1])[1], key_times(&h, ids[0])[1]], [one, one + F, one + 2 * F]); } + +/// Releasing a Quick Offset drag with its row scrolled out of view ends it: a later plain drag of +/// that bar moves the selection together, as its own undo step. +#[test] +fn a_quick_offset_released_off_screen_ends() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap(); + let ids: Vec = + (0..40).map(|i| s.execute("layer.newSolid", json!({"name": format!("L{i}"), "color": "#e04020"})).unwrap()["layer"].as_u64().unwrap()).collect(); + let (top, next) = (ids[39], ids[38]); + s.execute("layer.select", json!({"layers": [top, next]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(ctrl_alt())); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: ctrl_alt() }); + h.step(); + for i in 1..=12 { + h.input_mut().events.push(Event::PointerMoved(from + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + // Let go of Ctrl+Alt, scroll the row away, then release the button. + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::default())); + h.state_mut().ui.timeline.scroll_y = 10_000.0; + h.run_steps(2); + assert!(h.state().auto.find(&format!("timeline.layer.{top}.bar")).is_none(), "the row is out of view"); + let to = from + vec2(120.0, 0.0); + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::default() }); + h.run_steps(2); + let staggered = (start(&h, next), start(&h, top)); + assert!(staggered.0 > 0 && staggered.1 == 0, "the Quick Offset applied: {staggered:?}"); + h.state_mut().ui.timeline.scroll_y = 0.0; + h.run_steps(3); + let from = center(&h, &format!("timeline.layer.{top}.bar")); + drag(&mut h, from, from + vec2(160.0, 0.0), Modifiers::default()); + let d = start(&h, top); + assert!(d > 0 && start(&h, next) == staggered.0 + d, "a plain drag moves both by the same amount"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!((start(&h, next), start(&h, top)), staggered, "as its own undo step"); +}