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 a3cf844..4ffaabe 100644 Binary files a/tests/snapshots/editor-window.png and b/tests/snapshots/editor-window.png differ