Merge pull request #3412 from arnav-hiwarkar/fix/1828-timeline-layer-snapping

Timeline: Shift-dragging a layer bar snaps to layer ends, the current time, markers and keys (#1828)
This commit is contained in:
ash
2026-11-10 20:10:39 +05:30
committed by GitHub
3 changed files with 151 additions and 29 deletions
+80 -27
View File
@@ -588,15 +588,27 @@ pub fn snap_time(candidates: &[f64], t: f64, tolerance: f64) -> f64 {
/// Times the current-time indicator snaps to (comp seconds).
fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
snap_targets(comp, rows, &[], false)
}
/// [`snap_candidates`] without the `edited` layers' In / Out points; when `moving` them, without
/// their markers and keys either (those move along).
fn snap_targets(comp: &Comp, rows: &[Row], edited: &[u64], moving: bool) -> Vec<f64> {
let skip = |l: LayerId, contents: bool| edited.contains(&l.0) && (moving || !contents);
let mut v = vec![0.0, comp.work_area.0.seconds(), comp.work_area.1.seconds(), comp.duration.seconds()];
v.extend(comp.markers.iter().map(|m| m.time.seconds()));
for l in &comp.layers {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
if !skip(l.id, false) {
v.push(l.in_point.seconds());
v.push(l.out_point.seconds());
}
if !skip(l.id, true) {
v.extend(l.markers.iter().map(|m| l.comp_time(m.time).seconds()));
}
}
for r in rows {
if let RowKind::Prop { uid } = r.kind
&& !skip(r.layer, true)
&& let Some(l) = comp.layer(r.layer)
&& let Some(p) = l.props.find(uid)
{
@@ -3473,44 +3485,85 @@ fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &e
ctx.data(|d| d.get_temp::<QuickBar>(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
}
/// Moving and trimming a layer bar by whole frames.
/// One layer bar drag: the layers it edits, the pointer's travel (px), the grabbed layer's moving
/// ends and the snap targets when it started (comp seconds), and the whole frames sent so far.
#[derive(Clone)]
struct BarDrag {
edited: Vec<u64>,
px: f64,
ends: Vec<f64>,
targets: Arc<[f64]>,
sent: i64,
}
/// The whole frames a bar drag of `px` moves its ends by: truncated as the pointer goes, or with
/// `shift`, the move that puts one of `ends` on the nearest target within 8 px of the pointer
/// (#1828). Measured from the pointer, never the snapped result, so dragging on lets go. The
/// move stays whole frames, so an end off the frame grid lands within half a frame of its target.
fn bar_drag_frames(px: f64, pps: f64, fd: f64, ends: &[f64], targets: &[f64], shift: bool) -> i64 {
let secs = px / pps;
let snapped = shift
.then(|| {
ends.iter()
.flat_map(|e| targets.iter().map(move |t| t - e))
.filter(|d| ((d - secs) * pps).abs() <= 8.0)
.min_by(|a, b| (a - secs).abs().total_cmp(&(b - secs).abs()))
})
.flatten();
// `as` saturates (NaN → 0), so a zero frame duration or zoom can't panic.
snapped.map_or((secs / fd).trunc() as i64, |d| (d / fd).round() as i64)
}
/// Moving and trimming a layer bar by whole frames; Shift snaps its ends to the current time,
/// markers, keys, the work area and other layers' In / Out points, as in After Effects (#1828).
fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
let BarResps { body, lin, lout } = bars;
let pps = g.tl.pps;
let fd = g.tl.fd;
let ctx = g.tl.ctx.clone();
let actions = &mut g.tl.actions;
let selected = &g.selected;
let ui_actions = &mut g.ui_actions;
let layer = rc.layer;
let drag_key = format!("bar-{}", layer.id.0);
for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
if kind == "move" && quick {
continue;
}
let id = egui::Id::unique(("bar-drag", layer.id.0, kind));
if resp.dragged() {
let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
acc += resp.drag_delta().x;
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
if frames != 0 {
acc -= (frames as f64 * fd * pps) as f32;
let d = frames as f64 * fd;
// Moving or trimming a selected layer acts on the whole
// selection; each layer trims from its own in/out point.
let targets = json!(edited_layers(layer.id, selected));
let params = match kind {
"move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
"in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
_ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
let shift = ctx.input(|i| i.modifiers.shift);
// Moving or trimming a selected layer acts on the whole selection as it is when the
// drag starts; each layer trims from its own in/out point. A Shift-drag on an
// unselected bar adds it to the selection (`layer_bar_hits`) and moves them all.
let start = || {
let mut edited = edited_layers(layer.id, &g.selected);
if kind == "move" && shift && !g.selected.contains(&layer.id) {
edited.extend(g.selected.iter().map(|l| l.0));
}
let (i, o) = (layer.in_point.seconds(), layer.out_point.seconds());
let ends = match kind {
"move" => vec![i, o],
"in" => vec![i],
_ => vec![o],
};
actions.push(("layer.timing".into(), params));
let mut targets = snap_targets(&g.tl.comp, &g.tl.rows, &edited, kind == "move");
targets.push(g.tl.time.seconds());
BarDrag { edited, px: 0.0, ends, targets: targets.into(), sent: 0 }
};
let mut drag = ctx.data(|d| d.get_temp::<BarDrag>(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start);
drag.px += f64::from(resp.drag_delta().x);
let frames = bar_drag_frames(drag.px, g.tl.pps, g.tl.fd, &drag.ends, &drag.targets, shift);
if frames != drag.sent {
let d = frames.saturating_sub(drag.sent) as f64 * g.tl.fd;
drag.sent = frames;
let params = match kind {
"move" => json!({"layers": drag.edited, "delta": d, "merge": drag_key}),
"in" => json!({"layers": drag.edited, "inDelta": d, "merge": drag_key}),
_ => json!({"layers": drag.edited, "outDelta": d, "merge": drag_key}),
};
g.tl.actions.push(("layer.timing".into(), params));
}
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
ctx.data_mut(|d| d.insert_temp(id, drag));
}
if resp.drag_stopped() {
ctx.data_mut(|d| d.remove::<f32>(egui::Id::unique(("bar-acc", layer.id.0, kind))));
ui_actions.push(UiAct::EndMerge);
ctx.data_mut(|d| d.remove::<BarDrag>(id));
g.ui_actions.push(UiAct::EndMerge);
}
}
}
@@ -434,6 +434,34 @@ fn cti_snaps_to_the_nearest_candidate() {
assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0));
}
/// A dragged layer's own In / Out points are no snap targets; moved, neither are its markers
/// (#1828).
#[test]
fn bar_drags_dont_snap_to_their_own_layer() {
let mut app = app();
let bx = app.session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id.0;
app.session.execute("layer.addMarker", json!({"layers": [bx], "time": 1.0})).unwrap();
let comp = app.session.active_comp().unwrap().clone();
let rows = build_rows(&app, &comp);
let all = snap_targets(&comp, &rows, &[], false);
let trimmed = snap_targets(&comp, &rows, &[bx], false);
let moved = snap_targets(&comp, &rows, &[bx], true);
assert_eq!(trimmed.len(), all.len() - 2, "trimming drops only the layer's In and Out");
assert!(trimmed.contains(&1.0) && !moved.contains(&1.0), "moving drops the layer's marker: {moved:?}");
}
/// Shift snaps a bar drag's nearest end to a target within 8 px of the pointer; farther away, or
/// without Shift, the bar moves by the whole frames the pointer covered (#1828).
#[test]
fn bar_drags_snap_their_ends_with_shift() {
let (pps, fd, ends) = (100.0, 0.1, [2.0, 6.0]);
assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], true), 10, "5 px short of 2 → 3 s snaps");
assert_eq!(bar_drag_frames(95.0, pps, fd, &ends, &[3.0], false), 9, "no snap without Shift");
assert_eq!(bar_drag_frames(88.0, pps, fd, &ends, &[3.0], true), 8, "12 px away lets go");
assert_eq!(bar_drag_frames(104.0, pps, fd, &ends, &[3.0, 7.06], true), 11, "the target nearest the pointer wins");
assert_eq!(bar_drag_frames(5.0, 0.0, 0.0, &ends, &[0.0], true), i64::MAX, "zero zoom saturates, no panic");
}
/// Every string drawn by one frame of `draw`.
fn drawn_text(mut draw: impl FnMut(&mut egui::Ui)) -> Vec<String> {
fn collect(shape: &egui::Shape, out: &mut Vec<String>) {
+43 -2
View File
@@ -45,18 +45,46 @@ fn timing(h: &Harness<'_, EffectcraftApp>, layer: u64) -> (f64, f64, f64) {
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
drag_with(h, from, to, Modifiers::NONE);
}
fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) {
h.event(Event::PointerMoved(from));
h.event(Event::ModifiersChanged(modifiers));
h.step();
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
h.step();
for k in 1..=10 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
h.step();
}
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
/// Shift-dragging a bar end or the bar snaps it to another layer's In / Out point a few pixels
/// away; without Shift it stops on the frame the pointer reached (#1828).
#[test]
fn shift_dragging_a_bar_snaps_to_other_layers() {
for shift in [false, true] {
let (mut h, solid, pre) = harness();
let mods = if shift { Modifiers::SHIFT } else { Modifiers::NONE };
let (b, p) = (bar(&h, solid), bar(&h, pre));
let y = b.center().y;
// The solid's Out end (6 s), grabbed 2 px inside, to 6 px short of the precomp's Out (7 s).
drag_with(&mut h, pos2(b.max.x - 2.0, y), pos2(p.max.x - 8.0, y), mods);
let (i, o, _) = timing(&h, solid);
assert!((i - 2.0).abs() < 1e-6, "shift {shift}: in stays at 2 s, got {i}");
assert_eq!((o - 7.0).abs() < 1e-6, shift, "shift {shift}: out {o} snaps to 7 s only with Shift");
// The whole bar, so its In (2 s) lands 5 px short of the precomp's In (3 s).
let b = bar(&h, solid);
drag_with(&mut h, pos2(b.center().x, y), pos2(b.center().x + p.min.x - b.min.x - 5.0, y), mods);
let (i, _, _) = timing(&h, solid);
assert_eq!((i - 3.0).abs() < 1e-6, shift, "shift {shift}: in {i} snaps to 3 s only with Shift");
}
}
/// Both ends of every bar show the trim cursor, just inside and just outside the end; the middle
/// doesn't. The same holds for a selected layer, a twirled-open layer and a short bar.
fn assert_trim_cursor_at_ends(h: &mut Harness<'_, EffectcraftApp>, layer: u64, case: &str) {
@@ -120,6 +148,19 @@ fn dragging_bar_ends_trims_in_and_out() {
assert!((i - 3.0).abs() < 1e-6 && (o - 8.0).abs() < 1.5 / 30.0 && s.abs() < 1e-6, "out extended to ~8 s, not slipped: in {i} out {o} start {s}");
}
/// A Shift-drag on an unselected bar adds it to the selection and moves the whole selection
/// together, so the other selected layers keep their offset (#1828).
#[test]
fn shift_dragging_an_unselected_bar_moves_the_selection() {
let (mut h, solid, pre) = harness();
h.state_mut().session.execute("layer.select", json!({"layers": [pre]})).unwrap();
h.run_steps(2);
let b = bar(&h, solid);
drag_with(&mut h, b.center(), b.center() + vec2(b.width() / 4.0 * 2.5, 0.0), Modifiers::SHIFT);
let ((si, ..), (pi, ..)) = (timing(&h, solid), timing(&h, pre));
assert!(si > 4.0 && (pi - si - 1.0).abs() < 1e-6, "both moved by the same amount: solid in {si}, precomp in {pi}");
}
/// With Frames and Frame Count Start at 1, In moves to 00061 but the Duration column still counts
/// the layer's 120 frames (#1807).
#[test]