1use std::time::Duration;
14
15use crate::rng::Rng;
16use crate::InputMode;
17
18#[derive(Debug, Clone, Copy, PartialEq)]
20pub struct Vec2 {
21 pub x: f64,
22 pub y: f64,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct WheelStep {
28 pub at: Duration,
29 pub delta: Vec2,
30}
31
32const HUMAN_GAP_MIN_MS: f64 = 16.0;
36const HUMAN_GAP_MAX_MS: f64 = 32.0;
37
38const HUMAN_DECAY: f64 = 0.55;
42
43const HUMAN_MAX_TICKS: usize = 24;
47
48const RESIDUAL_THRESHOLD_PX: f64 = 1.0;
52
53#[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 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 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 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}