1use std::collections::BTreeMap;
4use std::time::{Duration, Instant};
5
6use crate::layout;
7use crate::store::{LetterStat, Progress};
8
9pub const HINT_AFTER: Duration = Duration::from_millis(1200);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Mode {
14 Recall,
15 Words,
16 Review,
17 Real,
18 Links,
19 Mirror,
20}
21
22impl Mode {
23 pub const ALL: [Mode; 6] = [
24 Mode::Recall,
25 Mode::Words,
26 Mode::Review,
27 Mode::Real,
28 Mode::Links,
29 Mode::Mirror,
30 ];
31
32 pub fn id(self) -> &'static str {
34 match self {
35 Mode::Recall => "recall",
36 Mode::Words => "words",
37 Mode::Review => "review",
38 Mode::Real => "real",
39 Mode::Links => "links",
40 Mode::Mirror => "mirror",
41 }
42 }
43
44 pub fn label(self) -> &'static str {
45 match self {
46 Mode::Recall => "key recall",
47 Mode::Words => "new keys",
48 Mode::Review => "review",
49 Mode::Real => "real text 60s",
50 Mode::Links => "markdown links",
51 Mode::Mirror => "mirror (bonus)",
52 }
53 }
54
55 pub fn label_for(id: &str) -> &'static str {
56 Mode::ALL
57 .iter()
58 .find(|m| m.id() == id)
59 .map(|m| m.label())
60 .unwrap_or("other")
61 }
62
63 pub fn time_limit(self) -> Option<Duration> {
64 (self == Mode::Real).then(|| Duration::from_secs(60))
65 }
66
67 pub fn describe(self, stage: usize) -> String {
68 match self {
69 Mode::Recall if stage == 0 => "Single home-row keys. Pick a stage to drill its moved keys.".into(),
70 Mode::Recall => format!(
71 "One key at a time from this stage's moves: {}. Keys you miss or hesitate on come back more often.",
72 layout::focus(stage).iter().map(char::to_string).collect::<Vec<_>>().join(" ")
73 ),
74 Mode::Words if stage == 0 => "Common words from your own typing.".into(),
75 Mode::Words => "30 words you actually type, heavy in this stage's moved keys. The gate counts this one.".into(),
76 Mode::Review if stage < 2 => "Same as new keys until stage 2.".into(),
77 Mode::Review => format!("Words from every stage so far (1 to {stage}), so earlier swaps stay sharp."),
78 Mode::Real if stage == 0 => "60 seconds of your most common words. Three of these set your baseline.".into(),
79 Mode::Real => "60 seconds of your most common words. The gate compares this with your baseline.".into(),
80 Mode::Links => "[text](link) shapes for your symbol layers. The gate ignores it.".into(),
81 Mode::Mirror => "Left hand only. Hold the mirror key for right-hand letters; the second letter on each left key is what it types then. The gate ignores it.".into(),
82 }
83 }
84}
85
86pub struct Rng(u64);
88
89impl Rng {
90 pub fn new() -> Rng {
91 let seed = std::time::SystemTime::now()
92 .duration_since(std::time::UNIX_EPOCH)
93 .map(|d| d.as_nanos() as u64)
94 .unwrap_or(0x9e37_79b9_7f4a_7c15);
95 Rng::seeded(seed)
96 }
97
98 pub fn seeded(seed: u64) -> Rng {
99 Rng(seed | 1)
100 }
101
102 pub fn next_u64(&mut self) -> u64 {
103 let mut x = self.0;
104 x ^= x << 13;
105 x ^= x >> 7;
106 x ^= x << 17;
107 self.0 = x;
108 x
109 }
110
111 pub fn unit(&mut self) -> f64 {
113 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
114 }
115
116 pub fn below(&mut self, n: usize) -> usize {
117 (self.unit() * n as f64) as usize
118 }
119
120 pub fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
121 &items[self.below(items.len())]
122 }
123}
124
125impl Default for Rng {
126 fn default() -> Self {
127 Rng::new()
128 }
129}
130
131fn stage_words(stage: usize) -> Vec<String> {
133 let own = layout::words(stage);
134 if stage > 0 && !own.is_empty() {
135 return own.to_vec();
136 }
137 let focus = layout::focus(stage);
138 let common = layout::common_words();
139 let picked: Vec<String> = common
140 .iter()
141 .filter(|w| w.chars().any(|c| focus.contains(&c)))
142 .take(80)
143 .cloned()
144 .collect();
145 if picked.len() >= 10 {
146 picked
147 } else {
148 common.iter().take(200).cloned().collect()
149 }
150}
151
152fn words_from(pool: &[String], n: usize, rng: &mut Rng) -> String {
153 (0..n)
154 .map(|_| rng.pick(pool).as_str())
155 .collect::<Vec<_>>()
156 .join(" ")
157}
158
159fn weighted_char(stage: usize, progress: &Progress, rng: &mut Rng) -> char {
161 let mut focus = layout::focus(stage);
162 if focus.is_empty() {
163 focus = "asdfjkl;".chars().collect();
164 }
165 let stats = progress.letters(stage);
166 let weights: Vec<f64> = focus
167 .iter()
168 .map(|c| match stats.get(&c.to_string()) {
169 Some(s) if s.n > 0 => {
170 1.0 + 12.0 * (s.miss as f64 / s.n as f64) + s.ms / s.n as f64 / 350.0
171 }
172 _ => 3.0,
173 })
174 .collect();
175 let mut t = weights.iter().sum::<f64>() * rng.unit();
176 for (c, w) in focus.iter().zip(&weights) {
177 t -= w;
178 if t <= 0.0 {
179 return *c;
180 }
181 }
182 focus[0]
183}
184
185pub fn build(mode: Mode, stage: usize, progress: &Progress, rng: &mut Rng) -> String {
186 let common = layout::common_words();
187 match mode {
188 Mode::Recall => {
189 let several = layout::focus(stage).len() > 1;
190 let mut out = String::new();
191 let mut prev = None;
192 while out.chars().count() < 40 {
193 let c = weighted_char(stage, progress, rng);
194 if several && Some(c) == prev {
195 continue;
196 }
197 out.push(c);
198 prev = Some(c);
199 }
200 out
201 }
202 Mode::Words => words_from(&stage_words(stage), 30, rng),
203 Mode::Review => {
204 let pool: Vec<String> = if stage < 2 {
205 stage_words(stage)
206 } else {
207 (1..=stage).flat_map(stage_words).collect()
208 };
209 words_from(&pool, 30, rng)
210 }
211 Mode::Real => (0..260)
212 .map(|_| common[(rng.unit().powf(2.2) * common.len() as f64) as usize].as_str())
213 .collect::<Vec<_>>()
214 .join(" "),
215 Mode::Links => {
216 let top = &common[..300.min(common.len())];
217 (0..10)
218 .map(|_| {
219 let (a, b, c) = (rng.pick(top), rng.pick(top), rng.pick(top));
220 if rng.unit() < 0.5 {
221 format!("see [{a} {b}]({c}.md)")
222 } else {
223 format!("[{a}](https://{b}.dev/{c})")
224 }
225 })
226 .collect::<Vec<_>>()
227 .join(" ")
228 }
229 Mode::Mirror => {
230 let pool: Vec<String> = common
231 .iter()
232 .take(150)
233 .filter(|w| w.len() >= 2 && w.chars().all(|c| c.is_ascii_lowercase()))
234 .cloned()
235 .collect();
236 words_from(&pool, 20, rng)
237 }
238 }
239}
240
241pub struct Drill {
242 pub mode: Mode,
243 pub stage: usize,
244 pub text: Vec<char>,
245 pub pos: usize,
246 pub keys: u64,
247 pub misses: u64,
248 pub error: bool,
249 pub done: bool,
250 started: Option<Instant>,
251 last_key: Option<Instant>,
252 last_progress: Instant,
253 finished_at: Option<Instant>,
254 log: BTreeMap<String, LetterStat>,
255}
256
257impl Drill {
258 pub fn new(mode: Mode, stage: usize, text: String, now: Instant) -> Drill {
259 Drill {
260 mode,
261 stage,
262 text: text.chars().collect(),
263 pos: 0,
264 keys: 0,
265 misses: 0,
266 error: false,
267 done: false,
268 started: None,
269 last_key: None,
270 last_progress: now,
271 finished_at: None,
272 log: BTreeMap::new(),
273 }
274 }
275
276 pub fn expected(&self) -> Option<char> {
277 (!self.done)
278 .then(|| self.text.get(self.pos).copied())
279 .flatten()
280 }
281
282 pub fn started(&self) -> bool {
283 self.started.is_some()
284 }
285
286 pub fn touch(&mut self, now: Instant) {
288 self.last_progress = now;
289 }
290
291 pub fn press(&mut self, c: char, now: Instant) {
293 if self.done {
294 return;
295 }
296 let Some(want) = self.expected() else { return };
297 if self.started.is_none() {
298 self.started = Some(now);
299 self.last_key = Some(now);
300 }
301 self.keys += 1;
302 let stat = self.log.entry(want.to_string()).or_default();
303 if c == want {
304 if want != ' ' && self.pos > 0 {
305 let gap = now - self.last_key.unwrap_or(now);
306 stat.n += 1;
307 stat.ms += gap.min(Duration::from_secs(3)).as_secs_f64() * 1000.0;
308 }
309 self.pos += 1;
310 self.last_key = Some(now);
311 self.last_progress = now;
312 self.error = false;
313 if self.pos >= self.text.len() {
314 self.finish(now);
315 }
316 } else {
317 self.misses += 1;
318 stat.miss += 1;
319 self.error = true;
320 }
321 }
322
323 pub fn tick(&mut self, now: Instant) {
325 if let (Some(limit), false) = (self.mode.time_limit(), self.done)
326 && self.elapsed(now) >= limit
327 {
328 self.finish(now);
329 }
330 }
331
332 fn finish(&mut self, now: Instant) {
333 self.done = true;
334 self.finished_at = Some(now);
335 }
336
337 pub fn elapsed(&self, now: Instant) -> Duration {
338 match self.started {
339 Some(s) => self.finished_at.unwrap_or(now) - s,
340 None => Duration::ZERO,
341 }
342 }
343
344 pub fn remaining(&self, now: Instant) -> Option<Duration> {
345 self.mode
346 .time_limit()
347 .map(|l| l.saturating_sub(self.elapsed(now)))
348 }
349
350 pub fn wpm(&self, now: Instant) -> f64 {
351 let minutes = self.elapsed(now).as_secs_f64().max(1.0) / 60.0;
352 self.pos as f64 / 5.0 / minutes
353 }
354
355 pub fn accuracy(&self) -> f64 {
356 if self.keys == 0 {
357 100.0
358 } else {
359 100.0 * (self.keys - self.misses) as f64 / self.keys as f64
360 }
361 }
362
363 pub fn hesitating(&self, now: Instant) -> bool {
364 !self.done && now - self.last_progress >= HINT_AFTER
365 }
366
367 pub fn letters(&self) -> &BTreeMap<String, LetterStat> {
368 &self.log
369 }
370
371 pub fn slowest(&self, n: usize) -> Vec<(char, f64, u64)> {
373 let mut v: Vec<(char, f64, u64)> = self
374 .log
375 .iter()
376 .filter_map(|(c, s)| {
377 let ch = c.chars().next()?;
378 Some((ch, s.average_ms()?, s.miss))
379 })
380 .collect();
381 v.sort_by(|a, b| b.1.total_cmp(&a.1));
382 v.truncate(n);
383 v
384 }
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390
391 fn at(start: Instant, ms: u64) -> Instant {
392 start + Duration::from_millis(ms)
393 }
394
395 #[test]
396 fn wrong_keys_count_as_misses_and_do_not_advance() {
397 let t0 = Instant::now();
398 let mut d = Drill::new(Mode::Words, 1, "ab".into(), t0);
399 d.press('x', at(t0, 0));
400 assert_eq!((d.pos, d.misses, d.error), (0, 1, true));
401 d.press('a', at(t0, 100));
402 d.press('b', at(t0, 400));
403 assert!(d.done);
404 assert_eq!(d.keys, 3);
405 assert!((d.accuracy() - 66.666).abs() < 0.01);
406 assert_eq!(d.letters()["b"].ms, 300.0);
407 assert_eq!(d.letters()["a"].miss, 1);
408 }
409
410 #[test]
411 fn timed_drills_stop_at_the_limit() {
412 let t0 = Instant::now();
413 let mut d = Drill::new(Mode::Real, 0, "a".repeat(500), t0);
414 d.press('a', t0);
415 d.tick(at(t0, 59_000));
416 assert!(!d.done);
417 d.tick(at(t0, 60_000));
418 assert!(d.done);
419 assert_eq!(d.elapsed(at(t0, 90_000)), Duration::from_secs(60));
420 }
421
422 #[test]
423 fn wpm_is_characters_over_five_per_minute() {
424 let t0 = Instant::now();
425 let mut d = Drill::new(Mode::Words, 1, "hello world".into(), t0);
426 for (i, c) in "hello world".chars().enumerate() {
427 d.press(c, at(t0, i as u64 * 1200));
428 }
429 assert!((d.wpm(at(t0, 99_000)) - 11.0).abs() < 0.01);
431 }
432
433 #[test]
434 fn the_hint_shows_after_a_pause() {
435 let t0 = Instant::now();
436 let d = Drill::new(Mode::Recall, 1, "e".into(), t0);
437 assert!(!d.hesitating(at(t0, 1000)));
438 assert!(d.hesitating(at(t0, 1300)));
439 }
440
441 #[test]
442 fn recall_only_uses_the_stage_keys() {
443 let mut rng = Rng::seeded(42);
444 let text = build(Mode::Recall, 3, &Progress::default(), &mut rng);
445 let focus = layout::focus(3);
446 assert_eq!(text.chars().count(), 40);
447 assert!(text.chars().all(|c| focus.contains(&c)));
448 assert!(text.chars().zip(text.chars().skip(1)).all(|(a, b)| a != b));
449 }
450
451 #[test]
452 fn every_mode_builds_text() {
453 let mut rng = Rng::seeded(7);
454 for stage in 0..=layout::last() {
455 for mode in Mode::ALL {
456 assert!(!build(mode, stage, &Progress::default(), &mut rng).is_empty());
457 }
458 }
459 }
460}