agg_gui/wheel.rs
1//! Wheel-delta normalization: OS / DOM scroll deltas → agg-gui **notches**.
2//!
3//! agg-gui's [`Event::MouseWheel`](crate::event::Event::MouseWheel)
4//! carries a *notch* count, not pixels: every consumer scales it itself
5//! ([`ScrollView`](crate::widgets::scroll_view) multiplies by
6//! [`PIXELS_PER_NOTCH`], text areas and tree views do the same, and zoom
7//! consumers raise their per-notch factor to the power of the delta).
8//! Shells are therefore responsible for converting whatever their
9//! platform reports into notches, and this module is that conversion in
10//! one testable place — the native shells (`LineDelta` through,
11//! `PixelDelta / 40`) and the web shell (`WheelEvent.deltaMode`) had
12//! drifted apart, and a shim that forwarded raw pixels multiplied every
13//! scroll in the app by ~40-100×.
14//!
15//! # Precision deltas are forwarded fractionally
16//!
17//! A macOS trackpad (and a smooth-scrolling mouse) reports pixel deltas of
18//! 1-10 px per event, i.e. a *fraction* of a notch. Those fractions are
19//! passed straight through: 4 px of finger travel is 0.1 notch, which a
20//! `ScrollView` turns back into 4 px, so precision scrolling stays 1:1
21//! **and** continuous — no banking into 40 px jumps. A classic wheel still
22//! arrives as whole notches (one `LineDelta` line, or ~100 px per click
23//! in browsers that report wheels in pixels), so consumers that want a
24//! crisp per-click step get one; consumers must therefore scale by the
25//! delta's magnitude rather than reading only its sign, or a trackpad
26//! would take a full step per 2-pixel event. Every consumer in agg-gui
27//! does (scroll containers multiply, zooms use `step.powf(delta)`).
28//!
29//! An earlier revision banked sub-notch travel and emitted only whole
30//! notches. That made trackpads stutter in 40 px steps on the web while
31//! the native shell — which never banked — was smooth; the two shells now
32//! agree, via [`to_notches`] on both.
33
34/// Pixels one wheel notch is worth. The scale
35/// [`ScrollView`](crate::widgets::scroll_view) scrolls by, and the
36/// divisor the native shells apply to a trackpad's pixel delta.
37pub const PIXELS_PER_NOTCH: f64 = 40.0;
38
39/// Notches one "page" (DOM `deltaMode` 2) is worth.
40///
41/// Browsers only report page deltas for Page Up / Page Down style wheel
42/// devices, and the CSS spec leaves the size to the user agent. Eight
43/// notches — 320 px through a `ScrollView` — is deliberately conservative:
44/// enough to read as a page jump, not so much that a stray event throws
45/// the user out of the document.
46pub const NOTCHES_PER_PAGE: f64 = 8.0;
47
48/// How a platform expressed its wheel delta. The discriminants match the
49/// DOM's `WheelEvent.deltaMode`, so a web shell can convert with
50/// [`WheelDeltaMode::from_dom`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum WheelDeltaMode {
53 /// Pixels — a precision device (trackpad, smooth wheel) or a browser
54 /// that reports even a classic wheel this way (~100 px per notch).
55 Pixel,
56 /// Lines. One line is one notch: this is what a classic wheel step
57 /// means, and what winit's `LineDelta` already carries.
58 Line,
59 /// Pages.
60 Page,
61}
62
63impl WheelDeltaMode {
64 /// DOM `WheelEvent.deltaMode` → this enum. Unknown values are treated
65 /// as pixels, which is what every browser in practice reports.
66 pub fn from_dom(delta_mode: u32) -> WheelDeltaMode {
67 match delta_mode {
68 1 => WheelDeltaMode::Line,
69 2 => WheelDeltaMode::Page,
70 _ => WheelDeltaMode::Pixel,
71 }
72 }
73}
74
75/// Raw delta → notches, before any accumulation. Pure; see
76/// [`WheelNormalizer`] for the part that keeps state.
77pub fn to_notches(delta: f64, mode: WheelDeltaMode) -> f64 {
78 if !delta.is_finite() {
79 return 0.0;
80 }
81 match mode {
82 WheelDeltaMode::Pixel => delta / PIXELS_PER_NOTCH,
83 WheelDeltaMode::Line => delta,
84 WheelDeltaMode::Page => delta * NOTCHES_PER_PAGE,
85 }
86}
87
88/// Stateless per-event converter a shell keeps beside its wheel listener.
89///
90/// Sign convention is the caller's — the normalizer only changes units,
91/// so a shell that flips the browser's "positive = scroll down" into
92/// agg-gui's "positive = wheel forward" keeps doing that at its own call
93/// site. Kept as a type (rather than two calls to [`to_notches`]) so the
94/// shells have one obvious place to convert both axes.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct WheelNormalizer;
97
98impl WheelNormalizer {
99 pub fn new() -> WheelNormalizer {
100 WheelNormalizer
101 }
102
103 /// Convert one event's deltas to notches — fractional for precision
104 /// devices, whole for a classic wheel. Non-finite input becomes `0.0`.
105 pub fn normalize(&mut self, delta_x: f64, delta_y: f64, mode: WheelDeltaMode) -> (f64, f64) {
106 (to_notches(delta_x, mode), to_notches(delta_y, mode))
107 }
108
109 /// No-op: the normalizer no longer banks partial travel, so there is
110 /// nothing to forget. Kept so 0.5.0 callers (which reset on canvas
111 /// blur / gesture cancel) still compile; it will be removed in 0.6.
112 pub fn reset(&mut self) {}
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 /// `reset` survives for 0.5.x callers and changes nothing: there is
120 /// no banked travel to forget.
121 #[test]
122 fn reset_is_a_harmless_no_op() {
123 let mut n = WheelNormalizer::new();
124 assert_eq!(n.normalize(0.0, 4.0, WheelDeltaMode::Pixel), (0.0, 0.1));
125 n.reset();
126 assert_eq!(n.normalize(0.0, 4.0, WheelDeltaMode::Pixel), (0.0, 0.1));
127 }
128
129 /// A classic wheel notch arrives whole in every mode a browser can
130 /// report it in.
131 #[test]
132 fn one_notch_in_each_mode() {
133 let mut n = WheelNormalizer::new();
134 // Chrome/Edge/Safari: 100 CSS px per notch.
135 assert_eq!(n.normalize(0.0, 100.0, WheelDeltaMode::Pixel).1, 2.5);
136 // Firefox: 3 lines per notch.
137 assert_eq!(n.normalize(0.0, 3.0, WheelDeltaMode::Line).1, 3.0);
138 // A page device.
139 assert_eq!(
140 n.normalize(0.0, 1.0, WheelDeltaMode::Page).1,
141 NOTCHES_PER_PAGE
142 );
143 }
144
145 /// The precision case: a 4 px trackpad event is a tenth of a notch
146 /// and is delivered as exactly that, every event, so a `ScrollView`
147 /// moves 4 px per event instead of 40 px every tenth event.
148 #[test]
149 fn sub_notch_deltas_pass_through_fractionally() {
150 let mut n = WheelNormalizer::new();
151 for _ in 0..10 {
152 assert_eq!(n.normalize(0.0, 4.0, WheelDeltaMode::Pixel).1, 0.1);
153 }
154 // 400 px of travel is ten notches, however it is chopped up.
155 let mut fine = WheelNormalizer::new();
156 let total: f64 = (0..400)
157 .map(|_| fine.normalize(0.0, 1.0, WheelDeltaMode::Pixel).1)
158 .sum();
159 assert!((total - 10.0).abs() < 1e-9, "{total}");
160 let mut coarse = WheelNormalizer::new();
161 assert_eq!(coarse.normalize(0.0, 400.0, WheelDeltaMode::Pixel).1, 10.0);
162 }
163
164 /// No state: reversing direction answers at once, and the two axes
165 /// never influence each other.
166 #[test]
167 fn direction_changes_and_axes_are_independent() {
168 let mut n = WheelNormalizer::new();
169 assert_eq!(n.normalize(0.0, 30.0, WheelDeltaMode::Pixel).1, 0.75);
170 assert_eq!(n.normalize(0.0, -30.0, WheelDeltaMode::Pixel).1, -0.75);
171 assert_eq!(n.normalize(30.0, 20.0, WheelDeltaMode::Pixel), (0.75, 0.5));
172 assert_eq!(n.normalize(10.0, 0.0, WheelDeltaMode::Pixel), (0.25, 0.0));
173 }
174
175 /// Nonsense in, nothing out: a `NaN` delta (seen from at least one
176 /// browser extension) becomes zero rather than propagating.
177 #[test]
178 fn non_finite_deltas_are_ignored() {
179 let mut n = WheelNormalizer::new();
180 assert_eq!(
181 n.normalize(f64::NAN, f64::INFINITY, WheelDeltaMode::Pixel),
182 (0.0, 0.0)
183 );
184 assert_eq!(n.normalize(0.0, 40.0, WheelDeltaMode::Pixel).1, 1.0);
185 }
186
187 /// DOM deltaMode mapping, including the "anything else is pixels"
188 /// fallback.
189 #[test]
190 fn dom_delta_modes_map_across() {
191 assert_eq!(WheelDeltaMode::from_dom(0), WheelDeltaMode::Pixel);
192 assert_eq!(WheelDeltaMode::from_dom(1), WheelDeltaMode::Line);
193 assert_eq!(WheelDeltaMode::from_dom(2), WheelDeltaMode::Page);
194 assert_eq!(WheelDeltaMode::from_dom(7), WheelDeltaMode::Pixel);
195 }
196}