diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 246084cc1..474023cdc 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -77,6 +77,17 @@ pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 { const TRIM_OUTSIDE: f32 = 4.0; const TRIM_INSIDE: f32 = 5.0; +/// A middle-drag in the timeline pans it (`hand_tool_pan`, #2854): a middle press here starts no +/// drag, so it can't move a bar or a key. A widget that takes a drag on press was given it this frame, +/// before anything here ran. Handing that drag to an id nothing uses and ending it means no +/// widget sees it start or stop. +fn ignore_middle_press(ui: &egui::Ui, rect: Rect) { + if ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Middle) && !i.pointer.primary_down()) { + ui.ctx().set_dragged_id(egui::Id::unique("tl-middle-press")); + ui.ctx().stop_dragging(); + } +} + /// A layer bar's body (drag to move) and its In/Out trim zones. Short bars keep their middle /// third as body; a bar too short for that is all trim zones (the Out one on top). fn trim_zones(bar: Rect) -> (Rect, Rect, Rect) { @@ -1850,6 +1861,7 @@ fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw { /// open rows and the drags in progress) lives in /// `app.ui.timeline` and egui's temp data. pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { + ignore_middle_press(ui, rect); let Some(cid) = app.session.active_comp_id() else { empty_timeline(app, ui, rect); return; @@ -4835,7 +4847,7 @@ fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); } -/// Spacebar held: the Hand tool. +/// Spacebar held: the Hand tool. A middle-drag begun in the rows, layer list included, pans too. fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { let rect = g.tl.rect; let graph_x0 = g.tl.graph_x0; @@ -4847,6 +4859,7 @@ fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { let max_scroll = g.tl.max_scroll; let ctx = g.tl.ctx.clone(); let comp = g.tl.comp.clone(); + let mut pan = ui.input(|i| i.pointer.middle_down() && !i.pointer.primary_down() && i.pointer.press_origin().is_some_and(|p| rows_rect.contains(p))); if super::space_hand(&ctx) { let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y)); let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag()); @@ -4854,14 +4867,15 @@ fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) { if hand.hovered() || hand.dragged() { ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } - if hand.dragged() { - let dy = ui.input(|i| i.pointer.delta().y); - app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll); - if app.ui.timeline.pps.is_some() { - let start = app.ui.timeline.start; - let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); - app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last); - } + pan |= hand.dragged(); + } + if pan { + let delta = ui.input(|i| i.pointer.delta()); + app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - delta.y).clamp(0.0, max_scroll); + if app.ui.timeline.pps.is_some() { + let start = app.ui.timeline.start; + let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0); + app.ui.timeline.start = (start - delta.x as f64 / pps).clamp(0.0, last); } } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index bb7b2d075..70c6d12d5 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -216,11 +216,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let resp = phase_menu(app, ui, &ectx, &mut v); phase_hover(app, ui, &ectx, &resp, &mut v); phase_press(app, ui, &ectx, &resp, &mut v); - phase_dragstart(app, ui, &ectx, &resp, &mut v); - phase_drag(app, ui, &ectx, &resp, &mut v); - phase_stopped(app, ui, &ectx, &resp, &mut v); - phase_clicked(app, &ectx, &resp, &mut v); - phase_dblclick(app, &ectx, &resp, &mut v); + if !middle_pan(app, ui, &resp, &v) { + phase_dragstart(app, ui, &ectx, &resp, &mut v); + phase_drag(app, ui, &ectx, &resp, &mut v); + phase_stopped(app, ui, &ectx, &resp, &mut v); + phase_clicked(app, &ectx, &resp, &mut v); + phase_dblclick(app, &ectx, &resp, &mut v); + } phase_tail(app, ui, &ectx, &mut v); } @@ -1064,6 +1066,24 @@ fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp v.text_owns = text_owns; } +/// A middle-drag pans with any tool and doesn't enter the gesture phases, so nothing else can +/// happen under it (#2854). A left-button gesture already running keeps going until it ends. +fn middle_pan(app: &mut EffectcraftApp, ui: &egui::Ui, resp: &egui::Response, v: &Vw<'_>) -> bool { + let (middle, primary) = ui.input(|i| (i.pointer.middle_down(), i.pointer.primary_down() || i.pointer.primary_released())); + if !middle || !resp.is_pointer_button_down_on() || (primary && ui.data(|d| d.get_temp::(v.gid)).is_some()) { + return false; + } + // No left button: a stored gesture is one whose release the viewer missed. + ui.data_mut(|d| d.remove::(v.gid)); + // Movement from before the press (in its frame) is not part of the drag. + let d = ui.input(|i| if i.pointer.button_pressed(egui::PointerButton::Middle) { egui::Vec2::ZERO } else { i.pointer.delta() }); + if d != egui::Vec2::ZERO && d.x.is_finite() && d.y.is_finite() { + app.ui.viewer.pan = [app.ui.viewer.pan[0] + d.x, app.ui.viewer.pan[1] + d.y]; + } + v.ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + true +} + fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) { let comp_rect = v.comp_rect; let zoom = v.zoom; @@ -1100,7 +1120,7 @@ fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, { let cpt = map.to_comp(pos); let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos); - let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool }; + let tool = if space_pan { Tool::Hand } else { app.ui.tool }; let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), // Region of Interest armed: the drag draws it. diff --git a/crates/ui-egui/tests/it/ui_keyframes.rs b/crates/ui-egui/tests/it/ui_keyframes.rs index b137a06fc..d8cce232d 100644 --- a/crates/ui-egui/tests/it/ui_keyframes.rs +++ b/crates/ui-egui/tests/it/ui_keyframes.rs @@ -42,6 +42,47 @@ fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec { ks } +/// #2854: a middle-drag on a key, a layer bar, its trim edge or the marker bin edits nothing and +/// adds no undo step; it only pans. +#[test] +fn middle_drag_on_a_key_or_layer_bar_changes_nothing() { + let (mut h, id, uid) = harness(); + let center = |h: &Harness<'_, EffectcraftApp>, name: String| { + let e = h.state().auto.find(&name).unwrap_or_else(|| panic!("no {name}")).clone(); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let bar = center(&h, format!("timeline.layer.{}.bar", id.0)); + let trim = center(&h, format!("timeline.layer.{}.bar.in", id.0)); + let bin = center(&h, "timeline.markerBin".to_string()); + let key = keys(&h, uid)[1]; + let state = |h: &Harness<'_, EffectcraftApp>| { + let comp = h.state().session.active_comp().unwrap(); + let l = comp.layer(id).unwrap(); + (l.in_point, l.out_point, l.start_time, comp.markers.len(), key_times(h, id), h.state().session.history.undo.len()) + }; + h.state_mut().session.execute("edit.deselectAll", json!({})).unwrap(); + h.run_steps(2); + let before = state(&h); + for from in [key, bar, trim, bin] { + let to = from + egui::vec2(90.0, 0.0); + h.input_mut().events.push(Event::PointerMoved(from)); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Middle, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=8 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Middle, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(state(&h), before, "from {from:?}"); + assert!(h.state().session.state.selected_layers.is_empty(), "from {from:?}: the press selects nothing"); + } + // The left button still drags the key. + drag(&mut h, key, key + egui::vec2(90.0, 0.0), egui::Modifiers::NONE); + assert_ne!(key_times(&h, id), before.4); +} + /// 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)); diff --git a/crates/ui-egui/tests/it/ui_timeline_pan.rs b/crates/ui-egui/tests/it/ui_timeline_pan.rs index 8523ded30..56bbd8a45 100644 --- a/crates/ui-egui/tests/it/ui_timeline_pan.rs +++ b/crates/ui-egui/tests/it/ui_timeline_pan.rs @@ -1,5 +1,6 @@ //! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well -//! as the time graph horizontally (#2083). +//! as the time graph horizontally (#2083). A middle-drag does the same, from the layer list too +//! (#2854). use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; @@ -28,18 +29,49 @@ fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { } fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) { + drag_with(h, egui::PointerButton::Primary, from, to); +} + +fn drag_with(h: &mut Harness<'_, EffectcraftApp>, button: egui::PointerButton, from: Pos2, to: Pos2) { h.event(Event::PointerMoved(from)); h.step(); - h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: from, button, pressed: true, modifiers: Modifiers::NONE }); h.step(); for k in 1..=10 { h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0))); h.step(); } - h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: to, button, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(3); } +#[test] +fn middle_drag_pans_from_the_layer_list_and_the_bars() { + let (mut h, layer) = harness(); + h.state_mut().ui.timeline.pps = Some(400.0); + h.state_mut().ui.timeline.start = 1.0; + h.run_steps(3); + let order = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect::>(); + let before = order(&h); + let center = |r: [f32; 4]| pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0); + let row = center(h.state().auto.find(&format!("timeline.layer.{layer}.row")).expect("layer row").rect); + drag_with(&mut h, egui::PointerButton::Middle, row, row - vec2(0.0, 180.0)); + let y = h.state().ui.timeline.scroll_y; + assert!(y > 100.0, "the list scrolled: {y}"); + assert_eq!(h.state().ui.timeline.start, 1.0, "a vertical drag leaves time alone"); + assert_eq!(order(&h), before, "the layers stay in order"); + assert!(h.state().session.state.selected_layers.is_empty(), "the press selects nothing"); + // In the time graph, just right of the row, whatever the zoom has left of its bar. + h.state_mut().ui.timeline.scroll_y = 0.0; + h.run_steps(2); + let r = h.state().auto.find(&format!("timeline.layer.{layer}.row")).expect("layer row").rect; + let graph = pos2(r[0] + r[2] + 100.0, r[1] + r[3] / 2.0); + drag_with(&mut h, egui::PointerButton::Middle, graph, graph - vec2(80.0, 0.0)); + let start = h.state().ui.timeline.start; + assert!(start > 1.1, "time panned: {start}"); + assert_eq!(order(&h), before); +} + #[test] fn spacebar_drag_pans_the_rows_vertically() { let (mut h, layer) = harness(); diff --git a/crates/ui-egui/tests/it/ui_viewer.rs b/crates/ui-egui/tests/it/ui_viewer.rs index f0086d28a..afb8a78de 100644 --- a/crates/ui-egui/tests/it/ui_viewer.rs +++ b/crates/ui-egui/tests/it/ui_viewer.rs @@ -1771,6 +1771,66 @@ fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() { assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}"); } +/// #2854: a middle-drag over a layer pans the view and leaves the layer alone, selected or not, +/// with any tool, and adds no undo step. +#[test] +fn middle_drag_over_a_layer_pans_and_leaves_the_layer_where_it_is() { + let mut h = harness(); + let id = layer_named(&h, "Box"); + let c = rect(&h, "viewer.comp").center(); + for (select, tool) in [(false, Tool::Selection), (true, Tool::Selection), (true, Tool::Rotate), (true, Tool::Type)] { + let selection = if select { json!({"layers": [id.0]}) } else { json!({"layers": []}) }; + h.state_mut().session.execute("layer.select", selection).unwrap(); + h.state_mut().ui.tool = tool; + h.state_mut().ui.viewer.pan = [0.0, 0.0]; + h.run_steps(2); + let (p0, steps) = (position_of(&h, id), h.state().session.history.undo.len()); + drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0)); + let pan = h.state().ui.viewer.pan; + assert!((pan[0] - 120.0).abs() < 1.0 && (pan[1] - 60.0).abs() < 1.0, "{select} {tool:?}: {pan:?}"); + assert_eq!(position_of(&h, id), p0, "{select} {tool:?}: the layer stays"); + assert_eq!(h.state().session.history.undo.len(), steps, "{select} {tool:?}: no undo step"); + } +} + +/// #2854: a left drag whose release the viewer never saw (the panel wasn't drawn on that frame) +/// leaves its move gesture stored. A middle press must not run it. +#[test] +fn middle_drag_does_not_run_a_gesture_left_over_from_a_missed_release() { + let mut h = harness(); + let id = layer_named(&h, "Box"); + h.state_mut().session.execute("layer.select", json!({"layers": [id.0]})).unwrap(); + h.run_steps(2); + let c = rect(&h, "viewer.comp").center(); + let p = egui::PointerButton::Primary; + h.input_mut().events.push(Event::PointerMoved(c)); + h.input_mut().events.push(Event::PointerButton { pos: c, button: p, pressed: true, modifiers: Default::default() }); + h.step(); + for i in 1..=4 { + h.input_mut().events.push(Event::PointerMoved(c + vec2(10.0 * i as f32, 0.0))); + h.step(); + } + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "layer.openLayer", json!({"layer": id.0})).unwrap(); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: c + vec2(40.0, 0.0), button: p, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::Composition); + h.run_steps(3); + let c = rect(&h, "viewer.comp").center(); + let p0 = position_of(&h, id); + drag_with(&mut h, egui::PointerButton::Middle, c, c + vec2(120.0, 60.0)); + assert_eq!(position_of(&h, id), p0, "the stored move didn't run"); +} + +fn layer_named(h: &Harness<'_, EffectcraftApp>, name: &str) -> LayerId { + h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == name).unwrap().id +} + +fn position_of(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> [f64; 3] { + h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/position").unwrap().value.as_vec3() +} + /// A Spacebar press or release (`repeat`: the keyboard's auto-repeat while it is held). fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool, repeat: bool) { h.input_mut().events.push(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat, modifiers: Default::default() });