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 read only the sign). Shells are therefore responsible for
8//! converting whatever their platform reports into notches, and this
9//! module is that conversion in one testable place — the native shells
10//! (`LineDelta` through, `PixelDelta / 40`) and the web shell
11//! (`WheelEvent.deltaMode`) had drifted apart, and a shim that forwarded
12//! raw pixels multiplied every scroll in the app by ~40-100×.
13//!
14//! # Precision deltas are accumulated, not forwarded fractionally
15//!
16//! A macOS trackpad (and a smooth-scrolling mouse) reports pixel deltas of
17//! 1-10 px per event, i.e. a *fraction* of a notch. Forwarding fractions
18//! is fine for consumers that scale by the magnitude but wrong for the
19//! ones that only read the sign — a zoom would take a full step per
20//! 2-pixel event. [`WheelNormalizer`] therefore accumulates sub-notch
21//! travel and emits only whole notches, keeping the remainder for the
22//! next event: nothing is dropped (40 px of finger travel always produces
23//! exactly one notch, which a `ScrollView` turns back into 40 px, so
24//! precision scrolling stays 1:1), and no consumer ever sees a fraction.
25//!
26//! The accumulator is per-axis and resets when the direction flips, so a
27//! reversal takes effect immediately instead of first burning the
28//! leftover of the previous direction.
29
30/// Pixels one wheel notch is worth. The scale
31/// [`ScrollView`](crate::widgets::scroll_view) scrolls by, and the
32/// divisor the native shells apply to a trackpad's pixel delta.
33pub const PIXELS_PER_NOTCH: f64 = 40.0;
34
35/// Notches one "page" (DOM `deltaMode` 2) is worth.
36///
37/// Browsers only report page deltas for Page Up / Page Down style wheel
38/// devices, and the CSS spec leaves the size to the user agent. Eight
39/// notches — 320 px through a `ScrollView` — is deliberately conservative:
40/// enough to read as a page jump, not so much that a stray event throws
41/// the user out of the document.
42pub const NOTCHES_PER_PAGE: f64 = 8.0;
43
44/// How a platform expressed its wheel delta. The discriminants match the
45/// DOM's `WheelEvent.deltaMode`, so a web shell can convert with
46/// [`WheelDeltaMode::from_dom`].
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum WheelDeltaMode {
49 /// Pixels — a precision device (trackpad, smooth wheel) or a browser
50 /// that reports even a classic wheel this way (~100 px per notch).
51 Pixel,
52 /// Lines. One line is one notch: this is what a classic wheel step
53 /// means, and what winit's `LineDelta` already carries.
54 Line,
55 /// Pages.
56 Page,
57}
58
59impl WheelDeltaMode {
60 /// DOM `WheelEvent.deltaMode` → this enum. Unknown values are treated
61 /// as pixels, which is what every browser in practice reports.
62 pub fn from_dom(delta_mode: u32) -> WheelDeltaMode {
63 match delta_mode {
64 1 => WheelDeltaMode::Line,
65 2 => WheelDeltaMode::Page,
66 _ => WheelDeltaMode::Pixel,
67 }
68 }
69}
70
71/// Raw delta → notches, before any accumulation. Pure; see
72/// [`WheelNormalizer`] for the part that keeps state.
73pub fn to_notches(delta: f64, mode: WheelDeltaMode) -> f64 {
74 if !delta.is_finite() {
75 return 0.0;
76 }
77 match mode {
78 WheelDeltaMode::Pixel => delta / PIXELS_PER_NOTCH,
79 WheelDeltaMode::Line => delta,
80 WheelDeltaMode::Page => delta * NOTCHES_PER_PAGE,
81 }
82}
83
84/// Per-axis sub-notch accumulator (see the module docs).
85///
86/// One instance per input source; a shell keeps it beside its event
87/// listener. Sign convention is the caller's — the normalizer only
88/// changes units, so a shell that flips the browser's
89/// "positive = scroll down" into agg-gui's "positive = wheel forward"
90/// keeps doing that at its own call site.
91#[derive(Debug, Clone, Copy, Default)]
92pub struct WheelNormalizer {
93 acc_x: f64,
94 acc_y: f64,
95}
96
97impl WheelNormalizer {
98 pub fn new() -> WheelNormalizer {
99 WheelNormalizer::default()
100 }
101
102 /// Convert one event's deltas to whole notches, carrying whatever
103 /// did not add up to one into the next call.
104 pub fn normalize(&mut self, delta_x: f64, delta_y: f64, mode: WheelDeltaMode) -> (f64, f64) {
105 let x = accumulate(&mut self.acc_x, to_notches(delta_x, mode));
106 let y = accumulate(&mut self.acc_y, to_notches(delta_y, mode));
107 (x, y)
108 }
109
110 /// Forget any partial travel — for a shell that lost the pointer
111 /// (canvas blur, gesture cancel) and must not apply half a notch
112 /// from before to whatever comes next.
113 pub fn reset(&mut self) {
114 self.acc_x = 0.0;
115 self.acc_y = 0.0;
116 }
117}
118
119/// Slack allowed when deciding whether the banked travel has reached a
120/// whole notch. Ten 4-px events *are* 40 px, but ten binary `0.1`s fall a
121/// few ulps short of `1.0`; without this the tenth event of a steady
122/// trackpad scroll would silently bank instead of firing, and the notch
123/// would arrive one event late forever after.
124const NOTCH_EPSILON: f64 = 1e-9;
125
126/// Add `notches` to `acc` and take out the whole part, resetting first
127/// when the direction reverses.
128fn accumulate(acc: &mut f64, notches: f64) -> f64 {
129 if notches == 0.0 {
130 return 0.0;
131 }
132 if *acc != 0.0 && acc.signum() != notches.signum() {
133 *acc = 0.0;
134 }
135 *acc += notches;
136 let whole = (acc.abs() + NOTCH_EPSILON).trunc().copysign(*acc);
137 *acc -= whole;
138 whole
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 /// A classic wheel notch arrives whole in every mode a browser can
146 /// report it in.
147 #[test]
148 fn one_notch_in_each_mode() {
149 let mut n = WheelNormalizer::new();
150 // Chrome/Edge/Safari: 100 CSS px per notch.
151 assert_eq!(n.normalize(0.0, 100.0, WheelDeltaMode::Pixel).1, 2.0);
152 // Firefox: 3 lines per notch.
153 assert_eq!(n.normalize(0.0, 3.0, WheelDeltaMode::Line).1, 3.0);
154 // A page device.
155 assert_eq!(
156 n.normalize(0.0, 1.0, WheelDeltaMode::Page).1,
157 NOTCHES_PER_PAGE
158 );
159 }
160
161 /// The precision case: 4 px events emit nothing until they add up,
162 /// and then exactly one notch — with the leftover carried, so no
163 /// travel is lost and 40 px of finger always means one notch.
164 #[test]
165 fn sub_notch_deltas_accumulate_instead_of_jumping_or_vanishing() {
166 let mut n = WheelNormalizer::new();
167 for _ in 0..9 {
168 assert_eq!(
169 n.normalize(0.0, 4.0, WheelDeltaMode::Pixel).1,
170 0.0,
171 "a fraction of a notch must not reach a sign-reading consumer"
172 );
173 }
174 assert_eq!(n.normalize(0.0, 4.0, WheelDeltaMode::Pixel).1, 1.0);
175
176 // 400 px of travel is ten notches, however it is chopped up.
177 let mut fine = WheelNormalizer::new();
178 let total: f64 = (0..400)
179 .map(|_| fine.normalize(0.0, 1.0, WheelDeltaMode::Pixel).1)
180 .sum();
181 assert_eq!(total, 10.0);
182 let mut coarse = WheelNormalizer::new();
183 assert_eq!(coarse.normalize(0.0, 400.0, WheelDeltaMode::Pixel).1, 10.0);
184 }
185
186 /// Reversing direction answers at once rather than first spending the
187 /// notch fragment banked in the other direction.
188 #[test]
189 fn a_direction_change_drops_the_partial_notch() {
190 let mut n = WheelNormalizer::new();
191 assert_eq!(n.normalize(0.0, 30.0, WheelDeltaMode::Pixel).1, 0.0);
192 assert_eq!(n.normalize(0.0, -30.0, WheelDeltaMode::Pixel).1, 0.0);
193 // …and 40 more px the *new* way is a whole notch, not 10 px worth
194 // of catching up.
195 assert_eq!(n.normalize(0.0, -10.0, WheelDeltaMode::Pixel).1, -1.0);
196 }
197
198 /// The two axes bank separately — a diagonal trackpad swipe must not
199 /// let horizontal travel pay for a vertical notch.
200 #[test]
201 fn the_axes_accumulate_independently() {
202 let mut n = WheelNormalizer::new();
203 assert_eq!(n.normalize(30.0, 20.0, WheelDeltaMode::Pixel), (0.0, 0.0));
204 assert_eq!(n.normalize(10.0, 0.0, WheelDeltaMode::Pixel), (1.0, 0.0));
205 assert_eq!(n.normalize(0.0, 20.0, WheelDeltaMode::Pixel), (0.0, 1.0));
206 }
207
208 /// Nonsense in, nothing out: a `NaN` delta (seen from at least one
209 /// browser extension) must not poison the accumulator.
210 #[test]
211 fn non_finite_deltas_are_ignored() {
212 let mut n = WheelNormalizer::new();
213 assert_eq!(
214 n.normalize(f64::NAN, f64::INFINITY, WheelDeltaMode::Pixel),
215 (0.0, 0.0)
216 );
217 assert_eq!(n.normalize(0.0, 40.0, WheelDeltaMode::Pixel).1, 1.0);
218 }
219
220 /// `reset` throws the partial travel away.
221 #[test]
222 fn reset_forgets_partial_travel() {
223 let mut n = WheelNormalizer::new();
224 n.normalize(0.0, 30.0, WheelDeltaMode::Pixel);
225 n.reset();
226 assert_eq!(n.normalize(0.0, 30.0, WheelDeltaMode::Pixel).1, 0.0);
227 }
228
229 /// DOM deltaMode mapping, including the "anything else is pixels"
230 /// fallback.
231 #[test]
232 fn dom_delta_modes_map_across() {
233 assert_eq!(WheelDeltaMode::from_dom(0), WheelDeltaMode::Pixel);
234 assert_eq!(WheelDeltaMode::from_dom(1), WheelDeltaMode::Line);
235 assert_eq!(WheelDeltaMode::from_dom(2), WheelDeltaMode::Page);
236 assert_eq!(WheelDeltaMode::from_dom(7), WheelDeltaMode::Pixel);
237 }
238}