Merge pull request #6360 from dotCooCoo/fix/touchpad-ctrl-zoom

Canvas: Cmd/Ctrl-wheel input finer than a whole notch zooms; only whole notches scroll sideways
This commit is contained in:
BFlat
2026-11-10 05:28:23 +07:00
committed by GitHub
4 changed files with 118 additions and 15 deletions
+79 -6
View File
@@ -4645,7 +4645,7 @@ fn wheel_over_a_numeric_panel_field_does_not_start_a_canvas_zoom_glide() {
/// `ui.wheel`: with Zoom with Mouse Wheel off, Cmd/Ctrl-wheel zooms about the pointer while the
/// option is off and scrolls sideways while it is on, and a trackpad pinch, two fingers spread on a touch
/// screen, Alt + Cmd/Ctrl-wheel and Alt-wheel still zoom. A Cmd/Ctrl-wheel notch with a plain
/// one right after it scrolls two notches in all, and a trackpad gesture during which Cmd/Ctrl
/// one right after it scrolls two notches in all, and a wheel gesture during which Cmd/Ctrl
/// is pressed and released scrolls sideways until the gesture ends, while an Alt gesture that
/// pauses past egui's scroll timeout no longer holds Alt for the next move. With Zoom with
/// Mouse Wheel also on, Cmd/Ctrl-wheel scrolls sideways and the wheel zooms.
@@ -4747,16 +4747,16 @@ fn cmd_wheel_scrolls_sideways_preference() {
(zoom - 1.25).abs() < 1e-6 && moved.x > notch - 0.5 && moved.x < 1.25 * notch + 0.5 && moved.y.abs() < 1e-3,
"on: a pinch during a Cmd-wheel notch zooms by the pinch, and the notch scrolls sideways ({zoom}, {moved:?})"
);
// A trackpad gesture starts with no modifier, and Cmd is pressed and released during it:
// egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps scrolling
// sideways until then.
// A gesture egui names as a wheel starts with no modifier, and Cmd is pressed and released
// during it: egui keeps Cmd in its wheel state until the gesture ends, and the canvas keeps
// scrolling sideways until then.
let before = *app.view().unwrap();
let swipe = |phase, modifiers| egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, 6.0),
phase,
modifiers,
source: egui::MouseWheelSource::Unknown,
source: egui::MouseWheelSource::Wheel,
};
app.synthetic.extend([
egui::Event::PointerMoved(at),
@@ -4772,7 +4772,7 @@ fn cmd_wheel_scrolls_sideways_preference() {
let scroll = (after.center - before.center) * after.zoom;
assert!(
(after.zoom / before.zoom - 1.0).abs() < 1e-9 && (scroll.x + 12.0).abs() < 0.01 && scroll.y == 0.0,
"on: a trackpad gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})",
"on: a wheel gesture scrolls sideways to its end after Cmd is let go ({}, {scroll:?})",
after.zoom / before.zoom
);
// An Alt-wheel gesture pauses past egui's scroll timeout with no frame in between: egui
@@ -4820,6 +4820,79 @@ fn cmd_wheel_scrolls_sideways_preference() {
assert!(zooms_in(turn(&mut app, "")), "with Zoom with Mouse Wheel: the wheel zooms in about the pointer");
}
/// With Scroll Sideways with Cmd/Ctrl + Wheel on, Cmd/Ctrl with a touchpad zooms about the pointer
/// while whole line notches scroll sideways: a trackpad or point source, line deltas smaller than
/// a notch, and a whole notch later in the same turn. With Zoom with Mouse Wheel on, Cmd/Ctrl with
/// a touchpad zooms too.
#[test]
fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ctx = egui::Context::default();
let mut t = 0.0;
let mut run = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
t += 1.0 / 60.0;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
time: Some(t),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| show(app, ui));
out.textures_delta.clear();
};
run(&mut app, vec![]);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
let ev = |unit, dy: f32, source| egui::Event::MouseWheel {
unit,
delta: vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::COMMAND,
source,
};
// Sends each event on its own frame, then lets egui finish and end the turn:
// → (zoom ratio, scroll in points, how far the point under `at` moved).
let mut turn = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let before = *app.view().unwrap();
run(app, vec![egui::Event::PointerMoved(at)]);
for e in events {
run(app, vec![e]);
}
for _ in 0..40 {
run(app, vec![]);
}
let after = *app.view().unwrap();
let moved = Xf::new(rect, &after).to_doc(at).distance(Xf::new(rect, &before).to_doc(at));
(after.zoom / before.zoom, (after.center - before.center) * after.zoom, moved)
};
use egui::{MouseWheelSource as S, MouseWheelUnit as U};
let zooms_in = |(zoom, _, moved): (f64, _, f64)| zoom > 1.0005 && moved < 1e-6;
let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64)| (zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0;
assert!(app.session.prefs.ctrl_wheel_scrolls_sideways, "the option is on by default");
// A wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line.
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a line delta smaller than a notch zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)])), "several small deltas zoom");
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])), "a whole notch in the same turn zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)])), "point deltas of an unnamed source zoom");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "a trackpad scroll zooms");
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)])), "its momentum zooms");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "a whole notch scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)])), "a turn of two notches scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)])), "a part of a line from a named wheel scrolls sideways");
// A turn ends when the wheel goes idle, so the next whole notch scrolls sideways again.
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "a touchpad turn");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "the notch after it scrolls sideways");
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap();
assert!(zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)])), "with Zoom with Mouse Wheel: a small line delta zooms");
assert!(
zooms_in(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)])),
"with Zoom with Mouse Wheel: a whole notch in the same turn zooms"
);
assert!(zooms_in(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)])), "with Zoom with Mouse Wheel: a trackpad scroll zooms");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)])), "with Zoom with Mouse Wheel: a whole notch scrolls sideways");
}
/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms
/// about the pointer by egui's Cmd-wheel zoom, as Cmd-wheel does with the option off, and
/// Cmd-wheel alone scrolls sideways.
+33 -5
View File
@@ -65,6 +65,9 @@ pub(super) struct WheelTurn {
/// When the last wheel event came (`InputState::time`), and whether a trackpad sent it.
last: f64,
trackpad: bool,
/// A wheel event of this turn came from a touchpad ([`fine_steps`]), so Cmd/Ctrl zooms for the
/// rest of the turn, also through a whole notch.
fine: bool,
/// The zoom (natural log) wheel notches still have to glide in.
pub(super) glide: f64,
}
@@ -351,11 +354,26 @@ struct ThumbDrag {
max: f32,
}
/// Whether a wheel event comes from a touchpad and not from a mouse wheel's notches. egui names
/// the source on the Mac and reports `Unknown` elsewhere. There, point deltas (Wayland) and line
/// deltas that are not whole notches (Windows and X11) count as a touchpad. A browser reports a
/// mouse wheel in points, so the web build counts only line deltas that are not whole notches.
fn fine_steps(unit: egui::MouseWheelUnit, delta: egui::Vec2, source: egui::MouseWheelSource) -> bool {
let notch = |d: f32| (d - d.round()).abs() < 1e-3;
match source {
egui::MouseWheelSource::Trackpad | egui::MouseWheelSource::Momentum => true,
egui::MouseWheelSource::Wheel => false,
egui::MouseWheelSource::Unknown if unit == egui::MouseWheelUnit::Point => !cfg!(target_arch = "wasm32"),
egui::MouseWheelSource::Unknown => !notch(delta.x) || !notch(delta.y),
}
}
/// What the wheel, a pinch and a two-finger drag on a touch screen did over the canvas this frame:
/// a zoom factor (about the pointer) and a scroll (screen points the content moves). The wheel scrolls, Cmd- and Alt-wheel (Option
/// on the Mac) zoom; with General › Scroll Sideways with Cmd/Ctrl + Wheel, Cmd/Ctrl-wheel scrolls
/// sideways instead, and Alt + Cmd/Ctrl-wheel still zooms. With General › Zoom with Mouse Wheel the
/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. `line` and `page`: points
/// wheel and Alt-wheel zoom, Shift-wheel scrolls up and down and Cmd/Ctrl-wheel sideways. Cmd/Ctrl
/// with a touchpad ([`fine_steps`]) zooms under either preference. `line` and `page`: points
/// per wheel line and page. `turn`: kept by the caller across frames. With Performance › Animated
/// Zoom (`animate`) a mouse wheel's notches zoom in a glide over a few frames, as scrolling does
/// (#888); off, each notch zooms on the frame it arrives (#4283). A trackpad's fine steps zoom at
@@ -385,7 +403,7 @@ pub(super) fn wheel(
// `turn.alt` follows `turn.held` after each Start or Move, so a trackpad gesture that held
// Alt keeps zooming until it ends, also after Alt is let go.
for e in &i.events {
if let egui::Event::MouseWheel { phase, modifiers, source, .. } = e {
if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } = e {
// A wheel event that comes after egui's scroll timeout ends the turn first, as
// `on_wheel_event` does (1 s for fingers resting on a trackpad mid-gesture, else
// 0.15 s), also when no frame ran in between.
@@ -393,9 +411,11 @@ pub(super) fn wheel(
if i.time - turn.last > timeout {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
turn.fine = false;
}
turn.last = i.time;
turn.trackpad = *source == egui::MouseWheelSource::Trackpad;
turn.fine |= fine_steps(*unit, *delta, *source);
match phase {
egui::TouchPhase::Start => {
turn.gesture = true;
@@ -413,6 +433,7 @@ pub(super) fn wheel(
egui::TouchPhase::End | egui::TouchPhase::Cancel => {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
turn.fine = false;
}
}
}
@@ -420,6 +441,7 @@ pub(super) fn wheel(
if !i.is_scrolling() {
turn.gesture = false;
turn.held = egui::Modifiers::NONE;
turn.fine = false;
}
let (mut zoom, mut scroll) = (f64::from(i.zoom_delta()), i.smooth_scroll_delta);
// Scroll Sideways with Cmd/Ctrl + Wheel: while egui's wheel state holds Cmd/Ctrl without
@@ -427,7 +449,7 @@ pub(super) fn wheel(
// default speed, which the app keeps. The canvas scrolls sideways by those points instead.
// A trackpad or web pinch still zooms. With fingers on a touch screen, egui's zoom is the
// fingers' alone, and the canvas leaves it as it is.
if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && i.multi_touch().is_none() {
if prefs.ctrl_wheel_scrolls_sideways && turn.held.command && !turn.held.alt && !turn.fine && i.multi_touch().is_none() {
let pinch: f64 = i.events.iter().filter_map(|e| if let egui::Event::Zoom(f) = e { Some(f64::from(*f)) } else { None }).product();
let wheel_zoom = zoom / pinch;
if wheel_zoom.is_finite() && wheel_zoom > 0.0 {
@@ -461,9 +483,15 @@ pub(super) fn wheel(
let mut scroll = pan;
for e in &i.events {
match e {
egui::Event::MouseWheel { unit, delta, modifiers, .. } => {
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => {
let d = points(unit, *delta);
if modifiers.command {
// The turn ends after egui's scroll timeout, as above.
if i.time - turn.last > 0.150 {
turn.fine = false;
}
turn.last = i.time;
turn.fine |= fine_steps(*unit, *delta, *source);
if modifiers.command && !turn.fine {
scroll.x += d.x + d.y;
} else if modifiers.shift {
scroll.y += d.x + d.y;
+5 -3
View File
@@ -3995,9 +3995,11 @@ desktop app): in View › Pixel Preview, on softens placed and embedded bitmaps;
`disableAutoAddDelete` (off by default): on, the Pen no longer adds or deletes an anchor when a click lands on a
segment or an anchor of a selected path.
`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl-wheel scrolls the canvas sideways instead of zooming while
`zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel and a pinch on a Mac
trackpad, on a touch screen or in the web build still zoom about the pointer. With `zoomWithMouseWheel` off, once a
`ctrlWheelScrollsSideways` (on by default): on, Cmd/Ctrl with a mouse wheel's whole notches scrolls the canvas sideways
instead of zooming while `zoomWithMouseWheel` is off; off, Cmd/Ctrl-wheel zooms about the pointer. Alt + Cmd/Ctrl-wheel
and a pinch on a Mac trackpad, on a touch screen or in the web build still zoom about the pointer. Under either
wheel preference, Cmd/Ctrl zooms about the pointer for the rest of a wheel turn once the turn has a trackpad event,
point deltas from an unnamed source in the desktop app, or line deltas that are not whole notches. With `zoomWithMouseWheel` off, once a
trackpad gesture holds Alt, it zooms until it ends, also after Alt is let go. The control channel's
`ui.wheel` with `cmd: true` turns the wheel with Cmd/Ctrl held.
File diff suppressed because one or more lines are too long