diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index be0ef05d5..4de3233e8 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,85 @@ 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. 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::(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::(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..1cd5e3423 100644 --- a/crates/ui-egui/src/panels/timeline/tests.rs +++ b/crates/ui-egui/src/panels/timeline/tests.rs @@ -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 { fn collect(shape: &egui::Shape, out: &mut Vec) { diff --git a/crates/ui-egui/tests/it/ui_timeline_trim.rs b/crates/ui-egui/tests/it/ui_timeline_trim.rs index 6f95f744f..9aaf1f500 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) { @@ -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]