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;
22
23pub struct App {
24 pub progress: Progress,
25 path: PathBuf,
26 mode: Mode,
27 drill: Drill,
28 typing: bool,
29 rng: Rng,
30 message: Option<(String, bool)>,
32 check: Vec<String>,
34 layout_id: String,
35 override_armed: bool,
37 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 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 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 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}