From 3c6abe3606c868990ad5ba7bec2e259431b222aa Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 18:44:24 +0530 Subject: [PATCH] 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]