diff --git a/crates/engine/src/commands/key_transform.rs b/crates/engine/src/commands/key_transform.rs index 82037d52..b7843926 100644 --- a/crates/engine/src/commands/key_transform.rs +++ b/crates/engine/src/commands/key_transform.rs @@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> { /// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first /// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by /// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames. +/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the +/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the +/// way, or rounded steps, are never lost; only the final position counts. fn transform(s: &mut Session, p: &Value) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let merge = merge_p(p); + let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some(); + let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge; + let (sel, base) = match (&s.key_move, s.history.undo.last()) { + (Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())), + _ => (s.state.selected_keys.clone(), None), + }; + if from_start { + s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO }); + } + // The comp as it was when the drag started (its keys are what the transform applies to). + let start = base.clone().unwrap_or_else(|| s.project.clone()); + let comp = start.comp(cid).ok_or(EngineError::NoComp)?; let fr = comp.frame_rate; - let sel = s.state.selected_keys.clone(); let ts = f_p(p, "timeScale").unwrap_or(1.0); let toff = f_p(p, "timeOffset").unwrap_or(0.0); let vs = f_p(p, "valueScale").unwrap_or(1.0); @@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result { fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0))) }; let touch_values = vs != 1.0 || voff != 0.0; - s.edit("Transform Keyframes", merge_p(p), |proj, st| { + s.edit("Transform Keyframes", merge, |proj, st| { + if let Some(b) = base { + *proj = (*b).clone(); + } let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); for k in &sel { @@ -136,7 +153,7 @@ pub fn specs() -> Vec { "Transform Keyframes", [], None, - "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}", + "{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}", has_keys, transform ), diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index d59bf61c..1b7bc617 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -938,3 +938,22 @@ fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); assert!(s.state.selected_keys.is_empty()); } + +#[test] +fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]); + s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); + // Squeezed onto one frame on the way, then back: the three keys survive. + for k in [0.5, 0.01, 0.0001, 1.0] { + s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap(); + } + assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]); + // A slow move in tiny steps still arrives (each step is the whole offset). + s.history.merge_key = None; + for i in 1..=30 { + s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap(); + } + assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]); +} diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs index 655fe619..d34bace3 100644 --- a/crates/ui-egui/src/panels/graph.rs +++ b/crates/ui-egui/src/panels/graph.rs @@ -272,7 +272,6 @@ 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, false, actions); // Keys and handles. let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; - let fr = comp.frame_rate; for c in &cs { let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); for k in &c.keys { @@ -323,28 +322,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag()); app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); - let shift = ui.input(|i| i.modifiers.shift); - if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) { - actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift}))); + let mods = ui.input(|i| i.modifiers); + let key = &c.prop.keys[k.idx]; + if kresp.clicked() && mods.command { + // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline). + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]}))); + if mods.alt { + actions.push(("keys.toggleHold".into(), json!({}))); + } else { + let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear; + actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }}))); + } + } else if kresp.clicked() && mods.shift { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true}))); + } else if (kresp.clicked() || kresp.drag_started()) && !k.sel { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift}))); + } + // A drag moves every selected key, by how far the pointer went from where the button + // went down (the key doesn't jump under the pointer); Shift keeps it to the time or + // the value axis, whichever moved more. + if kresp.drag_started() + && let Some(o) = ui.input(|i| i.pointer.press_origin()) + { + ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t })); } if kresp.dragged() && let Some(pt) = kresp.interact_pointer_pos() + && let Some(g) = ctx.data(|dd| dd.get_temp::(key_grab_id())) { ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); - let merge = format!("gkey-{uid}-{}", k.idx); + let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y); + let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) }; // 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(); - if d < comps.len() { - comps[d] = vmap(pt.y); - } - params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) }; + let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t }; + let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true}); + if value_ok && !speed && c.editable { + params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y)); + params["dim"] = json!(d); } - actions.push(("keys.set".into(), params)); + actions.push(("keys.transform".into(), params)); } if kresp.drag_stopped() { ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); @@ -500,3 +517,14 @@ fn fmt(v: f64, step: f64) -> String { let s = format!("{v:.dec$}"); if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } } + +/// A Graph Editor key drag: where the button went down and the grabbed key's comp time then. +#[derive(Clone, Copy, Debug)] +struct KeyGrab { + press: Pos2, + t: f64, +} + +fn key_grab_id() -> egui::Id { + egui::Id::new("graph-key-grab") +} diff --git a/crates/ui-egui/src/panels/graph_tools.rs b/crates/ui-egui/src/panels/graph_tools.rs index 5585661c..a13a5396 100644 --- a/crates/ui-egui/src/panels/graph_tools.rs +++ b/crates/ui-egui/src/panels/graph_tools.rs @@ -29,7 +29,8 @@ struct BoxDrag { handle: usize, /// Fixed opposite point (comp s, value). anchor: [f64; 2], - /// Pointer data position of the previous frame. + /// Where the drag started (comp s, value): the pointer for a move, the handle for a scale. + /// Every step sends the whole transform since then (`keys.transform {fromStart}`). last: [f64; 2], } @@ -134,7 +135,7 @@ pub(crate) fn transform_box( }); } let Some(resp) = resp_any else { return }; - let Some(mut st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; + let Some(st) = ctx.data(|d| d.get_temp::(box_id())) else { return }; if (st.handle == 8) == handles { return; } @@ -142,21 +143,20 @@ pub(crate) fn transform_box( && let Some(pt) = resp.interact_pointer_pos() { let cur = data(pt); - let mut params = json!({"merge": "graph-box"}); + let mut params = json!({"merge": "graph-box", "fromStart": true}); 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); + // Scale relative to where the handle was when the drag started. + let now = st.last; 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 { @@ -250,6 +250,8 @@ struct AltScale { anchor: f64, /// The dragged end is the group's last key (else its first). end_is_max: bool, + /// The group's span when the drag started (set on its first step). + span: Option, } fn alt_id() -> egui::Id { @@ -272,9 +274,9 @@ fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> { 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 } + AltScale { anchor: lo, end_is_max: true, span: None } } else if (key_ct - lo).abs() < 1e-6 { - AltScale { anchor: hi, end_is_max: false } + AltScale { anchor: hi, end_is_max: false, span: None } } else { return false; }; @@ -293,16 +295,16 @@ pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp: 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; + // The group's span when the drag started: every step sends the whole scale since then. + let span = st.span.unwrap_or((if st.end_is_max { hi } else { lo }) - st.anchor); + if st.span.is_none() { + ctx.data_mut(|d| d.insert_temp(alt_id(), AltScale { span: Some(span), ..st })); + } 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"}))); - } + actions.push(("keys.transform".into(), json!({"timeScale": want / span, "timeAnchor": st.anchor, "merge": "key-alt-scale", "fromStart": true}))); } /// An Alt-drag group scale is in progress (the plain key move stands aside). diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index bcd0bb9b..fc14369e 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -147,3 +147,34 @@ fn shift_click_toggles_and_ctrl_click_switches_interpolation() { click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND }); assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold); } + +#[test] +fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() { + let (mut h, id, uid) = harness(); + let s = &mut h.state_mut().session; + s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap(); + s.execute("keys.selectAll", json!({})).unwrap(); + h.state_mut().ui.timeline.graph_editor = true; + h.run_steps(4); + let at = |h: &Harness<'_, EffectcraftApp>, i: usize| { + let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let (k0, k1) = (at(&h, 0), at(&h, 1)); + let px_per_s = k1.x - k0.x; + let values = |h: &Harness<'_, EffectcraftApp>| -> Vec { + h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.value.as_f64()).collect() + }; + // Shift: only along time, both keys, by 0.5 s. + drag(&mut h, k1, pos2(k1.x + px_per_s * 0.5, k1.y - 6.0), egui::Modifiers { shift: true, ..Default::default() }); + assert_eq!(key_times(&h, id), vec![0.5, 1.5], "both selected keys moved"); + assert_eq!(values(&h), vec![0.0, 100.0], "Shift kept the values"); + let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count(); + assert_eq!(undo, 1, "one undo step per drag"); + // Without Shift the values move too (both keys, by the same amount). + h.run_steps(2); + let k1 = at(&h, 1); + drag(&mut h, k1, pos2(k1.x, k1.y + 20.0), Default::default()); + let v = values(&h); + assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}"); +}