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:
bflatasticandClaude Opus 5.5 committed 2026-10-05 07:58:03 +07:00
1 parent daed03305a
commit 9ea1f1325d
5 files changed
+124 -2

No files matched your search

+32
View File
@@ -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",
+24
View File
@@ -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]);
}
+17
View File
@@ -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));
+21 -2
View File
@@ -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()}]})));
+30
View File
@@ -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));
}