Graph Editor: a handle drag moves the handles of every selected key (#284)

Dragging a Bezier handle in the Graph Editor changed only that key's
ease. As in After Effects, the same side's handles of every selected
key now move with it, by as much as the pointer moved (time and value
or speed alike), in one undo step. Each handle keeps its offset from
where the drag began, so a handle stopped at a limit (100 % influence)
doesn't drag the others on.

Test: a_graph_handle_drag_moves_the_handles_of_every_selected_key
(ui_keyframes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 23:15:34 +07:00
parent 04ccf6f906
commit cf7263d6c6
2 changed files with 120 additions and 15 deletions

View File

@ -306,28 +306,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
let hr = Rect::from_center_size(hp, vec2(10.0, 10.0));
let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag());
app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle");
// The same side's handles of every selected key move with it, by as much as the
// pointer moved (After Effects), in one undo step.
if hresp.drag_started() {
let grabs: Vec<HandleGrab> = cs
.iter()
.flat_map(|c2| {
c2.keys.iter().flat_map(move |k2| {
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
layer: c2.layer.id,
prop: c2.prop.uid,
dim: c2.editable.then_some(c2.dim),
time: k2.time,
out: h2.out,
key: (k2.t, k2.v),
end: (h2.t, h2.v),
negative: h2.speed < 0.0,
dur: h2.dur,
})
})
})
.collect();
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
}
if hresp.dragged()
&& let Some(pt) = hresp.interact_pointer_pos()
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
{
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
let mut dt = (tm.t(pt.x) - k.t) * if h.out { 1.0 } else { -1.0 };
dt = dt.max(h.dur * 0.001);
let influence = (dt / h.dur.max(1e-9) * 100.0).clamp(0.1, 100.0);
let sp = if speed {
let mag = vmap(pt.y).max(0.0);
if h.speed < 0.0 { -mag } else { mag }
} else {
let dv = vmap(pt.y) - k.v;
dv / dt * if h.out { 1.0 } else { -1.0 }
};
let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": format!("ghandle-{uid}-{}", k.idx)});
if c.editable {
params["dim"] = json!(d);
let (dt, dv) = (tm.t(pt.x) - tm.t(press.x), vmap(pt.y) - vmap(press.y));
let merge = format!("ghandle-{uid}-{}", k.idx);
for g in &grabs {
actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge)));
}
actions.push(("keys.setEase".into(), params));
}
if hresp.drag_stopped() {
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
ctx.data_mut(|dd| {
dd.insert_temp(drag_id, false);
dd.remove::<HandleDrag>(handle_drag_id());
});
actions.push(("__endMerge".into(), json!({})));
}
}
@ -558,3 +576,50 @@ struct KeyGrab {
fn key_grab_id() -> egui::Id {
egui::Id::new("graph-key-grab")
}
/// A Bezier handle moving with a handle drag: its key, side and dimension, the key's place
/// (comp seconds, graph value) and the handle's end when the drag began.
#[derive(Clone, Debug)]
struct HandleGrab {
layer: LayerId,
prop: u64,
/// `None` for a property drawn as one curve (spatial).
dim: Option<usize>,
time: Tick,
out: bool,
key: (f64, f64),
end: (f64, f64),
/// The speed was negative (the speed graph shows its magnitude).
negative: bool,
/// Segment duration (layer seconds) the influence is relative to.
dur: f64,
}
impl HandleGrab {
/// `keys.setEase` putting the handle's end at (`t` comp seconds, `v` in the graph).
fn ease(&self, t: f64, v: f64, speed: bool, merge: &str) -> serde_json::Value {
let dir = if self.out { 1.0 } else { -1.0 };
let dt = ((t - self.key.0) * dir).max(self.dur * 0.001);
let influence = (dt / self.dur.max(1e-9) * 100.0).clamp(0.1, 100.0);
let sp = if speed {
let mag = v.max(0.0);
if self.negative { -mag } else { mag }
} else {
(v - self.key.1) / dt * dir
};
let side = if self.out { "out" } else { "in" };
let mut params =
json!({"layer": self.layer.0, "prop": self.prop, "time": self.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": merge});
if let Some(d) = self.dim {
params["dim"] = json!(d);
}
params
}
}
/// A handle drag: where the button went down, and the handles that move.
type HandleDrag = (Pos2, Vec<HandleGrab>);
fn handle_drag_id() -> egui::Id {
egui::Id::new("graph-handle-drag")
}

View File

@ -293,3 +293,43 @@ fn clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve()
assert!((applied * 100.0 - out).abs() < 1e-9, "{applied} vs {out}");
assert_eq!(h.state().session.history.undo.len(), steps + 2);
}
/// #284: in the Graph Editor, dragging the handle of one selected key moves the same handle of
/// every selected key with it (After Effects), in one undo step.
#[test]
fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() {
let (mut h, id, uid) = harness();
let s = &mut h.state_mut().session;
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": 2.0, "value": 0.0})).unwrap();
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
s.execute("keys.selectAll", json!({})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
h.state_mut().ui.timeline.graph_editor = true;
h.run_steps(4);
// (in, out) influence of each key.
let influences = |h: &Harness<'_, EffectcraftApp>| -> Vec<(f64, f64)> {
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
let keys = l.props.prop("transform/opacity").unwrap().keys.clone();
keys.iter().map(|k| (k.in_ease.first().map_or(0.0, |e| e.influence), k.out_ease.first().map_or(0.0, |e| e.influence))).collect()
};
let before = influences(&h);
let handle = |h: &Harness<'_, EffectcraftApp>, key: usize| {
let e = h.state().auto.find(&format!("timeline.graph.handle.{uid}.0.{key}.out")).unwrap_or_else(|| panic!("no handle {key}")).clone();
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
};
let px_per_s = {
let at = |i: usize| h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap().rect[0];
at(1) - at(0)
};
let (k0, k1) = (handle(&h, 0), handle(&h, 1));
let undo = h.state().session.history.undo.len();
// Key 1's out handle 0.2 s further out (the keys are 1 s apart): key 0's goes along.
drag(&mut h, k1, k1 + egui::vec2(px_per_s * 0.2, 0.0), Default::default());
let after = influences(&h);
for i in [0, 1] {
assert!((after[i].1 - before[i].1 - 0.2).abs() < 0.01, "key {i}: {:?} → {:?}", before[i], after[i]);
}
assert_eq!((after[1].0, after[2].0), (before[1].0, before[2].0), "the in handles stay");
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen");
}