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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 17:01:27 +07:00
parent 41be200749
commit 0ac5f9fb9b
2 changed files with 94 additions and 5 deletions

View File

@ -330,6 +330,18 @@ fn snap_candidates(comp: &Comp, rows: &[Row]) -> Vec<f64> {
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::<f64>(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"}));
}

View File

@ -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));
}