M5.9: keyframe drags in the Timeline follow the pointer to the end; Shift snaps
Dragging a keyframe moved it a frame or two and then let go: the key's widget id includes its time, so after the first move it was a new widget and egui ended the drag. The Timeline now follows the whole gesture itself (`KeyDrag`: from where the button went down, by whole frames, every selected key, one undo step), like After Effects. Holding Shift snaps the grabbed key to the current time, the work area and composition markers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d4a0902ea
commit
f3db1ebe38
|
|
@ -779,6 +779,19 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
|
|||
fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drag")
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
|
||||
/// (seconds) and how many frames the selected keys have moved so far.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct KeyDrag {
|
||||
origin_x: f32,
|
||||
anchor: f64,
|
||||
applied: i64,
|
||||
}
|
||||
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
|
|
@ -1934,21 +1947,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
|
||||
if frames != 0 {
|
||||
acc -= (frames as f64 * fd * pps) as f32;
|
||||
actions.push(("keys.move".into(), json!({"delta": frames as f64 * fd, "merge": "key-drag"})));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|d| d.remove::<f32>(egui::Id::new("key-drag-acc")));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
// A plain drag moves the selected keys. The timeline follows it (below): this
|
||||
// widget's id changes with the key's time, so its own drag would end after
|
||||
// the first frame of movement.
|
||||
// (From where the button went down: a drag starts a few pixels later.)
|
||||
if kresp.drag_started()
|
||||
&& !super::graph_tools::alt_scale_active(&ctx)
|
||||
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2031,6 +2038,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||||
}
|
||||
}
|
||||
// Keyframe drag: the selected keys move by whole frames with the pointer until it is
|
||||
// released (one undo step); Shift snaps the grabbed key to the current time, the work area
|
||||
// and composition markers.
|
||||
if let Some(mut kd) = ctx.data(|d| d.get_temp::<KeyDrag>(key_drag_id())) {
|
||||
let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
|
||||
match ptr.filter(|_| down) {
|
||||
Some(ptr) => {
|
||||
let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps;
|
||||
if shift {
|
||||
let snaps = [time, comp.work_area.0, comp.work_area.1].into_iter().chain(comp.markers.iter().map(|m| m.time)).map(Tick::seconds);
|
||||
let reach = 8.0 / pps;
|
||||
if let Some(s) = snaps.filter(|s| (s - target).abs() <= reach).min_by(|a, b| (a - target).abs().total_cmp(&(b - target).abs())) {
|
||||
target = s;
|
||||
}
|
||||
}
|
||||
let total = ((target - kd.anchor) / fd).round() as i64;
|
||||
if total != kd.applied {
|
||||
actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})));
|
||||
kd.applied = total;
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
None => {
|
||||
ctx.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
}
|
||||
if empty.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
|
||||
//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C /
|
||||
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 4 s, 30 fps comp with a Box layer whose Opacity has keys at 0 s and 1 s, revealed in the
|
||||
/// Timeline; returns the harness, the layer and the property uid.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Keys", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers[0].id;
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("layer.select", json!({"layers": [id.0]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
(h, id, uid)
|
||||
}
|
||||
|
||||
fn key_times(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Vec<f64> {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
/// Screen centres of the property's keys, left to right.
|
||||
fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<Pos2> {
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
ks
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in small steps (a real drag), release.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
for i in 1..=24 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_drag_follows_the_pointer_for_its_whole_length() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
assert_eq!(ks.len(), 2);
|
||||
let px_per_s = ks[1].x - ks[0].x;
|
||||
// Drag the 1 s key to where 2.5 s is: many frames, one gesture.
|
||||
let to = pos2(ks[1].x + px_per_s * 1.5, ks[1].y);
|
||||
drag(&mut h, ks[1], to, Default::default());
|
||||
let ts = key_times(&h, id);
|
||||
assert!((ts[1] - 2.5).abs() <= 2.0 / 30.0, "the key follows the whole drag: {ts:?}");
|
||||
let steps = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Keyframes").count();
|
||||
assert_eq!(steps, 1, "one undo step per drag");
|
||||
// Shift snaps it to the current time indicator (at 0.5 s), from a few pixels away.
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(0.5));
|
||||
h.run_steps(2);
|
||||
let ks = keys(&h, uid);
|
||||
let near = pos2(ks[0].x + px_per_s * 0.5 + 4.0, ks[1].y);
|
||||
drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() });
|
||||
assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time");
|
||||
}
|
||||
Loading…
Reference in New Issue