From 0ac5f9fb9b5224d8bc03c675afd62981dd5c12f5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 17:01:27 +0700 Subject: [PATCH] Timeline: Shift-dragging the work area snaps to the current time (#252) Shift-dragging the work area's begin or end handle now snaps it to the current-time indicator, keyframes, layer in and out points and markers within 8 px, with the snap candidates the CTI and keyframe drags use. The work area bar itself drags too, as in After Effects: it moves the work area and keeps its length, and with Shift the nearer end snaps. Test: shift_dragging_the_work_area_snaps_to_the_current_time. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/timeline.rs | 43 ++++++++++++++++-- crates/ui-egui/tests/ui_timeline_zoom.rs | 56 +++++++++++++++++++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9cc575b4..2730391b 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -330,6 +330,18 @@ fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec { v } +/// Shift-dragging the work area or one of its ends (#252): the correction (seconds) that puts +/// the nearest of the `moving` times on the current time, a key, a layer in / out point or a +/// marker within 8 px (the work area's own ends are no targets). +fn work_area_snap(app: &EffectcraftApp, comp: &Comp, moving: &[f64], pps: f64) -> f64 { + let (a, b) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); + let mut cands = snap_candidates(comp, &build_rows(app, comp)); + cands.retain(|c| (c - a).abs() > 1e-9 && (c - b).abs() > 1e-9); + cands.push(app.session.time().seconds()); + let tol = 8.0 / pps.max(1e-6); + moving.iter().map(|t| snap_time(&cands, *t, tol) - t).filter(|d| *d != 0.0).min_by(|x, y| x.abs().total_cmp(&y.abs())).unwrap_or(0.0) +} + /// Timeline horizontal mapping. #[derive(Clone, Copy, Debug)] pub(crate) struct TMap { @@ -1079,10 +1091,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(ruler, 0.0, t.tl_ruler_bg); app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps)); // Work area bar: below the ruler, beside the column headers (After Effects' layout), with - // blue begin / end handles; the cache bar runs under it. + // blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work + // area, the handles move its ends; Shift snaps (#252). let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h)); - let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), wa_row.min.y + 3.0), pos2(tm.x(comp.work_area.1.seconds()), wa_row.max.y - 6.0)); + let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds()); + let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0)); p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR); + let shift = ui.input(|i| i.modifiers.shift); + let bar = wa.intersect(wa_row); + let bresp = ui.interact(bar, egui::Id::new("wa-bar"), Sense::drag()); + app.auto.add("timeline.workArea.bar", bar, "Work area"); + let bar_start = egui::Id::new("wa-bar-start"); + if bresp.drag_started() { + ctx.data_mut(|d| d.insert_temp(bar_start, wa0)); + } + if bresp.dragged() + && let (Some(a0), Some(d)) = (ctx.data(|d| d.get_temp::(bar_start)), bresp.total_drag_delta()) + { + let len = wa1 - wa0; + let mut a = a0 + d.x as f64 / pps.max(1e-6); + if shift { + a += work_area_snap(app, &comp, &[a, a + len], pps); + } + let a = fr.snap_nearest(Tick::from_seconds_f64(a.clamp(0.0, (comp.duration.seconds() - len).max(0.0)))).seconds(); + let _ = app.session.execute("comp.workArea", json!({"start": a, "end": a + len, "merge": "wa-drag"})); + } for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] { let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0)); let handle = if set == "begin" { @@ -1096,7 +1129,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.dragged() && let Some(pt) = resp.interact_pointer_pos() { - let secs = fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0))).seconds(); + let mut secs = tm.t(pt.x).max(0.0); + if shift { + secs += work_area_snap(app, &comp, &[secs], pps); + } + let secs = fr.snap_nearest(Tick::from_seconds_f64(secs)).seconds(); let key = if set == "begin" { "start" } else { "end" }; let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"})); } diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index c8bbd72b..29f26b08 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -64,16 +64,24 @@ fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect { } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + drag_with(h, from, to, Modifiers::NONE); +} + +/// [`drag`] with `modifiers` held. +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); h.event(Event::PointerMoved(from)); 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..=8 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 8.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.run_steps(2); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.step(); } /// The visible span (seconds) from the ruler's automation label ("start,pps") and width. @@ -161,3 +169,47 @@ fn spacebar_drag_scrolls_the_time_graph() { drag(&mut h, from, from - vec2(400.0, 0.0)); assert!(in_point(&h) < -1.0, "{}", in_point(&h)); } + +/// #252: Shift-dragging a work area end, or the whole work area bar, snaps to the current-time +/// indicator when it comes within reach; without Shift it lands where the pointer is. +#[test] +fn shift_dragging_the_work_area_snaps_to_the_current_time() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Short", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.ui.timeline.pps = Some(100.0); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| app); + let reset = |h: &mut Harness<'_, EffectcraftApp>| { + h.state_mut().session.execute("comp.workArea", json!({"start": 1.0, "end": 4.0})).unwrap(); + h.state_mut().session.execute("time.set", json!({"time": 6.0})).unwrap(); + h.run_steps(3); + }; + let work_area = |h: &Harness<'_, EffectcraftApp>| { + let wa = h.state().session.active_comp().unwrap().work_area; + (wa.0.seconds(), wa.1.seconds()) + }; + let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6; + reset(&mut h); + // Screen x of a time, from the handles at 1 s and 4 s. + let (b, e) = (rect(&h, "timeline.workArea.begin").center(), rect(&h, "timeline.workArea.end").center()); + let pps = (e.x - b.x) / 3.0; + let x = |t: f32| b.x + (t - 1.0) * pps; + // The end handle to 5 px before the CTI (6 s). + let to = pos2(x(6.0) - 5.0, e.y); + drag(&mut h, e, to); + assert!(work_area(&h).1 < 5.99, "no Shift: no snap {:?}", work_area(&h)); + reset(&mut h); + drag_with(&mut h, e, to, Modifiers::SHIFT); + assert!(near(work_area(&h), (1.0, 6.0)), "the end snaps to the CTI: {:?}", work_area(&h)); + // The bar moved so its end comes 5 px before the CTI: it moves as a whole and snaps. + reset(&mut h); + let mid = rect(&h, "timeline.workArea.bar").center(); + let to = mid + vec2(x(6.0) - 5.0 - e.x, 0.0); + drag(&mut h, mid, to); + let (a, z) = work_area(&h); + assert!((z - a - 3.0).abs() < 1e-6 && z < 5.99 && a > 1.5, "no Shift: moved, no snap {a}..{z}"); + reset(&mut h); + drag_with(&mut h, mid, to, Modifiers::SHIFT); + assert!(near(work_area(&h), (3.0, 6.0)), "the bar snaps its end to the CTI: {:?}", work_area(&h)); +}