veilus-human-input 0.1.0

Human-like mouse paths, wheel scrolling and typing rhythm for browser automation: pure functions, parameters supplied by the caller.
Documentation
//! Mouse-wheel scrolling.
//!
//! A notched wheel sends equal steps with pauses between them. An
//! accelerated wheel (macOS) turns one flick into a ramp: a tiny first event,
//! growing ones, then a plateau of large deltas a few milliseconds apart.

use std::ops::Range;
use std::time::Duration;

use rand::Rng;

use crate::sample::log_normal;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelStyle {
    /// Equal notches, as a wheel with detents (the Windows default).
    Notched,
    /// macOS wheel acceleration: each flick ramps up from a small delta.
    MacAccelerated,
}

/// Every tunable number of the wheel model.
#[derive(Debug, Clone, PartialEq)]
pub struct WheelParams {
    /// Pixels per notch, random jitter around it, and the pause after each.
    pub notch_px: i32,
    pub notch_jitter_px: i32,
    pub notch_gap_ms: Range<u64>,
    /// Accelerated flick: first, second and third delta, then a log-normal
    /// plateau (median, sigma, cap), events this far apart.
    pub accel_first_px: i32,
    pub accel_second_px: Range<i32>,
    pub accel_third_px: Range<i32>,
    pub accel_median_px: f64,
    pub accel_sigma: f64,
    pub accel_max_px: f64,
    pub accel_gap_ms: Range<u64>,
    /// An accelerated scroll lands within this fraction of the requested
    /// amount — nobody scrolls the same exact distance twice.
    pub accel_distance_jitter: f64,
}

impl Default for WheelParams {
    fn default() -> Self {
        Self {
            // Chromium scrolls 100 px per notch by default.
            notch_px: 100,
            notch_jitter_px: 0,
            notch_gap_ms: 30..120,
            accel_first_px: 2,
            accel_second_px: 20..60,
            accel_third_px: 80..200,
            accel_median_px: 300.0,
            accel_sigma: 0.4,
            accel_max_px: 800.0,
            accel_gap_ms: 10..25,
            accel_distance_jitter: 0.05,
        }
    }
}

/// Wheel events for one scroll of `amount` px (positive), and the pause AFTER
/// each. The deltas add up to exactly `amount`.
pub fn wheel_events(
    style: WheelStyle,
    amount: i32,
    p: &WheelParams,
    rng: &mut impl Rng,
) -> Vec<(i32, Duration)> {
    match style {
        WheelStyle::Notched => notches(amount, p, rng),
        WheelStyle::MacAccelerated => flick(amount, p, rng),
    }
}

/// Distance actually scrolled for a request of `amount` px: exact for a
/// notched wheel, within ±[`WheelParams::accel_distance_jitter`] otherwise.
pub fn scroll_distance(style: WheelStyle, amount: i32, p: &WheelParams, rng: &mut impl Rng) -> i32 {
    match style {
        WheelStyle::Notched => amount,
        WheelStyle::MacAccelerated => {
            let j = p.accel_distance_jitter;
            let f = rng.gen_range(1.0 - j..=1.0 + j);
            ((f64::from(amount) * f).round() as i32).max(1)
        }
    }
}

fn flick(amount: i32, p: &WheelParams, rng: &mut impl Rng) -> Vec<(i32, Duration)> {
    let mut left = amount.max(0);
    let mut out = Vec::new();
    for i in 0.. {
        if left == 0 {
            break;
        }
        let want = match i {
            0 => p.accel_first_px,
            1 => rng.gen_range(p.accel_second_px.clone()),
            2 => rng.gen_range(p.accel_third_px.clone()),
            _ => log_normal(p.accel_median_px, p.accel_sigma, rng).min(p.accel_max_px) as i32,
        };
        let step = want.max(1).min(left);
        left -= step;
        out.push((
            step,
            Duration::from_millis(rng.gen_range(p.accel_gap_ms.clone())),
        ));
    }
    out
}

fn notches(amount: i32, p: &WheelParams, rng: &mut impl Rng) -> Vec<(i32, Duration)> {
    let mut left = amount.max(0);
    let mut out = Vec::new();
    while left > 0 {
        let j = p.notch_jitter_px;
        let notch = (p.notch_px + rng.gen_range(-j..=j)).max(1);
        let step = notch.min(left);
        left -= step;
        out.push((
            step,
            Duration::from_millis(rng.gen_range(p.notch_gap_ms.clone())),
        ));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::rngs::StdRng;
    use rand::SeedableRng;

    fn rng(seed: u64) -> StdRng {
        StdRng::seed_from_u64(seed)
    }

    #[test]
    fn notches_add_up_to_amount() {
        let p = WheelParams {
            notch_jitter_px: 15,
            ..WheelParams::default()
        };
        for (seed, amount) in [(1, 300), (2, 1), (3, 2_000), (4, 99)] {
            let steps = wheel_events(WheelStyle::Notched, amount, &p, &mut rng(seed));
            assert_eq!(
                steps.iter().map(|s| s.0).sum::<i32>(),
                amount,
                "seed {seed}"
            );
            assert!(steps.iter().all(|s| s.0 > 0 && s.0 <= p.notch_px + 15));
        }
        let big = wheel_events(WheelStyle::Notched, 2_000, &p, &mut rng(9));
        let sizes: std::collections::HashSet<i32> = big.iter().map(|s| s.0).collect();
        assert!(sizes.len() > 3, "every notch the same: {sizes:?}");
    }

    #[test]
    fn default_notches_are_exact() {
        let steps = wheel_events(
            WheelStyle::Notched,
            450,
            &WheelParams::default(),
            &mut rng(1),
        );
        let sizes: Vec<i32> = steps.iter().map(|s| s.0).collect();
        assert_eq!(sizes, vec![100, 100, 100, 100, 50]);
    }

    #[test]
    fn mac_flick_ramps_up_and_adds_up() {
        let p = WheelParams::default();
        for (seed, amount) in [(1, 2_000), (2, 300), (3, 5_000), (4, 1)] {
            let ev = wheel_events(WheelStyle::MacAccelerated, amount, &p, &mut rng(seed));
            assert_eq!(ev.iter().map(|e| e.0).sum::<i32>(), amount, "seed {seed}");
            assert_eq!(ev[0].0, p.accel_first_px.min(amount));
        }
        let ev = wheel_events(WheelStyle::MacAccelerated, 5_000, &p, &mut rng(9));
        assert!(
            ev.iter().any(|e| e.0 > 200),
            "no large delta — no acceleration"
        );
        assert!(ev
            .iter()
            .all(|e| p.accel_gap_ms.contains(&(e.1.as_millis() as u64))));
    }

    #[test]
    fn mac_distance_varies_notched_does_not() {
        let p = WheelParams::default();
        let mut r = rng(11);
        let d: Vec<i32> = (0..50)
            .map(|_| scroll_distance(WheelStyle::MacAccelerated, 600, &p, &mut r))
            .collect();
        assert!(d.iter().all(|x| (570..=630).contains(x)), "{d:?}");
        assert!(d.iter().any(|&x| x != 600));
        assert_eq!(scroll_distance(WheelStyle::Notched, 600, &p, &mut r), 600);
    }
}