1use std::time::Duration;
26
27use crate::rng::Rng;
28use crate::InputMode;
29
30#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Point {
34 pub x: f64,
35 pub y: f64,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum MouseButton {
43 Left,
44 Middle,
45 Right,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MouseStepKind {
51 Move,
53 Down,
55 Up,
57 Click,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct MouseStep {
66 pub at: Duration,
68 pub point: Point,
70 pub kind: MouseStepKind,
72 pub button: MouseButton,
74}
75
76const FITTS_A_MS: f64 = 100.0;
81
82const FITTS_B_MS: f64 = 150.0;
84
85const FITTS_TARGET_WIDTH_PX: f64 = 32.0;
90
91const HUMAN_MOVE_CAP_MS: f64 = 1200.0;
95
96const SAMPLE_PERIOD_MS: f64 = 16.0;
99
100const OVERSHOOT_PX: f64 = 5.0;
103
104const BEZIER_PERP_MIN: f64 = 0.05;
107const BEZIER_PERP_MAX: f64 = 0.25;
108
109const HOVER_MIN_MS: f64 = 80.0;
113const HOVER_MAX_MS: f64 = 250.0;
114
115const PRESS_MIN_MS: f64 = 30.0;
117const PRESS_MAX_MS: f64 = 90.0;
118
119#[must_use]
136pub fn mouse_path(start: Point, end: Point, mode: InputMode, seed: u64) -> Vec<MouseStep> {
137 match mode {
138 InputMode::Robotic => Vec::new(),
139 InputMode::Careful => careful_path(end),
140 InputMode::Human => human_path(start, end, seed),
141 }
142}
143
144fn careful_path(end: Point) -> Vec<MouseStep> {
145 let zero = Duration::ZERO;
146 vec![
147 MouseStep {
148 at: zero,
149 point: end,
150 kind: MouseStepKind::Move,
151 button: MouseButton::Left,
152 },
153 MouseStep {
154 at: zero,
155 point: end,
156 kind: MouseStepKind::Down,
157 button: MouseButton::Left,
158 },
159 MouseStep {
160 at: zero,
161 point: end,
162 kind: MouseStepKind::Up,
163 button: MouseButton::Left,
164 },
165 MouseStep {
166 at: zero,
167 point: end,
168 kind: MouseStepKind::Click,
169 button: MouseButton::Left,
170 },
171 ]
172}
173
174#[allow(clippy::too_many_lines)]
175fn human_path(start: Point, end: Point, seed: u64) -> Vec<MouseStep> {
176 let mut rng = Rng::seed_from_u64(seed);
177
178 let dx = end.x - start.x;
179 let dy = end.y - start.y;
180 let distance = (dx * dx + dy * dy).sqrt();
181
182 let id = (distance / FITTS_TARGET_WIDTH_PX + 1.0).log2();
184 let mut total_ms = FITTS_A_MS + FITTS_B_MS * id;
185 if total_ms > HUMAN_MOVE_CAP_MS {
186 total_ms = HUMAN_MOVE_CAP_MS;
187 }
188
189 let perp_sign = if rng.next_f64() < 0.5 { -1.0 } else { 1.0 };
194 let perp_mag_a = distance * rng.next_uniform(BEZIER_PERP_MIN, BEZIER_PERP_MAX);
195 let perp_mag_b = distance * rng.next_uniform(BEZIER_PERP_MIN, BEZIER_PERP_MAX);
196 let (perp_x, perp_y) = if distance > f64::EPSILON {
197 let inv = 1.0 / distance;
198 (-dy * inv, dx * inv) } else {
200 (0.0, 0.0)
201 };
202 let cp1 = Point {
203 x: start.x + dx / 3.0 + perp_sign * perp_mag_a * perp_x,
204 y: start.y + dy / 3.0 + perp_sign * perp_mag_a * perp_y,
205 };
206 let cp2 = Point {
207 x: start.x + 2.0 * dx / 3.0 + perp_sign * perp_mag_b * perp_x,
208 y: start.y + 2.0 * dy / 3.0 + perp_sign * perp_mag_b * perp_y,
209 };
210
211 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
213 let n_steps = ((total_ms / SAMPLE_PERIOD_MS).ceil() as usize).max(1);
214 let mut out: Vec<MouseStep> = Vec::with_capacity(n_steps + 4);
215
216 for i in 0..=n_steps {
217 #[allow(clippy::cast_precision_loss)]
218 let t = (i as f64) / (n_steps as f64);
219 let p = cubic_bezier(start, cp1, cp2, end, t);
220 let p = if distance > 16.0 && t > 0.85 {
224 let overshoot_t = (t - 0.85) / 0.15; let lobe = 4.0 * overshoot_t * (1.0 - overshoot_t); let inv = 1.0 / distance;
227 Point {
228 x: p.x + lobe * OVERSHOOT_PX * dx * inv,
229 y: p.y + lobe * OVERSHOOT_PX * dy * inv,
230 }
231 } else {
232 p
233 };
234 let at_ms = total_ms * t;
235 out.push(MouseStep {
236 at: ms(at_ms),
237 point: p,
238 kind: MouseStepKind::Move,
239 button: MouseButton::Left,
240 });
241 }
242
243 let hover_ms = rng.next_uniform(HOVER_MIN_MS, HOVER_MAX_MS);
245 let press_ms = rng.next_uniform(PRESS_MIN_MS, PRESS_MAX_MS);
246 let down_at = total_ms + hover_ms;
247 let up_at = down_at + press_ms;
248
249 out.push(MouseStep {
250 at: ms(down_at),
251 point: end,
252 kind: MouseStepKind::Down,
253 button: MouseButton::Left,
254 });
255 out.push(MouseStep {
256 at: ms(up_at),
257 point: end,
258 kind: MouseStepKind::Up,
259 button: MouseButton::Left,
260 });
261 out.push(MouseStep {
262 at: ms(up_at),
263 point: end,
264 kind: MouseStepKind::Click,
265 button: MouseButton::Left,
266 });
267
268 out
269}
270
271fn cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
272 let u = 1.0 - t;
273 let b0 = u * u * u;
274 let b1 = 3.0 * u * u * t;
275 let b2 = 3.0 * u * t * t;
276 let b3 = t * t * t;
277 Point {
278 x: b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x,
279 y: b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y,
280 }
281}
282
283fn ms(value: f64) -> Duration {
284 let v = value.max(0.0).round();
288 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
289 let ms_int = v as u64;
290 Duration::from_millis(ms_int)
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 const ORIGIN: Point = Point { x: 0.0, y: 0.0 };
298 const FAR: Point = Point { x: 800.0, y: 600.0 };
299
300 #[test]
301 fn robotic_is_empty() {
302 let steps = mouse_path(ORIGIN, FAR, InputMode::Robotic, 0);
303 assert!(steps.is_empty());
304 }
305
306 #[test]
307 fn careful_is_four_steps_at_endpoint() {
308 let steps = mouse_path(ORIGIN, FAR, InputMode::Careful, 0);
309 assert_eq!(steps.len(), 4);
310 for s in &steps {
311 assert_eq!(s.point, FAR);
312 assert_eq!(s.at, Duration::ZERO);
313 assert_eq!(s.button, MouseButton::Left);
314 }
315 assert_eq!(steps[0].kind, MouseStepKind::Move);
316 assert_eq!(steps[1].kind, MouseStepKind::Down);
317 assert_eq!(steps[2].kind, MouseStepKind::Up);
318 assert_eq!(steps[3].kind, MouseStepKind::Click);
319 }
320
321 #[test]
322 fn human_starts_at_start_ends_at_end() {
323 let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
324 let first = steps.first().expect("non-empty");
325 let last_move = steps
326 .iter()
327 .rev()
328 .find(|s| s.kind == MouseStepKind::Move)
329 .expect("at least one move");
330 assert!((first.point.x - ORIGIN.x).abs() < 1.0);
332 assert!((first.point.y - ORIGIN.y).abs() < 1.0);
333 assert!(
335 (last_move.point.x - FAR.x).abs() < OVERSHOOT_PX + 1.0,
336 "last move x off from end: {} vs {}",
337 last_move.point.x,
338 FAR.x
339 );
340 assert!(
341 (last_move.point.y - FAR.y).abs() < OVERSHOOT_PX + 1.0,
342 "last move y off from end: {} vs {}",
343 last_move.point.y,
344 FAR.y
345 );
346 }
347
348 #[test]
349 fn human_emits_many_moves_for_long_distance() {
350 let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
351 let moves = steps
352 .iter()
353 .filter(|s| s.kind == MouseStepKind::Move)
354 .count();
355 assert!(moves > 20, "expected many move samples; got {moves}");
359 }
360
361 #[test]
362 fn human_click_sequence_present() {
363 let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
364 let kinds: Vec<MouseStepKind> = steps.iter().map(|s| s.kind).collect();
365 assert!(kinds.contains(&MouseStepKind::Down));
366 assert!(kinds.contains(&MouseStepKind::Up));
367 assert!(kinds.contains(&MouseStepKind::Click));
368 let down = kinds
370 .iter()
371 .position(|k| *k == MouseStepKind::Down)
372 .unwrap();
373 let up = kinds.iter().position(|k| *k == MouseStepKind::Up).unwrap();
374 let click = kinds
375 .iter()
376 .position(|k| *k == MouseStepKind::Click)
377 .unwrap();
378 assert!(down < up, "down must precede up");
379 assert!(up <= click, "up must precede or coincide with click");
380 }
381
382 #[test]
383 fn human_zero_distance_still_produces_click() {
384 let steps = mouse_path(ORIGIN, ORIGIN, InputMode::Human, 7);
385 let kinds: Vec<MouseStepKind> = steps.iter().map(|s| s.kind).collect();
387 assert!(kinds.contains(&MouseStepKind::Down));
388 assert!(kinds.contains(&MouseStepKind::Up));
389 assert!(kinds.contains(&MouseStepKind::Click));
390 }
391
392 #[test]
393 fn human_is_deterministic_under_seed() {
394 let a = mouse_path(ORIGIN, FAR, InputMode::Human, 1234);
395 let b = mouse_path(ORIGIN, FAR, InputMode::Human, 1234);
396 assert_eq!(a, b);
397 }
398
399 #[test]
400 fn human_seed_change_changes_path() {
401 let a = mouse_path(ORIGIN, FAR, InputMode::Human, 1);
402 let b = mouse_path(ORIGIN, FAR, InputMode::Human, 2);
403 let mid_a = a[a.len() / 2].point;
407 let mid_b = b[b.len() / 2].point;
408 let diff = ((mid_a.x - mid_b.x).powi(2) + (mid_a.y - mid_b.y).powi(2)).sqrt();
409 assert!(
410 diff > 1.0,
411 "paths suspiciously identical: {mid_a:?} vs {mid_b:?}"
412 );
413 }
414
415 #[test]
416 fn human_total_duration_capped() {
417 let far = Point {
419 x: 100_000.0,
420 y: 0.0,
421 };
422 let steps = mouse_path(ORIGIN, far, InputMode::Human, 0);
423 let last_click = steps
424 .iter()
425 .rev()
426 .find(|s| s.kind == MouseStepKind::Click)
427 .expect("click present");
428 assert!(
431 last_click.at.as_millis() <= 1540,
432 "total duration not capped: {}ms",
433 last_click.at.as_millis()
434 );
435 }
436
437 #[test]
438 fn human_steps_monotonic_in_time() {
439 let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 99);
440 let times: Vec<u128> = steps.iter().map(|s| s.at.as_millis()).collect();
441 for w in times.windows(2) {
442 assert!(w[0] <= w[1], "times not monotonic: {w:?}");
443 }
444 }
445
446 #[test]
447 fn human_path_stays_within_overshoot_bound() {
448 let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
451 let max_excursion = OVERSHOOT_PX + BEZIER_PERP_MAX * 1000.0; for s in &steps {
453 if s.kind != MouseStepKind::Move {
454 continue;
455 }
456 let dx = FAR.x - ORIGIN.x;
458 let dy = FAR.y - ORIGIN.y;
459 let len = (dx * dx + dy * dy).sqrt();
460 let perp = ((s.point.x - ORIGIN.x) * (-dy) + (s.point.y - ORIGIN.y) * dx).abs() / len;
461 assert!(
462 perp < max_excursion + 2.0,
463 "point too far from direct line: {perp}px > {}",
464 max_excursion + 2.0
465 );
466 }
467 }
468}