From cf7263d6c65ef21bcb557bc17adbee35078f397a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 23:15:34 +0700 Subject: [PATCH] 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 --- crates/ui-egui/src/panels/graph.rs | 95 +++++++++++++++++++++++----- crates/ui-egui/tests/ui_keyframes.rs | 40 ++++++++++++ 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 0b03ef39..8d7bcbdd 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -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 = 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::(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::(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::(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, + 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); + +fn handle_drag_id() -> egui::Id { + egui::Id::new("graph-handle-drag") +} diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 0d0846f7..82805f60 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -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"); +}