Skip to main content

veilus_human_input/
keyboard.rs

1//! Typing rhythm and US keyboard layout.
2//!
3//! The pause between two keys is log-normal (a long right tail), longer after
4//! a space or punctuation, with an occasional hesitation. Each key is held for
5//! a random time between keydown and keyup.
6
7use std::ops::Range;
8use std::time::Duration;
9
10use rand::Rng;
11
12use crate::sample::log_normal;
13
14/// Every tunable number of the typing model.
15#[derive(Debug, Clone, PartialEq)]
16pub struct TypingParams {
17    /// Pause between two keys: log-normal median and sigma, clamped.
18    pub gap_median_ms: f64,
19    pub gap_sigma: f64,
20    pub gap_min_ms: f64,
21    pub gap_max_ms: f64,
22    /// Multiplier for the pause after whitespace or punctuation.
23    pub boundary_factor: f64,
24    /// Chance of an extra hesitation, and its length.
25    pub hesitate_chance: f64,
26    pub hesitate_ms: Range<u64>,
27    /// Key hold time between keydown and keyup.
28    pub hold_ms: Range<u64>,
29    /// Cap on the total duration of one `timings` call; longer text is
30    /// scaled down uniformly to fit. `None` keeps the human pace however long.
31    pub total_budget: Option<Duration>,
32}
33
34impl Default for TypingParams {
35    fn default() -> Self {
36        Self {
37            gap_median_ms: 180.0,
38            gap_sigma: 0.5,
39            gap_min_ms: 50.0,
40            gap_max_ms: 800.0,
41            boundary_factor: 1.3,
42            hesitate_chance: 0.08,
43            hesitate_ms: 300..1000,
44            hold_ms: 70..130,
45            total_budget: None,
46        }
47    }
48}
49
50/// Timing of one character: hold, then pause before the next key (zero for
51/// the last character).
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct KeyTiming {
54    pub hold: Duration,
55    pub gap: Duration,
56}
57
58/// Timings for every character of `text` (chars, not bytes).
59pub fn timings(text: &str, p: &TypingParams, rng: &mut impl Rng) -> Vec<KeyTiming> {
60    let chars: Vec<char> = text.chars().collect();
61    let out: Vec<KeyTiming> = chars
62        .iter()
63        .enumerate()
64        .map(|(i, &ch)| KeyTiming {
65            hold: Duration::from_millis(rng.gen_range(p.hold_ms.clone())),
66            gap: if i + 1 == chars.len() {
67                Duration::ZERO
68            } else {
69                gap_after(ch, p, rng)
70            },
71        })
72        .collect();
73
74    let total: Duration = out.iter().map(|k| k.hold + k.gap).sum();
75    match p.total_budget {
76        Some(budget) if total > budget => {
77            let scale = budget.as_secs_f64() / total.as_secs_f64();
78            out.into_iter()
79                .map(|k| KeyTiming {
80                    hold: k.hold.mul_f64(scale),
81                    gap: k.gap.mul_f64(scale),
82                })
83                .collect()
84        }
85        _ => out,
86    }
87}
88
89fn gap_after(ch: char, p: &TypingParams, rng: &mut impl Rng) -> Duration {
90    let mut ms = log_normal(p.gap_median_ms, p.gap_sigma, rng);
91    if ch.is_whitespace() || ch.is_ascii_punctuation() {
92        ms *= p.boundary_factor;
93    }
94    let mut ms = ms.clamp(p.gap_min_ms, p.gap_max_ms);
95    if rng.gen_bool(p.hesitate_chance) {
96        ms += rng.gen_range(p.hesitate_ms.clone()) as f64;
97    }
98    Duration::from_millis(ms.round() as u64)
99}
100
101/// Physical key of a character on a US keyboard: `KeyboardEvent.code`, the
102/// Windows virtual key code (`keyCode`), and whether Shift is held.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct UsKey {
105    pub code: &'static str,
106    pub key_code: u32,
107    pub shift: bool,
108}
109
110/// `None` for characters not on a US keyboard (accented letters, emoji);
111/// typing those needs an input method, which this crate does not model.
112pub fn us_key(ch: char) -> Option<UsKey> {
113    const LETTER_CODES: [&str; 26] = [
114        "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK",
115        "KeyL", "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV",
116        "KeyW", "KeyX", "KeyY", "KeyZ",
117    ];
118    const DIGIT_CODES: [&str; 10] = [
119        "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8",
120        "Digit9",
121    ];
122    // (unshifted, shifted, code, Windows keyCode)
123    const PUNCT: [(char, char, &str, u32); 11] = [
124        ('-', '_', "Minus", 189),
125        ('=', '+', "Equal", 187),
126        ('[', '{', "BracketLeft", 219),
127        (']', '}', "BracketRight", 221),
128        ('\\', '|', "Backslash", 220),
129        (';', ':', "Semicolon", 186),
130        ('\'', '"', "Quote", 222),
131        (',', '<', "Comma", 188),
132        ('.', '>', "Period", 190),
133        ('/', '?', "Slash", 191),
134        ('`', '~', "Backquote", 192),
135    ];
136    const SHIFTED_DIGITS: &str = ")!@#$%^&*(";
137    let key = |code, key_code, shift| {
138        Some(UsKey {
139            code,
140            key_code,
141            shift,
142        })
143    };
144    match ch {
145        'a'..='z' => key(
146            LETTER_CODES[ch as usize - 'a' as usize],
147            ch as u32 - 32,
148            false,
149        ),
150        'A'..='Z' => key(LETTER_CODES[ch as usize - 'A' as usize], ch as u32, true),
151        '0'..='9' => key(DIGIT_CODES[ch as usize - '0' as usize], ch as u32, false),
152        ' ' => key("Space", 32, false),
153        _ => {
154            if let Some(i) = SHIFTED_DIGITS.find(ch) {
155                return key(DIGIT_CODES[i], 48 + i as u32, true);
156            }
157            PUNCT.iter().find_map(|&(plain, shifted, code, key_code)| {
158                if ch == plain {
159                    key(code, key_code, false)
160                } else if ch == shifted {
161                    key(code, key_code, true)
162                } else {
163                    None
164                }
165            })
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use rand::rngs::StdRng;
174    use rand::SeedableRng;
175
176    fn ms(d: Duration) -> f64 {
177        d.as_secs_f64() * 1000.0
178    }
179
180    #[test]
181    fn one_timing_per_char_and_no_pause_after_last() {
182        let p = TypingParams::default();
183        let t = timings("naïve café", &p, &mut StdRng::seed_from_u64(1));
184        assert_eq!(t.len(), 10, "counts chars, not bytes");
185        assert_eq!(t.last().unwrap().gap, Duration::ZERO);
186        assert!(t
187            .iter()
188            .all(|k| p.hold_ms.contains(&(k.hold.as_millis() as u64))));
189    }
190
191    #[test]
192    fn gaps_stay_in_range_and_are_uneven() {
193        let p = TypingParams::default();
194        let gaps: Vec<f64> = timings(&"a".repeat(200), &p, &mut StdRng::seed_from_u64(7))
195            .iter()
196            .take(199)
197            .map(|k| ms(k.gap))
198            .collect();
199        let max = p.gap_max_ms + p.hesitate_ms.end as f64;
200        assert!(gaps.iter().all(|&g| (p.gap_min_ms..=max).contains(&g)));
201        let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
202        let sd = (gaps.iter().map(|g| (g - mean).powi(2)).sum::<f64>() / gaps.len() as f64).sqrt();
203        assert!(sd > 50.0, "standard deviation {sd} ms — too regular");
204    }
205
206    #[test]
207    fn pause_after_space_is_longer() {
208        let p = TypingParams::default();
209        let mut rng = StdRng::seed_from_u64(3);
210        // Median, not mean: hesitations make the mean too noisy.
211        let median = |c: char, rng: &mut StdRng| {
212            let mut v: Vec<f64> = (0..401).map(|_| ms(gap_after(c, &p, rng))).collect();
213            v.sort_by(f64::total_cmp);
214            v[200]
215        };
216        let (word, space) = (median('a', &mut rng), median(' ', &mut rng));
217        assert!(space > word * 1.15, "after space {space} vs in word {word}");
218    }
219
220    #[test]
221    fn budget_scales_long_text_and_leaves_short_text() {
222        let p = TypingParams {
223            total_budget: Some(Duration::from_secs(5)),
224            ..TypingParams::default()
225        };
226        let long = timings(&"x".repeat(2_000), &p, &mut StdRng::seed_from_u64(5));
227        let total: Duration = long.iter().map(|k| k.hold + k.gap).sum();
228        assert!(total <= Duration::from_millis(5_005), "{total:?}");
229        // Control row: short text is not scaled.
230        let short = timings("hello", &p, &mut StdRng::seed_from_u64(5));
231        assert!(short
232            .iter()
233            .all(|k| k.hold >= Duration::from_millis(p.hold_ms.start)));
234        // And no budget means no scaling at all.
235        let free = timings(
236            &"x".repeat(2_000),
237            &TypingParams::default(),
238            &mut StdRng::seed_from_u64(5),
239        );
240        assert!(free.iter().map(|k| k.hold + k.gap).sum::<Duration>() > Duration::from_secs(60));
241    }
242
243    #[test]
244    fn us_key_maps_letters_digits_and_shifted_symbols() {
245        assert_eq!(
246            us_key('h'),
247            Some(UsKey {
248                code: "KeyH",
249                key_code: 72,
250                shift: false
251            })
252        );
253        assert_eq!(
254            us_key('H'),
255            Some(UsKey {
256                code: "KeyH",
257                key_code: 72,
258                shift: true
259            })
260        );
261        assert_eq!(
262            us_key('@'),
263            Some(UsKey {
264                code: "Digit2",
265                key_code: 50,
266                shift: true
267            })
268        );
269        assert_eq!(
270            us_key('?'),
271            Some(UsKey {
272                code: "Slash",
273                key_code: 191,
274                shift: true
275            })
276        );
277        assert_eq!(us_key(' ').map(|k| k.code), Some("Space"));
278        assert_eq!(us_key('é'), None);
279    }
280}