1use std::path::PathBuf;
4use std::time::Instant;
5
6use ratatui::Frame;
7use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8use ratatui::layout::{Constraint, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Paragraph, Wrap};
12
13use crate::drill::{Drill, Mode, Rng, build};
14use crate::gate;
15use crate::layout;
16use crate::store::{Progress, Run};
17use crate::warmup::{self, Warmup};
18use crate::{coach, oryx, report};
19
20const PINK: Color = Color::Magenta;
21const TEAL: Color = Color::Cyan;
22const ABANDON_WINDOW: std::time::Duration = std::time::Duration::from_secs(10 * 60);
24
25pub struct App {
26 pub progress: Progress,
27 path: PathBuf,
28 leaning: Vec<char>,
30 mode: Mode,
31 drill: Drill,
32 typing: bool,
33 rng: Rng,
34 message: Option<(String, bool)>,
36 check: Vec<String>,
38 layout_id: String,
39 override_armed: bool,
41 warmup: Option<Warmup>,
43 abandons: Vec<Instant>,
46 pub should_quit: bool,
47}
48
49fn now_ms() -> u64 {
50 std::time::SystemTime::now()
51 .duration_since(std::time::UNIX_EPOCH)
52 .map(|d| d.as_millis() as u64)
53 .unwrap_or(0)
54}
55
56impl App {
57 pub fn new(progress: Progress, path: PathBuf, now: Instant) -> App {
58 let mut rng = Rng::new();
59 let stage = progress.stage.min(layout::last());
60 let leaning = crate::drill::leaning(Mode::Words, stage, &progress);
61 let drill = Drill::new(
62 Mode::Words,
63 stage,
64 build(Mode::Words, stage, &progress, &mut rng),
65 now,
66 );
67 App {
68 progress,
69 path,
70 leaning,
71 mode: Mode::Words,
72 drill,
73 typing: false,
74 rng,
75 message: None,
76 check: Vec::new(),
77 layout_id: oryx::layout_id(),
78 override_armed: false,
79 warmup: None,
80 abandons: Vec::new(),
81 should_quit: false,
82 }
83 }
84
85 fn stage(&self) -> usize {
86 self.progress.stage.min(layout::last())
87 }
88
89 fn save(&mut self) {
90 if let Err(e) = self.progress.save(&self.path) {
91 self.message = Some((format!("Couldn't save progress: {e}"), false));
92 }
93 }
94
95 fn new_drill(&mut self, now: Instant) {
96 let text = build(self.mode, self.stage(), &self.progress, &mut self.rng);
97 self.leaning = crate::drill::leaning(self.mode, self.stage(), &self.progress);
98 self.drill = Drill::new(self.mode, self.stage(), text, now);
99 }
100
101 fn set_stage(&mut self, stage: usize, now: Instant) {
102 self.progress.stage = stage.min(layout::last());
103 self.check.clear();
104 self.message = None;
105 self.save();
106 self.new_drill(now);
107 }
108
109 pub fn tick(&mut self, now: Instant) {
111 if self.warmup.as_ref().is_some_and(|w| w.done(now)) {
112 self.end_warmup(true);
113 }
114 if self.typing {
115 self.drill.tick(now);
116 if self.drill.done {
117 self.finish(now);
118 }
119 }
120 }
121
122 fn needs_warmup(&self) -> bool {
123 self.progress.warmed_up.as_deref() != Some(coach::today(now_ms()).as_str())
124 }
125
126 fn end_warmup(&mut self, completed: bool) {
127 self.warmup = None;
128 self.progress.warmed_up = Some(coach::today(now_ms()));
129 self.save();
130 self.message = Some(if completed {
131 (
132 "Warmed up. Fingers loose, shoulders down. Press enter to start.".into(),
133 true,
134 )
135 } else {
136 (
137 "Skipping the warm-up? Bold. Your wrists will remember this.".into(),
138 false,
139 )
140 });
141 }
142
143 fn finish(&mut self, now: Instant) {
144 self.typing = false;
145 let (stage, mode) = (self.drill.stage, self.drill.mode);
146 let wpm = (self.drill.wpm(now) * 10.0).round() / 10.0;
148 let acc = (self.drill.accuracy() * 10.0).round() / 10.0;
149 let previous_best = self
150 .progress
151 .runs_for(stage, mode.id())
152 .iter()
153 .map(|r| r.wpm)
154 .fold(None, |best: Option<f64>, w| {
155 Some(best.map_or(w, |b| b.max(w)))
156 });
157 let t = std::time::SystemTime::now()
158 .duration_since(std::time::UNIX_EPOCH)
159 .map(|d| d.as_millis() as u64)
160 .unwrap_or(0);
161 let run = Run {
162 t,
163 stage,
164 mode: mode.id().into(),
165 wpm,
166 acc,
167 secs: (self.drill.elapsed(now).as_secs_f64() * 10.0).round() / 10.0,
168 letters: self.drill.letters().clone(),
169 typos: self.drill.typos().clone(),
170 };
171 self.progress.record(run, self.drill.pairs());
172 self.save();
173
174 let mut good = true;
175 let verdict = match mode {
176 Mode::Words if stage > 0 => {
177 good = acc >= gate::WORDS_ACCURACY;
178 let hits = gate::words_hits(&self.progress, stage).min(gate::WORDS_NEEDED);
179 if good && hits >= gate::WORDS_NEEDED {
180 "That one counts, and that's the accuracy part of the gate done!".to_string()
181 } else if good {
182 format!("That one counts: {hits} of {}.", gate::WORDS_NEEDED)
183 } else {
184 "Not one for the gate this time. Every rep still teaches your fingers; slow and smooth wins.".to_string()
185 }
186 }
187 Mode::Real if stage == 0 => "Saved toward your baseline.".to_string(),
188 Mode::Real => match self.progress.baseline() {
189 Some(b) => {
190 let target = b * gate::SPEED_SHARE;
191 good = wpm >= target && acc >= gate::REAL_ACCURACY;
192 format!("Target is {target:.0} WPM at 95%+.")
193 }
194 None => "Record a baseline in stage 0 to get a target.".to_string(),
195 },
196 Mode::Recall => {
197 let slow: Vec<String> = self
198 .drill
199 .slowest(3)
200 .iter()
201 .map(|(c, ms, miss)| {
202 if *miss > 0 {
203 format!("{c} {ms:.0}ms, {miss} missed")
204 } else {
205 format!("{c} {ms:.0}ms")
206 }
207 })
208 .collect();
209 format!("Slowest: {}", slow.join(" · "))
210 }
211 _ => String::new(),
212 };
213 let record = match previous_best {
214 Some(best) if wpm > best && acc >= 90.0 => " New speed record for this stage!",
215 _ => "",
216 };
217 self.message = Some((
218 format!("{wpm:.0} WPM · {acc:.1}% accuracy. {verdict}{record}"),
219 good,
220 ));
221 }
222
223 pub fn handle_key(&mut self, key: KeyEvent, now: Instant) {
224 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
225 self.should_quit = true;
226 return;
227 }
228 if let Some(w) = &mut self.warmup {
229 match key.code {
230 KeyCode::Enter | KeyCode::Tab | KeyCode::Char(' ') => {
231 w.skip_step(now);
232 if w.done(now) {
233 self.end_warmup(true);
234 }
235 }
236 KeyCode::Char('s') => self.end_warmup(false),
237 KeyCode::Esc => {
238 self.warmup = None;
239 self.message = Some((
240 "Warm-up paused. Press enter when you're ready.".into(),
241 true,
242 ));
243 }
244 _ => {}
245 }
246 return;
247 }
248 if self.typing {
249 match key.code {
250 KeyCode::Esc => {
251 self.typing = false;
252 self.abandons
253 .retain(|t| now.duration_since(*t) < ABANDON_WINDOW);
254 self.abandons.push(now);
255 self.message = Some(if self.abandons.len() >= 3 {
256 (
257 "That's a few restarts in a row. Frustration makes fingers tense, and tense fingers miss. Take five and come back loose.".into(),
258 false,
259 )
260 } else {
261 ("Stopped. Nothing was saved.".into(), true)
262 });
263 self.new_drill(now);
264 }
265 KeyCode::Char(c)
266 if !key
267 .modifiers
268 .intersects(KeyModifiers::ALT | KeyModifiers::SUPER) =>
269 {
270 self.drill.press(c, now);
271 if self.drill.done {
272 self.finish(now);
273 }
274 }
275 _ => {}
276 }
277 return;
278 }
279 let stage = self.stage();
280 match key.code {
281 KeyCode::Char('q') => self.should_quit = true,
282 KeyCode::Left | KeyCode::Char('[') if stage > 0 => self.set_stage(stage - 1, now),
283 KeyCode::Right | KeyCode::Char(']') if stage < layout::last() => {
284 self.set_stage(stage + 1, now)
285 }
286 KeyCode::Char(d @ '1'..='6') => {
287 self.mode = Mode::ALL[d as usize - '1' as usize];
288 self.message = None;
289 self.new_drill(now);
290 }
291 KeyCode::Enter | KeyCode::Tab | KeyCode::Char(' ') => {
292 let advice = self.advice();
293 if advice.blocks() && !self.override_armed {
294 self.override_armed = true;
295 self.message = Some((
296 format!(
297 "{} Press enter again to practise anyway, rebel.",
298 advice.message
299 ),
300 false,
301 ));
302 return;
303 }
304 self.override_armed = false;
305 if self.needs_warmup() {
306 self.warmup = Some(Warmup::new(now));
307 self.message = None;
308 return;
309 }
310 if self.drill.done || self.drill.started() {
311 self.new_drill(now);
312 }
313 self.message = None;
314 self.drill.touch(now);
315 self.typing = true;
316 }
317 KeyCode::Char('u') => {
318 self.warmup = Some(Warmup::new(now));
319 self.message = None;
320 }
321 KeyCode::Char('n') => {
322 self.message = None;
323 self.new_drill(now);
324 }
325 KeyCode::Char('c') => {
326 self.check = match oryx::fetch(&self.layout_id) {
327 Ok(snap) => report::check(&snap, stage),
328 Err(e) => vec![format!("Couldn't read Oryx: {e}")],
329 };
330 }
331 KeyCode::Char('g') => {
332 let out = crate::charts::default_path(&self.path);
333 let now_ms = std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .map(|d| d.as_millis() as u64)
336 .unwrap_or(0);
337 self.message = Some(
338 match crate::charts::write(&self.progress, &out, now_ms)
339 .and_then(|()| crate::open_url(&out.display().to_string()))
340 {
341 Ok(()) => (format!("Charts opened from {}", out.display()), true),
342 Err(e) => (format!("Couldn't make the charts: {e}"), false),
343 },
344 );
345 }
346 KeyCode::Char('o') => {
347 let url = oryx::edit_url(&self.layout_id);
348 self.message = Some(match crate::open_url(&url) {
349 Ok(()) => (format!("Opened {url}"), true),
350 Err(e) => (format!("Couldn't open {url}: {e}"), false),
351 });
352 }
353 KeyCode::Char(c) => {
354 if let Some(tick) = gate::items(&self.progress, stage)
355 .iter()
356 .filter_map(|i| i.tick)
357 .find(|t| t.key() == c)
358 {
359 tick.toggle(&mut self.progress, stage);
360 self.save();
361 }
362 }
363 _ => {}
364 }
365 }
366
367 pub fn render(&self, frame: &mut Frame, now: Instant) {
368 let next_lines = report::next_stage_compact(self.stage(), "").len()
370 + if self.check.is_empty() {
371 0
372 } else {
373 self.check.len() + 1
374 };
375 let gate_lines = gate::items(&self.progress, self.stage())
376 .iter()
377 .map(|i| 1 + usize::from(!i.detail.is_empty()))
378 .sum::<usize>()
379 + 1;
380 let spare = frame.area().height.saturating_sub(3 + 1 + 5 + 1 + 8);
382 let middle_height = ((next_lines.max(gate_lines) + 2).min(18) as u16).min(spare);
383 let [header, blurb, keyboard, middle, trainer, footer] = Layout::vertical([
384 Constraint::Length(3),
385 Constraint::Length(1),
386 Constraint::Length(5),
387 Constraint::Length(middle_height),
388 Constraint::Fill(1),
389 Constraint::Length(1),
390 ])
391 .areas(frame.area());
392 self.render_header(frame, header);
393 frame.render_widget(
394 Paragraph::new(layout::blurb(self.stage()))
395 .style(dim())
396 .wrap(Wrap { trim: true }),
397 blurb,
398 );
399 self.render_keyboard(frame, keyboard, now);
400 let [next, gate] =
401 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
402 .areas(middle);
403 self.render_next(frame, next);
404 self.render_gate(frame, gate);
405 self.render_trainer(frame, trainer, now);
406 let help = if self.warmup.is_some() {
407 "enter next step · s skip the warm-up · esc pause"
408 } else if self.typing {
409 "type the text · esc stops without saving"
410 } else {
411 "←/→ stage · 1-6 drill · enter start · u warm-up · n new text · g charts · c check oryx · o open oryx · k/v/w tick · q quit"
412 };
413 frame.render_widget(Paragraph::new(help).style(dim()), footer);
414 }
415
416 fn render_header(&self, frame: &mut Frame, area: Rect) {
417 let s = self.stage();
418 let (home, sfb) = layout::stats(s);
419 let title = Line::from(vec![
420 Span::styled(
421 format!("{} ", layout::plan().name.to_lowercase()),
422 Style::new().fg(PINK).add_modifier(Modifier::BOLD),
423 ),
424 Span::styled(
425 format!("stage {s} of {} · {}", layout::last(), layout::name(s)),
426 Style::new().add_modifier(Modifier::BOLD),
427 ),
428 Span::styled(
429 format!(" {home}% home row · {sfb}% same-finger pairs"),
430 dim(),
431 ),
432 ]);
433 let mut steps = Vec::new();
434 for i in 0..=layout::last() {
435 let passed = gate::passed(&self.progress, i);
436 let style = if i == s {
437 Style::new()
438 .fg(Color::Black)
439 .bg(PINK)
440 .add_modifier(Modifier::BOLD)
441 } else if passed {
442 Style::new().fg(Color::Green)
443 } else {
444 dim()
445 };
446 steps.push(Span::styled(
447 format!(" {i}{} ", if passed { "✓" } else { "" }),
448 style,
449 ));
450 steps.push(Span::raw(" "));
451 }
452 let advice = self.advice();
453 let (icon, color) = match advice.level {
454 coach::Level::Good => ("✓", Color::Green),
455 coach::Level::Nudge => ("»", Color::Yellow),
456 coach::Level::Stop => ("✋", Color::LightRed),
457 };
458 let coach_line = Line::from(vec![
459 Span::styled(
460 format!("{icon} {}", advice.message),
461 Style::new().fg(color).add_modifier(Modifier::BOLD),
462 ),
463 Span::styled(
464 format!(
465 " today {:.0}/{:.0} min · session {:.0} min",
466 advice.today_minutes,
467 coach::DAILY_GOAL,
468 advice.session_minutes
469 ),
470 dim(),
471 ),
472 ]);
473 frame.render_widget(
474 Paragraph::new(vec![title, Line::from(steps), coach_line]),
475 area,
476 );
477 }
478
479 pub fn advice(&self) -> coach::Advice {
480 coach::assess(&self.progress.runs, now_ms())
481 }
482
483 fn render_keyboard(&self, frame: &mut Frame, area: Rect, now: Instant) {
484 let s = self.stage();
485 let keys = layout::keys(s);
486 let mirror_mode = self.mode == Mode::Mirror;
487 let hint = if self.typing && self.drill.hesitating(now) {
488 self.drill.expected()
489 } else {
490 None
491 };
492 let hinted = |index: usize| -> bool {
493 let Some(h) = hint else { return false };
494 if mirror_mode {
495 index % 10 < 5 && (keys[index] == h || keys[layout::mirror(index)] == h)
496 } else {
497 keys[index] == h
498 }
499 };
500 let mut lines = Vec::new();
501 for row in 0..3 {
502 let mut spans = vec![Span::styled(
503 format!("{:>4} ", layout::OUTER_LEFT[row]),
504 dim(),
505 )];
506 for col in 0..10 {
507 let i = row * 10 + col;
508 if col == 5 {
509 spans.push(Span::raw(" "));
510 }
511 let mut style = if layout::changed_now(s, i) {
512 Style::new().fg(PINK).add_modifier(Modifier::BOLD)
513 } else if layout::moved(s, i) {
514 Style::new().fg(TEAL)
515 } else {
516 Style::new()
517 };
518 if hinted(i) {
519 style = Style::new()
520 .fg(Color::Black)
521 .bg(Color::Yellow)
522 .add_modifier(Modifier::BOLD);
523 }
524 if mirror_mode && col < 5 {
525 spans.push(Span::styled(format!(" {}", keys[i]), style));
526 spans.push(Span::styled(
527 format!("{} ", keys[layout::mirror(i)]),
528 Style::new().fg(PINK),
529 ));
530 } else {
531 spans.push(Span::styled(format!(" {} ", keys[i]), style));
532 }
533 }
534 spans.push(Span::styled(
535 format!(" {}", layout::OUTER_RIGHT[row]),
536 dim(),
537 ));
538 lines.push(Line::from(spans));
539 }
540 let legend = Line::from(vec![
541 Span::styled("pink", Style::new().fg(PINK)),
542 Span::styled(" moves this stage · ", dim()),
543 Span::styled("teal", Style::new().fg(TEAL)),
544 Span::styled(" moved earlier · yellow: the key you're looking for", dim()),
545 ]);
546 let block = Block::bordered().title(" voyager ").title_bottom(legend);
547 frame.render_widget(Paragraph::new(lines).block(block), area);
548 }
549
550 fn render_next(&self, frame: &mut Frame, area: Rect) {
551 let s = self.stage();
552 let url = oryx::edit_url(&self.layout_id);
553 let mut lines: Vec<Line> = report::next_stage_compact(s, &url)
554 .into_iter()
555 .map(Line::from)
556 .collect();
557 if !self.check.is_empty() {
558 lines.push(Line::raw(""));
559 lines.extend(
560 self.check
561 .iter()
562 .map(|l| Line::styled(l.clone(), Style::new().fg(TEAL))),
563 );
564 }
565 let block = Block::bordered().title(" next oryx edits (o open · c check) ");
566 frame.render_widget(
567 Paragraph::new(lines)
568 .block(block)
569 .wrap(Wrap { trim: false }),
570 area,
571 );
572 }
573
574 fn render_gate(&self, frame: &mut Frame, area: Rect) {
575 let s = self.stage();
576 let mut lines = Vec::new();
577 for item in gate::items(&self.progress, s) {
578 let mark = if item.ok {
579 Span::styled("[✓] ", Style::new().fg(Color::Green))
580 } else {
581 Span::styled("[ ] ", dim())
582 };
583 let key = item
584 .tick
585 .map_or(String::new(), |t| format!(" ({})", t.key()));
586 lines.push(Line::from(vec![
587 mark,
588 Span::raw(item.label),
589 Span::styled(key, Style::new().fg(PINK)),
590 ]));
591 if !item.detail.is_empty() {
592 lines.push(Line::styled(format!(" {}", item.detail), dim()));
593 }
594 }
595 if gate::passed(&self.progress, s) {
596 let msg = if s < layout::last() {
597 format!(
598 "Gate passed. Make the stage {} edits, flash, then →.",
599 s + 1
600 )
601 } else {
602 format!("Every gate passed. You type {}.", layout::plan().name)
603 };
604 lines.push(Line::styled(msg, Style::new().fg(Color::Green)));
605 }
606 let block = Block::bordered().title(format!(
607 " gate before stage {} ",
608 (s + 1).min(layout::last())
609 ));
610 frame.render_widget(
611 Paragraph::new(lines)
612 .block(block)
613 .wrap(Wrap { trim: false }),
614 area,
615 );
616 }
617
618 fn render_warmup(&self, frame: &mut Frame, area: Rect, w: &Warmup, now: Instant) {
619 let current = w.current(now);
620 let mut lines = vec![
621 Line::styled(
622 format!(
623 "About {} seconds. Gentle: nothing should hurt.",
624 Warmup::total()
625 ),
626 dim(),
627 ),
628 Line::raw(""),
629 ];
630 for (i, step) in warmup::STEPS.iter().enumerate() {
631 let line = match current {
632 Some((c, left)) if c == i => Line::from(vec![
633 Span::styled(
634 format!(" ▶ {} ", step.title),
635 Style::new()
636 .fg(Color::Black)
637 .bg(PINK)
638 .add_modifier(Modifier::BOLD),
639 ),
640 Span::styled(format!(" {left}s "), Style::new().fg(PINK)),
641 Span::raw(step.how),
642 ]),
643 Some((c, _)) if c > i => {
644 Line::styled(format!(" ✓ {}", step.title), Style::new().fg(Color::Green))
645 }
646 _ => Line::styled(format!(" {} ({}s)", step.title, step.secs), dim()),
647 };
648 lines.push(line);
649 }
650 if let Some(b) = w.breath(now) {
651 lines.push(Line::raw(""));
652 lines.push(Line::styled(
653 format!(" {b}"),
654 Style::new().fg(TEAL).add_modifier(Modifier::BOLD),
655 ));
656 }
657 let block = Block::bordered()
658 .border_style(Style::new().fg(TEAL))
659 .title(" warm-up ");
660 frame.render_widget(
661 Paragraph::new(lines)
662 .block(block)
663 .wrap(Wrap { trim: false }),
664 area,
665 );
666 }
667
668 fn render_trainer(&self, frame: &mut Frame, area: Rect, now: Instant) {
669 if let Some(w) = &self.warmup {
670 self.render_warmup(frame, area, w, now);
671 return;
672 }
673 let s = self.stage();
674 let mut tabs = Vec::new();
675 for (n, m) in Mode::ALL.iter().enumerate() {
676 let style = if *m == self.mode {
677 Style::new()
678 .fg(Color::Black)
679 .bg(Color::White)
680 .add_modifier(Modifier::BOLD)
681 } else {
682 dim()
683 };
684 tabs.push(Span::styled(format!(" {} {} ", n + 1, m.label()), style));
685 tabs.push(Span::raw(" "));
686 }
687 let mut lines = vec![Line::from(tabs), Line::styled(self.mode.describe(s), dim())];
688 if let Some(line) = self.leaning_line() {
689 lines.push(line);
690 }
691 lines.push(Line::raw(""));
692 lines.extend(self.text_lines());
693 lines.push(Line::raw(""));
694 lines.push(self.status_line(now));
695 if let Some((msg, good)) = &self.message {
696 let color = if *good { Color::Green } else { Color::Yellow };
698 lines.push(Line::styled(msg.clone(), Style::new().fg(color)));
699 }
700 let recent: Vec<_> = self.progress.runs.iter().rev().take(3).collect();
701 if !recent.is_empty() {
702 lines.push(Line::raw(""));
703 for r in recent {
704 lines.push(Line::styled(
705 format!(
706 " stage {} · {:<14} {:>3.0} wpm {:>5.1}%",
707 r.stage,
708 Mode::label_for(&r.mode),
709 r.wpm,
710 r.acc
711 ),
712 dim(),
713 ));
714 }
715 }
716 let border = if self.typing {
717 Style::new().fg(PINK)
718 } else {
719 Style::new()
720 };
721 let block = Block::bordered().border_style(border).title(" trainer ");
722 frame.render_widget(
723 Paragraph::new(lines)
724 .block(block)
725 .wrap(Wrap { trim: false }),
726 area,
727 );
728 }
729
730 fn text_lines(&self) -> Vec<Line<'static>> {
731 let d = &self.drill;
732 if d.done {
733 return vec![Line::styled("Done. Press enter for another round.", dim())];
734 }
735 if d.mode == Mode::Recall {
736 let cur = d.expected().unwrap_or(' ');
737 let next: String = d
738 .text
739 .iter()
740 .skip(d.pos + 1)
741 .take(3)
742 .flat_map(|c| [*c, ' '])
743 .collect();
744 let style = if d.error {
745 Style::new()
746 .fg(Color::Black)
747 .bg(Color::LightRed)
748 .add_modifier(Modifier::BOLD)
749 } else {
750 Style::new()
751 .fg(Color::Black)
752 .bg(PINK)
753 .add_modifier(Modifier::BOLD)
754 };
755 return vec![Line::from(vec![
756 Span::raw(" "),
757 Span::styled(format!(" {cur} "), style),
758 Span::styled(format!(" next: {next}"), dim()),
759 ])];
760 }
761 let mut start = d.pos.saturating_sub(40);
762 while start > 0 && d.text[start - 1] != ' ' {
763 start -= 1;
764 }
765 let end = (start + 240).min(d.text.len());
766 let mut spans = Vec::new();
767 for i in start..end {
768 let c = d.text[i];
769 let style = if i < d.pos {
770 Style::new()
771 } else if i == d.pos {
772 let base = Style::new().add_modifier(Modifier::UNDERLINED | Modifier::BOLD);
773 if d.error {
774 base.bg(Color::LightRed).fg(Color::Black)
775 } else {
776 base.fg(PINK)
777 }
778 } else {
779 dim()
780 };
781 let shown = if c == ' ' && i == d.pos { '·' } else { c };
782 spans.push(Span::styled(shown.to_string(), style));
783 }
784 vec![Line::from(spans)]
785 }
786
787 fn leaning_line(&self) -> Option<Line<'static>> {
789 let keys: Vec<String> = self.leaning.iter().map(char::to_string).collect();
790 let named = match keys.as_slice() {
791 [] => return None,
792 [one] => one.clone(),
793 [rest @ .., last] => format!("{} and {last}", rest.join(", ")),
794 };
795 Some(Line::styled(
796 format!("A few extra words with {named} in them, your trickiest keys right now."),
797 dim(),
798 ))
799 }
800
801 fn status_line(&self, now: Instant) -> Line<'static> {
802 let d = &self.drill;
803 if !self.typing && self.needs_warmup() {
804 return Line::styled(
805 "Press enter to start. First session today, so a one-minute warm-up comes first.",
806 dim(),
807 );
808 }
809 if !self.typing {
810 return Line::styled(
811 "Press enter to start. If you hesitate, the key lights up on the map.",
812 dim(),
813 );
814 }
815 if !d.started() {
816 return Line::styled(
817 "Go! The timer starts on your first key.",
818 Style::new().fg(PINK),
819 );
820 }
821 let progress = match d.remaining(now) {
822 Some(r) => format!("{}s left", r.as_secs() + u64::from(r.subsec_millis() > 0)),
823 None => format!("{}/{}", d.pos, d.text.len()),
824 };
825 Line::styled(
826 format!(
827 "{progress} · {:.0} wpm · {:.1}% accuracy",
828 d.wpm(now),
829 d.accuracy()
830 ),
831 Style::new().fg(TEAL),
832 )
833 }
834}
835
836fn dim() -> Style {
837 Style::new().fg(Color::DarkGray)
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use ratatui::Terminal;
844 use ratatui::backend::TestBackend;
845 use ratatui::crossterm::event::KeyEventKind;
846
847 fn key(code: KeyCode) -> KeyEvent {
848 KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Press)
849 }
850
851 fn app() -> (App, PathBuf) {
852 let path = std::env::temp_dir().join(format!(
853 "very-normal-app-{}-{:?}.json",
854 std::process::id(),
855 std::thread::current().id()
856 ));
857 let p = Progress {
858 stage: 1,
859 warmed_up: Some(coach::today(now_ms())),
860 ..Default::default()
861 };
862 (App::new(p, path.clone(), Instant::now()), path)
863 }
864
865 fn screen(app: &App) -> String {
866 let mut t = Terminal::new(TestBackend::new(200, 40)).unwrap();
867 t.draw(|f| app.render(f, Instant::now())).unwrap();
868 t.backend()
869 .buffer()
870 .content()
871 .iter()
872 .map(|c| c.symbol())
873 .collect::<String>()
874 }
875
876 #[test]
877 fn shows_the_stage_and_the_next_edits() {
878 let (app, _) = app();
879 let s = screen(&app);
880 assert!(s.contains("stage 1 of 9"));
881 assert!(s.contains("Stage 2: I and O come home"));
882 assert!(s.contains("configure.zsa.io/voyager/layouts/"));
883 assert!(s.contains("keys: i→a o→k a→i k→o (keep alt)"));
884 }
885
886 #[test]
887 fn typing_a_whole_drill_records_a_run() {
888 let (mut app, path) = app();
889 let now = Instant::now();
890 app.handle_key(key(KeyCode::Enter), now);
891 let text: Vec<char> = app.drill.text.clone();
892 for c in text {
893 app.handle_key(key(KeyCode::Char(c)), now);
894 }
895 assert!(!app.typing);
896 assert_eq!(app.progress.runs.len(), 1);
897 assert_eq!(app.progress.runs[0].mode, "words");
898 assert_eq!(app.progress.runs[0].acc, 100.0);
899 assert!(Progress::load(&path).unwrap().runs.len() == 1);
900 std::fs::remove_file(path).ok();
901 }
902
903 #[test]
904 fn letters_typed_outside_a_drill_tick_boxes_instead() {
905 let (mut app, path) = app();
906 app.handle_key(key(KeyCode::Char('v')), Instant::now());
907 assert!(app.progress.manual(1).vim);
908 app.handle_key(key(KeyCode::Right), Instant::now());
909 assert_eq!(app.progress.stage, 2);
910 std::fs::remove_file(path).ok();
911 }
912
913 #[test]
914 fn escape_abandons_a_drill_without_saving() {
915 let (mut app, path) = app();
916 let now = Instant::now();
917 app.handle_key(key(KeyCode::Enter), now);
918 app.handle_key(key(KeyCode::Char('x')), now);
919 app.handle_key(key(KeyCode::Esc), now);
920 assert!(!app.typing);
921 assert!(app.progress.runs.is_empty());
922 std::fs::remove_file(path).ok();
923 }
924}
925
926#[cfg(test)]
927mod coach_tests {
928 use super::*;
929 use ratatui::crossterm::event::KeyEventKind;
930
931 #[test]
932 fn the_coach_makes_you_ask_twice_after_a_long_session() {
933 let path =
934 std::env::temp_dir().join(format!("very-normal-coach-{}.json", std::process::id()));
935 let end = now_ms();
936 let runs = (0..13u64)
937 .map(|i| Run {
938 t: end - (12 - i) * 120_000,
939 stage: 1,
940 mode: "words".into(),
941 wpm: 15.0,
942 acc: 98.0,
943 secs: 120.0,
944 ..Run::default()
945 })
946 .collect();
947 let p = Progress {
948 stage: 1,
949 runs,
950 warmed_up: Some(coach::today(now_ms())),
951 ..Default::default()
952 };
953 let mut app = App::new(p, path.clone(), Instant::now());
954 let enter =
955 KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Press);
956 app.handle_key(enter, Instant::now());
957 assert!(!app.typing);
958 assert!(app.message.as_ref().unwrap().0.contains("rebel"));
959 app.handle_key(enter, Instant::now());
960 assert!(app.typing);
961 std::fs::remove_file(path).ok();
962 }
963}
964
965#[cfg(test)]
966mod warmup_tests {
967 use super::*;
968 use ratatui::crossterm::event::KeyEventKind;
969
970 fn key(code: KeyCode) -> KeyEvent {
971 KeyEvent::new_with_kind(code, KeyModifiers::NONE, KeyEventKind::Press)
972 }
973
974 #[test]
975 fn the_first_drill_of_the_day_starts_with_a_warm_up() {
976 let path =
977 std::env::temp_dir().join(format!("very-normal-warmup-{}.json", std::process::id()));
978 let mut app = App::new(
979 Progress {
980 stage: 1,
981 ..Default::default()
982 },
983 path.clone(),
984 Instant::now(),
985 );
986 let now = Instant::now();
987 app.handle_key(key(KeyCode::Enter), now);
988 assert!(app.warmup.is_some() && !app.typing);
989
990 app.handle_key(key(KeyCode::Char('s')), now);
991 assert!(app.warmup.is_none());
992 assert!(app.message.as_ref().unwrap().0.contains("Bold"));
993 assert_eq!(app.progress.warmed_up, Some(coach::today(now_ms())));
994
995 app.handle_key(key(KeyCode::Enter), now);
996 assert!(app.typing, "no second warm-up the same day");
997 std::fs::remove_file(path).ok();
998 }
999
1000 #[test]
1001 fn enter_steps_through_the_warm_up_to_the_end() {
1002 let path =
1003 std::env::temp_dir().join(format!("very-normal-warmup2-{}.json", std::process::id()));
1004 let mut app = App::new(Progress::default(), path.clone(), Instant::now());
1005 let now = Instant::now();
1006 app.handle_key(key(KeyCode::Char('u')), now);
1007 for _ in 0..warmup::STEPS.len() {
1008 app.handle_key(key(KeyCode::Enter), now);
1009 }
1010 assert!(app.warmup.is_none());
1011 assert!(app.message.as_ref().unwrap().0.contains("Warmed up"));
1012 std::fs::remove_file(path).ok();
1013 }
1014}
1015
1016#[cfg(test)]
1017mod verdict_tests {
1018 use super::*;
1019 use crate::store::Run;
1020
1021 fn words_run(acc: f64) -> Run {
1022 Run {
1023 t: 1,
1024 stage: 1,
1025 mode: "words".into(),
1026 wpm: 15.0,
1027 acc,
1028 secs: 60.0,
1029 ..Run::default()
1030 }
1031 }
1032
1033 #[test]
1034 fn the_number_shown_is_the_number_judged() {
1035 let p = Progress {
1037 runs: vec![words_run((96.96f64 * 10.0).round() / 10.0)],
1038 ..Default::default()
1039 };
1040 assert_eq!(gate::words_hits(&p, 1), 1);
1041 }
1042}
1043
1044#[cfg(test)]
1045mod frustration_tests {
1046 use super::*;
1047 use ratatui::crossterm::event::KeyEventKind;
1048
1049 #[test]
1050 fn several_restarts_suggest_a_break() {
1051 let path = std::env::temp_dir().join(format!(
1052 "very-normal-frustration-{}.json",
1053 std::process::id()
1054 ));
1055 let p = Progress {
1056 stage: 1,
1057 warmed_up: Some(coach::today(now_ms())),
1058 ..Default::default()
1059 };
1060 let mut app = App::new(p, path.clone(), Instant::now());
1061 let key = |c| KeyEvent::new_with_kind(c, KeyModifiers::NONE, KeyEventKind::Press);
1062 for _ in 0..3 {
1063 app.handle_key(key(KeyCode::Enter), Instant::now());
1064 app.handle_key(key(KeyCode::Esc), Instant::now());
1065 }
1066 assert!(app.message.as_ref().unwrap().0.contains("Frustration"));
1067 std::fs::remove_file(path).ok();
1068 }
1069}