use std::time::Duration;
use crate::rng::Rng;
use crate::InputMode;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseButton {
Left,
Middle,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MouseStepKind {
Move,
Down,
Up,
Click,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MouseStep {
pub at: Duration,
pub point: Point,
pub kind: MouseStepKind,
pub button: MouseButton,
}
const FITTS_A_MS: f64 = 100.0;
const FITTS_B_MS: f64 = 150.0;
const FITTS_TARGET_WIDTH_PX: f64 = 32.0;
const HUMAN_MOVE_CAP_MS: f64 = 1200.0;
const SAMPLE_PERIOD_MS: f64 = 16.0;
const OVERSHOOT_PX: f64 = 5.0;
const BEZIER_PERP_MIN: f64 = 0.05;
const BEZIER_PERP_MAX: f64 = 0.25;
const HOVER_MIN_MS: f64 = 80.0;
const HOVER_MAX_MS: f64 = 250.0;
const PRESS_MIN_MS: f64 = 30.0;
const PRESS_MAX_MS: f64 = 90.0;
#[must_use]
pub fn mouse_path(start: Point, end: Point, mode: InputMode, seed: u64) -> Vec<MouseStep> {
match mode {
InputMode::Robotic => Vec::new(),
InputMode::Careful => careful_path(end),
InputMode::Human => human_path(start, end, seed),
}
}
fn careful_path(end: Point) -> Vec<MouseStep> {
let zero = Duration::ZERO;
vec![
MouseStep {
at: zero,
point: end,
kind: MouseStepKind::Move,
button: MouseButton::Left,
},
MouseStep {
at: zero,
point: end,
kind: MouseStepKind::Down,
button: MouseButton::Left,
},
MouseStep {
at: zero,
point: end,
kind: MouseStepKind::Up,
button: MouseButton::Left,
},
MouseStep {
at: zero,
point: end,
kind: MouseStepKind::Click,
button: MouseButton::Left,
},
]
}
#[allow(clippy::too_many_lines)]
fn human_path(start: Point, end: Point, seed: u64) -> Vec<MouseStep> {
let mut rng = Rng::seed_from_u64(seed);
let dx = end.x - start.x;
let dy = end.y - start.y;
let distance = (dx * dx + dy * dy).sqrt();
let id = (distance / FITTS_TARGET_WIDTH_PX + 1.0).log2();
let mut total_ms = FITTS_A_MS + FITTS_B_MS * id;
if total_ms > HUMAN_MOVE_CAP_MS {
total_ms = HUMAN_MOVE_CAP_MS;
}
let perp_sign = if rng.next_f64() < 0.5 { -1.0 } else { 1.0 };
let perp_mag_a = distance * rng.next_uniform(BEZIER_PERP_MIN, BEZIER_PERP_MAX);
let perp_mag_b = distance * rng.next_uniform(BEZIER_PERP_MIN, BEZIER_PERP_MAX);
let (perp_x, perp_y) = if distance > f64::EPSILON {
let inv = 1.0 / distance;
(-dy * inv, dx * inv) } else {
(0.0, 0.0)
};
let cp1 = Point {
x: start.x + dx / 3.0 + perp_sign * perp_mag_a * perp_x,
y: start.y + dy / 3.0 + perp_sign * perp_mag_a * perp_y,
};
let cp2 = Point {
x: start.x + 2.0 * dx / 3.0 + perp_sign * perp_mag_b * perp_x,
y: start.y + 2.0 * dy / 3.0 + perp_sign * perp_mag_b * perp_y,
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let n_steps = ((total_ms / SAMPLE_PERIOD_MS).ceil() as usize).max(1);
let mut out: Vec<MouseStep> = Vec::with_capacity(n_steps + 4);
for i in 0..=n_steps {
#[allow(clippy::cast_precision_loss)]
let t = (i as f64) / (n_steps as f64);
let p = cubic_bezier(start, cp1, cp2, end, t);
let p = if distance > 16.0 && t > 0.85 {
let overshoot_t = (t - 0.85) / 0.15; let lobe = 4.0 * overshoot_t * (1.0 - overshoot_t); let inv = 1.0 / distance;
Point {
x: p.x + lobe * OVERSHOOT_PX * dx * inv,
y: p.y + lobe * OVERSHOOT_PX * dy * inv,
}
} else {
p
};
let at_ms = total_ms * t;
out.push(MouseStep {
at: ms(at_ms),
point: p,
kind: MouseStepKind::Move,
button: MouseButton::Left,
});
}
let hover_ms = rng.next_uniform(HOVER_MIN_MS, HOVER_MAX_MS);
let press_ms = rng.next_uniform(PRESS_MIN_MS, PRESS_MAX_MS);
let down_at = total_ms + hover_ms;
let up_at = down_at + press_ms;
out.push(MouseStep {
at: ms(down_at),
point: end,
kind: MouseStepKind::Down,
button: MouseButton::Left,
});
out.push(MouseStep {
at: ms(up_at),
point: end,
kind: MouseStepKind::Up,
button: MouseButton::Left,
});
out.push(MouseStep {
at: ms(up_at),
point: end,
kind: MouseStepKind::Click,
button: MouseButton::Left,
});
out
}
fn cubic_bezier(p0: Point, p1: Point, p2: Point, p3: Point, t: f64) -> Point {
let u = 1.0 - t;
let b0 = u * u * u;
let b1 = 3.0 * u * u * t;
let b2 = 3.0 * u * t * t;
let b3 = t * t * t;
Point {
x: b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x,
y: b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y,
}
}
fn ms(value: f64) -> Duration {
let v = value.max(0.0).round();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let ms_int = v as u64;
Duration::from_millis(ms_int)
}
#[cfg(test)]
mod tests {
use super::*;
const ORIGIN: Point = Point { x: 0.0, y: 0.0 };
const FAR: Point = Point { x: 800.0, y: 600.0 };
#[test]
fn robotic_is_empty() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Robotic, 0);
assert!(steps.is_empty());
}
#[test]
fn careful_is_four_steps_at_endpoint() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Careful, 0);
assert_eq!(steps.len(), 4);
for s in &steps {
assert_eq!(s.point, FAR);
assert_eq!(s.at, Duration::ZERO);
assert_eq!(s.button, MouseButton::Left);
}
assert_eq!(steps[0].kind, MouseStepKind::Move);
assert_eq!(steps[1].kind, MouseStepKind::Down);
assert_eq!(steps[2].kind, MouseStepKind::Up);
assert_eq!(steps[3].kind, MouseStepKind::Click);
}
#[test]
fn human_starts_at_start_ends_at_end() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
let first = steps.first().expect("non-empty");
let last_move = steps
.iter()
.rev()
.find(|s| s.kind == MouseStepKind::Move)
.expect("at least one move");
assert!((first.point.x - ORIGIN.x).abs() < 1.0);
assert!((first.point.y - ORIGIN.y).abs() < 1.0);
assert!(
(last_move.point.x - FAR.x).abs() < OVERSHOOT_PX + 1.0,
"last move x off from end: {} vs {}",
last_move.point.x,
FAR.x
);
assert!(
(last_move.point.y - FAR.y).abs() < OVERSHOOT_PX + 1.0,
"last move y off from end: {} vs {}",
last_move.point.y,
FAR.y
);
}
#[test]
fn human_emits_many_moves_for_long_distance() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
let moves = steps
.iter()
.filter(|s| s.kind == MouseStepKind::Move)
.count();
assert!(moves > 20, "expected many move samples; got {moves}");
}
#[test]
fn human_click_sequence_present() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
let kinds: Vec<MouseStepKind> = steps.iter().map(|s| s.kind).collect();
assert!(kinds.contains(&MouseStepKind::Down));
assert!(kinds.contains(&MouseStepKind::Up));
assert!(kinds.contains(&MouseStepKind::Click));
let down = kinds
.iter()
.position(|k| *k == MouseStepKind::Down)
.unwrap();
let up = kinds.iter().position(|k| *k == MouseStepKind::Up).unwrap();
let click = kinds
.iter()
.position(|k| *k == MouseStepKind::Click)
.unwrap();
assert!(down < up, "down must precede up");
assert!(up <= click, "up must precede or coincide with click");
}
#[test]
fn human_zero_distance_still_produces_click() {
let steps = mouse_path(ORIGIN, ORIGIN, InputMode::Human, 7);
let kinds: Vec<MouseStepKind> = steps.iter().map(|s| s.kind).collect();
assert!(kinds.contains(&MouseStepKind::Down));
assert!(kinds.contains(&MouseStepKind::Up));
assert!(kinds.contains(&MouseStepKind::Click));
}
#[test]
fn human_is_deterministic_under_seed() {
let a = mouse_path(ORIGIN, FAR, InputMode::Human, 1234);
let b = mouse_path(ORIGIN, FAR, InputMode::Human, 1234);
assert_eq!(a, b);
}
#[test]
fn human_seed_change_changes_path() {
let a = mouse_path(ORIGIN, FAR, InputMode::Human, 1);
let b = mouse_path(ORIGIN, FAR, InputMode::Human, 2);
let mid_a = a[a.len() / 2].point;
let mid_b = b[b.len() / 2].point;
let diff = ((mid_a.x - mid_b.x).powi(2) + (mid_a.y - mid_b.y).powi(2)).sqrt();
assert!(
diff > 1.0,
"paths suspiciously identical: {mid_a:?} vs {mid_b:?}"
);
}
#[test]
fn human_total_duration_capped() {
let far = Point {
x: 100_000.0,
y: 0.0,
};
let steps = mouse_path(ORIGIN, far, InputMode::Human, 0);
let last_click = steps
.iter()
.rev()
.find(|s| s.kind == MouseStepKind::Click)
.expect("click present");
assert!(
last_click.at.as_millis() <= 1540,
"total duration not capped: {}ms",
last_click.at.as_millis()
);
}
#[test]
fn human_steps_monotonic_in_time() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 99);
let times: Vec<u128> = steps.iter().map(|s| s.at.as_millis()).collect();
for w in times.windows(2) {
assert!(w[0] <= w[1], "times not monotonic: {w:?}");
}
}
#[test]
fn human_path_stays_within_overshoot_bound() {
let steps = mouse_path(ORIGIN, FAR, InputMode::Human, 42);
let max_excursion = OVERSHOOT_PX + BEZIER_PERP_MAX * 1000.0; for s in &steps {
if s.kind != MouseStepKind::Move {
continue;
}
let dx = FAR.x - ORIGIN.x;
let dy = FAR.y - ORIGIN.y;
let len = (dx * dx + dy * dy).sqrt();
let perp = ((s.point.x - ORIGIN.x) * (-dy) + (s.point.y - ORIGIN.y) * dx).abs() / len;
assert!(
perp < max_excursion + 2.0,
"point too far from direct line: {perp}px > {}",
max_excursion + 2.0
);
}
}
}