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}