1use std::ops::Range;
8use std::time::Duration;
9
10use rand::Rng;
11
12use crate::sample::log_normal;
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct TypingParams {
17 pub gap_median_ms: f64,
19 pub gap_sigma: f64,
20 pub gap_min_ms: f64,
21 pub gap_max_ms: f64,
22 pub boundary_factor: f64,
24 pub hesitate_chance: f64,
26 pub hesitate_ms: Range<u64>,
27 pub hold_ms: Range<u64>,
29 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#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct KeyTiming {
54 pub hold: Duration,
55 pub gap: Duration,
56}
57
58pub 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#[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
110pub 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 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 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 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 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}