mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M5.11: Graph Editor: drags move every selected key, Shift keeps an axis, Ctrl / Ctrl+Alt clicks; drags and the transform box never merge or lose keys
- `keys.transform {fromStart}`: each step of a merged drag 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 (Alt-drag scaling, the transform box) are no longer merged for good, and a slow
transform-box move, whose per-frame offsets rounded to nothing, now moves the keys.
- Dragging a key in the Graph Editor moves every selected key in time and value (it moved only
the grabbed one), by how far the pointer went from where the button went down (the key no
longer jumps under the pointer); Shift keeps the drag to the time or the value axis.
- Shift+click toggles a key in or out of the selection; Ctrl+click switches Linear ↔ Auto
Bezier and Ctrl+Alt+click toggles Hold, as in the Timeline.
- The transform box and the Timeline's Alt-drag scale send whole-gesture values.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9929b5f9ac
commit
daed03305a
5 files changed
+130
-33
No files matched your search
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
@@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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::<KeyGrab>(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<f64> = 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")
|
||||
}
|
||||
@@ -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::<BoxDrag>(box_id())) else { return };
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<BoxDrag>(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<f64>,
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
@@ -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<f64> {
|
||||
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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user