Skip to main content

chromiumoxide/
mouse.rs

1//! Smart mouse movement with human-like bezier curves and position tracking.
2//!
3//! This module provides realistic mouse movement simulation using cubic bezier
4//! curves, configurable jitter, overshoot, and easing. The [`SmartMouse`] struct
5//! tracks the current mouse position across operations so that every movement
6//! starts from where the cursor actually is.
7
8use crate::layout::Point;
9use rand::RngExt;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::Duration;
12
13/// Configuration for smart mouse movement behavior.
14#[derive(Debug, Clone)]
15pub struct SmartMouseConfig {
16    /// Number of intermediate steps for movement (higher = smoother). Default: 25.
17    pub steps: usize,
18    /// Overshoot factor past the target (0.0 = none, 1.0 = full distance). Default: 0.15.
19    pub overshoot: f64,
20    /// Per-step jitter in CSS pixels. Default: 1.5.
21    pub jitter: f64,
22    /// Base delay between movement steps in milliseconds. Default: 8.
23    pub step_delay_ms: u64,
24    /// Whether to apply ease-in-out timing (acceleration/deceleration). Default: true.
25    pub easing: bool,
26}
27
28impl Default for SmartMouseConfig {
29    fn default() -> Self {
30        Self {
31            steps: 25,
32            overshoot: 0.15,
33            jitter: 1.5,
34            step_delay_ms: 8,
35            easing: true,
36        }
37    }
38}
39
40/// A single step in a mouse movement path.
41#[derive(Debug, Clone)]
42pub struct MovementStep {
43    /// The point to move to.
44    pub point: Point,
45    /// Delay before dispatching the next step.
46    pub delay: Duration,
47}
48
49/// Evaluate a cubic bezier curve at parameter `t` in [0, 1].
50///
51/// Given control points P0, P1, P2, P3:
52/// B(t) = (1-t)³·P0 + 3(1-t)²·t·P1 + 3(1-t)·t²·P2 + t³·P3
53fn cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
54    let inv = 1.0 - t;
55    let inv2 = inv * inv;
56    let inv3 = inv2 * inv;
57    let t2 = t * t;
58    let t3 = t2 * t;
59
60    Point {
61        x: inv3 * p0.x + 3.0 * inv2 * t * p1.x + 3.0 * inv * t2 * p2.x + t3 * p3.x,
62        y: inv3 * p0.y + 3.0 * inv2 * t * p1.y + 3.0 * inv * t2 * p2.y + t3 * p3.y,
63    }
64}
65
66/// Ease-in-out cubic function for natural acceleration/deceleration.
67fn ease_in_out(t: f64) -> f64 {
68    if t < 0.5 {
69        4.0 * t * t * t
70    } else {
71        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
72    }
73}
74
75/// Generate a human-like mouse movement path from `from` to `to`.
76///
77/// Returns a series of [`MovementStep`]s that, when dispatched sequentially,
78/// produce a realistic-looking cursor trajectory with natural timing.
79pub fn generate_path(from: Point, to: Point, config: &SmartMouseConfig) -> Vec<MovementStep> {
80    let mut rng = rand::rng();
81    let steps = config.steps.max(2);
82
83    let dx = to.x - from.x;
84    let dy = to.y - from.y;
85    let distance = (dx * dx + dy * dy).sqrt();
86
87    // For very short moves, just go directly
88    if distance < 2.0 {
89        return vec![MovementStep {
90            point: to,
91            delay: Duration::from_millis(config.step_delay_ms),
92        }];
93    }
94
95    // Perpendicular unit vector for control point offsets
96    let (perp_x, perp_y) = if distance > 0.001 {
97        (-dy / distance, dx / distance)
98    } else {
99        (0.0, 1.0)
100    };
101
102    // Random control point offsets (curved path)
103    let spread = distance * 0.3;
104    let offset1: f64 = rng.random_range(-spread..spread);
105    let offset2: f64 = rng.random_range(-spread..spread);
106
107    let cp1 = Point {
108        x: from.x + dx * 0.25 + perp_x * offset1,
109        y: from.y + dy * 0.25 + perp_y * offset1,
110    };
111    let cp2 = Point {
112        x: from.x + dx * 0.75 + perp_x * offset2,
113        y: from.y + dy * 0.75 + perp_y * offset2,
114    };
115
116    // Determine whether to overshoot
117    let should_overshoot = config.overshoot > 0.0 && distance > 10.0;
118
119    let overshoot_target = if should_overshoot {
120        let overshoot_amount = distance * config.overshoot * rng.random_range(0.5..1.5);
121        Point {
122            x: to.x + (dx / distance) * overshoot_amount,
123            y: to.y + (dy / distance) * overshoot_amount,
124        }
125    } else {
126        to
127    };
128
129    let main_steps = if should_overshoot {
130        (steps as f64 * 0.85) as usize
131    } else {
132        steps
133    };
134
135    let mut path = Vec::with_capacity(steps + 2);
136
137    // Main bezier path
138    let end = if should_overshoot {
139        overshoot_target
140    } else {
141        to
142    };
143
144    for i in 1..=main_steps {
145        let raw_t = i as f64 / main_steps as f64;
146        let t = if config.easing {
147            ease_in_out(raw_t)
148        } else {
149            raw_t
150        };
151
152        let mut p = cubic_bezier(from, cp1, cp2, end, t);
153
154        // Add jitter except near the end
155        if config.jitter > 0.0 && i < main_steps.saturating_sub(2) {
156            p.x += rng.random_range(-config.jitter..config.jitter);
157            p.y += rng.random_range(-config.jitter..config.jitter);
158        }
159
160        // Vary delay for natural timing
161        let delay_variation: f64 = rng.random_range(0.7..1.3);
162        let delay = Duration::from_millis((config.step_delay_ms as f64 * delay_variation) as u64);
163
164        path.push(MovementStep { point: p, delay });
165    }
166
167    // Correction steps back to actual target after overshoot
168    if should_overshoot {
169        let correction_steps = steps.saturating_sub(main_steps).max(3);
170        let last = path.last().map(|s| s.point).unwrap_or(overshoot_target);
171
172        for i in 1..=correction_steps {
173            let t = i as f64 / correction_steps as f64;
174            let t = if config.easing { ease_in_out(t) } else { t };
175
176            let p = Point {
177                x: last.x + (to.x - last.x) * t,
178                y: last.y + (to.y - last.y) * t,
179            };
180
181            let delay = Duration::from_millis((config.step_delay_ms as f64 * 0.6) as u64);
182            path.push(MovementStep { point: p, delay });
183        }
184    }
185
186    // Ensure the final point is exactly the target
187    if let Some(last) = path.last_mut() {
188        last.point = to;
189    }
190
191    path
192}
193
194/// Tracks the current mouse position and generates human-like movement paths.
195///
196/// Uses lock-free atomics for interior mutability — safe to use behind `Arc`
197/// and `&self` references without any mutex. Use this alongside CDP
198/// `Input.dispatchMouseEvent` calls so that every movement starts from
199/// the real cursor location instead of teleporting.
200pub struct SmartMouse {
201    pos_x: AtomicU64,
202    pos_y: AtomicU64,
203    config: SmartMouseConfig,
204}
205
206impl std::fmt::Debug for SmartMouse {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        let pos = self.position();
209        f.debug_struct("SmartMouse")
210            .field("position", &pos)
211            .field("config", &self.config)
212            .finish()
213    }
214}
215
216impl SmartMouse {
217    /// Create a new `SmartMouse` starting at (0, 0) with default configuration.
218    pub fn new() -> Self {
219        Self {
220            pos_x: AtomicU64::new(0.0_f64.to_bits()),
221            pos_y: AtomicU64::new(0.0_f64.to_bits()),
222            config: SmartMouseConfig::default(),
223        }
224    }
225
226    /// Create a `SmartMouse` with custom configuration.
227    pub fn with_config(config: SmartMouseConfig) -> Self {
228        Self {
229            pos_x: AtomicU64::new(0.0_f64.to_bits()),
230            pos_y: AtomicU64::new(0.0_f64.to_bits()),
231            config,
232        }
233    }
234
235    /// Get the current tracked mouse position.
236    pub fn position(&self) -> Point {
237        Point {
238            x: f64::from_bits(self.pos_x.load(Ordering::Relaxed)),
239            y: f64::from_bits(self.pos_y.load(Ordering::Relaxed)),
240        }
241    }
242
243    /// Set the mouse position directly (e.g., after a teleport or click).
244    pub fn set_position(&self, point: Point) {
245        self.pos_x.store(point.x.to_bits(), Ordering::Relaxed);
246        self.pos_y.store(point.y.to_bits(), Ordering::Relaxed);
247    }
248
249    /// Get the movement configuration.
250    pub fn config(&self) -> &SmartMouseConfig {
251        &self.config
252    }
253
254    /// Generate a movement path from the current position to `target`.
255    ///
256    /// This updates the tracked position to `target` and returns a series of
257    /// [`MovementStep`]s for dispatching intermediate `MouseMoved` events.
258    pub fn path_to(&self, target: Point) -> Vec<MovementStep> {
259        let from = self.position();
260        self.set_position(target);
261        generate_path(from, target, &self.config)
262    }
263}
264
265impl Default for SmartMouse {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_cubic_bezier_endpoints() {
277        let p0 = Point::new(0.0, 0.0);
278        let p1 = Point::new(25.0, 50.0);
279        let p2 = Point::new(75.0, 50.0);
280        let p3 = Point::new(100.0, 100.0);
281
282        let start = cubic_bezier(p0, p1, p2, p3, 0.0);
283        assert!((start.x - p0.x).abs() < 1e-10);
284        assert!((start.y - p0.y).abs() < 1e-10);
285
286        let end = cubic_bezier(p0, p1, p2, p3, 1.0);
287        assert!((end.x - p3.x).abs() < 1e-10);
288        assert!((end.y - p3.y).abs() < 1e-10);
289    }
290
291    #[test]
292    fn test_cubic_bezier_midpoint() {
293        // Straight line: control points on the line
294        let p0 = Point::new(0.0, 0.0);
295        let p1 = Point::new(33.3, 33.3);
296        let p2 = Point::new(66.6, 66.6);
297        let p3 = Point::new(100.0, 100.0);
298
299        let mid = cubic_bezier(p0, p1, p2, p3, 0.5);
300        // Should be approximately (50, 50) for a straight-line bezier
301        assert!((mid.x - 50.0).abs() < 1.0);
302        assert!((mid.y - 50.0).abs() < 1.0);
303    }
304
305    #[test]
306    fn test_ease_in_out_boundaries() {
307        assert!((ease_in_out(0.0)).abs() < 1e-10);
308        assert!((ease_in_out(1.0) - 1.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn test_ease_in_out_midpoint() {
313        let mid = ease_in_out(0.5);
314        assert!((mid - 0.5).abs() < 1e-10);
315    }
316
317    #[test]
318    fn test_ease_in_out_monotonic() {
319        let mut prev = 0.0;
320        for i in 1..=100 {
321            let t = i as f64 / 100.0;
322            let val = ease_in_out(t);
323            assert!(
324                val >= prev,
325                "ease_in_out should be monotonically increasing"
326            );
327            prev = val;
328        }
329    }
330
331    #[test]
332    fn test_generate_path_ends_at_target() {
333        let from = Point::new(10.0, 20.0);
334        let to = Point::new(500.0, 300.0);
335        let config = SmartMouseConfig::default();
336
337        let path = generate_path(from, to, &config);
338
339        assert!(!path.is_empty());
340        let last = &path.last().unwrap().point;
341        assert!(
342            (last.x - to.x).abs() < 1e-10 && (last.y - to.y).abs() < 1e-10,
343            "path must end exactly at target, got ({}, {})",
344            last.x,
345            last.y
346        );
347    }
348
349    #[test]
350    fn test_generate_path_short_distance() {
351        let from = Point::new(100.0, 100.0);
352        let to = Point::new(100.5, 100.5);
353        let config = SmartMouseConfig::default();
354
355        let path = generate_path(from, to, &config);
356
357        assert_eq!(
358            path.len(),
359            1,
360            "very short moves should produce a single step"
361        );
362        assert!((path[0].point.x - to.x).abs() < 1e-10);
363        assert!((path[0].point.y - to.y).abs() < 1e-10);
364    }
365
366    #[test]
367    fn test_generate_path_no_overshoot() {
368        let from = Point::new(0.0, 0.0);
369        let to = Point::new(200.0, 200.0);
370        let config = SmartMouseConfig {
371            overshoot: 0.0,
372            ..Default::default()
373        };
374
375        let path = generate_path(from, to, &config);
376        assert_eq!(path.len(), config.steps);
377    }
378
379    #[test]
380    fn test_generate_path_no_jitter() {
381        let from = Point::new(0.0, 0.0);
382        let to = Point::new(200.0, 200.0);
383        let config = SmartMouseConfig {
384            jitter: 0.0,
385            overshoot: 0.0,
386            easing: false,
387            ..Default::default()
388        };
389
390        // Without jitter or easing, successive runs with same from/to
391        // should produce paths that lie on a bezier curve (no random noise
392        // except from control point placement).
393        let path = generate_path(from, to, &config);
394        assert!(!path.is_empty());
395        let last = &path.last().unwrap().point;
396        assert!((last.x - to.x).abs() < 1e-10);
397        assert!((last.y - to.y).abs() < 1e-10);
398    }
399
400    #[test]
401    fn test_generate_path_step_count_with_overshoot() {
402        let from = Point::new(0.0, 0.0);
403        let to = Point::new(500.0, 500.0);
404        let config = SmartMouseConfig {
405            steps: 30,
406            overshoot: 0.2,
407            ..Default::default()
408        };
409
410        let path = generate_path(from, to, &config);
411        // With overshoot: main_steps (~85%) + correction_steps (~15%, min 3)
412        assert!(path.len() >= config.steps);
413    }
414
415    #[test]
416    fn test_generate_path_no_huge_jumps() {
417        let from = Point::new(0.0, 0.0);
418        let to = Point::new(300.0, 300.0);
419        let config = SmartMouseConfig {
420            steps: 50,
421            overshoot: 0.0,
422            jitter: 0.0,
423            ..Default::default()
424        };
425
426        let path = generate_path(from, to, &config);
427
428        let mut prev = from;
429        let max_distance = (300.0_f64 * 300.0 + 300.0 * 300.0).sqrt(); // total distance
430
431        for step in &path {
432            let dx = step.point.x - prev.x;
433            let dy = step.point.y - prev.y;
434            let step_dist = (dx * dx + dy * dy).sqrt();
435            // No single step should jump more than half the total distance
436            assert!(
437                step_dist < max_distance * 0.6,
438                "step jumped {} pixels (max total: {})",
439                step_dist,
440                max_distance
441            );
442            prev = step.point;
443        }
444    }
445
446    #[test]
447    fn test_smart_mouse_position_tracking() {
448        let mouse = SmartMouse::new();
449
450        assert_eq!(mouse.position(), Point::new(0.0, 0.0));
451
452        mouse.set_position(Point::new(100.0, 200.0));
453        assert_eq!(mouse.position(), Point::new(100.0, 200.0));
454    }
455
456    #[test]
457    fn test_smart_mouse_path_to_updates_position() {
458        let mouse = SmartMouse::new();
459        let target = Point::new(500.0, 300.0);
460
461        let path = mouse.path_to(target);
462        assert!(!path.is_empty());
463
464        // Position should now be at the target
465        assert_eq!(mouse.position(), target);
466    }
467
468    #[test]
469    fn test_smart_mouse_consecutive_paths() {
470        let mouse = SmartMouse::with_config(SmartMouseConfig {
471            overshoot: 0.0,
472            jitter: 0.0,
473            ..Default::default()
474        });
475
476        let target1 = Point::new(100.0, 100.0);
477        let path1 = mouse.path_to(target1);
478        assert!(!path1.is_empty());
479        assert_eq!(mouse.position(), target1);
480
481        let target2 = Point::new(400.0, 300.0);
482        let _path2 = mouse.path_to(target2);
483        assert_eq!(mouse.position(), target2);
484    }
485
486    #[test]
487    fn test_smart_mouse_same_position_no_move() {
488        let mouse = SmartMouse::new();
489        mouse.set_position(Point::new(100.0, 100.0));
490
491        let path = mouse.path_to(Point::new(100.0, 100.0));
492        // Zero distance should produce a single direct step
493        assert_eq!(path.len(), 1);
494    }
495
496    #[test]
497    fn test_smart_mouse_custom_config() {
498        let config = SmartMouseConfig {
499            steps: 10,
500            overshoot: 0.0,
501            jitter: 0.0,
502            step_delay_ms: 16,
503            easing: false,
504        };
505
506        let mouse = SmartMouse::with_config(config.clone());
507        let path = mouse.path_to(Point::new(200.0, 200.0));
508
509        assert_eq!(path.len(), config.steps);
510    }
511
512    #[test]
513    fn test_movement_delays_are_reasonable() {
514        let config = SmartMouseConfig {
515            step_delay_ms: 10,
516            ..Default::default()
517        };
518
519        let path = generate_path(Point::new(0.0, 0.0), Point::new(500.0, 500.0), &config);
520
521        for step in &path {
522            // Delays should be within 0-30ms range for a 10ms base
523            assert!(
524                step.delay.as_millis() <= 30,
525                "delay too large: {:?}",
526                step.delay
527            );
528        }
529    }
530
531    #[test]
532    fn test_default_config() {
533        let config = SmartMouseConfig::default();
534        assert_eq!(config.steps, 25);
535        assert!((config.overshoot - 0.15).abs() < 1e-10);
536        assert!((config.jitter - 1.5).abs() < 1e-10);
537        assert_eq!(config.step_delay_ms, 8);
538        assert!(config.easing);
539    }
540}