Canvas: a touchpad's End or Cancel ends its wheel turn with Zoom with Mouse Wheel on

With Zoom with Mouse Wheel on, a wheel turn that a touchpad began did not
end on the End or Cancel phase, so a whole Cmd/Ctrl notch within 150 ms,
or in the same frame, still zoomed instead of scrolling sideways. The
turn now ends on those phases, as it does in the Scroll Sideways branch.

Tests cover End and Cancel in the frame of the next notch, a Start phase,
the scroll timeout with no frame in between, a table of fine_steps cases,
and exact zoom and scroll amounts.

Follows #6360.
This commit is contained in:
dotCooCoo
2026-11-09 17:24:42 -08:00
parent bca98461c4
commit 2989954301
2 changed files with 126 additions and 18 deletions
+121 -16
View File
@@ -4868,29 +4868,134 @@ fn cmd_with_a_touchpad_zooms_and_a_whole_notch_scrolls_sideways() {
};
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;
let zooms_by = |(zoom, _, moved): (f64, vectorcraft_geom::Vec2, f64), want: f64| (zoom / want - 1.0).abs() < 1e-6 && moved < 1e-6;
// `lines` wheel lines to the left.
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
let sideways = |(zoom, scroll, _): (f64, vectorcraft_geom::Vec2, f64), lines: f64| {
(zoom - 1.0).abs() < 1e-9 && (scroll.x + lines * line).abs() < 0.5 && 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 wheel message of 30 is a quarter of a 120 notch, and arrives as a quarter line. The zoom
// is exponential in the wheel's motion, so more motion in a turn multiplies the quarter's.
let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]);
assert!(zooms_in(small), "a line delta smaller than a notch zooms");
let quarter = small.0;
assert!(
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 0.25, S::Unknown)]), quarter.powi(2)),
"several small deltas zoom"
);
assert!(
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)),
"a whole notch in the same turn adds its zoom"
);
let point = turn(&mut app, vec![ev(U::Point, 6.0, S::Unknown)]);
assert!(zooms_in(point), "point deltas of an unnamed source zoom");
assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Trackpad)]), point.0), "a trackpad scroll zooms as far");
assert!(zooms_by(turn(&mut app, vec![ev(U::Point, 6.0, S::Momentum)]), point.0), "its momentum zooms as far");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "a whole notch scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 2.0, S::Unknown)]), 2.0), "a turn of two notches scrolls sideways");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 0.25, S::Wheel)]), 0.25), "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");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "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");
let small = turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown)]);
assert!(zooms_in(small), "with Zoom with Mouse Wheel: a small line delta zooms");
let quarter = small.0;
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"
zooms_by(turn(&mut app, vec![ev(U::Line, 0.25, S::Unknown), ev(U::Line, 1.0, S::Unknown)]), quarter.powi(5)),
"with Zoom with Mouse Wheel: a whole notch in the same turn adds its zoom"
);
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");
assert!(sideways(turn(&mut app, vec![ev(U::Line, 1.0, S::Unknown)]), 1.0), "with Zoom with Mouse Wheel: a whole notch scrolls sideways");
}
/// A wheel turn that a touchpad began ends with an End or Cancel phase, also in the frame the next
/// notch arrives, and after egui's scroll timeout with no frame in between. Under either
/// preference, the whole notch after it scrolls sideways. A notch inside the turn zooms.
#[test]
fn a_touchpad_wheel_turn_ends_with_its_end_phase_or_the_scroll_timeout() {
use egui::{MouseWheelSource as S, MouseWheelUnit as U, TouchPhase as P};
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 frame_time = 1.0 / 60.0;
let mut run = |app: &mut VectorcraftApp, dt: f64, events: Vec<egui::Event>| {
t += dt;
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, frame_time, vec![]);
let rect = app.canvas_rect.unwrap();
let at = rect.center() + vec2(60.0, 40.0);
let line = f64::from(ctx.options(|o| o.input_options.line_scroll_speed));
let ev =
|unit, dy: f32, phase, source| egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase, modifiers: egui::Modifiers::COMMAND, source };
// Point deltas zoom at once, so no glide is left when the next event comes.
let small = || ev(U::Point, 6.0, P::Move, S::Unknown);
let notch = || ev(U::Line, 1.0, P::Move, S::Unknown);
// A frame with a small delta, then `events` in one frame `dt` seconds later, and the wheel
// going idle: → (zoom ratio, scroll in points) since the small delta.
let mut turn = |app: &mut VectorcraftApp, dt: f64, events: Vec<egui::Event>| {
run(app, frame_time, vec![egui::Event::PointerMoved(at)]);
run(app, frame_time, vec![small()]);
let before = *app.view().unwrap();
run(app, dt, events);
for _ in 0..40 {
run(app, frame_time, vec![]);
}
let after = *app.view().unwrap();
(after.zoom / before.zoom, (after.center - before.center) * after.zoom)
};
let sideways = |(zoom, scroll): (f64, vectorcraft_geom::Vec2)| (zoom - 1.0).abs() < 1e-9 && (scroll.x + line).abs() < 0.5 && scroll.y == 0.0;
for wheel_zooms in [false, true] {
app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": wheel_zooms})).unwrap();
assert!(turn(&mut app, frame_time, vec![notch()]).0 > 1.01, "a notch inside the turn zooms ({wheel_zooms})");
assert!(
turn(&mut app, frame_time, vec![ev(U::Point, 0.0, P::Start, S::Trackpad), notch()]).0 > 1.01,
"a Start phase does not end the turn ({wheel_zooms})"
);
for phase in [P::End, P::Cancel] {
let r = turn(&mut app, frame_time, vec![ev(U::Point, 0.0, phase, S::Trackpad), notch()]);
assert!(sideways(r), "the notch after {phase:?} in the same frame scrolls sideways ({wheel_zooms}, {r:?})");
}
let (zoom, scroll) = turn(&mut app, 0.2, vec![notch()]);
assert!(
(zoom - 1.0).abs() < 1e-9 && scroll.x < -1.0 && scroll.y == 0.0,
"the notch after the scroll timeout scrolls sideways ({wheel_zooms}, {zoom}, {scroll:?})"
);
}
}
/// Which wheel events count as a touchpad: a named trackpad or its momentum, never a named wheel,
/// and for an unnamed source, point deltas or line and page deltas more than 0.001 from a whole
/// number on either axis.
#[test]
fn fine_steps_tells_touchpad_input_from_whole_notches() {
use egui::{MouseWheelSource as S, MouseWheelUnit as U};
for unit in [U::Point, U::Line, U::Page] {
for delta in [vec2(0.0, 1.0), vec2(0.0, 0.25), vec2(-1.5, 0.0)] {
assert!(view::fine_steps(unit, delta, S::Trackpad), "{unit:?} {delta:?} from a trackpad");
assert!(view::fine_steps(unit, delta, S::Momentum), "{unit:?} {delta:?} from its momentum");
assert!(!view::fine_steps(unit, delta, S::Wheel), "{unit:?} {delta:?} from a named wheel");
}
}
assert!(view::fine_steps(U::Point, vec2(0.0, 1.0), S::Unknown), "whole points from an unnamed source");
for unit in [U::Line, U::Page] {
for (x, y) in [(0.0, 0.25), (0.0, -0.25), (0.25, 1.0), (1.0, -0.5), (0.0, 0.002), (0.0, 0.998), (0.0, 1.002), (0.0, 1.0 / 120.0)] {
assert!(view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is finer than a notch");
}
for (x, y) in [(0.0, 0.0), (0.0, 1.0), (0.0, -1.0), (0.0, 2.0), (-3.0, 1.0), (0.0, 0.9995), (0.0, 1.0005), (0.0, 0.0005)] {
assert!(!view::fine_steps(unit, vec2(x, y), S::Unknown), "{unit:?} ({x}, {y}) is a whole notch");
}
}
}
/// With Scroll Sideways with Cmd/Ctrl + Wheel on and Animated Zoom off, Alt + Cmd-wheel zooms
+5 -2
View File
@@ -358,7 +358,7 @@ struct ThumbDrag {
/// 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 {
pub(super) 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,
@@ -483,7 +483,7 @@ pub(super) fn wheel(
let mut scroll = pan;
for e in &i.events {
match e {
egui::Event::MouseWheel { unit, delta, modifiers, source, .. } => {
egui::Event::MouseWheel { unit, delta, phase, modifiers, source, .. } => {
let d = points(unit, *delta);
// The turn ends after egui's scroll timeout, as above.
if i.time - turn.last > 0.150 {
@@ -502,6 +502,9 @@ pub(super) fn wheel(
turn.glide += f64::from(d.y) * WHEEL_ZOOM;
scroll.x += d.x;
}
if matches!(phase, egui::TouchPhase::End | egui::TouchPhase::Cancel) {
turn.fine = false;
}
}
egui::Event::Zoom(f) => zoom *= f64::from(*f),
_ => {}