Skip to main content

cranpose_app_shell/
wheel.rs

1//! One mouse-wheel / trackpad sample, in the shell's wheel convention.
2//!
3//! Every host that has a wheel — the winit desktop loop, the browser's `wheel`
4//! listener — has to answer the same four questions in the same order: is this
5//! a zoom gesture, does a rotary handler want it, is it an axis-swapped
6//! horizontal scroll, and otherwise how much does the hovered scrollable move.
7//! [`AppShell::wheel_scrolled`](crate::AppShell::wheel_scrolled) is that answer,
8//! and this type is its input, so a host's whole job is normalizing its native
9//! event into a [`WheelScroll`].
10//!
11//! # Sign convention
12//!
13//! `delta` is **logical pixels, positive when the content being scrolled should
14//! move down and right** — the direction a wheel turned up / away from the user
15//! produces. That is winit's convention and the one the scroll modifiers are
16//! written against (a positive vertical delta walks a `ScrollState` back toward
17//! zero). The DOM's is the opposite: `WheelEvent::delta_y` is positive when the
18//! wheel is turned *down*, so the browser host negates on the way in. Getting
19//! this wrong does not fail loudly — it scrolls backwards.
20
21use cranpose_ui::Modifiers;
22use cranpose_ui_graphics::Point;
23
24/// Logical pixels one wheel notch scrolls, and the notch the ctrl+wheel zoom
25/// step is defined against.
26const NOTCH_LOGICAL_PX: f32 = 40.0;
27/// Zoom applied by one ctrl+wheel notch.
28const ZOOM_PER_NOTCH: f32 = 1.2;
29
30/// A mouse-wheel or trackpad scroll sample ready for
31/// [`AppShell::wheel_scrolled`](crate::AppShell::wheel_scrolled).
32#[derive(Clone, Copy, Debug, PartialEq)]
33pub struct WheelScroll {
34    /// Scroll amount in logical pixels; positive moves content down and right
35    /// (see the module docs — this is winit's sign, not the DOM's).
36    pub delta: Point,
37    /// Keyboard modifiers held during the sample. `ctrl` makes it a zoom
38    /// gesture, `alt` turns a vertical wheel into a horizontal scroll.
39    pub modifiers: Modifiers,
40    /// Monotonic milliseconds, for the rotary event's velocity tracking. Only
41    /// differences between samples are meaningful.
42    pub uptime_millis: u64,
43}
44
45impl WheelScroll {
46    /// A sample with no modifiers held.
47    pub fn new(delta: Point, uptime_millis: u64) -> Self {
48        Self {
49            delta,
50            modifiers: Modifiers::NONE,
51            uptime_millis,
52        }
53    }
54
55    /// This sample with `modifiers` held.
56    pub fn with_modifiers(mut self, modifiers: Modifiers) -> Self {
57        self.modifiers = modifiers;
58        self
59    }
60
61    /// Whether this sample is the zoom gesture (ctrl+wheel, which is also how
62    /// trackpad pinches arrive in a browser) rather than a scroll.
63    pub fn is_zoom(&self) -> bool {
64        self.modifiers.ctrl
65    }
66
67    /// The multiplicative zoom step for a ctrl+wheel sample: one notch up
68    /// (positive delta) zooms in by [`ZOOM_PER_NOTCH`].
69    pub fn zoom_factor(&self) -> f32 {
70        ZOOM_PER_NOTCH.powf(self.delta.y / NOTCH_LOGICAL_PX)
71    }
72
73    /// The delta the hovered scrollable should see: with alt held, a vertical
74    /// wheel drives the horizontal axis instead (the shift-less way to scroll
75    /// a row on a wheel that only has a vertical axis).
76    pub fn scroll_delta(&self) -> Point {
77        if !self.modifiers.alt {
78            return self.delta;
79        }
80        let x = if self.delta.x.abs() <= f32::EPSILON {
81            self.delta.y
82        } else {
83            self.delta.x
84        };
85        Point { x, y: 0.0 }
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    fn wheel(x: f32, y: f32) -> WheelScroll {
94        WheelScroll::new(Point { x, y }, 0)
95    }
96
97    #[test]
98    fn a_plain_sample_is_neither_a_zoom_nor_axis_swapped() {
99        let sample = wheel(3.0, -40.0);
100
101        assert!(!sample.is_zoom());
102        assert_eq!(sample.scroll_delta(), sample.delta);
103    }
104
105    #[test]
106    fn ctrl_makes_a_sample_a_zoom_that_grows_when_the_wheel_turns_up() {
107        let up = wheel(0.0, NOTCH_LOGICAL_PX).with_modifiers(Modifiers {
108            ctrl: true,
109            ..Modifiers::NONE
110        });
111        let down = wheel(0.0, -NOTCH_LOGICAL_PX).with_modifiers(Modifiers {
112            ctrl: true,
113            ..Modifiers::NONE
114        });
115
116        assert!(up.is_zoom());
117        assert!((up.zoom_factor() - ZOOM_PER_NOTCH).abs() < 1.0e-6);
118        // Zooming out by a notch must undo zooming in by one, or a pinch
119        // in-and-out drifts the scale.
120        assert!((up.zoom_factor() * down.zoom_factor() - 1.0).abs() < 1.0e-6);
121    }
122
123    #[test]
124    fn alt_moves_a_vertical_wheel_onto_the_horizontal_axis() {
125        let alt = Modifiers {
126            alt: true,
127            ..Modifiers::NONE
128        };
129
130        assert_eq!(
131            wheel(0.0, 48.0).with_modifiers(alt).scroll_delta(),
132            Point { x: 48.0, y: 0.0 }
133        );
134        // A device that already reports a horizontal axis keeps it: only the
135        // vertical-only wheel needs the swap.
136        assert_eq!(
137            wheel(12.0, 48.0).with_modifiers(alt).scroll_delta(),
138            Point { x: 12.0, y: 0.0 }
139        );
140    }
141}