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"); +}