Skip to main content

glass/browser/
mouse.rs

1//! Mouse movement engine with bounded smooth pointer paths.
2//!
3//! Generates human-like mouse movement trajectories between points using
4//! configurable interaction modes ([`InteractionMode::Human`] for bounded
5//! smooth paths, [`InteractionMode::Fast`] for direct jumps).
6
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10/// A 2D point for mouse path calculations.
11#[derive(Debug, Clone, Copy, PartialEq)]
12pub struct Point {
13    pub x: f64,
14    pub y: f64,
15}
16
17/// A smooth pointer-motion generator.
18///
19/// The engine uses a bounded number of samples and small per-movement
20/// variations. This keeps the event stream realistic without turning a click
21/// into hundreds of unnecessary CDP round trips.
22pub struct MouseEngine {
23    pub min_speed: f64,
24    pub max_speed: f64,
25    pub steps_per_second: u32,
26    seed: AtomicU64,
27}
28
29impl Default for MouseEngine {
30    fn default() -> Self {
31        let seed = SystemTime::now()
32            .duration_since(UNIX_EPOCH)
33            .map(|duration| duration.as_nanos() as u64)
34            .unwrap_or(0x9e37_79b9_7f4a_7c15)
35            | 1;
36        Self {
37            min_speed: 400.0,
38            max_speed: 800.0,
39            steps_per_second: 60,
40            seed: AtomicU64::new(seed),
41        }
42    }
43}
44
45impl MouseEngine {
46    pub fn new() -> Self {
47        Self::default()
48    }
49
50    #[cfg(test)]
51    fn with_seed(seed: u64) -> Self {
52        Self {
53            min_speed: 400.0,
54            max_speed: 800.0,
55            steps_per_second: 60,
56            seed: AtomicU64::new(seed | 1),
57        }
58    }
59
60    fn next_unit(&self) -> f64 {
61        let mut current = self.seed.load(Ordering::Relaxed);
62        loop {
63            let next = current
64                .wrapping_mul(6_364_136_223_846_793_005)
65                .wrapping_add(1_442_695_040_888_963_407);
66            match self.seed.compare_exchange_weak(
67                current,
68                next,
69                Ordering::Relaxed,
70                Ordering::Relaxed,
71            ) {
72                Ok(_) => return next as f64 / u64::MAX as f64,
73                Err(actual) => current = actual,
74            }
75        }
76    }
77
78    /// Generate a cubic Bezier curve between two points.
79    fn bezier_curve(&self, start: Point, end: Point, cp1: Point, cp2: Point, t: f64) -> Point {
80        let t2 = t * t;
81        let t3 = t2 * t;
82        let mt = 1.0 - t;
83        let mt2 = mt * mt;
84        let mt3 = mt2 * mt;
85
86        Point {
87            x: mt3 * start.x + 3.0 * mt2 * t * cp1.x + 3.0 * mt * t2 * cp2.x + t3 * end.x,
88            y: mt3 * start.y + 3.0 * mt2 * t * cp1.y + 3.0 * mt * t2 * cp2.y + t3 * end.y,
89        }
90    }
91
92    /// Generate slightly varied control points around a direct trajectory.
93    fn generate_control_points(&self, start: Point, end: Point) -> (Point, Point) {
94        let dx = end.x - start.x;
95        let dy = end.y - start.y;
96        let distance = (dx * dx + dy * dy).sqrt();
97        if distance < f64::EPSILON {
98            return (start, end);
99        }
100
101        let direction = (dx / distance, dy / distance);
102        let normal = (-direction.1, direction.0);
103        let bend = (self.next_unit() * 2.0 - 1.0) * distance * 0.12;
104        let along = (self.next_unit() * 2.0 - 1.0) * distance * 0.04;
105
106        let cp1 = Point {
107            x: start.x + dx * 0.28 + normal.0 * bend + direction.0 * along,
108            y: start.y + dy * 0.28 + normal.1 * bend + direction.1 * along,
109        };
110        let cp2 = Point {
111            x: start.x + dx * 0.72 - normal.0 * bend * 0.7 + direction.0 * along * 0.4,
112            y: start.y + dy * 0.72 - normal.1 * bend * 0.7 + direction.1 * along * 0.4,
113        };
114        (cp1, cp2)
115    }
116
117    /// Generate a bounded list of points along a smooth path.
118    pub fn generate_path(&self, start: Point, end: Point) -> Vec<Point> {
119        let dx = end.x - start.x;
120        let dy = end.y - start.y;
121        let distance = (dx * dx + dy * dy).sqrt();
122        if distance < f64::EPSILON {
123            return vec![start];
124        }
125
126        let speed = self.min_speed + (self.max_speed - self.min_speed) * self.next_unit();
127        let duration = distance / speed;
128        let steps = (duration * self.steps_per_second as f64)
129            .round()
130            .clamp(8.0, 120.0) as usize;
131        let (cp1, cp2) = self.generate_control_points(start, end);
132        let mut points = Vec::with_capacity(steps + 1);
133
134        for index in 0..=steps {
135            let t = index as f64 / steps as f64;
136            let eased = if t < 0.5 {
137                2.0 * t * t
138            } else {
139                1.0 - (-2.0 * t + 2.0).powi(2) / 2.0
140            };
141            points.push(self.bezier_curve(start, end, cp1, cp2, eased));
142        }
143        points
144    }
145
146    /// Calculate the cadence between consecutive pointer samples.
147    ///
148    /// Samples are evenly timed so the eased spatial curve produces actual
149    /// acceleration and deceleration instead of having its timing cancelled
150    /// out by distance-proportional sleeps.
151    pub fn move_delay(&self, _start: Point, _end: Point) -> Duration {
152        let cadence = 1.0 / self.steps_per_second.max(1) as f64;
153        let jitter = 0.9 + self.next_unit() * 0.2;
154        Duration::from_secs_f64((cadence * jitter).max(0.001))
155    }
156
157    /// Return a short, varied mouse-down dwell before release.
158    pub fn click_delay(&self) -> Duration {
159        Duration::from_secs_f64(0.04 + self.next_unit() * 0.05)
160    }
161
162    /// Generate the press/release events after the pointer reaches a target.
163    pub fn generate_click_events(&self, point: Point) -> Vec<MouseEvent> {
164        vec![
165            MouseEvent {
166                event_type: "mousePressed".to_string(),
167                x: point.x,
168                y: point.y,
169                button: "left".to_string(),
170                click_count: 1,
171            },
172            MouseEvent {
173                event_type: "mouseReleased".to_string(),
174                x: point.x,
175                y: point.y,
176                button: "left".to_string(),
177                click_count: 1,
178            },
179        ]
180    }
181
182    pub fn generate_double_click_events(&self, point: Point) -> Vec<MouseEvent> {
183        vec![
184            MouseEvent {
185                event_type: "mousePressed".to_string(),
186                x: point.x,
187                y: point.y,
188                button: "left".to_string(),
189                click_count: 1,
190            },
191            MouseEvent {
192                event_type: "mouseReleased".to_string(),
193                x: point.x,
194                y: point.y,
195                button: "left".to_string(),
196                click_count: 1,
197            },
198            MouseEvent {
199                event_type: "mousePressed".to_string(),
200                x: point.x,
201                y: point.y,
202                button: "left".to_string(),
203                click_count: 2,
204            },
205            MouseEvent {
206                event_type: "mouseReleased".to_string(),
207                x: point.x,
208                y: point.y,
209                button: "left".to_string(),
210                click_count: 2,
211            },
212        ]
213    }
214
215    pub fn generate_drag_events(&self, start: Point, end: Point) -> Vec<MouseEvent> {
216        let path = self.generate_path(start, end);
217        let mut events = vec![MouseEvent {
218            event_type: "mousePressed".to_string(),
219            x: start.x,
220            y: start.y,
221            button: "left".to_string(),
222            click_count: 1,
223        }];
224        for point in path.iter().skip(1).take(path.len().saturating_sub(2)) {
225            events.push(MouseEvent {
226                event_type: "mouseMoved".to_string(),
227                x: point.x,
228                y: point.y,
229                button: "left".to_string(),
230                click_count: 1,
231            });
232        }
233        events.push(MouseEvent {
234            event_type: "mouseReleased".to_string(),
235            x: end.x,
236            y: end.y,
237            button: "left".to_string(),
238            click_count: 1,
239        });
240        events
241    }
242}
243
244#[derive(Debug, Clone)]
245pub struct MouseEvent {
246    pub event_type: String,
247    pub x: f64,
248    pub y: f64,
249    pub button: String,
250    pub click_count: u32,
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn path_preserves_endpoints_and_has_bounded_samples() {
259        let engine = MouseEngine::with_seed(7);
260        let start = Point { x: 0.0, y: 0.0 };
261        let end = Point {
262            x: 1_000.0,
263            y: 600.0,
264        };
265        let path = engine.generate_path(start, end);
266        assert_eq!(path.first(), Some(&start));
267        assert_eq!(path.last(), Some(&end));
268        assert!((8..=121).contains(&path.len()));
269    }
270
271    #[test]
272    fn path_is_not_always_a_teleport() {
273        let engine = MouseEngine::with_seed(11);
274        let path = engine.generate_path(Point { x: 0.0, y: 0.0 }, Point { x: 400.0, y: 0.0 });
275        assert!(path.len() > 2);
276        assert!(path.iter().any(|point| point.y.abs() > f64::EPSILON));
277    }
278
279    #[test]
280    fn click_events_only_press_and_release_after_motion() {
281        let events = MouseEngine::with_seed(13).generate_click_events(Point { x: 2.0, y: 3.0 });
282        assert_eq!(events.len(), 2);
283        assert_eq!(events[0].event_type, "mousePressed");
284        assert_eq!(events[1].event_type, "mouseReleased");
285    }
286
287    #[test]
288    fn movement_and_click_delays_stay_in_human_ranges() {
289        let engine = MouseEngine::with_seed(17);
290        let move_delay = engine.move_delay(Point { x: 0.0, y: 0.0 }, Point { x: 5.0, y: 5.0 });
291        assert!((Duration::from_millis(15)..=Duration::from_millis(19)).contains(&move_delay));
292        assert!(
293            (Duration::from_millis(40)..=Duration::from_millis(90)).contains(&engine.click_delay())
294        );
295    }
296}