Skip to main content

vs_humanize/
scroll.rs

1//! Wheel scroll synthesis.
2//!
3//! `Human`: split a large scroll into many small wheel events with
4//! an exponential decay (initial wheel turns are big, late wheels
5//! taper as the user "arrives") and 16–32 ms gaps. The decay matches
6//! real trackpad inertia.
7//!
8//! `Careful`: one wheel event carrying the entire delta.
9//!
10//! `Robotic`: empty vec — engine falls back to `Element.scrollBy` or
11//! similar JS dispatch.
12
13use std::time::Duration;
14
15use crate::rng::Rng;
16use crate::InputMode;
17
18/// 2D vector (delta).
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Vec2 {
21    pub x: f64,
22    pub y: f64,
23}
24
25/// One wheel-tick event.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct WheelStep {
28    pub at: Duration,
29    pub delta: Vec2,
30}
31
32// --- Tuning ---
33
34/// Minimum / maximum gap between wheel events in Human mode (ms).
35const HUMAN_GAP_MIN_MS: f64 = 16.0;
36const HUMAN_GAP_MAX_MS: f64 = 32.0;
37
38/// Inertia decay factor per tick. Each successive tick carries
39/// `prev * decay` of the remaining delta. With `decay = 0.55`, a
40/// large scroll burns down in ~8 ticks.
41const HUMAN_DECAY: f64 = 0.55;
42
43/// Cap on the number of inertia ticks. Prevents the synthesizer
44/// from emitting hundreds of micro-events on a pathologically large
45/// scroll request.
46const HUMAN_MAX_TICKS: usize = 24;
47
48/// Pixel threshold below which we stop emitting ticks and stuff any
49/// residual into the final event. Avoids the "fractional pixel
50/// forever" tail of pure exponential decay.
51const RESIDUAL_THRESHOLD_PX: f64 = 1.0;
52
53/// Synthesize a scroll sequence delivering total `delta` over time.
54///
55/// Returns events in chronological order; the sum of all `delta`
56/// values equals the requested total (modulo rounding to whole
57/// pixels in the last step).
58///
59/// - `InputMode::Human`: exponentially-decaying inertia ticks with
60///   randomized 16–32 ms gaps. Total tick count bounded.
61/// - `InputMode::Careful`: one event at `at = 0` carrying the full
62///   delta.
63/// - `InputMode::Robotic`: empty vec.
64#[must_use]
65pub fn scroll_sequence(delta: Vec2, mode: InputMode, seed: u64) -> Vec<WheelStep> {
66    match mode {
67        InputMode::Robotic => Vec::new(),
68        InputMode::Careful => careful_sequence(delta),
69        InputMode::Human => human_sequence(delta, seed),
70    }
71}
72
73fn careful_sequence(delta: Vec2) -> Vec<WheelStep> {
74    vec![WheelStep {
75        at: Duration::ZERO,
76        delta,
77    }]
78}
79
80fn human_sequence(delta: Vec2, seed: u64) -> Vec<WheelStep> {
81    if delta.x.abs() < f64::EPSILON && delta.y.abs() < f64::EPSILON {
82        return Vec::new();
83    }
84    let mut rng = Rng::seed_from_u64(seed);
85    let mut out: Vec<WheelStep> = Vec::new();
86    let mut remaining = delta;
87    let mut t_ms = 0.0;
88
89    for i in 0..HUMAN_MAX_TICKS {
90        let weight = if i == 0 {
91            1.0 - HUMAN_DECAY
92        } else {
93            (1.0 - HUMAN_DECAY) * (1.0 - residual_fraction(&remaining, &delta))
94        };
95        let mut step = Vec2 {
96            x: remaining.x * (1.0 - HUMAN_DECAY).max(weight),
97            y: remaining.y * (1.0 - HUMAN_DECAY).max(weight),
98        };
99        // If residual is tiny, dump everything into this last step
100        // and exit the loop on the next iteration check.
101        let residual_mag = (remaining.x * remaining.x + remaining.y * remaining.y).sqrt();
102        if residual_mag <= RESIDUAL_THRESHOLD_PX {
103            step = remaining;
104        }
105        out.push(WheelStep {
106            at: ms(t_ms),
107            delta: step,
108        });
109        remaining = Vec2 {
110            x: remaining.x - step.x,
111            y: remaining.y - step.y,
112        };
113        let residual_mag2 = (remaining.x * remaining.x + remaining.y * remaining.y).sqrt();
114        if residual_mag2 <= RESIDUAL_THRESHOLD_PX {
115            // Fold any leftover sub-pixel residual into the previous
116            // step to ensure the sum is exact.
117            if let Some(last) = out.last_mut() {
118                last.delta.x += remaining.x;
119                last.delta.y += remaining.y;
120            }
121            break;
122        }
123        t_ms += rng.next_uniform(HUMAN_GAP_MIN_MS, HUMAN_GAP_MAX_MS);
124    }
125    out
126}
127
128fn residual_fraction(remaining: &Vec2, total: &Vec2) -> f64 {
129    let r = (remaining.x * remaining.x + remaining.y * remaining.y).sqrt();
130    let t = (total.x * total.x + total.y * total.y).sqrt();
131    if t <= f64::EPSILON {
132        0.0
133    } else {
134        r / t
135    }
136}
137
138fn ms(value: f64) -> Duration {
139    let v = value.max(0.0).round();
140    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
141    let ms_int = v as u64;
142    Duration::from_millis(ms_int)
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn robotic_is_empty() {
151        let steps = scroll_sequence(Vec2 { x: 0.0, y: 600.0 }, InputMode::Robotic, 0);
152        assert!(steps.is_empty());
153    }
154
155    #[test]
156    fn careful_is_single_event_with_full_delta() {
157        let d = Vec2 { x: 0.0, y: 800.0 };
158        let steps = scroll_sequence(d, InputMode::Careful, 0);
159        assert_eq!(steps.len(), 1);
160        assert_eq!(steps[0].delta, d);
161        assert_eq!(steps[0].at, Duration::ZERO);
162    }
163
164    #[test]
165    fn human_empty_delta_yields_empty_sequence() {
166        let steps = scroll_sequence(Vec2 { x: 0.0, y: 0.0 }, InputMode::Human, 7);
167        assert!(steps.is_empty());
168    }
169
170    #[test]
171    fn human_emits_multiple_ticks() {
172        let steps = scroll_sequence(Vec2 { x: 0.0, y: 800.0 }, InputMode::Human, 42);
173        assert!(
174            steps.len() >= 3,
175            "expected several inertia ticks; got {}",
176            steps.len()
177        );
178        assert!(
179            steps.len() <= HUMAN_MAX_TICKS,
180            "tick cap violated: {}",
181            steps.len()
182        );
183    }
184
185    #[test]
186    fn human_sum_equals_requested_delta() {
187        let d = Vec2 { x: 0.0, y: 800.0 };
188        let steps = scroll_sequence(d, InputMode::Human, 42);
189        let sum_x: f64 = steps.iter().map(|s| s.delta.x).sum();
190        let sum_y: f64 = steps.iter().map(|s| s.delta.y).sum();
191        assert!((sum_x - d.x).abs() < 0.5);
192        assert!((sum_y - d.y).abs() < 0.5);
193    }
194
195    #[test]
196    fn human_inertia_first_tick_largest() {
197        // Decay model means the magnitude of the first tick exceeds
198        // the magnitude of the last tick (which carries the small
199        // residual or a small final fraction).
200        let steps = scroll_sequence(Vec2 { x: 0.0, y: 1200.0 }, InputMode::Human, 42);
201        assert!(steps.len() >= 2);
202        let first_mag = steps[0].delta.y.abs();
203        let last_mag = steps.last().unwrap().delta.y.abs();
204        assert!(
205            first_mag >= last_mag,
206            "expected first tick magnitude {first_mag} >= last tick magnitude {last_mag}"
207        );
208    }
209
210    #[test]
211    fn human_is_deterministic_under_seed() {
212        let a = scroll_sequence(Vec2 { x: 0.0, y: 1000.0 }, InputMode::Human, 99);
213        let b = scroll_sequence(Vec2 { x: 0.0, y: 1000.0 }, InputMode::Human, 99);
214        assert_eq!(a, b);
215    }
216
217    #[test]
218    fn human_steps_monotonic_in_time() {
219        let steps = scroll_sequence(Vec2 { x: 0.0, y: 1000.0 }, InputMode::Human, 99);
220        for w in steps.windows(2) {
221            assert!(w[0].at <= w[1].at);
222        }
223    }
224}