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/// Restarts this close together suggest frustration.
23const ABANDON_WINDOW: std::time::Duration = std::time::Duration::from_secs(10 * 60);
24
25pub struct App {
26    pub progress: Progress,
27    path: PathBuf,
28    /// The troublesome keys this drill's words lean on.
29    leaning: Vec<char>,
30    mode: Mode,
31    drill: Drill,
32    typing: bool,
33    rng: Rng,
34    /// The result of the last drill, or of the last thing you did.
35    message: Option<(String, bool)>,
36    /// The last `c` check against Oryx.
37    check: Vec<String>,
38    layout_id: String,
39    /// Set when the coach said stop and you pressed enter anyway once.
40    override_armed: bool,
41    /// The warm-up, while it runs.
42    warmup: Option<Warmup>,
43    /// When you recently gave up on a drill with esc; several close together
44    /// usually means frustration.
45    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    /// Ends a timed drill once its minute is up.
110    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        // round once, so the number shown, the number saved and the number judged agree
147        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        // tall enough for the next stage's edits (plus any check) and the gate
369        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        // the trainer keeps at least 8 rows; the middle panels take what is left
381        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            // misses are yellow, not red: they're information, not a telling-off
697            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    /// Names the keys the words lean on: extra reps, said kindly.
788    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        // 96.96% shows as 97.0%, so it has to count as 97%
1036        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}