From f455b8b2fbe91a7c6ee5d45da9633b3a7f1077c0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:22:51 -0400 Subject: [PATCH] M5.8: motion paths + graph transform box Graph Editor (ANM-4) and timeline keyframe tools: - Transform box around two or more selected keys: corners scale time and value about the opposite corner, edges one axis, the inside moves the keys (keys.transform, one undo step per drag, times land on frames). - Snap: dragged key times snap to the current time and other keys. - Show Reference Graph: the other graph type, faint, behind the curves. - Timeline Alt-drag of a key group's first or last key scales the group in time about the other end. - keys.transform takes dims (only the shown dimensions change). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/graph.rs | 52 ++++- crates/ui-egui/src/panels/graph_tools.rs | 281 +++++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/timeline.rs | 14 +- crates/ui-egui/tests/ui_viewer.rs | 73 ++++++ 5 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/panels/graph_tools.rs diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 7a3072e6..655fe619 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -231,11 +231,45 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); } + // Show Reference Graph: the other graph type, faint, scaled to the plot. + if app.ui.timeline.graph_reference && !cs.is_empty() { + let other = curves(app, comp, ectx, tm, plot, !speed); + let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY); + for c in &other { + for (_, v) in &c.pts { + rlo = rlo.min(*v); + rhi = rhi.max(*v); + } + } + if rlo.is_finite() { + let span = (rhi - rlo).max(1e-9); + let lines: Vec<(Color32, Vec)> = other + .iter() + .map(|c| { + ( + c.color, + c.pts + .iter() + .map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)) + .collect(), + ) + }) + .collect(); + super::graph_tools::reference_graph(&pc, plot, &lines); + } + } // Curves. for c in &cs { let pts: Vec = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect(); pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color))); } + // Transform box around several selected keys (inside below the keys, handles above). + let sel_keys: Vec = cs + .iter() + .flat_map(|c| c.keys.iter().filter(|k| k.sel).map(move |k| super::graph_tools::SelKey { t: k.t, v: k.v, dim: c.dim, editable: c.editable })) + .collect(); + let all_key_times: Vec<(u64, usize, f64)> = cs.iter().flat_map(|c| c.keys.iter().map(move |k| (c.prop.uid, k.idx, k.t))).collect(); + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions); // Keys and handles. let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; let fr = comp.frame_rate; @@ -298,7 +332,10 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); let merge = format!("gkey-{uid}-{}", k.idx); - let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0)))); + // Graph Editor ▸ Snap: to the current time and other keys. + let others: Vec = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect(); + let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others); + let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct))); let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); if !speed && c.editable { let mut comps = c.prop.keys[k.idx].value.components(); @@ -316,6 +353,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte } } + super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions); + // Background: click deselects keys, drag box-selects. if bg.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); @@ -374,6 +413,17 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte fit_selection(app, &cs, tm, plot); } x += 6.0; + let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box); + if text_button(app, ui, p, &mut x, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") { + app.ui.timeline.graph_snap = !snap; + } + if text_button(app, ui, p, &mut x, y, "Reference", reference, "reference", "Show Reference Graph") { + app.ui.timeline.graph_reference = !reference; + } + if text_button(app, ui, p, &mut x, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") { + app.ui.timeline.graph_transform_box = !tbox; + } + x += 6.0; let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { let layer = comp.layer(*l)?; let tr = layer.transform()?; diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs new file mode 100644 index 00000000..c42fd329 --- /dev/null +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -0,0 +1,281 @@ +//! Graph Editor and timeline keyframe tools: the transform box around several selected keys +//! (scale and move them in time and value), key-time snapping, and Alt-drag of a key group's +//! first or last key in the timeline to scale the group in time. Edits are `keys.transform`. + +use effectcraft_engine::project::Comp; +use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use super::timeline::TMap; +use crate::EffectcraftApp; + +type Actions = Vec<(String, Value)>; + +/// A selected key point in the graph: comp seconds, graph value, the dimension it shows, and +/// whether its value is editable there. +#[derive(Clone, Copy)] +pub(crate) struct SelKey { + pub t: f64, + pub v: f64, + pub dim: usize, + pub editable: bool, +} + +/// Transform box geometry (data space): [t0, v0, t1, v1]. +#[derive(Clone, Copy, Debug)] +struct BoxDrag { + /// 0–3 corners (t0v1, t1v1, t1v0, t0v0 = screen TL, TR, BR, BL), 4–7 edges (top, right, + /// bottom, left), 8 = move. + handle: usize, + /// Fixed opposite point (comp s, value). + anchor: [f64; 2], + /// Pointer data position of the previous frame. + last: [f64; 2], +} + +fn box_id() -> egui::Id { + egui::Id::new("graph-transform-box") +} + +/// Bounds of the selected keys: (t0, v0, t1, v1). +fn bounds(keys: &[SelKey]) -> Option<[f64; 4]> { + if keys.len() < 2 { + return None; + } + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for k in keys { + b = [b[0].min(k.t), b[1].min(k.v), b[2].max(k.t), b[3].max(k.v)]; + } + (b[2] > b[0] || b[3] > b[1]).then_some(b) +} + +fn handle_points(r: Rect) -> [Pos2; 8] { + let c = r.center(); + [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), pos2(c.x, r.min.y), pos2(r.max.x, c.y), pos2(c.x, r.max.y), pos2(r.min.x, c.y)] +} + +/// The transform box for two or more selected keys. Call before the keys are drawn (its inside +/// sits below them) with `handles = false`, and after them with `handles = true` (the handles sit +/// on top). Dragging a corner scales time and value about the opposite corner, an edge one axis, +/// the inside moves the keys; times land on frames. +#[allow(clippy::too_many_arguments)] +pub(crate) fn transform_box( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + p: &egui::Painter, + keys: &[SelKey], + tm: TMap, + ymap: &dyn Fn(f64) -> f32, + vmap: &dyn Fn(f32) -> f64, + speed: bool, + handles: bool, + actions: &mut Actions, +) { + if !app.ui.timeline.graph_transform_box { + return; + } + let Some(b) = bounds(keys) else { return }; + let r = Rect::from_min_max(pos2(tm.x(b[0]), ymap(b[3])), pos2(tm.x(b[2]), ymap(b[1]))).expand(6.0); + let value_ok = !speed && keys.iter().all(|k| k.editable); + let col = Color32::from_rgb(0xd8, 0xd8, 0xd8); + let ctx = ui.ctx().clone(); + let drag_id = egui::Id::new("graph-dragging"); + let hp = handle_points(r); + let data = |q: Pos2| [tm.t(q.x), vmap(q.y)]; + // Which data point each handle sits on, and its opposite. + let corner = |i: usize| -> [f64; 2] { + match i { + 0 => [b[0], b[3]], + 1 => [b[2], b[3]], + 2 => [b[2], b[1]], + 3 => [b[0], b[1]], + 4 => [(b[0] + b[2]) / 2.0, b[3]], + 5 => [b[2], (b[1] + b[3]) / 2.0], + 6 => [(b[0] + b[2]) / 2.0, b[1]], + _ => [b[0], (b[1] + b[3]) / 2.0], + } + }; + let mut start: Option = None; + let mut resp_any: Option = None; + if !handles { + p.rect_stroke(r, 0.0, Stroke::new(1.0, col.gamma_multiply(0.7)), StrokeKind::Middle); + let resp = ui.interact(r, egui::Id::new("graph-box-inside"), Sense::drag()); + app.auto.add("timeline.graph.transformBox", r, "Transform box"); + if resp.drag_started() + && let Some(o) = ui.input(|i| i.pointer.press_origin()) + { + start = Some(BoxDrag { handle: 8, anchor: [0.0; 2], last: data(o) }); + } + resp_any = Some(resp); + } else { + for (i, h) in hp.iter().enumerate() { + // Value handles only when values can change. + if !value_ok && matches!(i, 4 | 6) { + continue; + } + let hr = Rect::from_center_size(*h, vec2(7.0, 7.0)); + p.rect_filled(hr, 0.0, Color32::from_gray(0x20)); + p.rect_stroke(hr, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + let resp = ui.interact(hr.expand(2.0), egui::Id::new(("graph-box-handle", i)), Sense::drag()); + app.auto.add(&format!("timeline.graph.transformBox.{i}"), hr, "Transform box handle"); + if resp.drag_started() { + let opp = [2, 3, 0, 1, 6, 7, 4, 5][i]; + start = Some(BoxDrag { handle: i, anchor: corner(opp), last: corner(i) }); + } + if resp.dragged() || resp.drag_stopped() { + resp_any = Some(resp); + } + } + } + if let Some(s) = start { + ctx.data_mut(|d| { + d.insert_temp(box_id(), s); + d.insert_temp(drag_id, true); + }); + } + let Some(resp) = resp_any else { return }; + let Some(mut st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; + if (st.handle == 8) == handles { + return; + } + if resp.dragged() + && let Some(pt) = resp.interact_pointer_pos() + { + let cur = data(pt); + let mut params = json!({"merge": "graph-box"}); + if st.handle == 8 { + params["timeOffset"] = json!(cur[0] - st.last[0]); + if value_ok { + params["valueOffset"] = json!(cur[1] - st.last[1]); + } + st.last = cur; + } else { + let (time_axis, value_axis) = match st.handle { + 4 | 6 => (false, true), + 5 | 7 => (true, false), + _ => (true, true), + }; + // Scale relative to where the handle is now (incremental, so merged steps compose). + let now = corner(st.handle); + if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 { + let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]); + if k > 0.01 { + params["timeScale"] = json!(k); + params["timeAnchor"] = json!(st.anchor[0]); + } + } + if value_axis && value_ok && (now[1] - st.anchor[1]).abs() > 1e-9 { + params["valueScale"] = json!((cur[1] - st.anchor[1]) / (now[1] - st.anchor[1])); + params["valueAnchor"] = json!(st.anchor[1]); + } + } + let mut dims: Vec = keys.iter().map(|k| k.dim).collect(); + dims.sort_unstable(); + dims.dedup(); + params["dims"] = json!(dims); + if params.as_object().is_some_and(|m| m.len() > 2) { + actions.push(("keys.transform".into(), params)); + } + ctx.data_mut(|d| d.insert_temp(box_id(), st)); + } + if resp.drag_stopped() { + ctx.data_mut(|d| { + d.remove::(box_id()); + d.insert_temp(drag_id, false); + }); + actions.push(("__endMerge".into(), json!({}))); + } +} + +/// Graph Editor ▸ Snap: a dragged key time (comp s) snaps to the current time or another key's +/// time within 6 points. +pub(crate) fn snap_time(app: &EffectcraftApp, tm: TMap, t: f64, others: &[f64]) -> f64 { + if !app.ui.timeline.graph_snap { + return t; + } + let cti = app.session.time().seconds(); + let x = tm.x(t); + std::iter::once(cti) + .chain(others.iter().copied()) + .map(|c| (c, (tm.x(c) - x).abs())) + .filter(|(_, d)| *d < 6.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(c, _)| c) + .unwrap_or(t) +} + +// ---------------------------------------------------------------- timeline Alt-drag scaling + +#[derive(Clone, Copy, Debug)] +struct AltScale { + /// The fixed end of the group (comp s). + anchor: f64, + /// The dragged end is the group's last key (else its first). + end_is_max: bool, +} + +fn alt_id() -> egui::Id { + egui::Id::new("timeline-alt-scale") +} + +/// Selected keys' comp-time range. +fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> { + let ts: Vec = app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect(); + if ts.len() < 2 { + return None; + } + let lo = ts.iter().copied().fold(f64::MAX, f64::min); + let hi = ts.iter().copied().fold(f64::MIN, f64::max); + (hi - lo > 1e-9).then_some((lo, hi)) +} + +/// Alt-drag on the first or last key of a selected group starts scaling the group in time +/// about the other end. Returns true when it took the drag. +pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool { + let Some((lo, hi)) = sel_range(app, comp) else { return false }; + let st = if (key_ct - hi).abs() < 1e-6 { + AltScale { anchor: lo, end_is_max: true } + } else if (key_ct - lo).abs() < 1e-6 { + AltScale { anchor: hi, end_is_max: false } + } else { + return false; + }; + ctx.data_mut(|d| d.insert_temp(alt_id(), st)); + true +} + +/// While an Alt-drag scale is active: scale the group so its dragged end follows the pointer. +pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, tm: TMap, actions: &mut Actions) { + let Some(st) = ctx.data(|d| d.get_temp::(alt_id())) else { return }; + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| d.remove::(alt_id())); + actions.push(("__endMerge".into(), json!({}))); + return; + } + let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return }; + let fr = comp.frame_rate; + let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds(); + let end = if st.end_is_max { hi } else { lo }; + let span = end - st.anchor; + let want = target - st.anchor; + if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() { + return; + } + let k = want / span; + if (k - 1.0).abs() > 1e-6 { + actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"}))); + } +} + +/// An Alt-drag group scale is in progress (the plain key move stands aside). +pub(crate) fn alt_scale_active(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(alt_id())).is_some() +} + +/// Graph Editor ▸ Show Reference Graph: the other graph type, normalized to the plot, faint. +pub(crate) fn reference_graph(p: &egui::Painter, plot: Rect, curves: &[(Color32, Vec)]) { + for (c, pts) in curves { + p.add(egui::Shape::line(pts.clone(), Stroke::new(1.0, c.gamma_multiply(0.35)))); + } + let _ = plot; +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 239b84d3..16d1c0ee 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -8,6 +8,7 @@ pub mod effects_presets; pub mod forms; pub mod fx_widgets; pub mod graph; +pub mod graph_tools; pub mod info; pub mod key_dialogs; pub mod layer_panel; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ab85e8eb..e1563c44 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1320,7 +1320,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } }); - if kresp.dragged() { + // Alt-drag the first or last key of a selected group: scale the group in time. + if kresp.drag_started() + && ui.input(|i| i.modifiers.alt) + && let Some(c) = app.session.active_comp().cloned() + { + super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds()); + } + let alt_scaling = super::graph_tools::alt_scale_active(&ctx); + if kresp.dragged() && !alt_scaling { let acc_id = egui::Id::new("key-drag-acc"); let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); acc += kresp.drag_delta().x; @@ -1364,6 +1372,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + // Alt-drag key group scaling (follows the pointer until release). + if let Some(c) = app.session.active_comp().cloned() { + super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions); + } // Graph editor. if graph_on { super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index db3f9748..db739954 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -222,3 +222,76 @@ fn motion_path_key_drag_edits_that_key() { assert!((v1[1] - 300.0).abs() < 2.0 && (v1[0] - 500.0).abs() < 2.0, "{v1:?}"); assert_eq!(k[0].value.as_vec3(), [100.0, 100.0, 0.0]); } + +#[test] +fn graph_editor_transform_box_scales_selected_keys_in_time() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("prop.select", json!({"layer": box_id.0, "prop": uid})).unwrap(); + let keys: Vec<_> = [0.0, 1.0, 2.0].iter().map(|t| json!({"layer": box_id.0, "prop": uid, "time": t})).collect(); + s.execute("keys.select", json!({"keys": keys})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + for id in ["timeline.graph.snap", "timeline.graph.reference", "timeline.graph.transformBox", "timeline.graph.transformBox.5"] { + assert!(h.state().auto.find(id).is_some(), "missing {id}"); + } + // Drag the right edge handle to the right: the keys spread out in time, the first stays. + let r = rect(&h, "timeline.graph.transformBox.5"); + let k0 = rect(&h, &format!("timeline.graph.key.{uid}.0.0")).center(); + let k2 = rect(&h, &format!("timeline.graph.key.{uid}.0.2")).center(); + let to = r.center() + vec2((k2.x - k0.x) * 0.5, 0.0); + drag(&mut h, r.center(), to); + let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone(); + let ts: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(ts[0], 0.0); + assert!(ts[2] > 2.5 && ts[2] < 3.5, "{ts:?}"); + assert_eq!(p.keys.iter().map(|k| k.value.as_f64()).collect::>(), vec![0.0, 50.0, 100.0]); + let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count(); + assert_eq!(undo, 1, "one undo step per drag"); +} + +#[test] +fn timeline_alt_drag_scales_a_key_group_in_time() { + let mut h = harness(); + let box_id: LayerId = h.state().session.active_comp().unwrap().layers[0].id; + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [box_id.0]})).unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 50.0), (2.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": box_id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + let uid = s.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().uid; + s.execute("keys.selectAll", json!({"layers": [box_id.0]})).unwrap(); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap(); + h.run_steps(4); + let mut ks: Vec = + h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect(); + ks.sort_by(|a, b| a.x.total_cmp(&b.x)); + assert_eq!(ks.len(), 3, "{ks:?}"); + let (k0, k2) = (ks[0], ks[2]); + // Alt-drag the last key to where 3 s would be: the group scales by 1.5 about the first key. + let to = pos2(k0.x + (k2.x - k0.x) * 1.5, k2.y); + let alt = egui::Modifiers { alt: true, ..Default::default() }; + h.input_mut().events.push(Event::PointerMoved(k2)); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(alt)); + h.input_mut().events.push(Event::PointerButton { pos: k2, button: egui::PointerButton::Primary, pressed: true, modifiers: alt }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(k2 + (to - k2) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: alt }); + h.run_steps(2); + let p = h.state().session.active_comp().unwrap().layer(box_id).unwrap().props.prop("transform/opacity").unwrap().clone(); + let ts: Vec = p.keys.iter().map(|k| k.time.seconds()).collect(); + assert_eq!(ts.len(), 3); + assert_eq!(ts[0], 0.0); + assert!((ts[2] - 3.0).abs() < 0.1 && (ts[1] - 1.5).abs() < 0.1, "{ts:?}"); +}