Skip to main content

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}