mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 20:41:25 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
87000339ed
commit
3f3031d941
3 files changed
+123
-29
No files matched your search
@@ -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<f64> {
|
||||
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<f64> {
|
||||
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::<QuickBar>(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<u64>,
|
||||
px: f64,
|
||||
ends: Vec<f64>,
|
||||
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::<BarDrag>(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::<f32>(egui::Id::unique(("bar-acc", layer.id.0, kind))));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
ctx.data_mut(|d| d.remove::<BarDrag>(id));
|
||||
g.ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user