diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 28b10af3..aba24f17 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(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::>(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::(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::(key_drag_id())); + ui_actions.push(UiAct::EndMerge); + } + } + } if empty.drag_stopped() && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::new("tl-box"))) { diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs new file mode 100644 index 00000000..6fc844d2 --- /dev/null +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -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 { + 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 { + let mut ks: Vec = + 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"); +}