From 9ea1f1325d3ed4cadcc1e8d2e96531d048f06f81 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:58:03 +0700 Subject: [PATCH] M5.12: double-click a keyframe to edit its value; Select Equal / Previous / Following Keyframes; roving keys as dots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- crates/engine/src/commands/anim.rs | 32 +++++++++++++++++++++++++++ crates/engine/src/tests_menu_cmds.rs | 24 ++++++++++++++++++++ crates/ui-egui/src/panels/forms.rs | 17 ++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 23 +++++++++++++++++-- crates/ui-egui/tests/ui_keyframes.rs | 30 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 3d6e3ac2..d4dec045 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -151,6 +151,35 @@ fn select_all(s: &mut Session, p: &Value) -> Result { 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 { + 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 { 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 { &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", diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 1b7bc617..71694f69 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -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 = 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]); +} diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 9351a581..4df53e3a 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -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)); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ee6a3e89..d1a93618 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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()}]}))); diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index fc14369e..dd269d9f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -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)); +}