mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M5.12: double-click a keyframe to edit its value; Select Equal / Previous / Following Keyframes; roving keys as dots
- Double-clicking a keyframe of a numeric property (or its context menu ▸ Edit Value…) opens the property's value dialog for that key, as in After Effects (it only moved the current time); other keys still move the current time to them. - The keyframe context menu has Select Equal Keyframes, Select Previous Keyframes and Select Following Keyframes (`keys.selectEqual` / `selectPrevious` / `selectFollowing`). - Roving keys draw as small dots in the Timeline, like After Effects. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
daed03305a
commit
9ea1f1325d
5 files changed
+124
-2
No files matched your search
@@ -151,6 +151,35 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property
|
||||
/// with a selected key, its keys with the same value as a selected one / before the first
|
||||
/// selected one / after the last selected one (the selected keys stay selected).
|
||||
fn select_related(s: &mut Session, which: &str) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut sel = s.state.selected_keys.clone();
|
||||
let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect();
|
||||
props.sort();
|
||||
props.dedup();
|
||||
for (lid, uid) in props {
|
||||
let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue };
|
||||
let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect();
|
||||
let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue };
|
||||
for k in &pr.keys {
|
||||
let hit = match which {
|
||||
"equal" => picked.iter().any(|p| p.value == k.value),
|
||||
"previous" => k.time < first,
|
||||
_ => k.time > last,
|
||||
};
|
||||
let r = KeyRef { layer: lid, prop: uid, time: k.time };
|
||||
if hit && !sel.contains(&r) {
|
||||
sel.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
/// Move the selected keys by whole frames (Alt+→ / Alt+←, Shift for 10 frames).
|
||||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?;
|
||||
@@ -398,6 +427,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&json!({"frames": -10})
|
||||
)),
|
||||
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
|
||||
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
|
||||
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
|
||||
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
|
||||
cmd!(
|
||||
"keys.setEase",
|
||||
"Set Keyframe Ease",
|
||||
|
||||
@@ -957,3 +957,27 @@ fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_equal_previous_and_following_keyframes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
|
||||
let times = |s: &Session| {
|
||||
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
};
|
||||
pick(&mut s, 0.0);
|
||||
s.execute("keys.selectEqual", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 2.0]);
|
||||
pick(&mut s, 2.0);
|
||||
s.execute("keys.selectPrevious", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
|
||||
pick(&mut s, 1.0);
|
||||
s.execute("keys.selectFollowing", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
@@ -195,6 +195,23 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
||||
],
|
||||
)
|
||||
}
|
||||
// Double-click a keyframe (or its context menu ▸ Edit Value…): the property's value at
|
||||
// that key, like After Effects' value dialog.
|
||||
"keys.set" if !has(p, &["value", "newTime"]) => {
|
||||
let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l)));
|
||||
let pr = l.and_then(|l| l.props.find(p.get("prop").and_then(Value::as_u64)?));
|
||||
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
let Some((pr, t)) = pr.zip(t) else { return false };
|
||||
let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false };
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
let fields = match &k.value {
|
||||
KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)],
|
||||
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => return false,
|
||||
};
|
||||
(pr.name.clone(), fields)
|
||||
}
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
|
||||
@@ -1870,7 +1870,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
None if ks => t.keyframe_selected,
|
||||
None => t.keyframe,
|
||||
};
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
if k.roving {
|
||||
// Roving keys: a small dot (their time follows their neighbours).
|
||||
gp.circle_filled(c, 2.5, kcol);
|
||||
} else {
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
}
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
@@ -1896,13 +1901,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
actions.push(("keys.select".into(), p));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
// Edit the key's value (numeric properties), as After Effects does.
|
||||
if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
|
||||
} else {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
}
|
||||
}
|
||||
kresp.context_menu(|ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
@@ -1943,6 +1959,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
continue;
|
||||
}
|
||||
if ui.button(lbl).clicked() {
|
||||
if !ks {
|
||||
actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
|
||||
|
||||
@@ -178,3 +178,33 @@ fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() {
|
||||
let v = values(&h);
|
||||
assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_a_key_to_edit_its_value() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let k1 = keys(&h, uid)[1];
|
||||
h.input_mut().events.push(Event::PointerMoved(k1));
|
||||
h.step();
|
||||
// Both clicks inside egui's double-click time.
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the value dialog opened");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("form.field.value").is_some(), "one Opacity field");
|
||||
// OK keeps the key where it is (the dialog edits the value only).
|
||||
let ok = h.state().auto.find("form.ok").expect("form.ok").clone();
|
||||
let p = pos2(ok.rect[0] + ok.rect[2] / 2.0, ok.rect[1] + ok.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0));
|
||||
}
|
||||
Reference in new issue
Block a user