From 95297477fd3384e16dbf0da31dbf3f89a5878d73 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Tue, 13 Oct 2026 01:31:43 +0200 Subject: [PATCH] timeline: a middle-button drag pans, whatever the tool (fixes #1490) The monitors already pan with the middle button (#824). In the Timeline a middle-button drag went down the left button's path and selected, moved, marqueed, scrubbed or cut, depending on the tool. It now starts the Hand tool's pan from where the button went down. --- crates/ui-egui/src/panels/timeline.rs | 7 + .../ui-egui/tests/timeline_middle_pan_ui.rs | 145 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 crates/ui-egui/tests/timeline_middle_pan_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ac4caf53..f0a33db1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2234,6 +2234,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & g => g, }; let started = match (tool, grab) { + // a middle-button drag pans whatever the tool and whatever it starts on, as in the + // monitors (#824) and DaVinci Resolve (#1490); only a primary press is kept in + // `pressed`, so the pan starts from where the button went down + _ if resp.drag_started_by(egui::PointerButton::Middle) => { + let last = ctx.input(|i| i.pointer.press_origin()).unwrap_or(p); + Some(Drag::Pan { last, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }) + } (_, Grab::Other(Hit::Ruler)) => Some(Drag::Scrub { initial: app.session.playhead() }), (Tool::Hand, _) => Some(Drag::Pan { last: p, start_scroll: app.ui.timeline.target_scroll, start_v: app.ui.timeline.v_scroll }), (Tool::Zoom, _) => { diff --git a/crates/ui-egui/tests/timeline_middle_pan_ui.rs b/crates/ui-egui/tests/timeline_middle_pan_ui.rs new file mode 100644 index 00000000..a9426616 --- /dev/null +++ b/crates/ui-egui/tests/timeline_middle_pan_ui.rs @@ -0,0 +1,145 @@ +//! Headless UI tests of the middle mouse button in the Timeline (#1490): a middle-button drag pans +//! the view, whatever the tool and whatever it starts on, and never selects, moves or cuts a clip. +//! The Program and Source monitors already pan with it (#824). + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + + fn rect(&mut self, id: &str) -> egui::Rect { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap() as f32).collect(); + egui::Rect::from_min_size(egui::pos2(r[0], r[1]), egui::vec2(r[2], r[3])) + } + + fn send(&mut self, e: egui::Event) { + self.harness.input_mut().events.push(e); + self.frames(1); + } + + fn middle(&mut self, pos: egui::Pos2, pressed: bool) { + self.send(egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: egui::Modifiers::NONE }); + } + + /// Drag with the middle button from `from` to `to`, in small steps, a frame each. + fn middle_drag(&mut self, from: egui::Pos2, to: egui::Pos2) { + self.send(egui::Event::PointerMoved(from)); + self.middle(from, true); + for k in 1..=8 { + self.send(egui::Event::PointerMoved(from.lerp(to, k as f32 / 8.0))); + } + self.middle(to, false); + self.frames(2); + } + + /// A clip of Video 1 in view, wide enough to start a drag on: its id, its start (ticks) and + /// its rectangle, with nothing selected and the view at a known zoom and scroll. + fn setup(&mut self) -> (u64, i64, egui::Rect) { + self.ok("ui.set", json!({"focused": "Timeline", "timeline": {"pps": 50.0, "scroll": 4.0}})); + self.frames(4); + self.exec("timeline.select", json!({"clips": []})); + let shown = self.ok("ui.elements", json!({"prefix": "timeline.clip."})); + let shown: Vec = shown.as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect(); + let seq = self.exec("sequence.inspect", json!({})); + for it in seq["video"][0]["items"].as_array().unwrap().clone() { + let (clip, start) = (it["clip"].as_u64().unwrap(), it["start"].as_i64().unwrap()); + let id = format!("timeline.clip.{clip}"); + if shown.contains(&id) { + let r = self.rect(&id); + if r.width() > 40.0 && r.center().x > 600.0 { + return (clip, start, r); + } + } + } + panic!("no clip of Video 1 in view"); + } + + fn start_of(&mut self, clip: u64) -> i64 { + let seq = self.exec("sequence.inspect", json!({})); + let items = seq["video"][0]["items"].as_array().unwrap().clone(); + items.iter().find(|i| i["clip"] == clip).map(|i| i["start"].as_i64().unwrap()).unwrap_or_else(|| panic!("clip {clip} gone")) + } + + fn clips(&mut self) -> usize { + let seq = self.exec("sequence.inspect", json!({})); + seq["video"][0]["items"].as_array().unwrap().len() + } + + fn app(&mut self) -> &mut FilmcraftApp { + self.harness.state_mut() + } +} + +#[test] +fn a_middle_button_drag_on_a_clip_pans_and_leaves_the_clip_alone() { + let mut d = Driver::demo(); + let (clip, start, r) = d.setup(); + let from = r.center(); + d.middle_drag(from, from - egui::vec2(200.0, 0.0)); + let scroll = d.app().ui.timeline.target_scroll; + assert!((scroll - 8.0).abs() < 1e-6, "200 pt to the left at 50 pt a second scrolls 4 s later: {scroll}"); + assert!(d.app().session.state.selection.is_empty(), "the middle button selects nothing"); + assert_eq!(d.start_of(clip), start, "the clip stays where it was"); +} + +#[test] +fn a_middle_button_drag_pans_whatever_the_tool() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"tool": "Razor"})); + let (_, _, r) = d.setup(); + let before = d.clips(); + let from = r.center(); + d.middle_drag(from, from - egui::vec2(200.0, 0.0)); + let scroll = d.app().ui.timeline.target_scroll; + assert!((scroll - 8.0).abs() < 1e-6, "the Razor tool pans with the middle button too: {scroll}"); + assert_eq!(d.clips(), before, "the middle button cuts nothing"); +}