From 2bab19a1ae37b219e2e2ab6a01eba77e2ea5a064 Mon Sep 17 00:00:00 2001 From: luxick Date: Thu, 10 Sep 2026 21:49:31 +0200 Subject: [PATCH] Improve touchpad scrolling --- src/inspector.rs | 1 + src/view.rs | 60 +++++++++++++++--- src/view/tests.rs | 99 +++++++++++++++++++++++++++++- tests/snapshots/editor-window.png | Bin 82451 -> 85684 bytes 4 files changed, 150 insertions(+), 10 deletions(-) diff --git a/src/inspector.rs b/src/inspector.rs index a5cb163..5f9f1cc 100644 --- a/src/inspector.rs +++ b/src/inspector.rs @@ -642,6 +642,7 @@ fn canvas_section(ui: &mut Ui, doc: &mut Document, palette: &Palette, out: &mut "Double-click the canvas to add a note", "Drag a dot on a node's edge to connect it", "Middle- or right-drag to pan, wheel to zoom", + "Swipe to pan, Ctrl+swipe to zoom", "Hold Shift to add to the selection", ] { ui.label( diff --git a/src/view.rs b/src/view.rs index bc70222..232e259 100644 --- a/src/view.rs +++ b/src/view.rs @@ -299,6 +299,9 @@ pub struct CanvasView { viewport: Option, /// Framing asked for before the viewport size was known. pending_fit: Option, + /// Whether the scrolling now under way came from a touchpad rather than a + /// mouse wheel. See [`CanvasView::note_scroll_device`]. + scroll_is_touchpad: bool, } /// The text of the file behind the file node being edited. @@ -352,6 +355,7 @@ impl CanvasView { menu_at: None, viewport: None, pending_fit: None, + scroll_is_touchpad: false, } } @@ -775,7 +779,8 @@ impl CanvasView { // -- input ------------------------------------------------------------ - /// Panning is a drag; the wheel zooms, bare or with a modifier held. + /// Panning is a drag or a two-finger swipe; the wheel zooms, and so does + /// a swipe with the zoom modifier held. fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) { if background.dragged_by(PointerButton::Middle) || background.dragged_by(PointerButton::Secondary) @@ -784,19 +789,32 @@ impl CanvasView { ui.ctx().set_cursor_icon(CursorIcon::Grabbing); } + self.note_scroll_device(ui); + if !background.contains_pointer() { return; } let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else { return; }; - // A bare wheel arrives as scroll and is turned into a zoom by the - // same rule egui uses for the modifier it expects, so that the two - // zoom at one speed. Nothing else on the canvas wants the wheel: it is - // panned by dragging, and node contents are cropped rather than - // scrolled. - let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); - let zoom_delta = ui.input(|i| i.zoom_delta() * (speed * i.smooth_scroll_delta.y).exp()); + // Held down, the zoom modifier turns scrolling into zooming before it + // ever reaches here: egui reports it as a zoom and leaves no scroll + // behind. A pinch on a touchscreen arrives the same way. + let (scroll, mut zoom_delta) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta())); + if self.scroll_is_touchpad { + // Two fingers sweep the canvas about, in whatever direction they + // are moving: the delta carries both axes, and points the way the + // contents of a scroll area would go. + self.to_global.translation += scroll; + } else { + // A bare wheel arrives as scroll and is turned into a zoom by the + // same rule egui uses for the modifier it expects, so that the two + // zoom at one speed. Nothing else on the canvas wants the wheel: it + // is panned by dragging, and node contents are cropped rather than + // scrolled. + let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); + zoom_delta *= (speed * scroll.y).exp(); + } if zoom_delta != 1.0 { let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM) / self.to_global.scaling; @@ -806,6 +824,32 @@ impl CanvasView { } } + /// Notes whether the scrolling now under way is a touchpad swipe or a + /// mouse wheel, since the one pans and the other zooms. + /// + /// The unit the event carries tells them apart: a touchpad reports points, + /// a wheel reports lines. X11 is the exception, reporting lines for both, + /// so a fraction of a line is taken for a touchpad as well — there a + /// wheel notch is always a whole one. + /// + /// The answer is kept because egui spreads a long scroll over the frames + /// after it, and those frames carry no event to read it from. + fn note_scroll_device(&mut self, ui: &egui::Ui) { + ui.input(|i| { + for event in &i.events { + if let egui::Event::MouseWheel { unit, delta, .. } = event { + self.scroll_is_touchpad = match unit { + egui::MouseWheelUnit::Point => true, + egui::MouseWheelUnit::Line => { + delta.x.fract() != 0.0 || delta.y.fract() != 0.0 + } + egui::MouseWheelUnit::Page => false, + }; + } + } + }); + } + // -- painting --------------------------------------------------------- fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) { diff --git a/src/view/tests.rs b/src/view/tests.rs index c29d29a..0a7a410 100644 --- a/src/view/tests.rs +++ b/src/view/tests.rs @@ -546,8 +546,8 @@ fn the_wheel_zooms_over_a_node() { &mut doc, screen, vec![egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Point, - delta: vec2(0.0, 50.0), + unit: egui::MouseWheelUnit::Line, + delta: vec2(0.0, 1.0), modifiers: egui::Modifiers::default(), phase: egui::TouchPhase::Move, }], @@ -563,6 +563,101 @@ fn the_wheel_zooms_over_a_node() { assert!(moved < 1.0, "the canvas slid {moved} under the pointer"); } +/// A two-finger swipe on a touchpad slides the canvas about, sideways as +/// readily as up and down, rather than zooming the way the wheel does. +#[test] +fn a_two_finger_swipe_pans() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(600.0, 450.0); + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + + let before = view.to_global; + // Steps small enough that egui hands each one over the frame it arrives, + // with none of the smoothing it saves for the coarser wheel. + let swipe = vec2(4.0, 3.0); + let steps = 5; + for _ in 0..steps { + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: swipe, + modifiers: egui::Modifiers::default(), + phase: egui::TouchPhase::Move, + }], + ); + } + + assert_eq!( + view.zoom(), + before.scaling, + "the swipe zoomed instead of panning" + ); + let moved = view.to_global.translation - before.translation; + let wanted = swipe * steps as f32; + assert!( + (moved - wanted).length() < 1.0, + "the swipe panned by {moved:?}, wanted {wanted:?}" + ); +} + +/// With the zoom modifier down the same swipe zooms instead, at the pointer. +#[test] +fn ctrl_and_a_two_finger_swipe_zooms() { + let ctx = egui::Context::default(); + let mut doc = Document::new(); + doc.canvas = canvas_with_two_nodes(); + let mut view = CanvasView::new(); + + let screen = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0)); + let at = pos2(50.0, 50.0); + let under_pointer = view.to_global.inverse() * at; + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::PointerMoved(at)], + ); + + for _ in 0..5 { + pass( + &ctx, + &mut view, + &mut doc, + screen, + vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 4.0), + modifiers: egui::Modifiers::COMMAND, + phase: egui::TouchPhase::Move, + }], + ); + } + + assert!( + view.zoom() > 1.0, + "the swipe did not zoom in: {}", + view.zoom() + ); + let moved = (view.to_global.inverse() * at).distance(under_pointer); + assert!(moved < 1.0, "the canvas slid {moved} under the pointer"); +} + #[test] fn a_click_in_the_inspector_stays_in_the_inspector() { let ctx = egui::Context::default(); diff --git a/tests/snapshots/editor-window.png b/tests/snapshots/editor-window.png index a3cf844f3b0c7a38b408a11be0f6bee48ec1dece..4ffaabebcd62058c1fe8fa009180a5a1c265aec8 100644 GIT binary patch delta 12500 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 X)ivkeGB;)*@F(cgEgSRJ?>YJ35+*Eq