use std::ops::{Range, RangeInclusive};
use std::time::Duration;
use rand::Rng;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Step {
pub x: f64,
pub y: f64,
pub delay: Duration,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MouseParams {
pub tick: Duration,
pub min_steps: usize,
pub fitts_a_ms: f64,
pub fitts_b_ms: f64,
pub bend: Range<f64>,
pub bend_cap_px: f64,
pub overshoot_threshold_px: f64,
pub overshoot: Range<f64>,
pub overshoot_cap_px: f64,
pub overshoot_lateral_px: f64,
pub correction_bend_cap_px: f64,
pub detour_chance: f64,
pub detour_waypoints: RangeInclusive<usize>,
pub detour_min_px: f64,
pub inset: f64,
pub first_start_px: Range<f64>,
pub hover_ms: Range<u64>,
pub press_ms: Range<u64>,
pub wiggle_chance: f64,
}
impl Default for MouseParams {
fn default() -> Self {
Self {
tick: Duration::from_millis(8),
min_steps: 10,
fitts_a_ms: 100.0,
fitts_b_ms: 150.0,
bend: 0.05..0.25,
bend_cap_px: 120.0,
overshoot_threshold_px: 500.0,
overshoot: 0.03..0.08,
overshoot_cap_px: 60.0,
overshoot_lateral_px: 10.0,
correction_bend_cap_px: 4.0,
detour_chance: 0.2,
detour_waypoints: 1..=2,
detour_min_px: 50.0,
inset: 0.25,
first_start_px: 100.0..300.0,
hover_ms: 10..80,
press_ms: 80..140,
wiggle_chance: 0.2,
}
}
}
pub fn bounds(quad: &[f64]) -> Option<(f64, f64, f64, f64)> {
if quad.len() != 8 {
return None;
}
let xs = quad.iter().step_by(2);
let ys = quad.iter().skip(1).step_by(2);
let (x0, x1) = xs.fold((f64::MAX, f64::MIN), |(a, b), &v| (a.min(v), b.max(v)));
let (y0, y1) = ys.fold((f64::MAX, f64::MIN), |(a, b), &v| (a.min(v), b.max(v)));
(x1 - x0 >= 1.0 && y1 - y0 >= 1.0).then_some((x0, y0, x1, y1))
}
pub fn pick_point(b: (f64, f64, f64, f64), p: &MouseParams, rng: &mut impl Rng) -> (f64, f64) {
let (x0, y0, x1, y1) = b;
let at = |lo: f64, hi: f64, r: f64| lo + (hi - lo) * (p.inset + (1.0 - 2.0 * p.inset) * r);
let x = at(x0, x1, rng.gen());
let y = at(y0, y1, rng.gen());
(x.round(), y.round())
}
pub fn first_start(to: (f64, f64), p: &MouseParams, rng: &mut impl Rng) -> (f64, f64) {
let angle = rng.gen_range(0.0..std::f64::consts::TAU);
let dist = rng.gen_range(p.first_start_px.clone());
(
(to.0 + dist * angle.cos()).max(1.0).round(),
(to.1 + dist * angle.sin()).max(1.0).round(),
)
}
pub fn path(
from: (f64, f64),
to: (f64, f64),
target_width: f64,
p: &MouseParams,
rng: &mut impl Rng,
) -> Vec<Step> {
if !rng.gen_bool(p.detour_chance) {
return direct_path(from, to, target_width, p, rng);
}
let raw = detour(from, to, target_width, p, rng);
drop_repeats(from, raw)
}
fn detour(
from: (f64, f64),
to: (f64, f64),
target_width: f64,
p: &MouseParams,
rng: &mut impl Rng,
) -> Vec<Step> {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let dist = dx.hypot(dy).max(p.detour_min_px);
let (nx, ny) = (-dy / dist, dx / dist);
let mut steps = Vec::new();
let mut at = from;
for _ in 0..rng.gen_range(p.detour_waypoints.clone()) {
let t = rng.gen_range(0.2..0.8);
let off = rng.gen_range(-0.8..0.8) * dist;
let waypoint = (
(from.0 + dx * t + nx * off).max(1.0).round(),
(from.1 + dy * t + ny * off).max(1.0).round(),
);
steps.extend(legs(at, waypoint, target_width, p, rng));
at = waypoint;
}
steps.extend(legs(at, to, target_width, p, rng));
steps
}
fn drop_repeats(from: (f64, f64), steps: Vec<Step>) -> Vec<Step> {
let mut out: Vec<Step> = Vec::with_capacity(steps.len());
let mut prev = from;
for st in steps {
if (st.x, st.y) != prev {
prev = (st.x, st.y);
out.push(st);
} else if let Some(last) = out.last_mut() {
last.delay += st.delay;
}
}
out
}
pub fn direct_path(
from: (f64, f64),
to: (f64, f64),
target_width: f64,
p: &MouseParams,
rng: &mut impl Rng,
) -> Vec<Step> {
let raw = legs(from, to, target_width, p, rng);
drop_repeats(from, raw)
}
fn legs(
from: (f64, f64),
to: (f64, f64),
target_width: f64,
p: &MouseParams,
rng: &mut impl Rng,
) -> Vec<Step> {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let dist = dx.hypot(dy);
if dist <= p.overshoot_threshold_px {
return leg(from, to, target_width, p.bend_cap_px, p, rng);
}
let (ux, uy) = (dx / dist, dy / dist);
let ahead = (dist * rng.gen_range(p.overshoot.clone())).min(p.overshoot_cap_px);
let lateral = rng.gen_range(-p.overshoot_lateral_px..p.overshoot_lateral_px);
let past = (
(to.0 + ux * ahead - uy * lateral).max(1.0).round(),
(to.1 + uy * ahead + ux * lateral).max(1.0).round(),
);
let mut steps = leg(from, past, target_width, p.bend_cap_px, p, rng);
steps.extend(leg(
past,
to,
target_width,
p.correction_bend_cap_px,
p,
rng,
));
steps
}
fn leg(
from: (f64, f64),
to: (f64, f64),
target_width: f64,
bend_cap: f64,
p: &MouseParams,
rng: &mut impl Rng,
) -> Vec<Step> {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let dist = dx.hypot(dy);
let total_ms = p.fitts_a_ms + p.fitts_b_ms * (dist / target_width.max(1.0) + 1.0).log2();
let tick_ms = p.tick.as_secs_f64() * 1000.0;
let steps = ((total_ms / tick_ms).round() as usize).max(p.min_steps.max(1));
let (nx, ny) = if dist > 0.0 {
(-dy / dist, dx / dist)
} else {
(0.0, 0.0)
};
let side = if rng.gen_bool(0.5) { 1.0 } else { -1.0 };
let bend =
|r: f64| side * (dist * (p.bend.start + (p.bend.end - p.bend.start) * r)).min(bend_cap);
let (b1, b2) = (bend(rng.gen()), bend(rng.gen()));
let c1 = (from.0 + dx / 3.0 + nx * b1, from.1 + dy / 3.0 + ny * b1);
let c2 = (
from.0 + 2.0 * dx / 3.0 + nx * b2,
from.1 + 2.0 * dy / 3.0 + ny * b2,
);
(1..=steps)
.map(|i| {
let t = i as f64 / steps as f64;
let t = t * t * (3.0 - 2.0 * t);
let (x, y) = if i == steps {
to
} else {
bezier(from, c1, c2, to, t)
};
Step {
x: x.round(),
y: y.round(),
delay: p.tick,
}
})
.collect()
}
fn bezier(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), t: f64) -> (f64, f64) {
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
(
a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0,
a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1,
)
}
pub fn hover_pause(p: &MouseParams, rng: &mut impl Rng) -> Duration {
Duration::from_millis(rng.gen_range(p.hover_ms.clone()))
}
pub fn press_hold(p: &MouseParams, rng: &mut impl Rng) -> Duration {
Duration::from_millis(rng.gen_range(p.press_ms.clone()))
}
pub fn press_wiggle(p: &MouseParams, rng: &mut impl Rng) -> Vec<(f64, f64)> {
if !rng.gen_bool(p.wiggle_chance) {
return Vec::new();
}
(0..rng.gen_range(1..=2))
.map(|_| loop {
let d = (
f64::from(rng.gen_range(-1..=1)),
f64::from(rng.gen_range(-1..=1)),
);
if d != (0.0, 0.0) {
break d;
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn rng(seed: u64) -> StdRng {
StdRng::seed_from_u64(seed)
}
fn params() -> MouseParams {
MouseParams::default()
}
#[test]
fn path_ends_on_target_and_excludes_start() {
for seed in 0..50 {
let p = path(
(10.0, 10.0),
(600.0, 420.0),
80.0,
¶ms(),
&mut rng(seed),
);
let last = p.last().unwrap();
assert_eq!((last.x, last.y), (600.0, 420.0), "seed {seed}");
assert_ne!((p[0].x, p[0].y), (10.0, 10.0), "seed {seed}");
}
}
#[test]
fn projection_on_line_grows_monotonically() {
for seed in 0..50 {
let (from, to) = ((50.0, 400.0), (380.0, 190.0));
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let proj: Vec<f64> = direct_path(from, to, 60.0, ¶ms(), &mut rng(seed))
.iter()
.map(|s| (s.x - from.0) * dx + (s.y - from.1) * dy)
.collect();
let slack = dx.abs() + dy.abs();
assert!(
proj.windows(2).all(|w| w[1] >= w[0] - slack),
"seed {seed}: {proj:?}"
);
}
}
#[test]
fn every_point_is_on_one_side_of_the_line() {
for seed in 0..50 {
let (from, to) = ((0.0, 0.0), (300.0, 200.0));
let side: Vec<f64> = direct_path(from, to, 50.0, ¶ms(), &mut rng(seed))
.iter()
.map(|s| (to.0 - from.0) * (s.y - from.1) - (to.1 - from.1) * (s.x - from.0))
.filter(|c| c.abs() > 1000.0) .collect();
assert!(
side.iter().all(|c| *c > 0.0) || side.iter().all(|c| *c < 0.0),
"seed {seed}: path crosses the line {side:?}"
);
assert!(!side.is_empty(), "seed {seed}: perfectly straight path");
}
}
#[test]
fn farther_targets_take_more_points_and_time() {
let near = direct_path((0.0, 0.0), (120.0, 0.0), 40.0, ¶ms(), &mut rng(1));
let far = direct_path((0.0, 0.0), (480.0, 0.0), 40.0, ¶ms(), &mut rng(1));
assert!(far.len() > near.len(), "{} vs {}", far.len(), near.len());
let total = |p: &[Step]| p.iter().map(|s| s.delay).sum::<Duration>();
assert!(total(&far) > total(&near));
assert!(near.len() >= params().min_steps);
}
fn past_target(p: &[Step], from: (f64, f64), to: (f64, f64)) -> f64 {
let (dx, dy) = (to.0 - from.0, to.1 - from.1);
let d = dx.hypot(dy);
p.iter()
.map(|s| ((s.x - to.0) * dx + (s.y - to.1) * dy) / d)
.fold(f64::MIN, f64::max)
}
#[test]
fn long_moves_overshoot_then_correct_short_moves_do_not() {
let (from, to) = ((50.0, 50.0), (900.0, 700.0));
let cap = params().overshoot_cap_px;
for seed in 0..40 {
let p = direct_path(from, to, 100.0, ¶ms(), &mut rng(seed));
let last = p.last().unwrap();
assert_eq!((last.x, last.y), to, "seed {seed}");
let past = past_target(&p, from, to);
assert!(past > 5.0 && past <= cap + 2.0, "seed {seed}: {past}px");
}
let near_from = (600.0, 400.0);
for seed in 0..40 {
let p = direct_path(near_from, to, 100.0, ¶ms(), &mut rng(seed));
let past = past_target(&p, near_from, to);
assert!(past <= 1.0, "seed {seed}: short move overshot {past}px");
}
}
#[test]
fn path_sometimes_wanders_and_always_ends_on_target() {
let (from, to): ((f64, f64), (f64, f64)) = ((100.0, 100.0), (500.0, 300.0));
let straight = (to.0 - from.0).hypot(to.1 - from.1);
let mut wandering = 0;
for seed in 0..400 {
let p = path(from, to, 60.0, ¶ms(), &mut rng(seed));
let last = p.last().unwrap();
assert_eq!((last.x, last.y), to, "seed {seed}");
let mut len = 0.0;
let mut prev = from;
for s in &p {
len += (s.x - prev.0).hypot(s.y - prev.1);
prev = (s.x, s.y);
}
if len / straight > 1.5 {
wandering += 1;
}
}
assert!(
(30..=110).contains(&wandering),
"{wandering}/400 paths wander"
);
}
#[test]
fn no_step_repeats_the_previous_position() {
for seed in 0..200 {
let from = (10.0, 10.0);
let p = path(from, (300.0, 220.0), 40.0, ¶ms(), &mut rng(seed));
let mut prev = from;
for s in &p {
assert_ne!((s.x, s.y), prev, "seed {seed}");
prev = (s.x, s.y);
}
}
}
#[test]
fn points_are_spaced_by_the_polling_tick() {
let p = MouseParams {
tick: Duration::from_micros(16_667),
..params()
};
for seed in 0..20 {
let steps = path((40.0, 40.0), (900.0, 500.0), 60.0, &p, &mut rng(seed));
assert!(
steps.iter().all(|s| {
let n = s.delay.as_nanos() / p.tick.as_nanos();
n >= 1 && s.delay == p.tick * n as u32
}),
"seed {seed}"
);
assert!(steps.iter().filter(|s| s.delay == p.tick).count() * 10 > steps.len() * 8);
}
}
#[test]
fn click_point_stays_inside_inset_and_varies() {
let b = bounds(&[300.0, 200.0, 400.0, 200.0, 400.0, 240.0, 300.0, 240.0]).unwrap();
let mut r = rng(7);
let pts: Vec<(f64, f64)> = (0..100).map(|_| pick_point(b, ¶ms(), &mut r)).collect();
assert!(pts
.iter()
.all(|&(x, y)| (325.0..=375.0).contains(&x) && (210.0..=230.0).contains(&y)));
let distinct: std::collections::HashSet<(i64, i64)> =
pts.iter().map(|&(x, y)| (x as i64, y as i64)).collect();
assert!(
distinct.len() > 20,
"click points repeat: {}",
distinct.len()
);
}
#[test]
fn empty_or_malformed_box_has_no_click_point() {
assert_eq!(bounds(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0]), None);
assert_eq!(bounds(&[1.0, 2.0]), None);
}
#[test]
fn first_start_is_within_configured_distance() {
let p = params();
let mut r = rng(3);
for _ in 0..200 {
let s = first_start((500.0, 400.0), &p, &mut r);
let d = (s.0 - 500.0).hypot(s.1 - 400.0);
assert!(d >= p.first_start_px.start - 1.0 && d <= p.first_start_px.end + 1.0);
}
}
#[test]
fn hover_press_and_wiggle_follow_params() {
let p = params();
let mut r = rng(4);
for _ in 0..500 {
assert!(p
.hover_ms
.contains(&(hover_pause(&p, &mut r).as_millis() as u64)));
assert!(p
.press_ms
.contains(&(press_hold(&p, &mut r).as_millis() as u64)));
}
let wiggles: Vec<Vec<(f64, f64)>> = (0..1000).map(|_| press_wiggle(&p, &mut r)).collect();
let with = wiggles.iter().filter(|w| !w.is_empty()).count();
assert!((130..=270).contains(&with), "{with}/1000 presses drift");
assert!(wiggles
.iter()
.flatten()
.all(|&(dx, dy)| dx.abs() <= 1.0 && dy.abs() <= 1.0 && (dx, dy) != (0.0, 0.0)));
}
}