diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index b34fbd9d3..f79939c88 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -3330,8 +3330,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows); super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip); - // Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when - // zoomed in (registered last, so it takes the drag from the bars, keys and markers there). + // Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the + // time graph in time when zoomed in (registered last, so it takes the drag from the bars, + // keys and markers there). 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::new("tl-hand"), Sense::drag()); @@ -3339,10 +3340,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if hand.hovered() || hand.dragged() { ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); } - if hand.dragged() && 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); + 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); + } } } diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 630d865b6..a6903854d 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -63,6 +63,7 @@ mod ui_text_range_handles; mod ui_theme_favorites; mod ui_time_zoom_controls; mod ui_timeline_effect_label; +mod ui_timeline_pan; mod ui_timeline_shapes; mod ui_timeline_switches; mod ui_timeline_trim; diff --git a/crates/ui-egui/tests/it/ui_timeline_pan.rs b/crates/ui-egui/tests/it/ui_timeline_pan.rs new file mode 100644 index 000000000..8523ded30 --- /dev/null +++ b/crates/ui-egui/tests/it/ui_timeline_pan.rs @@ -0,0 +1,61 @@ +//! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well +//! as the time graph horizontally (#2083). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Modifiers, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +/// A 10 s comp with 40 solids, enough rows to scroll. +fn harness() -> (Harness<'static, EffectcraftApp>, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap(); + let mut top = 0; + for i in 0..40 { + top = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + } + s.execute("layer.select", json!({"layers": []})).unwrap(); + let app = EffectcraftApp::new(s); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app); + h.run_steps(4); + (h, top) +} + +fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { + h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +fn drag(h: &mut Harness<'_, EffectcraftApp>, 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.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.run_steps(3); +} + +#[test] +fn spacebar_drag_pans_the_rows_vertically() { + let (mut h, layer) = harness(); + assert_eq!(h.state().ui.timeline.scroll_y, 0.0); + let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").rect; + let from = pos2(bar[0] + 40.0, bar[1] + bar[3] / 2.0); + let timing = |h: &Harness<'_, EffectcraftApp>| { + let l = &h.state().session.active_comp().unwrap().layers[0]; + (l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds()) + }; + let before = timing(&h); + space(&mut h, true); + drag(&mut h, from, from - vec2(0.0, 180.0)); + space(&mut h, false); + let y = h.state().ui.timeline.scroll_y; + assert!(y > 100.0, "the rows scrolled vertically: {y}"); + assert_eq!(h.state().ui.timeline.start, 0.0, "the time graph did not pan sideways"); + assert_eq!(timing(&h), before, "the layer under the hand did not move"); +}