Improve touchpad scrolling

This commit is contained in:
2026-09-10 21:49:31 +02:00
parent 81072b9e0d
commit 2bab19a1ae
4 changed files with 150 additions and 10 deletions
+1
View File
@@ -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", "Double-click the canvas to add a note",
"Drag a dot on a node's edge to connect it", "Drag a dot on a node's edge to connect it",
"Middle- or right-drag to pan, wheel to zoom", "Middle- or right-drag to pan, wheel to zoom",
"Swipe to pan, Ctrl+swipe to zoom",
"Hold Shift to add to the selection", "Hold Shift to add to the selection",
] { ] {
ui.label( ui.label(
+52 -8
View File
@@ -299,6 +299,9 @@ pub struct CanvasView {
viewport: Option<Rect>, viewport: Option<Rect>,
/// Framing asked for before the viewport size was known. /// Framing asked for before the viewport size was known.
pending_fit: Option<PendingFit>, pending_fit: Option<PendingFit>,
/// 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. /// The text of the file behind the file node being edited.
@@ -352,6 +355,7 @@ impl CanvasView {
menu_at: None, menu_at: None,
viewport: None, viewport: None,
pending_fit: None, pending_fit: None,
scroll_is_touchpad: false,
} }
} }
@@ -775,7 +779,8 @@ impl CanvasView {
// -- input ------------------------------------------------------------ // -- 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) { fn handle_pan_and_zoom(&mut self, ui: &egui::Ui, background: &egui::Response) {
if background.dragged_by(PointerButton::Middle) if background.dragged_by(PointerButton::Middle)
|| background.dragged_by(PointerButton::Secondary) || background.dragged_by(PointerButton::Secondary)
@@ -784,19 +789,32 @@ impl CanvasView {
ui.ctx().set_cursor_icon(CursorIcon::Grabbing); ui.ctx().set_cursor_icon(CursorIcon::Grabbing);
} }
self.note_scroll_device(ui);
if !background.contains_pointer() { if !background.contains_pointer() {
return; return;
} }
let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else { let Some(pointer) = ui.input(|i| i.pointer.latest_pos()) else {
return; return;
}; };
// A bare wheel arrives as scroll and is turned into a zoom by the // Held down, the zoom modifier turns scrolling into zooming before it
// same rule egui uses for the modifier it expects, so that the two // ever reaches here: egui reports it as a zoom and leaves no scroll
// zoom at one speed. Nothing else on the canvas wants the wheel: it is // behind. A pinch on a touchscreen arrives the same way.
// panned by dragging, and node contents are cropped rather than let (scroll, mut zoom_delta) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta()));
// scrolled. if self.scroll_is_touchpad {
let speed = ui.ctx().options(|o| o.input_options.scroll_zoom_speed); // Two fingers sweep the canvas about, in whatever direction they
let zoom_delta = ui.input(|i| i.zoom_delta() * (speed * i.smooth_scroll_delta.y).exp()); // 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 { if zoom_delta != 1.0 {
let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM) let factor = (self.to_global.scaling * zoom_delta).clamp(MIN_ZOOM, MAX_ZOOM)
/ self.to_global.scaling; / 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 --------------------------------------------------------- // -- painting ---------------------------------------------------------
fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) { fn paint_grid(&self, ui: &egui::Ui, visible: Rect, palette: &Palette) {
+97 -2
View File
@@ -546,8 +546,8 @@ fn the_wheel_zooms_over_a_node() {
&mut doc, &mut doc,
screen, screen,
vec![egui::Event::MouseWheel { vec![egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point, unit: egui::MouseWheelUnit::Line,
delta: vec2(0.0, 50.0), delta: vec2(0.0, 1.0),
modifiers: egui::Modifiers::default(), modifiers: egui::Modifiers::default(),
phase: egui::TouchPhase::Move, 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"); 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] #[test]
fn a_click_in_the_inspector_stays_in_the_inspector() { fn a_click_in_the_inspector_stays_in_the_inspector() {
let ctx = egui::Context::default(); let ctx = egui::Context::default();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 84 KiB