A Linux trackpad's scroll steps at 10 logical pixels per notch (#2979)
Fixes #2978 A Wayland trackpad sends pixel travel. benilla asked 100 physical pixels for one wheel step and gave at most one step per event, so at scale 1.33 a list needed about 7.5 times GNOME's notch of finger travel. 10 axis units are one step on Wayland (SDL's `WAYLAND_WHEEL_AXIS_UNIT`, GLFW, Xwayland, mutter's `DISCRETE_SCROLL_STEP`). On Linux a notch is now 10 logical pixels at the window's base scale, and an event steps once per whole notch. macOS keeps Bevy's factor and its one step per event. Line events stay at one step each, like the reference's `WM_MOUSEWHEEL` (`0x76c1a9`), and winit reports only lines on Windows and X11. The `WheelNotches` deviation says so. Tests: `pixel_travel_steps_by_the_platforms_notch` runs both platforms' arms on any host, and `a_pixel_notch_reads_the_base_scale` pins the scale. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
@@ -574,12 +574,17 @@ pub(crate) fn sync_dispatch(
|
||||
})
|
||||
}
|
||||
|
||||
/// A wheel delta in lines (notches), whatever unit the OS reported: a trackpad's `Pixel` deltas
|
||||
/// go through Bevy's own conversion factor. Every wheel reader shares it.
|
||||
pub(crate) fn wheel_lines(unit: MouseScrollUnit, dy: f32) -> f32 {
|
||||
match unit {
|
||||
MouseScrollUnit::Line => dy,
|
||||
MouseScrollUnit::Pixel => dy / MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR,
|
||||
/// Whether `Pixel` scroll is Wayland's axis units, Linux's one pixel path: winit reports lines on
|
||||
/// X11 and on Windows.
|
||||
pub(crate) const WAYLAND_PIXEL_SCROLL: bool = cfg!(target_os = "linux");
|
||||
|
||||
/// The physical pixels of a trackpad's travel that make one notch. On Wayland it is 10 axis units,
|
||||
/// 10 logical pixels, as SDL, GLFW and Xwayland take one step; elsewhere Bevy's conversion factor.
|
||||
pub(crate) fn pixels_per_notch(scale_factor: f32, wayland: bool) -> f32 {
|
||||
if wayland {
|
||||
10.0 * scale_factor
|
||||
} else {
|
||||
MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,10 +639,11 @@ pub(crate) fn world_takes_wheel(dispatch: Option<&BindingDispatch>, mods: Mods,
|
||||
|
||||
/// Whole notches out of a stream of line deltas, carrying the fraction between frames.
|
||||
///
|
||||
/// Deviation: a trackpad's `Pixel` deltas are carried until they make a line, and step at most
|
||||
/// once per event ([`crate::ui_script::Record::Wheel`]). The reference steps on every
|
||||
/// `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a carried trackpad
|
||||
/// does not zoom or scroll at its event rate.
|
||||
/// Deviation: a trackpad's `Pixel` deltas are carried until they make a line, and an event steps
|
||||
/// once per whole line on Wayland and at most once elsewhere ([`Self::feed_pixels`]). The reference
|
||||
/// steps on every `WM_MOUSEWHEEL`, whatever its delta (`0x76c1a9`); the carry is kept because a
|
||||
/// carried trackpad does not zoom or scroll at its event rate, and Wayland takes every whole line
|
||||
/// because a 10-pixel notch capped per event would lose a fast swipe's travel.
|
||||
///
|
||||
/// For consumers that act per notch off the sign alone: the UI's `OnMouseWheel` (stock
|
||||
/// `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call, `UIPanelTemplates.lua:150`) and
|
||||
@@ -662,6 +668,17 @@ impl WheelNotches {
|
||||
self.carry -= whole;
|
||||
whole as i32
|
||||
}
|
||||
|
||||
/// The steps a `Pixel` event of `travel` physical pixels makes. `scale_factor` is the window's
|
||||
/// base scale, the one winit made the pixels physical with; `WOW_DPI`'s override is not.
|
||||
pub(crate) fn feed_pixels(&mut self, travel: f32, scale_factor: f32, wayland: bool) -> i32 {
|
||||
let whole = self.feed(travel / pixels_per_notch(scale_factor, wayland));
|
||||
if wayland {
|
||||
whole
|
||||
} else {
|
||||
whole.signum()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dispatch pass's head (see the module doc), ahead of the frame's records: the frame's
|
||||
@@ -2513,6 +2530,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Wayland's notch is 10 logical pixels and an event steps for each whole one; elsewhere it is
|
||||
/// Bevy's factor, whatever the scale, and an event steps at most once.
|
||||
#[test]
|
||||
fn pixel_travel_steps_by_the_platforms_notch() {
|
||||
let mut wayland = WheelNotches::default();
|
||||
assert_eq!(wayland.feed_pixels(13.0, 1.33, true), 0, "under 10 logical");
|
||||
assert_eq!(wayland.feed_pixels(0.5, 1.33, true), 1, "the carry adds up");
|
||||
assert_eq!(
|
||||
wayland.feed_pixels(-70.0, 1.33, true),
|
||||
-5,
|
||||
"each whole notch"
|
||||
);
|
||||
|
||||
let mut elsewhere = WheelNotches::default();
|
||||
assert_eq!(elsewhere.feed_pixels(250.0, 2.0, false), 1, "at most one");
|
||||
assert_eq!(
|
||||
elsewhere.feed_pixels(50.0, 2.0, false),
|
||||
1,
|
||||
"the fraction carries"
|
||||
);
|
||||
assert_eq!(
|
||||
elsewhere.feed_pixels(99.0, 2.0, false),
|
||||
0,
|
||||
"under the factor"
|
||||
);
|
||||
}
|
||||
|
||||
/// The window deactivate (`0x514490`) clears the direction bits a Lua Start set and keeps the
|
||||
/// mouse pair.
|
||||
#[test]
|
||||
|
||||
@@ -32,9 +32,9 @@ pub(crate) enum Record {
|
||||
/// A wheel turn, one step up (`true`) or down. The reference queues each `WM_MOUSEWHEEL`'s raw
|
||||
/// delta (`0x42d58c`, `0x424159`) and steps once per message on its sign alone (`setge` at
|
||||
/// `0x76c1a9`), so a line event is one step whatever its travel; a trackpad's `Pixel` events
|
||||
/// step as their carry completes a line ([`crate::bindings::WheelNotches`]), at most once
|
||||
/// each. A turn with no vertical travel is no record: `WM_MOUSEHWHEEL` takes the window
|
||||
/// procedure's default arm and never reaches the client.
|
||||
/// step as their carry completes lines ([`crate::bindings::WheelNotches::feed_pixels`]). A
|
||||
/// turn with no vertical travel is no record: `WM_MOUSEHWHEEL` takes the window procedure's
|
||||
/// default arm and never reaches the client.
|
||||
Wheel {
|
||||
up: bool,
|
||||
},
|
||||
@@ -104,6 +104,9 @@ pub(crate) fn collect_input_records(
|
||||
) {
|
||||
let events: Vec<&WindowEvent> = events.read().collect();
|
||||
let now = window.single().ok().and_then(Window::cursor_position);
|
||||
let base_scale = window
|
||||
.single()
|
||||
.map_or(1.0, |w| w.resolution.base_scale_factor());
|
||||
let moved = events
|
||||
.iter()
|
||||
.any(|e| matches!(e, WindowEvent::CursorMoved(_) | WindowEvent::CursorLeft(_)));
|
||||
@@ -144,12 +147,13 @@ pub(crate) fn collect_input_records(
|
||||
let travel = crate::bindings::notch_travel(w.x, w.y, mods.shift);
|
||||
let step = match w.unit {
|
||||
MouseScrollUnit::Line => i32::from(travel > 0.0) - i32::from(travel < 0.0),
|
||||
MouseScrollUnit::Pixel => records
|
||||
.notches
|
||||
.feed(crate::bindings::wheel_lines(w.unit, travel))
|
||||
.signum(),
|
||||
MouseScrollUnit::Pixel => records.notches.feed_pixels(
|
||||
travel,
|
||||
base_scale,
|
||||
crate::bindings::WAYLAND_PIXEL_SCROLL,
|
||||
),
|
||||
};
|
||||
if step != 0 {
|
||||
for _ in 0..step.unsigned_abs() {
|
||||
out.push(InputRecord {
|
||||
record: Record::Wheel { up: step > 0 },
|
||||
mods,
|
||||
@@ -427,7 +431,9 @@ mod tests {
|
||||
],
|
||||
);
|
||||
assert_eq!(shape(&got), ["WheelUp@Some(10.0)", "WheelUp@Some(10.0)"]);
|
||||
let quarter = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR / 4.0;
|
||||
let wayland = crate::bindings::WAYLAND_PIXEL_SCROLL;
|
||||
let notch = crate::bindings::pixels_per_notch(1.0, wayland);
|
||||
let quarter = notch / 4.0;
|
||||
let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, quarter); 3]);
|
||||
assert!(
|
||||
got.is_empty(),
|
||||
@@ -440,10 +446,10 @@ mod tests {
|
||||
["WheelUp@Some(10.0)"],
|
||||
"the fourth completes it"
|
||||
);
|
||||
// A pixel event carrying more than a line is still one step.
|
||||
let lines = MouseScrollUnit::SCROLL_UNIT_CONVERSION_FACTOR * 2.5;
|
||||
let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, -lines)]);
|
||||
assert_eq!(shape(&got), ["WheelDown@Some(10.0)"]);
|
||||
// A pixel event carrying more than a line: a record per step, two on Wayland, one elsewhere.
|
||||
let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, -notch * 2.5)]);
|
||||
let steps = if wayland { 2 } else { 1 };
|
||||
assert_eq!(shape(&got), vec!["WheelDown@Some(10.0)"; steps]);
|
||||
// The horizontal wheel is no notch: `WM_MOUSEHWHEEL` never reaches the client's wheel
|
||||
// walk.
|
||||
let sideways = WindowEvent::MouseWheel(MouseWheel {
|
||||
@@ -454,4 +460,17 @@ mod tests {
|
||||
});
|
||||
assert!(collect(&mut w, vec![sideways]).is_empty());
|
||||
}
|
||||
|
||||
/// `Pixel` travel is physical at the OS's scale, which `WOW_DPI`'s override leaves alone.
|
||||
#[test]
|
||||
fn a_pixel_notch_reads_the_base_scale() {
|
||||
let mut w = world();
|
||||
let mut windows = w.0.query_filtered::<&mut Window, With<PrimaryWindow>>();
|
||||
let mut window = windows.single_mut(&mut w.0).unwrap();
|
||||
window.resolution.set_scale_factor(2.0);
|
||||
window.resolution.set_scale_factor_override(Some(1.0));
|
||||
let notch = crate::bindings::pixels_per_notch(2.0, crate::bindings::WAYLAND_PIXEL_SCROLL);
|
||||
let got = collect(&mut w, vec![wheel(MouseScrollUnit::Pixel, notch)]);
|
||||
assert_eq!(got.len(), 1, "one notch at the base scale");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user