From 3f3031d94181e6ad1f5891ee824c0329290da1a1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 17:54:46 +0530 Subject: [PATCH 1/3] Timeline: Shift-dragging a layer bar snaps to layer ends, the current time, markers and keys (#1828) Moving a layer bar or dragging its In / Out end with Shift held snaps the moved edge, within 8 px of the pointer, to the current time, comp and layer markers, visible keys, the work area, the comp's start and end, and other layers' In / Out points, as in After Effects. The edited layers' own ends are no targets (nor, when moving, their markers and keys). Targets are gathered once per drag; snapping is measured from the pointer, so dragging on lets go. Without Shift a drag moves by the whole frames the pointer covered, as before. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 103 +++++++++++++++----- crates/ui-egui/src/panels/timeline/tests.rs | 17 ++++ crates/ui-egui/tests/it/ui_timeline_trim.rs | 32 +++++- 3 files changed, 123 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index be0ef05d5..be33e0e5a 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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 { + 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 { + 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,81 @@ fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &e ctx.data(|d| d.get_temp::(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, + px: f64, + ends: Vec, + 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. +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}), + // Moving or trimming a selected layer acts on the whole selection as it was when the + // drag started (a Shift-drag adds the layer to it); each layer trims from its own + // in/out point. + let start = || { + let edited = edited_layers(layer.id, &g.selected); + 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::(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start); + drag.px += f64::from(resp.drag_delta().x); + let shift = ctx.input(|i| i.modifiers.shift); + 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::(egui::Id::unique(("bar-acc", layer.id.0, kind)))); - ui_actions.push(UiAct::EndMerge); + ctx.data_mut(|d| d.remove::(id)); + g.ui_actions.push(UiAct::EndMerge); } } } diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index e42923e4a..9185c614b 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -432,6 +432,23 @@ fn cti_snaps_to_the_nearest_candidate() { let comp = app.session.active_comp().unwrap().clone(); let c = snap_candidates(&comp, &build_rows(&app, &comp)); assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); + // A dragged layer's own ends are no targets; moved, neither are its markers and keys (#1828). + let rows = build_rows(&app, &comp); + let l = comp.layers.first().unwrap().id.0; + assert_eq!(snap_targets(&comp, &rows, &[l], false).len(), c.len() - 2); + assert!(snap_targets(&comp, &rows, &[l], true).len() <= c.len() - 2); +} + +/// 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`. diff --git a/crates/ui-egui/tests/it/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs index 6f95f744f..0ba1c73a6 100644 --- a/crates/ui-egui/tests/it/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/it/ui_timeline_trim.rs @@ -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) { From 3c6abe3606c868990ad5ba7bec2e259431b222aa Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 18:44:24 +0530 Subject: [PATCH 2/3] Timeline: a Shift-drag on an unselected bar moves the whole selection (#1828) A Shift-press on an unselected bar adds it to the selection, so the drag now moves that selection and its layers are no snap targets. Tests cover the selection drag and that a moved layer's own marker is no target. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 13 ++++++++----- crates/ui-egui/src/panels/timeline/tests.rs | 19 +++++++++++++++---- crates/ui-egui/tests/it/ui_timeline_trim.rs | 13 +++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index be33e0e5a..f9804f995 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3526,11 +3526,15 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { } let id = egui::Id::unique(("bar-drag", layer.id.0, kind)); if resp.dragged() { - // Moving or trimming a selected layer acts on the whole selection as it was when the - // drag started (a Shift-drag adds the layer to it); each layer trims from its own - // in/out point. + 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 edited = edited_layers(layer.id, &g.selected); + 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], @@ -3543,7 +3547,6 @@ fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) { }; let mut drag = ctx.data(|d| d.get_temp::(id)).filter(|_| !resp.drag_started()).unwrap_or_else(start); drag.px += f64::from(resp.drag_delta().x); - let shift = ctx.input(|i| i.modifiers.shift); 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; diff --git a/crates/ui-egui/src/panels/timeline/tests.rs b/crates/ui-egui/src/panels/timeline/tests.rs index 9185c614b..1cd5e3423 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -432,11 +432,22 @@ fn cti_snaps_to_the_nearest_candidate() { let comp = app.session.active_comp().unwrap().clone(); let c = snap_candidates(&comp, &build_rows(&app, &comp)); assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0)); - // A dragged layer's own ends are no targets; moved, neither are its markers and keys (#1828). +} + +/// 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 l = comp.layers.first().unwrap().id.0; - assert_eq!(snap_targets(&comp, &rows, &[l], false).len(), c.len() - 2); - assert!(snap_targets(&comp, &rows, &[l], true).len() <= c.len() - 2); + 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 diff --git a/crates/ui-egui/tests/it/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs index 0ba1c73a6..9aaf1f500 100644 --- a/crates/ui-egui/tests/it/ui_timeline_trim.rs +++ b/crates/ui-egui/tests/it/ui_timeline_trim.rs @@ -148,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] From d0eb89904cff29d8216052d13f7fd5111b85af44 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 19:26:51 +0530 Subject: [PATCH 3/3] Timeline: note that a snapped bar edge off the frame grid lands within half a frame (#1828) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index f9804f995..4de3233e8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3498,7 +3498,8 @@ struct BarDrag { /// 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. +/// (#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