Skip to main content

vs_humanize/
mouse.rs

1//! Mouse path synthesis.
2//!
3//! `Human`: cubic Bezier from start to end with two perpendicular
4//! control points whose magnitude is a fraction of the path length;
5//! sampled at 16 ms (≈ 60 Hz). Total duration follows Fitts' law
6//! (Fitts, 1954) with a fixed target width — the function signature
7//! doesn't expose width, so we use a conservative default that
8//! approximates a typical clickable region.
9//!
10//! `Careful`: a single `Move` at the endpoint followed by the click
11//! triple (`Down`, `Up`, `Click`). No path; the engine's trusted
12//! dispatch handles the rest.
13//!
14//! `Robotic`: empty `Vec`. Engine falls back to JS synthetic
15//! dispatch.
16//!
17//! Bezier control-point construction picks the perpendicular offset
18//! direction from a coin flip and the magnitude from a uniform draw
19//! in [0.05·L, 0.25·L] — gives a visible curve without veering wildly
20//! off-axis. Overshoot is implemented by carrying the path 5 px past
21//! the endpoint along the direct-line direction then returning, which
22//! shows up in the sampled sequence as a brief reverse `Move` near
23//! the end.
24
25use std::time::Duration;
26
27use crate::rng::Rng;
28use crate::InputMode;
29
30/// 2D point in CSS pixels. The caller picks the coordinate frame
31/// (page-local or viewport-local); we just emit numbers.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Point {
34    pub x: f64,
35    pub y: f64,
36}
37
38/// Which physical button the step refers to. Vibesurfer only uses
39/// `Left` today, but `Middle` and `Right` are exposed so future
40/// primitives don't need a wire-format break.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum MouseButton {
43    Left,
44    Middle,
45    Right,
46}
47
48/// What kind of mouse event this step is.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum MouseStepKind {
51    /// Pointer movement only — no button change.
52    Move,
53    /// Button press.
54    Down,
55    /// Button release.
56    Up,
57    /// Logical click (DOM `click` event). Some engines synthesize
58    /// this from a `Down`/`Up` pair; emitting it explicitly lets the
59    /// engine choose.
60    Click,
61}
62
63/// One mouse event in a synthesized sequence.
64#[derive(Debug, Clone, Copy, PartialEq)]
65pub struct MouseStep {
66    /// Time since the start of the sequence.
67    pub at: Duration,
68    /// Coordinates at this instant.
69    pub point: Point,
70    /// Event kind.
71    pub kind: MouseStepKind,
72    /// Which button (only meaningful for `Down` / `Up` / `Click`).
73    pub button: MouseButton,
74}
75
76// --- Tuning constants ---
77
78/// Fitts' law intercept: time even a zero-distance move requires
79/// before the click sequence can start. Includes neural latency.
80const FITTS_A_MS: f64 = 100.0;
81
82/// Fitts' law slope: ms per bit of index-of-difficulty.
83const FITTS_B_MS: f64 = 150.0;
84
85/// Assumed target width for Fitts ID computation. We don't take it
86/// as a parameter because the engine doesn't always know the target
87/// rect when this function is called. 32 px is the typical clickable
88/// target on a desktop site (a button, an icon).
89const FITTS_TARGET_WIDTH_PX: f64 = 32.0;
90
91/// Hard cap on Bezier path total duration. PR 5 in M7 will surface a
92/// `?slow_dispatch` warning when this cap is hit; the math here only
93/// enforces it as a saturation.
94const HUMAN_MOVE_CAP_MS: f64 = 1200.0;
95
96/// Sampling period — 16 ms is roughly 60 Hz, which matches the
97/// dispatch rate of most production trackpads / mice.
98const SAMPLE_PERIOD_MS: f64 = 16.0;
99
100/// Overshoot distance in CSS pixels for human paths — see module
101/// docs for rationale.
102const OVERSHOOT_PX: f64 = 5.0;
103
104/// Range (in fractions of path length) for Bezier control-point
105/// perpendicular offset magnitude.
106const BEZIER_PERP_MIN: f64 = 0.05;
107const BEZIER_PERP_MAX: f64 = 0.25;
108
109/// Human pre-click hover range (ms). The mouse rests on the target
110/// briefly before the press — common in real users picking out a
111/// small element.
112const HOVER_MIN_MS: f64 = 80.0;
113const HOVER_MAX_MS: f64 = 250.0;
114
115/// Press-to-release dwell range (ms).
116const PRESS_MIN_MS: f64 = 30.0;
117const PRESS_MAX_MS: f64 = 90.0;
118
119/// Synthesize a left-click mouse path from `start` to `end`.
120///
121/// Returns the event sequence in chronological order. Each step's
122/// `at` is offset from time-zero (the call instant on the caller's
123/// side).
124///
125/// - `InputMode::Human`: Bezier path sampled at 16 ms, Fitts-bounded
126///   total duration capped at 1.2 s, overshoot + correction near the
127///   endpoint, hover-then-press-then-release-then-click ending.
128/// - `InputMode::Careful`: one `Move` at `end`, then `Down`/`Up`/
129///   `Click` at the same point with no delay between them.
130/// - `InputMode::Robotic`: empty vec.
131///
132/// `seed` selects the deterministic stream of random choices (control
133/// point offset direction, hover duration, press dwell, overshoot
134/// jitter). Same seed + same inputs produces a bit-identical output.
135#[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    // Fitts' law: index of difficulty + linear arrival model.
183    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    // Bezier control points: pick the perpendicular direction from a
190    // coin flip on the first random draw, magnitude from a uniform
191    // draw. Magnitudes can differ between the two control points so
192    // the curve isn't artificially symmetric.
193    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) // perpendicular to (dx, dy)
199    } 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    // Sample at ≥1 step so even a sub-16ms movement has one frame.
212    #[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        // Overshoot ramp: push slightly past `end` along the direct
221        // direction near the tail of the path, then correct. Only
222        // applied for paths of meaningful length.
223        let p = if distance > 16.0 && t > 0.85 {
224            let overshoot_t = (t - 0.85) / 0.15; // 0..1 within tail
225            let lobe = 4.0 * overshoot_t * (1.0 - overshoot_t); // 0..1..0 triangle
226            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    // Hover at the endpoint.
244    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    // Clamp negative inputs to zero (Duration is unsigned) and round
285    // to whole milliseconds — sub-ms timing is not meaningful given
286    // the platforms' event-loop granularity.
287    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        // Bezier path starts at `start` exactly at t=0.
331        assert!((first.point.x - ORIGIN.x).abs() < 1.0);
332        assert!((first.point.y - ORIGIN.y).abs() < 1.0);
333        // Last `Move` is near the endpoint (overshoot returns to it).
334        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        // Long-distance Fitts ID ~ log2(1000/32 + 1) ≈ 5 → ~850ms /
356        // 16ms ≈ 53 samples. Allow significant slack; just assert the
357        // count is "many" so a regression to single-step is caught.
358        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        // Down must come before Up which must come before Click.
369        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        // Even a zero-distance hover should yield the click triple.
386        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        // Total step count or some intermediate point should differ.
404        // Compare the midpoint of each path; they'd only coincide on
405        // accident.
406        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        // Pathological distance: Fitts ID would blow past the cap.
418        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        // Total = move cap + hover + press, with hover ≤ 250ms and
429        // press ≤ 90ms, so ≤ 1200 + 250 + 90 = 1540ms.
430        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        // No sampled point should exceed the line bounding box by
449        // more than overshoot + perp_max·distance.
450        let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
451        let max_excursion = OVERSHOOT_PX + BEZIER_PERP_MAX * 1000.0; // distance ~1000
452        for s in &steps {
453            if s.kind != MouseStepKind::Move {
454                continue;
455            }
456            // Distance from the direct line: project onto perpendicular.
457            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}