use std::ops::Range;
use std::time::Duration;
use rand::Rng;
use crate::sample::log_normal;
#[derive(Debug, Clone, PartialEq)]
pub struct TypingParams {
pub gap_median_ms: f64,
pub gap_sigma: f64,
pub gap_min_ms: f64,
pub gap_max_ms: f64,
pub boundary_factor: f64,
pub hesitate_chance: f64,
pub hesitate_ms: Range<u64>,
pub hold_ms: Range<u64>,
pub total_budget: Option<Duration>,
}
impl Default for TypingParams {
fn default() -> Self {
Self {
gap_median_ms: 180.0,
gap_sigma: 0.5,
gap_min_ms: 50.0,
gap_max_ms: 800.0,
boundary_factor: 1.3,
hesitate_chance: 0.08,
hesitate_ms: 300..1000,
hold_ms: 70..130,
total_budget: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeyTiming {
pub hold: Duration,
pub gap: Duration,
}
pub fn timings(text: &str, p: &TypingParams, rng: &mut impl Rng) -> Vec<KeyTiming> {
let chars: Vec<char> = text.chars().collect();
let out: Vec<KeyTiming> = chars
.iter()
.enumerate()
.map(|(i, &ch)| KeyTiming {
hold: Duration::from_millis(rng.gen_range(p.hold_ms.clone())),
gap: if i + 1 == chars.len() {
Duration::ZERO
} else {
gap_after(ch, p, rng)
},
})
.collect();
let total: Duration = out.iter().map(|k| k.hold + k.gap).sum();
match p.total_budget {
Some(budget) if total > budget => {
let scale = budget.as_secs_f64() / total.as_secs_f64();
out.into_iter()
.map(|k| KeyTiming {
hold: k.hold.mul_f64(scale),
gap: k.gap.mul_f64(scale),
})
.collect()
}
_ => out,
}
}
fn gap_after(ch: char, p: &TypingParams, rng: &mut impl Rng) -> Duration {
let mut ms = log_normal(p.gap_median_ms, p.gap_sigma, rng);
if ch.is_whitespace() || ch.is_ascii_punctuation() {
ms *= p.boundary_factor;
}
let mut ms = ms.clamp(p.gap_min_ms, p.gap_max_ms);
if rng.gen_bool(p.hesitate_chance) {
ms += rng.gen_range(p.hesitate_ms.clone()) as f64;
}
Duration::from_millis(ms.round() as u64)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UsKey {
pub code: &'static str,
pub key_code: u32,
pub shift: bool,
}
pub fn us_key(ch: char) -> Option<UsKey> {
const LETTER_CODES: [&str; 26] = [
"KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK",
"KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV",
"KeyW", "KeyX", "KeyY", "KeyZ",
];
const DIGIT_CODES: [&str; 10] = [
"Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8",
"Digit9",
];
const PUNCT: [(char, char, &str, u32); 11] = [
('-', '_', "Minus", 189),
('=', '+', "Equal", 187),
('[', '{', "BracketLeft", 219),
(']', '}', "BracketRight", 221),
('\\', '|', "Backslash", 220),
(';', ':', "Semicolon", 186),
('\'', '"', "Quote", 222),
(',', '<', "Comma", 188),
('.', '>', "Period", 190),
('/', '?', "Slash", 191),
('`', '~', "Backquote", 192),
];
const SHIFTED_DIGITS: &str = ")!@#$%^&*(";
let key = |code, key_code, shift| {
Some(UsKey {
code,
key_code,
shift,
})
};
match ch {
'a'..='z' => key(
LETTER_CODES[ch as usize - 'a' as usize],
ch as u32 - 32,
false,
),
'A'..='Z' => key(LETTER_CODES[ch as usize - 'A' as usize], ch as u32, true),
'0'..='9' => key(DIGIT_CODES[ch as usize - '0' as usize], ch as u32, false),
' ' => key("Space", 32, false),
_ => {
if let Some(i) = SHIFTED_DIGITS.find(ch) {
return key(DIGIT_CODES[i], 48 + i as u32, true);
}
PUNCT.iter().find_map(|&(plain, shifted, code, key_code)| {
if ch == plain {
key(code, key_code, false)
} else if ch == shifted {
key(code, key_code, true)
} else {
None
}
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn ms(d: Duration) -> f64 {
d.as_secs_f64() * 1000.0
}
#[test]
fn one_timing_per_char_and_no_pause_after_last() {
let p = TypingParams::default();
let t = timings("naïve café", &p, &mut StdRng::seed_from_u64(1));
assert_eq!(t.len(), 10, "counts chars, not bytes");
assert_eq!(t.last().unwrap().gap, Duration::ZERO);
assert!(t
.iter()
.all(|k| p.hold_ms.contains(&(k.hold.as_millis() as u64))));
}
#[test]
fn gaps_stay_in_range_and_are_uneven() {
let p = TypingParams::default();
let gaps: Vec<f64> = timings(&"a".repeat(200), &p, &mut StdRng::seed_from_u64(7))
.iter()
.take(199)
.map(|k| ms(k.gap))
.collect();
let max = p.gap_max_ms + p.hesitate_ms.end as f64;
assert!(gaps.iter().all(|&g| (p.gap_min_ms..=max).contains(&g)));
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
let sd = (gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / gaps.len() as f64).sqrt();
assert!(sd > 50.0, "standard deviation {sd} ms — too regular");
}
#[test]
fn pause_after_space_is_longer() {
let p = TypingParams::default();
let mut rng = StdRng::seed_from_u64(3);
let median = |c: char, rng: &mut StdRng| {
let mut v: Vec<f64> = (0..401).map(|_| ms(gap_after(c, &p, rng))).collect();
v.sort_by(f64::total_cmp);
v[200]
};
let (word, space) = (median('a', &mut rng), median(' ', &mut rng));
assert!(space > word * 1.15, "after space {space} vs in word {word}");
}
#[test]
fn budget_scales_long_text_and_leaves_short_text() {
let p = TypingParams {
total_budget: Some(Duration::from_secs(5)),
..TypingParams::default()
};
let long = timings(&"x".repeat(2_000), &p, &mut StdRng::seed_from_u64(5));
let total: Duration = long.iter().map(|k| k.hold + k.gap).sum();
assert!(total <= Duration::from_millis(5_005), "{total:?}");
let short = timings("hello", &p, &mut StdRng::seed_from_u64(5));
assert!(short
.iter()
.all(|k| k.hold >= Duration::from_millis(p.hold_ms.start)));
let free = timings(
&"x".repeat(2_000),
&TypingParams::default(),
&mut StdRng::seed_from_u64(5),
);
assert!(free.iter().map(|k| k.hold + k.gap).sum::<Duration>() > Duration::from_secs(60));
}
#[test]
fn us_key_maps_letters_digits_and_shifted_symbols() {
assert_eq!(
us_key('h'),
Some(UsKey {
code: "KeyH",
key_code: 72,
shift: false
})
);
assert_eq!(
us_key('H'),
Some(UsKey {
code: "KeyH",
key_code: 72,
shift: true
})
);
assert_eq!(
us_key('@'),
Some(UsKey {
code: "Digit2",
key_code: 50,
shift: true
})
);
assert_eq!(
us_key('?'),
Some(UsKey {
code: "Slash",
key_code: 191,
shift: true
})
);
assert_eq!(us_key(' ').map(|k| k.code), Some("Space"));
assert_eq!(us_key('é'), None);
}
}