Skip to main content

very_normal/
app.rs

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