Skip to main content

veilus_human_input/
wheel.rs

1//! Mouse-wheel scrolling.
2//!
3//! A notched wheel sends equal steps with pauses between them. An
4//! accelerated wheel (macOS) turns one flick into a ramp: a tiny first event,
5//! growing ones, then a plateau of large deltas a few milliseconds apart.
6
7use std::ops::Range;
8use std::time::Duration;
9
10use rand::Rng;
11
12use crate::sample::log_normal;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum WheelStyle {
16    /// Equal notches, as a wheel with detents (the Windows default).
17    Notched,
18    /// macOS wheel acceleration: each flick ramps up from a small delta.
19    MacAccelerated,
20}
21
22/// Every tunable number of the wheel model.
23#[derive(Debug, Clone, PartialEq)]
24pub struct WheelParams {
25    /// Pixels per notch, random jitter around it, and the pause after each.
26    pub notch_px: i32,
27    pub notch_jitter_px: i32,
28    pub notch_gap_ms: Range<u64>,
29    /// Accelerated flick: first, second and third delta, then a log-normal
30    /// plateau (median, sigma, cap), events this far apart.
31    pub accel_first_px: i32,
32    pub accel_second_px: Range<i32>,
33    pub accel_third_px: Range<i32>,
34    pub accel_median_px: f64,
35    pub accel_sigma: f64,
36    pub accel_max_px: f64,
37    pub accel_gap_ms: Range<u64>,
38    /// An accelerated scroll lands within this fraction of the requested
39    /// amount — nobody scrolls the same exact distance twice.
40    pub accel_distance_jitter: f64,
41}
42
43impl Default for WheelParams {
44    fn default() -> Self {
45        Self {
46            // Chromium scrolls 100 px per notch by default.
47            notch_px: 100,
48            notch_jitter_px: 0,
49            notch_gap_ms: 30..120,
50            accel_first_px: 2,
51            accel_second_px: 20..60,
52            accel_third_px: 80..200,
53            accel_median_px: 300.0,
54            accel_sigma: 0.4,
55            accel_max_px: 800.0,
56            accel_gap_ms: 10..25,
57            accel_distance_jitter: 0.05,
58        }
59    }
60}
61
62/// Wheel events for one scroll of `amount` px (positive), and the pause AFTER
63/// each. The deltas add up to exactly `amount`.
64pub fn wheel_events(
65    style: WheelStyle,
66    amount: i32,
67    p: &WheelParams,
68    rng: &mut impl Rng,
69) -> Vec<(i32, Duration)> {
70    match style {
71        WheelStyle::Notched => notches(amount, p, rng),
72        WheelStyle::MacAccelerated => flick(amount, p, rng),
73    }
74}
75
76/// Distance actually scrolled for a request of `amount` px: exact for a
77/// notched wheel, within ±[`WheelParams::accel_distance_jitter`] otherwise.
78pub fn scroll_distance(style: WheelStyle, amount: i32, p: &WheelParams, rng: &mut impl Rng) -> i32 {
79    match style {
80        WheelStyle::Notched => amount,
81        WheelStyle::MacAccelerated => {
82            let j = p.accel_distance_jitter;
83            let f = rng.gen_range(1.0 - j..=1.0 + j);
84            ((f64::from(amount) * f).round() as i32).max(1)
85        }
86    }
87}
88
89fn flick(amount: i32, p: &WheelParams, rng: &mut impl Rng) -> Vec<(i32, Duration)> {
90    let mut left = amount.max(0);
91    let mut out = Vec::new();
92    for i in 0.. {
93        if left == 0 {
94            break;
95        }
96        let want = match i {
97            0 => p.accel_first_px,
98            1 => rng.gen_range(p.accel_second_px.clone()),
99            2 => rng.gen_range(p.accel_third_px.clone()),
100            _ => log_normal(p.accel_median_px, p.accel_sigma, rng).min(p.accel_max_px) as i32,
101        };
102        let step = want.max(1).min(left);
103        left -= step;
104        out.push((
105            step,
106            Duration::from_millis(rng.gen_range(p.accel_gap_ms.clone())),
107        ));
108    }
109    out
110}
111
112fn notches(amount: i32, p: &WheelParams, rng: &mut impl Rng) -> Vec<(i32, Duration)> {
113    let mut left = amount.max(0);
114    let mut out = Vec::new();
115    while left > 0 {
116        let j = p.notch_jitter_px;
117        let notch = (p.notch_px + rng.gen_range(-j..=j)).max(1);
118        let step = notch.min(left);
119        left -= step;
120        out.push((
121            step,
122            Duration::from_millis(rng.gen_range(p.notch_gap_ms.clone())),
123        ));
124    }
125    out
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use rand::rngs::StdRng;
132    use rand::SeedableRng;
133
134    fn rng(seed: u64) -> StdRng {
135        StdRng::seed_from_u64(seed)
136    }
137
138    #[test]
139    fn notches_add_up_to_amount() {
140        let p = WheelParams {
141            notch_jitter_px: 15,
142            ..WheelParams::default()
143        };
144        for (seed, amount) in [(1, 300), (2, 1), (3, 2_000), (4, 99)] {
145            let steps = wheel_events(WheelStyle::Notched, amount, &p, &mut rng(seed));
146            assert_eq!(
147                steps.iter().map(|s| s.0).sum::<i32>(),
148                amount,
149                "seed {seed}"
150            );
151            assert!(steps.iter().all(|s| s.0 > 0 && s.0 <= p.notch_px + 15));
152        }
153        let big = wheel_events(WheelStyle::Notched, 2_000, &p, &mut rng(9));
154        let sizes: std::collections::HashSet<i32> = big.iter().map(|s| s.0).collect();
155        assert!(sizes.len() > 3, "every notch the same: {sizes:?}");
156    }
157
158    #[test]
159    fn default_notches_are_exact() {
160        let steps = wheel_events(
161            WheelStyle::Notched,
162            450,
163            &WheelParams::default(),
164            &mut rng(1),
165        );
166        let sizes: Vec<i32> = steps.iter().map(|s| s.0).collect();
167        assert_eq!(sizes, vec![100, 100, 100, 100, 50]);
168    }
169
170    #[test]
171    fn mac_flick_ramps_up_and_adds_up() {
172        let p = WheelParams::default();
173        for (seed, amount) in [(1, 2_000), (2, 300), (3, 5_000), (4, 1)] {
174            let ev = wheel_events(WheelStyle::MacAccelerated, amount, &p, &mut rng(seed));
175            assert_eq!(ev.iter().map(|e| e.0).sum::<i32>(), amount, "seed {seed}");
176            assert_eq!(ev[0].0, p.accel_first_px.min(amount));
177        }
178        let ev = wheel_events(WheelStyle::MacAccelerated, 5_000, &p, &mut rng(9));
179        assert!(
180            ev.iter().any(|e| e.0 > 200),
181            "no large delta — no acceleration"
182        );
183        assert!(ev
184            .iter()
185            .all(|e| p.accel_gap_ms.contains(&(e.1.as_millis() as u64))));
186    }
187
188    #[test]
189    fn mac_distance_varies_notched_does_not() {
190        let p = WheelParams::default();
191        let mut r = rng(11);
192        let d: Vec<i32> = (0..50)
193            .map(|_| scroll_distance(WheelStyle::MacAccelerated, 600, &p, &mut r))
194            .collect();
195        assert!(d.iter().all(|x| (570..=630).contains(x)), "{d:?}");
196        assert!(d.iter().any(|&x| x != 600));
197        assert_eq!(scroll_distance(WheelStyle::Notched, 600, &p, &mut r), 600);
198    }
199}