From 4abfd623d437e20f8769bc4d57837146248fdf4d Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:34:18 +0000 Subject: [PATCH 01/14] =?UTF-8?q?docs(roadmap):=20plan=20v0.7=20=E2=80=94?= =?UTF-8?q?=20practice=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordered steps for the v0.7 branch: Tab restart and F6 repeat; hide live WPM and blind mode in a new [typing] section; stop on error; cursor styles and a key sound; a pace cursor; practising missed words; idle detection; custom themes from files. --- docs/ROADMAP.md | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4b6ca14..d2999b0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -214,22 +214,35 @@ one pull request: - βœ… The CSV export gains raw WPM, consistency, missed and completed - βœ… `profile` joins `stats`, `settings` and `quit` on the menu's last row +## 🚧 v0.7 β€” Practice modes + +Worked in this order, one commit (or a few) per step, on one branch and +one pull request: + +1. 🚧 Plan (this section) +2. πŸ”² Quick restart on `Tab` while typing, and repeat the run you just + finished (the same words) from Results with `F6` +3. πŸ”² A `[typing]` section in the config and on the Settings screen: + hide the live WPM, and blind mode (no red/green while typing, the + numbers only at the end) +4. πŸ”² Stop on error: `letter` (a wrong key doesn't move the cursor) or + `word` (a word with a mistake can't be left until it's fixed) +5. πŸ”² Cursor styles β€” underline, block, bar β€” and an optional key sound + (the terminal's bell, on every key or only on mistakes) +6. πŸ”² Pace cursor: a second, dimmer cursor moving at your best for the + mode, or at a target WPM +7. πŸ”² Practise missed words: every session keeps the words you got wrong; + a `missed` run is built from your most-missed recent words +8. πŸ”² Idle detection: a run with a long stretch without a key (10 s by + default) is not saved, and Results says why +9. πŸ”² Custom themes from files in `~/.typerush/themes/`, on top of a + built-in or from scratch, listed and picked like the built-in ones +10. πŸ”² Review, end-to-end test, docs, version 0.7.0 + --- ## πŸ”² Planned -### v0.7 β€” Practice modes -- πŸ”² Practise missed words: a run built from the words you got wrong -- πŸ”² Repeat the run you just finished (same words) straight from Results β€” - v0.5's replay already does it from the Stats screen and `--replay` -- πŸ”² Stop-on-error and strict modes (must fix a mistake before moving on) -- πŸ”² Blind mode (no red/green while typing; results only at the end) -- πŸ”² Pace cursor: a ghost cursor at your PB or a target WPM -- πŸ”² Quick restart on `Tab`, and hide-live-WPM option -- πŸ”² Cursor styles (underline, block, bar) and an optional key-click sound -- πŸ”² Custom themes from a file in `~/.typerush/themes/` -- πŸ”² AFK detection: a run idle for too long is not saved - --- ## πŸ’‘ Ideas (not committed) From 31116cf76c2122693488628a7eef632e122d0cac Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:35:37 +0000 Subject: [PATCH 02/14] feat: Tab restarts while typing; F6 repeats the run from Results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tab while typing restarts with new words, like F5 and Ctrl+R β€” it is never in the text, which is split on whitespace. The typing footer now says "Tab / F5 restart". - F6 on Results types the run just finished again, the same words: a replay of it (App::repeat_run), saved as +replay since the words are now seen, and compared with it on Results. On a replay, F6 replays the original again. The Results footer gains "F6 repeat" (Ctrl+C, which quits everywhere, makes room). - The help overlay lists both and still fits 80x24. --- docs/ROADMAP.md | 2 +- src/app.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 31 +++++++++++++++++++++++++++--- src/ui/help.rs | 4 ++-- src/ui/mod.rs | 2 +- src/ui/results.rs | 8 +++----- src/ui/typing.rs | 4 ++-- 7 files changed, 85 insertions(+), 14 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2999b0..d0b58b7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -220,7 +220,7 @@ Worked in this order, one commit (or a few) per step, on one branch and one pull request: 1. 🚧 Plan (this section) -2. πŸ”² Quick restart on `Tab` while typing, and repeat the run you just +2. βœ… Quick restart on `Tab` while typing, and repeat the run you just finished (the same words) from Results with `F6` 3. πŸ”² A `[typing]` section in the config and on the Settings screen: hide the live WPM, and blind mode (no red/green while typing, the diff --git a/src/app.rs b/src/app.rs index 5ebb5ee..9a94094 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1147,6 +1147,28 @@ impl App { Ok(()) } + /// Type the run just finished again, the same words (`F6` on Results): + /// a replay of it β€” saved as `+replay`, since the words are now seen, + /// and compared with it on Results. A replay is simply replayed again. + pub fn repeat_run(&mut self) { + if self.replay.is_none() { + let mut label = self.session_label(); + while let Some(original) = label.strip_suffix("+replay") { + label = original.to_string(); + } + self.replay = Some(Replay { + label, + mode: self.mode, + words: self.session_text().split(' ').map(str::to_string).collect(), + pool: self.word_pool, + decor: self.word_decor, + wpm: self.wpm(), + timestamp: chrono::Local::now(), + }); + } + let _ = self.restart(); + } + /// Reset all session state and start typing `text` in `mode`. fn begin(&mut self, mode: Mode, text: Vec) { self.mode = mode; @@ -1525,6 +1547,32 @@ mod tests { assert_eq!(app.paused_for, Duration::ZERO); } + // ── practice (v0.7.0) ────────────────────────────────────────────────── + + /// F6 on Results types the same words again as a replay of the run + /// just finished; a second F6 replays it again, not a replay of the + /// replay. A run too long to keep its text still repeats nothing extra. + #[test] + fn repeat_types_the_same_words_as_a_replay() { + let mut app = custom_app(&["red", "fox"]); + app.started_at = Some(Instant::now() - Duration::from_secs(2)); + for ch in "red fox".chars() { + app.handle_char(ch); + } + assert_eq!(app.screen, Screen::Results); + let wpm = app.wpm(); + app.repeat_run(); + assert_eq!(app.screen, Screen::Typing); + let words: Vec<_> = app.words.iter().map(|w| w.text.as_str()).collect(); + assert_eq!(words, ["red", "fox"]); + assert_eq!(app.session_label(), "custom+replay"); + assert_eq!(app.replay.as_ref().unwrap().wpm, wpm); + app.end_early(); + app.repeat_run(); + assert_eq!(app.session_label(), "custom+replay"); + assert_eq!(app.replay.as_ref().unwrap().wpm, wpm, "still the first run"); + } + // ── deeper results (v0.6.0) ──────────────────────────────────────────── /// Each key lands in the second it was typed (paused time left out): diff --git a/src/main.rs b/src/main.rs index ae42cef..9a51d78 100644 --- a/src/main.rs +++ b/src/main.rs @@ -680,7 +680,9 @@ pub(crate) fn handle_typing_key(app: &mut App, code: KeyCode, mods: KeyModifiers KeyCode::F(2) => app.toggle_pause(), KeyCode::Char('p') if ctrl => app.toggle_pause(), KeyCode::Esc => app.end_early(), - KeyCode::F(5) => { + // Tab is never in the text (it is split on whitespace): a quick + // restart, like F5 and Ctrl+R. + KeyCode::F(5) | KeyCode::Tab => { if let Err(e) = app.restart() { app.show_error(&e); } @@ -730,8 +732,8 @@ fn is_altgr(typed_char: char, mods: KeyModifiers) -> bool { /// /// A fast typist is still typing when the run ends, so the next few keys /// land here. Only keys nobody presses while typing act β€” Enter or F5 -/// (or Ctrl+R, as while typing) restart, Esc goes to the menu, Tab to -/// stats, F1 opens help, Ctrl+C quits β€” +/// (or Ctrl+R, as while typing) restart, F6 repeats the same words, Esc +/// goes to the menu, Tab to stats, F1 opens help, Ctrl+C quits β€” /// so those stray letters, spaces and `?` can't skip the results. fn handle_results_key(app: &mut App, code: KeyCode, mods: KeyModifiers) { let ctrl_r = code == KeyCode::Char('r') && mods.contains(KeyModifiers::CONTROL); @@ -746,6 +748,8 @@ fn handle_results_key(app: &mut App, code: KeyCode, mods: KeyModifiers) { app.show_error(&e); } } + // The same words again, as a replay of this run. + KeyCode::F(6) => app.repeat_run(), KeyCode::Esc => app.screen = Screen::Menu, KeyCode::Tab => app.screen = Screen::Stats, _ => {} @@ -1704,6 +1708,27 @@ mod tests { assert_eq!(app.stats_view.as_ref().unwrap().category_name(), "all"); } + /// Tab restarts while typing; F6 on Results (or its footer hint) + /// repeats the same words. + #[test] + fn tab_restarts_and_f6_repeats() { + let mut app = make_typing_app(); + app.handle_char('h'); + handle_key(&mut app, KeyCode::Tab, KeyModifiers::NONE); + assert_eq!((app.screen, app.total_typed_chars), (Screen::Typing, 0)); + + let mut app = make_typing_app(); + for ch in "hello world".chars() { + app.handle_char(ch); + } + draw(&app); + let repeat = target_cell(&app, ClickAction::Key(KeyCode::F(6), KeyModifiers::NONE)); + click(&mut app, repeat); + let words: Vec<_> = app.words.iter().map(|w| w.text.as_str()).collect(); + assert_eq!(words, ["hello", "world"]); + assert_eq!(app.session_label(), "words-2+replay"); + } + // ── replay (v0.5.0) ────────────────────────────────────────────────────── fn saved(stats_file: &Path, mode: &str, text: &str, wpm: f64) { diff --git a/src/ui/help.rs b/src/ui/help.rs index 2ac0d28..cfc7a09 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -32,7 +32,8 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] heading(" TypeRush β€” keybindings"), Line::from(" ↑/↓ ←/β†’ hjkl move: menu, lists, calendar"), Line::from(" Enter Space start, choose, change"), - Line::from(" Ctrl+R F5 restart (results: Enter too)"), + Line::from(" Tab F5 Ctrl+R restart (results: Enter)"), + Line::from(" F6 results: the same words again"), Line::from(" Ctrl+P F2 pause / resume a run"), Line::from(" Esc finish a run / back"), Line::from(" Ctrl+Bksp Ctrl+W delete word"), @@ -46,7 +47,6 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] Line::from(" Ctrl+C quit"), Line::from(" Mouse click options, rows, hints;"), Line::from(" the wheel scrolls lists"), - Line::raw(""), dim(" Stats ~/.typerush/stats.json (not zen)"), dim(" Config ~/.typerush/config.toml"), dim(" Snippets ~/.typerush/snippets/*.txt"), diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 32312ad..0a0e746 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -733,7 +733,7 @@ mod tests { use crate::app::Screen; for (height, screen, first_hint) in [ (24, Screen::Menu, "↑/↓ category"), - (24, Screen::Typing, "Ctrl+R / F5 restart"), + (24, Screen::Typing, "Tab / F5 restart"), (24, Screen::Results, "Enter / F5 restart"), (24, Screen::Stats, "←/β†’ category"), (24, Screen::Help, "Esc / F1 close"), diff --git a/src/ui/results.rs b/src/ui/results.rs index c2b9664..b7efd92 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -13,7 +13,7 @@ use ratatui::{ widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph}, }; -use ratatui::crossterm::event::{KeyCode, KeyModifiers}; +use ratatui::crossterm::event::KeyCode; use super::{key, label}; use crate::{app::App, storage}; @@ -240,15 +240,13 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { render_chart(f, app, layout[2]); + // What fits in 80 columns; Ctrl+C quits here as everywhere. const HINTS: &[super::Hint] = &[ ("Enter / F5 restart", key(KeyCode::Enter)), + ("F6 repeat", key(KeyCode::F(6))), ("Esc menu", key(KeyCode::Esc)), ("Tab stats", key(KeyCode::Tab)), ("F1 help", key(KeyCode::F(1))), - ( - "Ctrl+C quit", - Some((KeyCode::Char('c'), KeyModifiers::CONTROL)), - ), ]; HINTS } diff --git a/src/ui/typing.rs b/src/ui/typing.rs index 4bac4a0..7b734af 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -50,14 +50,14 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { Some((KeyCode::Char('c'), KeyModifiers::CONTROL)), ); const RUNNING: &[super::Hint] = &[ - ("Ctrl+R / F5 restart", super::key(KeyCode::F(5))), + ("Tab / F5 restart", super::key(KeyCode::F(5))), ("Ctrl+P / F2 pause", super::key(KeyCode::F(2))), ("Esc finish", super::key(KeyCode::Esc)), QUIT, ]; const PAUSED: &[super::Hint] = &[ ("Ctrl+P / F2 resume", super::key(KeyCode::F(2))), - ("Ctrl+R / F5 restart", super::key(KeyCode::F(5))), + ("Tab / F5 restart", super::key(KeyCode::F(5))), ("Esc finish", super::key(KeyCode::Esc)), QUIT, ]; From fba6e61b32cb8b579b51b455ba79a0b4dd31e67d Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:38:17 +0000 Subject: [PATCH 03/14] =?UTF-8?q?feat(typing):=20a=20[typing]=20section=20?= =?UTF-8?q?=E2=80=94=20hide=20the=20live=20WPM,=20blind=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new [typing] section in config.toml (and options::TypingOptions in the app) holds how a run behaves and looks. Its first two settings: - live_wpm (default true): false hides WPM and accuracy while typing; the clock and mode still show. - blind (default false, or --blind for one launch): typed text is one neutral color, right or wrong β€” a wrong space isn't marked either β€” and no numbers show until Results. You see where you are, not how you're doing. Both are rows on the Settings screen (saved and used at once), keys for typerush config (typing.live_wpm, typing.blind), and commented defaults in the starter config; --blind applies even when the config file is broken, like every other flag. --- config.example.toml | 8 ++++ docs/ROADMAP.md | 2 +- src/app.rs | 3 ++ src/config/edit.rs | 24 +++++++++-- src/config/load.rs | 28 +++++++++++++ src/config/mod.rs | 17 ++++++++ src/main.rs | 35 ++++++++++++++-- src/options.rs | 30 ++++++++++++++ src/settings.rs | 28 ++++++++++++- src/ui/settings.rs | 6 ++- src/ui/typing.rs | 98 ++++++++++++++++++++++++++++++++++++--------- 11 files changed, 249 insertions(+), 30 deletions(-) create mode 100644 src/options.rs diff --git a/config.example.toml b/config.example.toml index be8fcd7..6493155 100644 --- a/config.example.toml +++ b/config.example.toml @@ -50,6 +50,14 @@ symbol_count = 25 # punctuation = false # attach commas, periods, quotes, etc. to ~25% of words # numbers = false # mix in random 1–4 digit number tokens (~12% of slots) +# ----------------------------------------------------------------------------- +# Typing β€” how a run behaves and looks while you type. The Settings screen +# changes these too; `--blind` turns blind mode on for one launch. +# ----------------------------------------------------------------------------- +# [typing] +# live_wpm = true # show WPM and accuracy while typing +# blind = false # no right/wrong colors while typing; numbers at the end + # ----------------------------------------------------------------------------- # Color overrides β€” applied ON TOP of the chosen theme. # Any slot omitted here keeps the theme's value. (The values below are diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d0b58b7..40ebdfd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -222,7 +222,7 @@ one pull request: 1. 🚧 Plan (this section) 2. βœ… Quick restart on `Tab` while typing, and repeat the run you just finished (the same words) from Results with `F6` -3. πŸ”² A `[typing]` section in the config and on the Settings screen: +3. βœ… A `[typing]` section in the config and on the Settings screen: hide the live WPM, and blind mode (no red/green while typing, the numbers only at the end) 4. πŸ”² Stop on error: `letter` (a wrong key doesn't move the cursor) or diff --git a/src/app.rs b/src/app.rs index 9a94094..6cf564a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -558,6 +558,8 @@ pub struct App { pub notice: Option, /// Active color palette β€” read by every UI module on every frame. pub theme: ThemePalette, + /// How a run behaves and looks (the `[typing]` settings, v0.7.0). + pub options: crate::options::TypingOptions, // --- per-key accuracy (v0.3.0) --- /// Per expected character: how many times it was typed correctly. @@ -686,6 +688,7 @@ impl App { error_message: None, notice: None, theme: palette, + options: Default::default(), key_hits: HashMap::new(), key_misses: HashMap::new(), timeline: Vec::new(), diff --git a/src/config/edit.rs b/src/config/edit.rs index c572eaa..33e625d 100644 --- a/src/config/edit.rs +++ b/src/config/edit.rs @@ -89,6 +89,14 @@ pub const KEYS: &[Key] = &[ name: "words.numbers", kind: Kind::Switch, }, + Key { + name: "typing.live_wpm", + kind: Kind::Switch, + }, + Key { + name: "typing.blind", + kind: Kind::Switch, + }, Key { name: "colors.accent", kind: Kind::Color, @@ -284,6 +292,8 @@ fn file_value(config: &Config, name: &str) -> Option { "words.pool" => words?.pool.clone(), "words.punctuation" => switch(words?.punctuation), "words.numbers" => switch(words?.numbers), + "typing.live_wpm" => switch(config.typing.as_ref()?.live_wpm), + "typing.blind" => switch(config.typing.as_ref()?.blind), "colors.accent" => colors?.accent.clone(), "colors.secondary" => colors?.secondary.clone(), "colors.correct" => colors?.correct.clone(), @@ -310,6 +320,8 @@ fn default_value(key: &Key, theme: &ThemePalette) -> Setting { "defaults.symbol_count" => Setting::Number(DEFAULT_SYMBOLS as i64), "words.pool" => text("common"), "words.punctuation" | "words.numbers" => Setting::Switch(false), + "typing.live_wpm" => Setting::Switch(true), + "typing.blind" => Setting::Switch(false), "colors.accent" => color(theme.accent), "colors.secondary" => color(theme.secondary), "colors.correct" => color(theme.correct), @@ -481,7 +493,7 @@ fn shown(path: &Path) -> String { mod tests { use super::*; use crate::config::load::{load_from, CliOverrides}; - use crate::config::{Colors, Defaults, Words}; + use crate::config::{Colors, Defaults, Typing, Words}; fn temp_config() -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().unwrap(); @@ -499,6 +511,7 @@ mod tests { defaults: _, colors: _, words: _, + typing: _, } = Config::default(); let Defaults { mode: _, @@ -512,6 +525,10 @@ mod tests { punctuation: _, numbers: _, } = Words::default(); + let Typing { + live_wpm: _, + blind: _, + } = Typing::default(); let Colors { accent: _, secondary: _, @@ -524,8 +541,9 @@ mod tests { neutral: _, background: _, } = Colors::default(); - // 1 theme + 5 defaults + 3 words + 9 colors (10 minus `extra`). - assert_eq!(KEYS.len(), 1 + 5 + 3 + 9); + // 1 theme + 5 defaults + 3 words + 2 typing + 9 colors (10 minus + // `extra`). + assert_eq!(KEYS.len(), 1 + 5 + 3 + 2 + 9); } /// One valid value for each kind of setting, as typed by a person. diff --git a/src/config/load.rs b/src/config/load.rs index 346c8f1..a3c2017 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use super::{Colors, Config}; use crate::app::Mode; +use crate::options::TypingOptions; use crate::storage; use crate::theme::{self, builtin, ThemePalette}; use crate::words::{CodeLang, WordDecor, WordPool}; @@ -29,6 +30,8 @@ pub struct ResolvedConfig { pub word_pool: WordPool, /// Punctuation / numbers decoration toggles (v0.4.0). pub word_decor: WordDecor, + /// The `[typing]` settings (v0.7.0). + pub typing: TypingOptions, } impl Default for ResolvedConfig { @@ -39,6 +42,7 @@ impl Default for ResolvedConfig { default_mode: Mode::Time(DEFAULT_SECONDS), word_pool: WordPool::Common, word_decor: WordDecor::default(), + typing: TypingOptions::default(), } } } @@ -61,6 +65,8 @@ pub struct CliOverrides<'a> { pub punctuation: Option, /// `--numbers` β†’ `Some(true)`, `--no-numbers` β†’ `Some(false)`. pub numbers: Option, + /// `--blind` β†’ `Some(true)`. + pub blind: Option, } /// Read and resolve `~/.typerush/config.toml`, then apply CLI overrides on @@ -175,6 +181,10 @@ fn resolve_with(raw: Config, cli: CliOverrides<'_>) -> (ResolvedConfig, Vec) -> (ResolvedConfig, Vec) -> TypingOptions { + let defaults = TypingOptions::default(); + let Some(typing) = typing else { + return defaults; + }; + TypingOptions { + live_wpm: typing.live_wpm.unwrap_or(defaults.live_wpm), + blind: typing.blind.unwrap_or(defaults.blind), + } +} + /// Translate the optional `[words]` section into a (pool, decor) pair. fn resolve_words_section( words: Option<&super::Words>, @@ -854,8 +877,13 @@ mod tests { word_pool: Some(WordPool::Extended), punctuation: Some(true), numbers: Some(true), + blind: Some(true), }, ); + assert!( + resolved.typing.blind, + "--blind applies on a broken file too" + ); assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!(warnings[0].starts_with("could not parse"), "{warnings:?}"); assert_eq!(resolved.palette, builtin::MONOKAI); diff --git a/src/config/mod.rs b/src/config/mod.rs index 3142c9c..3ad3326 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -21,6 +21,10 @@ //! punctuation = false # sprinkle commas/periods/quotes on ~25% of words //! numbers = false # replace ~12% of slots with random 1–4 digit numbers //! +//! [typing] # v0.7.0 β€” how a run behaves and looks +//! live_wpm = true # show WPM and accuracy while typing +//! blind = false # no right/wrong colors, numbers only at the end +//! //! [colors] # optional β€” overrides slots of the chosen theme //! accent = "#FF00FF" //! correct = "green" @@ -47,6 +51,19 @@ pub struct Config { pub colors: Option, /// Word source / decoration toggles (v0.4.0). pub words: Option, + /// How a run behaves and looks while typing (v0.7.0). + pub typing: Option, +} + +/// `[typing]` section: how a run behaves and looks while you type. Added +/// in v0.7.0. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Typing { + /// Show WPM and accuracy while typing (default true). + pub live_wpm: Option, + /// Blind mode: no right/wrong colors, no numbers until the end. + pub blind: Option, } /// Default mode + per-mode defaults. Pre-selects the matching row in the menu diff --git a/src/main.rs b/src/main.rs index 9a51d78..d6d8c28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod app; mod commands; mod config; mod game; +mod options; mod progress; mod settings; mod state; @@ -132,6 +133,11 @@ struct Cli { value_parser = count::)] replay: Option, + /// Blind mode for this run of TypeRush: no right/wrong colors while + /// typing, and the numbers only at the end. + #[arg(long)] + blind: bool, + /// One-shot theme override: dark | light | monokai | dracula. /// Takes precedence over the `theme` setting in `~/.typerush/config.toml`. #[arg(long)] @@ -175,6 +181,7 @@ impl Cli { }), punctuation: switch(self.punctuation, self.no_punctuation), numbers: switch(self.numbers, self.no_numbers), + blind: self.blind.then_some(true), } } } @@ -321,6 +328,7 @@ fn run_app(terminal: &mut Tui, cli: Cli) -> Result<()> { let (resolved, warnings) = config::load::load_or_default_with(cli.overrides()); let mut app = App::new(cli.file.clone(), resolved.palette, resolved.default_mode); app.set_word_source(resolved.word_pool, resolved.word_decor); + app.options = resolved.typing; let state_file = state::state_path(); app.load_custom_sources( words::snippets::discover_snippets(), @@ -1984,9 +1992,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (mut app, config_file) = settings_app(&dir); press(&mut app, KeyCode::Right); // light theme, saved - press(&mut app, KeyCode::End); - for _ in 0..5 { - press(&mut app, KeyCode::Down); + for _ in settings::ROWS { + press(&mut app, KeyCode::Down); // to the last row: reset } press(&mut app, KeyCode::Enter); assert!(app.settings.as_ref().unwrap().confirm_reset); @@ -2068,6 +2075,28 @@ mod tests { assert_eq!(app.theme, ThemePalette::default()); } + /// The typing switches on Settings β€” live wpm, blind β€” are saved to + /// `[typing]` and used at once. + #[test] + fn settings_typing_switches() { + let dir = tempfile::tempdir().unwrap(); + let (mut app, config_file) = settings_app(&dir); + let row = |row| settings::ROWS.iter().position(|r| *r == row).unwrap(); + app.settings_select(row(settings::Row::LiveWpm)); + press(&mut app, KeyCode::Enter); + app.settings_select(row(settings::Row::Blind)); + press(&mut app, KeyCode::Right); + assert!(!app.options.live_wpm && app.options.blind); + let (resolved, warnings) = config::load::load_from(&config_file, CliOverrides::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!resolved.typing.live_wpm && resolved.typing.blind); + let text = std::fs::read_to_string(&config_file).unwrap(); + assert!( + text.contains("[typing]\nlive_wpm = false\nblind = true"), + "{text}" + ); + } + /// The menu's settings option opens the screen too. #[test] fn menu_settings_option_opens_settings() { diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..9a12355 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,30 @@ +//! How a run behaves and looks while you type β€” the `[typing]` section of +//! the config, the Settings screen's typing rows and a few command-line +//! switches. Resolved once at start (and on each Settings change) into +//! `App::options`. + +/// The resolved `[typing]` settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TypingOptions { + /// Show WPM and accuracy while typing (the clock and mode always show). + pub live_wpm: bool, + /// Blind mode: typed text isn't colored right or wrong, and no numbers + /// show until the run ends. + pub blind: bool, +} + +impl Default for TypingOptions { + fn default() -> Self { + Self { + live_wpm: true, + blind: false, + } + } +} + +impl TypingOptions { + /// Whether the live WPM and accuracy are drawn: wanted, and not blind. + pub fn show_live_numbers(self) -> bool { + self.live_wpm && !self.blind + } +} diff --git a/src/settings.rs b/src/settings.rs index f4726c5..74f235d 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -19,16 +19,20 @@ pub enum Row { Punctuation, Numbers, Pool, + LiveWpm, + Blind, Reset, } /// Every row, in order. -pub const ROWS: [Row; 6] = [ +pub const ROWS: [Row; 8] = [ Row::Theme, Row::Start, Row::Punctuation, Row::Numbers, Row::Pool, + Row::LiveWpm, + Row::Blind, Row::Reset, ]; @@ -47,6 +51,8 @@ pub struct SettingsView { pub start: Mode, pub pool: WordPool, pub decor: WordDecor, + /// The `[typing]` settings. + pub typing: crate::options::TypingOptions, /// The first problem the loader has with the file (it doesn't parse, a /// value is out of range…), shown on the screen. pub problem: Option, @@ -75,6 +81,7 @@ impl SettingsView { start: resolved.default_mode, pool: resolved.word_pool, decor: resolved.word_decor, + typing: resolved.typing, problem: warnings.into_iter().next(), } } @@ -171,6 +178,24 @@ impl App { self.word_pool = pool; } } + Row::LiveWpm => { + let on = !view.typing.live_wpm; + if self + .save_settings(&[("typing.live_wpm", on.to_string())]) + .is_some() + { + self.options.live_wpm = on; + } + } + Row::Blind => { + let on = !view.typing.blind; + if self + .save_settings(&[("typing.blind", on.to_string())]) + .is_some() + { + self.options.blind = on; + } + } Row::Reset => {} } } @@ -246,6 +271,7 @@ impl App { self.theme = defaults.palette; self.word_pool = defaults.word_pool; self.word_decor = defaults.word_decor; + self.options = defaults.typing; self.menu_index = best_menu_match(&self.menu, defaults.default_mode); self.settings = Some(SettingsView::load(Some(&path), selected)); } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index d83dc83..1bbb018 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -56,8 +56,8 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { let block = block.title_bottom(Span::styled(saved_to, Style::default().fg(theme.pending))); f.render_widget(block, box_area); - // A blank line, the five settings, a blank line, reset, then any - // problem with the file. + // A blank line, the settings, a blank line, reset, then any problem + // with the file. let mut lines = vec![Line::raw("")]; let mut targets = app.click_targets.borrow_mut(); for (index, row) in ROWS.iter().enumerate() { @@ -154,6 +154,8 @@ fn row_line( Row::Punctuation => ("punctuation", on_off(view.decor.punctuation)), Row::Numbers => ("numbers", on_off(view.decor.numbers)), Row::Pool => ("10k words", on_off(view.pool == WordPool::Extended)), + Row::LiveWpm => ("live wpm", on_off(view.typing.live_wpm)), + Row::Blind => ("blind", on_off(view.typing.blind)), Row::Reset => unreachable!("drawn above"), }; let label = Span::styled( diff --git a/src/ui/typing.rs b/src/ui/typing.rs index 7b734af..b8c5079 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -135,21 +135,28 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { ), ]) } else { - Line::from(vec![ - Span::styled(" wpm ", Style::default().fg(theme.pending)), - Span::styled( - format!("{:>3.0}", app.wpm()), - Style::default() - .fg(theme.secondary) - .add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("acc ", Style::default().fg(theme.pending)), - Span::styled( - format!("{:>5.1}%", app.accuracy()), - Style::default().fg(theme.correct), - ), - Span::raw(" "), + let mut spans = vec![Span::raw(" ")]; + // Live numbers only when wanted (typing.live_wpm) and not blind: + // blind mode keeps every number for the end. + if app.options.show_live_numbers() { + spans.extend([ + Span::styled("wpm ", Style::default().fg(theme.pending)), + Span::styled( + format!("{:>3.0}", app.wpm()), + Style::default() + .fg(theme.secondary) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled("acc ", Style::default().fg(theme.pending)), + Span::styled( + format!("{:>5.1}%", app.accuracy()), + Style::default().fg(theme.correct), + ), + Span::raw(" "), + ]); + } + spans.extend([ Span::styled("time ", Style::default().fg(theme.pending)), Span::styled(timer_text, Style::default().fg(theme.accent)), Span::raw(" "), @@ -159,7 +166,8 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { format!("[{}]", crate::text::shorten(&app.session_label(), 32)), Style::default().fg(theme.mode_tag), ), - ]) + ]); + Line::from(spans) }; f.render_widget(Paragraph::new(header), area); @@ -296,6 +304,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // word that begins before the first visible line: a long word can // run onto it. let is_zen_mode = matches!(app.mode, Mode::Zen); + let blind = app.options.blind; let cursor_style = Style::default() .fg(app.theme.accent) .add_modifier(Modifier::UNDERLINED); @@ -316,9 +325,10 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // the character it sits on. The space between words is a character // like any other: the cursor lands on it once the word is typed. let space = has_space(word_index).then(|| { - let style = if word.space_missed { + // Blind: a wrong space isn't shown either. + let style = if word.space_missed && !blind { // A colored blank is invisible β€” underline so the wrong space shows. - style_for_char(CharState::Incorrect, is_zen_mode, &app.theme) + style_for_char(CharState::Incorrect, is_zen_mode, blind, &app.theme) .add_modifier(Modifier::UNDERLINED) } else if is_active_word && typed_char_count >= char_states.len() { cursor_style @@ -334,7 +344,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { let style = if is_active_word && char_index == typed_char_count { cursor_style } else { - style_for_char(*state, is_zen_mode, &app.theme) + style_for_char(*state, is_zen_mode, blind, &app.theme) }; (*ch, style) }); @@ -359,7 +369,13 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { /// Zen mode intentionally desaturates everything to the theme's muted shades /// (with correct chars in `neutral`) so the screen stays calm and the user /// isn't distracted by score-shaped feedback. -fn style_for_char(state: CharState, is_zen_mode: bool, theme: &ThemePalette) -> Style { +/// +/// Blind mode shows typed text in one neutral color, right or wrong: you +/// see where you are, not how you're doing. +fn style_for_char(state: CharState, is_zen_mode: bool, blind: bool, theme: &ThemePalette) -> Style { + if blind && state != CharState::Pending { + return Style::default().fg(theme.neutral); + } if is_zen_mode { return match state { CharState::Correct => Style::default().fg(theme.neutral), @@ -474,6 +490,48 @@ mod tests { assert!(footer.starts_with(" Ctrl+P / F2 resume"), "{footer}"); } + /// With the live WPM hidden the header keeps only the clock and mode; + /// blind mode hides them too and shows typed text in one neutral color, + /// right or wrong (a wrong space included) β€” nothing in the wrong color. + #[test] + fn hidden_numbers_and_blind_mode() { + let mut app = App::new(None, builtin::MONOKAI, Mode::Time(15)); + app.start_game(Mode::Words(3)).unwrap(); + app.words = vec![Word::new("abc".into()), Word::new("def".into())]; + for ch in "axcx".chars() { + app.handle_char(ch); + } + let draw = |app: &App| { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| crate::ui::render(f, app)).unwrap(); + terminal.backend().buffer().clone() + }; + let header = |buffer: &ratatui::buffer::Buffer| -> String { + (0..80).map(|x| buffer[(x, 0)].symbol()).collect() + }; + assert!(header(&draw(&app)).starts_with(" wpm")); + app.options.live_wpm = false; + let shown = header(&draw(&app)); + assert!( + shown.starts_with(" time") && !shown.contains("acc"), + "{shown}" + ); + + app.options = crate::options::TypingOptions { + blind: true, + ..Default::default() + }; + let buffer = draw(&app); + assert!( + header(&buffer).starts_with(" time"), + "no numbers when blind" + ); + let wrong = buffer.content().iter().any(|cell| { + cell.fg == builtin::MONOKAI.incorrect || cell.fg == builtin::MONOKAI.correct + }); + assert!(!wrong, "a right/wrong color while blind"); + } + /// An emoji with a skin-tone modifier is two characters drawn two cells /// each. Layout and drawing measure it the same way (per character), so /// rows hold exactly what was laid out and nothing is clipped off a row's From f4e1650fe42174fbc775407289bec6edfaba5fa2 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:40:25 +0000 Subject: [PATCH 04/14] =?UTF-8?q?feat(typing):=20stop=20on=20error=20?= =?UTF-8?q?=E2=80=94=20letter=20or=20word?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing.stop_on_error (Settings, typerush config, --stop-on-error): - letter: a wrong key doesn't move the cursor β€” it counts as a mistake and you type the right one; - word: wrong letters are typed, but a word with a mistake in it can't be left: the space (or, for the last word, the end of the run) waits until it is fixed. A refused key counts against accuracy like any mistake and turns the cursor the mistake color until the right key or a backspace comes (not in blind mode). typerush config gains a reusable choice-of-names kind of value; an unknown name in the file warns and means off. --- config.example.toml | 4 ++- docs/ROADMAP.md | 2 +- src/app.rs | 67 +++++++++++++++++++++++++++++++++++++++++++-- src/config/edit.rs | 19 +++++++++++-- src/config/load.rs | 50 ++++++++++++++++++++++++++++++--- src/config/mod.rs | 3 ++ src/main.rs | 32 ++++++++++++++++++++++ src/options.rs | 49 +++++++++++++++++++++++++++++++++ src/settings.rs | 18 +++++++++++- src/ui/settings.rs | 1 + src/ui/typing.rs | 9 +++++- 11 files changed, 242 insertions(+), 12 deletions(-) diff --git a/config.example.toml b/config.example.toml index 6493155..d3f6f14 100644 --- a/config.example.toml +++ b/config.example.toml @@ -52,11 +52,13 @@ symbol_count = 25 # ----------------------------------------------------------------------------- # Typing β€” how a run behaves and looks while you type. The Settings screen -# changes these too; `--blind` turns blind mode on for one launch. +# changes these too; `--blind` and `--stop-on-error` set them for one launch. # ----------------------------------------------------------------------------- # [typing] # live_wpm = true # show WPM and accuracy while typing # blind = false # no right/wrong colors while typing; numbers at the end +# stop_on_error = "off" # "off" | "letter" (a wrong key doesn't move the cursor) +# # | "word" (a word with a mistake can't be left) # ----------------------------------------------------------------------------- # Color overrides β€” applied ON TOP of the chosen theme. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 40ebdfd..cb258de 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -225,7 +225,7 @@ one pull request: 3. βœ… A `[typing]` section in the config and on the Settings screen: hide the live WPM, and blind mode (no red/green while typing, the numbers only at the end) -4. πŸ”² Stop on error: `letter` (a wrong key doesn't move the cursor) or +4. βœ… Stop on error: `letter` (a wrong key doesn't move the cursor) or `word` (a word with a mistake can't be left until it's fixed) 5. πŸ”² Cursor styles β€” underline, block, bar β€” and an optional key sound (the terminal's bell, on every key or only on mistakes) diff --git a/src/app.rs b/src/app.rs index 6cf564a..8bb4e52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,6 +21,7 @@ use std::time::{Duration, Instant}; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::Rect; +use crate::options::StopOnError; use crate::state::{self, AppState}; use crate::storage::{ResultsComparison, SessionRecord, StatsView}; use crate::theme::ThemePalette; @@ -574,6 +575,8 @@ pub struct App { pub timeline: Vec, /// The run was ended with Esc, not by its clock or its last character. pub ended_early: bool, + /// The last key was refused (stop on error): the cursor shows it. + pub rejected_key: bool, /// Characters of the text left untyped when the run ended /// ([`App::missed_chars`]), worked out once then β€” Results shows it on /// every frame, and a big custom file has a lot of text left. @@ -693,6 +696,7 @@ impl App { key_misses: HashMap::new(), timeline: Vec::new(), ended_early: false, + rejected_key: false, missed: 0, stats_cache: None, stats_view: None, @@ -1187,6 +1191,7 @@ impl App { self.key_misses.clear(); self.timeline.clear(); self.ended_early = false; + self.rejected_key = false; self.missed = 0; self.top_up_words(); self.screen = Screen::Typing; @@ -1303,7 +1308,20 @@ impl App { // Past the end of the word the cursor sits on the space after it. let expected_char = active_word.text.chars().nth(cursor_position); let target_char = expected_char.unwrap_or(' '); - let is_correct = typed_char == target_char; + // Stop on error: a key that may not go on is counted as a mistake + // and changes nothing else. Letter: any wrong key. Word: anything + // at the end of a word that still has a mistake in it (and at the + // end of the last word, which has no space to type). + let blocked = match self.options.stop_on_error { + StopOnError::Off => false, + StopOnError::Letter => typed_char != target_char, + StopOnError::Word => { + expected_char.is_none() + && (is_last_word || active_word.typed != active_word.text || typed_char != ' ') + } + }; + self.rejected_key = blocked; + let is_correct = typed_char == target_char && !blocked; // Per-key stats are kept for the key that *should* have been pressed. let key_counts = if is_correct { self.correct_chars += 1; @@ -1323,6 +1341,9 @@ impl App { tally.errors += 1; } + if blocked { + return; + } if expected_char.is_none() { // Cursor on the space after the word: that slot is a character too. active_word.space_missed = !is_correct; @@ -1333,7 +1354,10 @@ impl App { active_word.typed.push(typed_char); // Last character of the last word typed: there is no trailing space. - if is_last_word && cursor_position + 1 == word_len { + // Stopping on word errors, the run only ends once the word is right. + let word_done = self.options.stop_on_error != StopOnError::Word + || active_word.typed == active_word.text; + if is_last_word && cursor_position + 1 == word_len && word_done { self.advance_word(); } } @@ -1348,6 +1372,7 @@ impl App { if self.is_paused() || self.current_word >= self.words.len() { return; } + self.rejected_key = false; let active_word = &mut self.words[self.current_word]; // Cursor at the very start of the current word β†’ step back to the previous one. @@ -1576,6 +1601,44 @@ mod tests { assert_eq!(app.replay.as_ref().unwrap().wpm, wpm, "still the first run"); } + /// Stopping on letters, a wrong key is a mistake that doesn't move the + /// cursor; stopping on words, a word with a mistake can't be left β€” nor + /// the run finished on one β€” until it's fixed. + #[test] + fn stop_on_error_letter_and_word() { + let mut app = custom_app(&["ab", "cd"]); + app.options.stop_on_error = StopOnError::Letter; + app.handle_char('x'); + assert_eq!((app.words[0].typed.as_str(), app.rejected_key), ("", true)); + app.handle_char('a'); + app.handle_char('b'); + app.handle_char('x'); // where the space belongs + assert_eq!(app.current_word, 0); + app.handle_char(' '); + assert_eq!((app.current_word, app.rejected_key), (1, false)); + assert_eq!((app.total_typed_chars, app.correct_chars), (5, 3)); + + let mut app = custom_app(&["ab", "cd"]); + app.options.stop_on_error = StopOnError::Word; + app.handle_char('a'); + app.handle_char('x'); // typed, it's mid-word + assert_eq!(app.words[0].typed, "ax"); + app.handle_char(' '); + assert_eq!(app.current_word, 0, "can't leave a wrong word"); + app.handle_backspace(false); + app.handle_char('b'); + app.handle_char(' '); + assert_eq!(app.current_word, 1); + app.handle_char('c'); + app.handle_char('x'); // last character, wrong + assert_eq!(app.screen, Screen::Typing, "not finished on a mistake"); + app.handle_char('d'); // past the end: refused + assert_eq!(app.words[1].typed, "cx"); + app.handle_backspace(false); + app.handle_char('d'); + assert_eq!(app.screen, Screen::Results); + } + // ── deeper results (v0.6.0) ──────────────────────────────────────────── /// Each key lands in the second it was typed (paused time left out): diff --git a/src/config/edit.rs b/src/config/edit.rs index 33e625d..adab935 100644 --- a/src/config/edit.rs +++ b/src/config/edit.rs @@ -17,6 +17,7 @@ use super::load::{ }; use super::Config; use crate::app::Mode; +use crate::options::StopOnError; use crate::storage::write_atomic; use crate::text::printable; use crate::theme::{builtin, color, ThemePalette}; @@ -41,6 +42,8 @@ pub enum Kind { Switch, /// A color: `#RRGGBB`, an ANSI name or `reset`. Color, + /// One of a fixed set of names. + Choice(&'static [&'static str]), } /// One setting of `config.toml`, by its dotted name (`defaults.mode`). @@ -97,6 +100,10 @@ pub const KEYS: &[Key] = &[ name: "typing.blind", kind: Kind::Switch, }, + Key { + name: "typing.stop_on_error", + kind: Kind::Choice(&StopOnError::NAMES), + }, Key { name: "colors.accent", kind: Kind::Color, @@ -224,6 +231,10 @@ pub fn check(key: &Key, raw: &str) -> Result { }, // Written back the one way the parser reads it (`#A6E22E`, // `dark_gray`), so a printed value is always one of those. + Kind::Choice(names) => match names.contains(&lower.as_str()) { + true => text(&lower), + false => Err(format!("not one of {}", names.join(", "))), + }, Kind::Color => match color::parse_color(raw) { Ok(parsed) => text(&color::color_name(parsed)), Err(error) => Err(error.to_string()), @@ -294,6 +305,7 @@ fn file_value(config: &Config, name: &str) -> Option { "words.numbers" => switch(words?.numbers), "typing.live_wpm" => switch(config.typing.as_ref()?.live_wpm), "typing.blind" => switch(config.typing.as_ref()?.blind), + "typing.stop_on_error" => config.typing.as_ref()?.stop_on_error.clone(), "colors.accent" => colors?.accent.clone(), "colors.secondary" => colors?.secondary.clone(), "colors.correct" => colors?.correct.clone(), @@ -322,6 +334,7 @@ fn default_value(key: &Key, theme: &ThemePalette) -> Setting { "words.punctuation" | "words.numbers" => Setting::Switch(false), "typing.live_wpm" => Setting::Switch(true), "typing.blind" => Setting::Switch(false), + "typing.stop_on_error" => text("off"), "colors.accent" => color(theme.accent), "colors.secondary" => color(theme.secondary), "colors.correct" => color(theme.correct), @@ -528,6 +541,7 @@ mod tests { let Typing { live_wpm: _, blind: _, + stop_on_error: _, } = Typing::default(); let Colors { accent: _, @@ -541,9 +555,9 @@ mod tests { neutral: _, background: _, } = Colors::default(); - // 1 theme + 5 defaults + 3 words + 2 typing + 9 colors (10 minus + // 1 theme + 5 defaults + 3 words + 3 typing + 9 colors (10 minus // `extra`). - assert_eq!(KEYS.len(), 1 + 5 + 3 + 2 + 9); + assert_eq!(KEYS.len(), 1 + 5 + 3 + 3 + 9); } /// One valid value for each kind of setting, as typed by a person. @@ -557,6 +571,7 @@ mod tests { Kind::Pool => "10k", Kind::Switch => "on", Kind::Color => "#A6E22E", + Kind::Choice(names) => names[names.len() - 1], } } diff --git a/src/config/load.rs b/src/config/load.rs index a3c2017..7dce4ca 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use super::{Colors, Config}; use crate::app::Mode; -use crate::options::TypingOptions; +use crate::options::{StopOnError, TypingOptions}; use crate::storage; use crate::theme::{self, builtin, ThemePalette}; use crate::words::{CodeLang, WordDecor, WordPool}; @@ -67,6 +67,8 @@ pub struct CliOverrides<'a> { pub numbers: Option, /// `--blind` β†’ `Some(true)`. pub blind: Option, + /// `--stop-on-error `. + pub stop_on_error: Option, } /// Read and resolve `~/.typerush/config.toml`, then apply CLI overrides on @@ -181,10 +183,13 @@ fn resolve_with(raw: Config, cli: CliOverrides<'_>) -> (ResolvedConfig, Vec) -> (ResolvedConfig, Vec) -> TypingOptions { +/// The `[typing]` section, each unset value its default; an unknown name +/// is a warning and the default. +fn resolve_typing(typing: Option<&super::Typing>, warnings: &mut Vec) -> TypingOptions { let defaults = TypingOptions::default(); let Some(typing) = typing else { return defaults; }; + let stop_on_error = match typing.stop_on_error.as_deref() { + None => defaults.stop_on_error, + Some(name) => StopOnError::from_name(name).unwrap_or_else(|| { + warnings.push(format!( + "unknown stop_on_error '{}', using 'off' (try {})", + crate::text::printable(name), + StopOnError::NAMES.join(", ") + )); + defaults.stop_on_error + }), + }; TypingOptions { live_wpm: typing.live_wpm.unwrap_or(defaults.live_wpm), blind: typing.blind.unwrap_or(defaults.blind), + stop_on_error, } } @@ -455,6 +473,28 @@ mod tests { } } + /// `stop_on_error` takes off / letter / word; anything else warns and + /// is off. + #[test] + fn stop_on_error_from_the_config() { + let typing = |name: &str| Config { + typing: Some(crate::config::Typing { + stop_on_error: Some(name.into()), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, warnings) = resolve(typing("Letter"), None); + assert!(warnings.is_empty()); + assert_eq!(resolved.typing.stop_on_error, StopOnError::Letter); + let (resolved, warnings) = resolve(typing("strict"), None); + assert_eq!(resolved.typing.stop_on_error, StopOnError::Off); + assert!( + warnings[0].contains("unknown stop_on_error 'strict'"), + "{warnings:?}" + ); + } + #[test] fn daily_default_mode() { let raw = Config { @@ -878,12 +918,14 @@ mod tests { punctuation: Some(true), numbers: Some(true), blind: Some(true), + stop_on_error: Some(StopOnError::Word), }, ); assert!( resolved.typing.blind, "--blind applies on a broken file too" ); + assert_eq!(resolved.typing.stop_on_error, StopOnError::Word); assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!(warnings[0].starts_with("could not parse"), "{warnings:?}"); assert_eq!(resolved.palette, builtin::MONOKAI); diff --git a/src/config/mod.rs b/src/config/mod.rs index 3ad3326..9c64f25 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -24,6 +24,7 @@ //! [typing] # v0.7.0 β€” how a run behaves and looks //! live_wpm = true # show WPM and accuracy while typing //! blind = false # no right/wrong colors, numbers only at the end +//! stop_on_error = "off" # off | letter | word //! //! [colors] # optional β€” overrides slots of the chosen theme //! accent = "#FF00FF" @@ -64,6 +65,8 @@ pub struct Typing { pub live_wpm: Option, /// Blind mode: no right/wrong colors, no numbers until the end. pub blind: Option, + /// `off` | `letter` | `word`: whether a mistake must be fixed first. + pub stop_on_error: Option, } /// Default mode + per-mode defaults. Pre-selects the matching row in the menu diff --git a/src/main.rs b/src/main.rs index d6d8c28..cce2390 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,12 @@ where check_count(count, MAX) } +/// Value parser for `--stop-on-error`. +fn stop_on_error(value: &str) -> Result { + options::StopOnError::from_name(value) + .ok_or_else(|| format!("not one of {}", options::StopOnError::NAMES.join(", "))) +} + /// Command-line interface. Run with no args to open the interactive menu; pass /// any of the mode flags to skip the menu and jump straight into a session. #[derive(Parser, Debug)] @@ -138,6 +144,11 @@ struct Cli { #[arg(long)] blind: bool, + /// Stop on error for this run of TypeRush: off | letter (a wrong key + /// doesn't move the cursor) | word (a wrong word can't be left). + #[arg(long, value_name = "WHEN", value_parser = stop_on_error)] + stop_on_error: Option, + /// One-shot theme override: dark | light | monokai | dracula. /// Takes precedence over the `theme` setting in `~/.typerush/config.toml`. #[arg(long)] @@ -182,6 +193,7 @@ impl Cli { punctuation: switch(self.punctuation, self.no_punctuation), numbers: switch(self.numbers, self.no_numbers), blind: self.blind.then_some(true), + stop_on_error: self.stop_on_error, } } } @@ -2095,6 +2107,26 @@ mod tests { text.contains("[typing]\nlive_wpm = false\nblind = true"), "{text}" ); + + app.settings_select(row(settings::Row::StopOnError)); + press(&mut app, KeyCode::Right); // off β†’ letter + press(&mut app, KeyCode::Right); // β†’ word + assert_eq!(app.options.stop_on_error, options::StopOnError::Word); + press(&mut app, KeyCode::Right); // wraps β†’ off + assert_eq!(app.options.stop_on_error, options::StopOnError::Off); + let text = std::fs::read_to_string(&config_file).unwrap(); + assert!(text.contains("stop_on_error = \"off\""), "{text}"); + } + + /// `--stop-on-error` takes off / letter / word, nothing else. + #[test] + fn stop_on_error_flag() { + let cli = Cli::try_parse_from(["typerush", "--stop-on-error", "letter"]).unwrap(); + assert_eq!( + cli.overrides().stop_on_error, + Some(options::StopOnError::Letter) + ); + assert!(Cli::try_parse_from(["typerush", "--stop-on-error", "always"]).is_err()); } /// The menu's settings option opens the screen too. diff --git a/src/options.rs b/src/options.rs index 9a12355..238eb21 100644 --- a/src/options.rs +++ b/src/options.rs @@ -3,6 +3,38 @@ //! switches. Resolved once at start (and on each Settings change) into //! `App::options`. +/// Whether a mistake has to be fixed before you go on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StopOnError { + /// Mistakes are typed like any key (the default). + #[default] + Off, + /// A wrong key doesn't move the cursor: type the right one. + Letter, + /// Wrong letters are typed, but a word with a mistake in it can't be + /// left β€” the space (or the end of the text) waits until it's fixed. + Word, +} + +impl StopOnError { + /// The names `stop_on_error` takes, in the order Settings cycles them. + pub const NAMES: [&'static str; 3] = ["off", "letter", "word"]; + + pub fn name(self) -> &'static str { + Self::NAMES[self as usize] + } + + /// The setting called `name` (case-insensitive), if there is one. + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "off" => Some(Self::Off), + "letter" => Some(Self::Letter), + "word" => Some(Self::Word), + _ => None, + } + } +} + /// The resolved `[typing]` settings. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TypingOptions { @@ -11,6 +43,8 @@ pub struct TypingOptions { /// Blind mode: typed text isn't colored right or wrong, and no numbers /// show until the run ends. pub blind: bool, + /// Whether a mistake has to be fixed before going on. + pub stop_on_error: StopOnError, } impl Default for TypingOptions { @@ -18,6 +52,7 @@ impl Default for TypingOptions { Self { live_wpm: true, blind: false, + stop_on_error: StopOnError::Off, } } } @@ -28,3 +63,17 @@ impl TypingOptions { self.live_wpm && !self.blind } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stop_on_error_names_read_back() { + for name in StopOnError::NAMES { + assert_eq!(StopOnError::from_name(name).unwrap().name(), name); + } + assert_eq!(StopOnError::from_name("WORD"), Some(StopOnError::Word)); + assert_eq!(StopOnError::from_name("strict"), None); + } +} diff --git a/src/settings.rs b/src/settings.rs index 74f235d..8cb2264 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -21,11 +21,12 @@ pub enum Row { Pool, LiveWpm, Blind, + StopOnError, Reset, } /// Every row, in order. -pub const ROWS: [Row; 8] = [ +pub const ROWS: [Row; 9] = [ Row::Theme, Row::Start, Row::Punctuation, @@ -33,6 +34,7 @@ pub const ROWS: [Row; 8] = [ Row::Pool, Row::LiveWpm, Row::Blind, + Row::StopOnError, Row::Reset, ]; @@ -196,6 +198,20 @@ impl App { self.options.blind = on; } } + Row::StopOnError => { + use crate::options::StopOnError; + let names = StopOnError::NAMES; + let at = names + .iter() + .position(|n| *n == view.typing.stop_on_error.name()); + let name = names[cycle(at, names.len(), forward)]; + if self + .save_settings(&[("typing.stop_on_error", name.to_string())]) + .is_some() + { + self.options.stop_on_error = StopOnError::from_name(name).unwrap_or_default(); + } + } Row::Reset => {} } } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1bbb018..5bb80ab 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -156,6 +156,7 @@ fn row_line( Row::Pool => ("10k words", on_off(view.pool == WordPool::Extended)), Row::LiveWpm => ("live wpm", on_off(view.typing.live_wpm)), Row::Blind => ("blind", on_off(view.typing.blind)), + Row::StopOnError => ("stop on", view.typing.stop_on_error.name().to_string()), Row::Reset => unreachable!("drawn above"), }; let label = Span::styled( diff --git a/src/ui/typing.rs b/src/ui/typing.rs index b8c5079..f57f198 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -305,8 +305,15 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // run onto it. let is_zen_mode = matches!(app.mode, Mode::Zen); let blind = app.options.blind; + // A key refused by stop-on-error turns the cursor the mistake color + // until the right key (or a backspace) comes. + let cursor_color = if app.rejected_key && !blind { + app.theme.incorrect + } else { + app.theme.accent + }; let cursor_style = Style::default() - .fg(app.theme.accent) + .fg(cursor_color) .add_modifier(Modifier::UNDERLINED); let mut lines: Vec> = vec![Vec::new(); end_line - first_line]; let first_word = word_starts From 85f97b108a23c005b5c46c9314c7c6e7bc215a04 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:44:02 +0000 Subject: [PATCH 05/14] feat(typing): cursor styles and a key sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing.cursor (Settings, typerush config): underline (as before), block (the character in a solid block of the accent) or bar. The bar is the terminal's own cursor, shaped to a steady bar and placed on the next character, so it takes no room in the text and the character keeps its style; the shape is set when the setting changes and put back to the terminal's default on exit and in the panic hook. typing.sound: off, errors (a wrong or refused key) or keys (every key). It's the terminal's bell β€” one byte written after the key, so no audio library and nothing to install; terminals that flash instead of beep flash. The [typing] names share one resolver that warns on an unknown name. --- config.example.toml | 2 + docs/ROADMAP.md | 2 +- src/app.rs | 29 ++++++++++++++- src/config/edit.rs | 20 ++++++++-- src/config/load.rs | 63 +++++++++++++++++++++++++------ src/config/mod.rs | 6 +++ src/main.rs | 40 +++++++++++++++++++- src/options.rs | 67 +++++++++++++++++++++++++++++++++ src/settings.rs | 30 ++++++++++++++- src/ui/settings.rs | 2 + src/ui/typing.rs | 90 ++++++++++++++++++++++++++++++++++++++++----- 11 files changed, 322 insertions(+), 29 deletions(-) diff --git a/config.example.toml b/config.example.toml index d3f6f14..9cee765 100644 --- a/config.example.toml +++ b/config.example.toml @@ -59,6 +59,8 @@ symbol_count = 25 # blind = false # no right/wrong colors while typing; numbers at the end # stop_on_error = "off" # "off" | "letter" (a wrong key doesn't move the cursor) # # | "word" (a word with a mistake can't be left) +# cursor = "underline" # "underline" | "block" | "bar" +# sound = "off" # "off" | "errors" | "keys" β€” the terminal's bell # ----------------------------------------------------------------------------- # Color overrides β€” applied ON TOP of the chosen theme. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cb258de..7966f7a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -227,7 +227,7 @@ one pull request: numbers only at the end) 4. βœ… Stop on error: `letter` (a wrong key doesn't move the cursor) or `word` (a word with a mistake can't be left until it's fixed) -5. πŸ”² Cursor styles β€” underline, block, bar β€” and an optional key sound +5. βœ… Cursor styles β€” underline, block, bar β€” and an optional key sound (the terminal's bell, on every key or only on mistakes) 6. πŸ”² Pace cursor: a second, dimmer cursor moving at your best for the mode, or at a target WPM diff --git a/src/app.rs b/src/app.rs index 8bb4e52..b0449ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -21,7 +21,7 @@ use std::time::{Duration, Instant}; use ratatui::crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::Rect; -use crate::options::StopOnError; +use crate::options::{KeySound, StopOnError}; use crate::state::{self, AppState}; use crate::storage::{ResultsComparison, SessionRecord, StatsView}; use crate::theme::ThemePalette; @@ -577,6 +577,9 @@ pub struct App { pub ended_early: bool, /// The last key was refused (stop on error): the cursor shows it. pub rejected_key: bool, + /// The key just typed should ring the terminal bell (`typing.sound`); + /// the main loop rings it and clears this. + pub bell: bool, /// Characters of the text left untyped when the run ended /// ([`App::missed_chars`]), worked out once then β€” Results shows it on /// every frame, and a big custom file has a lot of text left. @@ -697,6 +700,7 @@ impl App { timeline: Vec::new(), ended_early: false, rejected_key: false, + bell: false, missed: 0, stats_cache: None, stats_view: None, @@ -1322,6 +1326,11 @@ impl App { }; self.rejected_key = blocked; let is_correct = typed_char == target_char && !blocked; + self.bell = match self.options.sound { + KeySound::Off => false, + KeySound::Errors => !is_correct, + KeySound::Keys => true, + }; // Per-key stats are kept for the key that *should* have been pressed. let key_counts = if is_correct { self.correct_chars += 1; @@ -1639,6 +1648,24 @@ mod tests { assert_eq!(app.screen, Screen::Results); } + /// The bell is asked for on every key, on mistakes only (refused keys + /// too), or never. + #[test] + fn key_sound_asks_for_the_bell() { + for (sound, right, wrong) in [ + (KeySound::Off, false, false), + (KeySound::Errors, false, true), + (KeySound::Keys, true, true), + ] { + let mut app = custom_app(&["ab"]); + app.options.sound = sound; + app.handle_char('a'); + assert_eq!(app.bell, right, "{sound:?} right key"); + app.handle_char('x'); + assert_eq!(app.bell, wrong, "{sound:?} wrong key"); + } + } + // ── deeper results (v0.6.0) ──────────────────────────────────────────── /// Each key lands in the second it was typed (paused time left out): diff --git a/src/config/edit.rs b/src/config/edit.rs index adab935..781797f 100644 --- a/src/config/edit.rs +++ b/src/config/edit.rs @@ -17,7 +17,7 @@ use super::load::{ }; use super::Config; use crate::app::Mode; -use crate::options::StopOnError; +use crate::options::{CursorStyle, KeySound, StopOnError}; use crate::storage::write_atomic; use crate::text::printable; use crate::theme::{builtin, color, ThemePalette}; @@ -104,6 +104,14 @@ pub const KEYS: &[Key] = &[ name: "typing.stop_on_error", kind: Kind::Choice(&StopOnError::NAMES), }, + Key { + name: "typing.cursor", + kind: Kind::Choice(&CursorStyle::NAMES), + }, + Key { + name: "typing.sound", + kind: Kind::Choice(&KeySound::NAMES), + }, Key { name: "colors.accent", kind: Kind::Color, @@ -306,6 +314,8 @@ fn file_value(config: &Config, name: &str) -> Option { "typing.live_wpm" => switch(config.typing.as_ref()?.live_wpm), "typing.blind" => switch(config.typing.as_ref()?.blind), "typing.stop_on_error" => config.typing.as_ref()?.stop_on_error.clone(), + "typing.cursor" => config.typing.as_ref()?.cursor.clone(), + "typing.sound" => config.typing.as_ref()?.sound.clone(), "colors.accent" => colors?.accent.clone(), "colors.secondary" => colors?.secondary.clone(), "colors.correct" => colors?.correct.clone(), @@ -335,6 +345,8 @@ fn default_value(key: &Key, theme: &ThemePalette) -> Setting { "typing.live_wpm" => Setting::Switch(true), "typing.blind" => Setting::Switch(false), "typing.stop_on_error" => text("off"), + "typing.cursor" => text("underline"), + "typing.sound" => text("off"), "colors.accent" => color(theme.accent), "colors.secondary" => color(theme.secondary), "colors.correct" => color(theme.correct), @@ -542,6 +554,8 @@ mod tests { live_wpm: _, blind: _, stop_on_error: _, + cursor: _, + sound: _, } = Typing::default(); let Colors { accent: _, @@ -555,9 +569,9 @@ mod tests { neutral: _, background: _, } = Colors::default(); - // 1 theme + 5 defaults + 3 words + 3 typing + 9 colors (10 minus + // 1 theme + 5 defaults + 3 words + 5 typing + 9 colors (10 minus // `extra`). - assert_eq!(KEYS.len(), 1 + 5 + 3 + 3 + 9); + assert_eq!(KEYS.len(), 1 + 5 + 3 + 5 + 9); } /// One valid value for each kind of setting, as typed by a person. diff --git a/src/config/load.rs b/src/config/load.rs index 7dce4ca..68dfb43 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use super::{Colors, Config}; use crate::app::Mode; -use crate::options::{StopOnError, TypingOptions}; +use crate::options::{CursorStyle, KeySound, StopOnError, TypingOptions}; use crate::storage; use crate::theme::{self, builtin, ThemePalette}; use crate::words::{CodeLang, WordDecor, WordPool}; @@ -210,24 +210,63 @@ fn resolve_typing(typing: Option<&super::Typing>, warnings: &mut Vec) -> let Some(typing) = typing else { return defaults; }; - let stop_on_error = match typing.stop_on_error.as_deref() { - None => defaults.stop_on_error, - Some(name) => StopOnError::from_name(name).unwrap_or_else(|| { - warnings.push(format!( - "unknown stop_on_error '{}', using 'off' (try {})", - crate::text::printable(name), - StopOnError::NAMES.join(", ") - )); - defaults.stop_on_error - }), - }; + let stop_on_error = named( + "stop_on_error", + typing.stop_on_error.as_deref(), + &StopOnError::NAMES, + StopOnError::from_name, + defaults.stop_on_error, + warnings, + ); + let cursor = named( + "cursor", + typing.cursor.as_deref(), + &CursorStyle::NAMES, + CursorStyle::from_name, + defaults.cursor, + warnings, + ); + let sound = named( + "sound", + typing.sound.as_deref(), + &KeySound::NAMES, + KeySound::from_name, + defaults.sound, + warnings, + ); TypingOptions { live_wpm: typing.live_wpm.unwrap_or(defaults.live_wpm), blind: typing.blind.unwrap_or(defaults.blind), stop_on_error, + cursor, + sound, } } +/// A setting that takes one of `names`: the default when unset, a warning +/// and the default when it isn't one of them. +fn named( + setting: &str, + value: Option<&str>, + names: &[&str], + from_name: fn(&str) -> Option, + default: T, + warnings: &mut Vec, +) -> T { + let Some(value) = value else { + return default; + }; + from_name(value).unwrap_or_else(|| { + warnings.push(format!( + "unknown {setting} '{}', using '{}' (try {})", + crate::text::printable(value), + names[0], + names.join(", ") + )); + default + }) +} + /// Translate the optional `[words]` section into a (pool, decor) pair. fn resolve_words_section( words: Option<&super::Words>, diff --git a/src/config/mod.rs b/src/config/mod.rs index 9c64f25..fd24032 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -25,6 +25,8 @@ //! live_wpm = true # show WPM and accuracy while typing //! blind = false # no right/wrong colors, numbers only at the end //! stop_on_error = "off" # off | letter | word +//! cursor = "underline" # underline | block | bar +//! sound = "off" # off | errors | keys (the terminal bell) //! //! [colors] # optional β€” overrides slots of the chosen theme //! accent = "#FF00FF" @@ -67,6 +69,10 @@ pub struct Typing { pub blind: Option, /// `off` | `letter` | `word`: whether a mistake must be fixed first. pub stop_on_error: Option, + /// `underline` | `block` | `bar`. + pub cursor: Option, + /// `off` | `errors` | `keys`: when the terminal bell sounds. + pub sound: Option, } /// Default mode + per-mode defaults. Pre-selects the matching row in the menu diff --git a/src/main.rs b/src/main.rs index cce2390..f5ab997 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,7 @@ use std::{ use anyhow::Result; use clap::Parser; use ratatui::crossterm::{ + cursor::SetCursorStyle, event::{ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, @@ -310,7 +311,8 @@ fn restore_terminal(terminal: &mut Tui) -> Result<()> { let screen = execute!( terminal.backend_mut(), LeaveAlternateScreen, - DisableMouseCapture + DisableMouseCapture, + SetCursorStyle::DefaultUserShape ); let cursor = terminal.show_cursor(); raw?; @@ -326,7 +328,12 @@ fn install_panic_hook() { let original = panic::take_hook(); panic::set_hook(Box::new(move |info| { let _ = disable_raw_mode(); - let _ = execute!(stdout(), LeaveAlternateScreen, DisableMouseCapture); + let _ = execute!( + stdout(), + LeaveAlternateScreen, + DisableMouseCapture, + SetCursorStyle::DefaultUserShape + ); original(info); })); } @@ -362,8 +369,19 @@ fn run_app(terminal: &mut Tui, cli: Cli) -> Result<()> { let mut last_tick = Instant::now(); let mut last_screen = app.screen; let mut session_saved = false; + let mut cursor_shape = options::CursorStyle::default(); loop { + // The bar cursor is the terminal's own: shape it when the setting + // changes (Settings can change it while TypeRush is open). + if app.options.cursor != cursor_shape { + cursor_shape = app.options.cursor; + let shape = match cursor_shape { + options::CursorStyle::Bar => SetCursorStyle::SteadyBar, + _ => SetCursorStyle::DefaultUserShape, + }; + execute!(terminal.backend_mut(), shape)?; + } terminal.draw(|frame| ui::render(frame, &app))?; // Block until either a key event arrives or the tick interval elapses. @@ -383,6 +401,13 @@ fn run_app(terminal: &mut Tui, cli: Cli) -> Result<()> { continue; } handle_key(&mut app, key.code, key.modifiers); + // The key sound is the terminal's bell: a byte, not a + // dependency on an audio library. + if std::mem::take(&mut app.bell) { + use std::io::Write; + let backend = terminal.backend_mut(); + let _ = backend.write_all(b"\x07").and_then(|()| backend.flush()); + } } Event::Mouse(mouse) => handle_mouse(&mut app, mouse), _ => {} @@ -2116,6 +2141,17 @@ mod tests { assert_eq!(app.options.stop_on_error, options::StopOnError::Off); let text = std::fs::read_to_string(&config_file).unwrap(); assert!(text.contains("stop_on_error = \"off\""), "{text}"); + + app.settings_select(row(settings::Row::Cursor)); + press(&mut app, KeyCode::Left); // underline β†’ bar (wraps) + assert_eq!(app.options.cursor, options::CursorStyle::Bar); + app.settings_select(row(settings::Row::Sound)); + press(&mut app, KeyCode::Enter); // off β†’ errors + assert_eq!(app.options.sound, options::KeySound::Errors); + let (resolved, warnings) = config::load::load_from(&config_file, CliOverrides::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(resolved.typing.cursor, options::CursorStyle::Bar); + assert_eq!(resolved.typing.sound, options::KeySound::Errors); } /// `--stop-on-error` takes off / letter / word, nothing else. diff --git a/src/options.rs b/src/options.rs index 238eb21..ca0ff27 100644 --- a/src/options.rs +++ b/src/options.rs @@ -35,6 +35,61 @@ impl StopOnError { } } +/// How the cursor is drawn. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CursorStyle { + /// The character under it underlined (the default). + #[default] + Underline, + /// The character under it in a solid block. + Block, + /// A thin bar before the character: the terminal's own cursor, so it + /// takes no room in the text. + Bar, +} + +impl CursorStyle { + pub const NAMES: [&'static str; 3] = ["underline", "block", "bar"]; + + pub fn name(self) -> &'static str { + Self::NAMES[self as usize] + } + + pub fn from_name(name: &str) -> Option { + let at = Self::NAMES + .iter() + .position(|n| n.eq_ignore_ascii_case(name))?; + Some([Self::Underline, Self::Block, Self::Bar][at]) + } +} + +/// When the terminal's bell sounds as you type. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum KeySound { + /// Never (the default). + #[default] + Off, + /// On a wrong (or refused) key. + Errors, + /// On every key. + Keys, +} + +impl KeySound { + pub const NAMES: [&'static str; 3] = ["off", "errors", "keys"]; + + pub fn name(self) -> &'static str { + Self::NAMES[self as usize] + } + + pub fn from_name(name: &str) -> Option { + let at = Self::NAMES + .iter() + .position(|n| n.eq_ignore_ascii_case(name))?; + Some([Self::Off, Self::Errors, Self::Keys][at]) + } +} + /// The resolved `[typing]` settings. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TypingOptions { @@ -45,6 +100,8 @@ pub struct TypingOptions { pub blind: bool, /// Whether a mistake has to be fixed before going on. pub stop_on_error: StopOnError, + pub cursor: CursorStyle, + pub sound: KeySound, } impl Default for TypingOptions { @@ -53,6 +110,8 @@ impl Default for TypingOptions { live_wpm: true, blind: false, stop_on_error: StopOnError::Off, + cursor: CursorStyle::Underline, + sound: KeySound::Off, } } } @@ -75,5 +134,13 @@ mod tests { } assert_eq!(StopOnError::from_name("WORD"), Some(StopOnError::Word)); assert_eq!(StopOnError::from_name("strict"), None); + for name in CursorStyle::NAMES { + assert_eq!(CursorStyle::from_name(name).unwrap().name(), name); + } + for name in KeySound::NAMES { + assert_eq!(KeySound::from_name(name).unwrap().name(), name); + } + assert_eq!(CursorStyle::from_name("BAR"), Some(CursorStyle::Bar)); + assert_eq!(KeySound::from_name("click"), None); } } diff --git a/src/settings.rs b/src/settings.rs index 8cb2264..b015681 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -22,11 +22,13 @@ pub enum Row { LiveWpm, Blind, StopOnError, + Cursor, + Sound, Reset, } /// Every row, in order. -pub const ROWS: [Row; 9] = [ +pub const ROWS: [Row; 11] = [ Row::Theme, Row::Start, Row::Punctuation, @@ -35,6 +37,8 @@ pub const ROWS: [Row; 9] = [ Row::LiveWpm, Row::Blind, Row::StopOnError, + Row::Cursor, + Row::Sound, Row::Reset, ]; @@ -212,6 +216,30 @@ impl App { self.options.stop_on_error = StopOnError::from_name(name).unwrap_or_default(); } } + Row::Cursor => { + use crate::options::CursorStyle; + let names = CursorStyle::NAMES; + let at = names.iter().position(|n| *n == view.typing.cursor.name()); + let name = names[cycle(at, names.len(), forward)]; + if self + .save_settings(&[("typing.cursor", name.to_string())]) + .is_some() + { + self.options.cursor = CursorStyle::from_name(name).unwrap_or_default(); + } + } + Row::Sound => { + use crate::options::KeySound; + let names = KeySound::NAMES; + let at = names.iter().position(|n| *n == view.typing.sound.name()); + let name = names[cycle(at, names.len(), forward)]; + if self + .save_settings(&[("typing.sound", name.to_string())]) + .is_some() + { + self.options.sound = KeySound::from_name(name).unwrap_or_default(); + } + } Row::Reset => {} } } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5bb80ab..3217f0f 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -157,6 +157,8 @@ fn row_line( Row::LiveWpm => ("live wpm", on_off(view.typing.live_wpm)), Row::Blind => ("blind", on_off(view.typing.blind)), Row::StopOnError => ("stop on", view.typing.stop_on_error.name().to_string()), + Row::Cursor => ("cursor", view.typing.cursor.name().to_string()), + Row::Sound => ("key sound", view.typing.sound.name().to_string()), Row::Reset => unreachable!("drawn above"), }; let label = Span::styled( diff --git a/src/ui/typing.rs b/src/ui/typing.rs index f57f198..16422a5 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -312,9 +312,22 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { } else { app.theme.accent }; - let cursor_style = Style::default() - .fg(cursor_color) - .add_modifier(Modifier::UNDERLINED); + // Underline and block style the character; the bar is the terminal's + // own cursor, placed on it below (the character keeps its usual style, + // or the mistake color after a refused key). + let bar = app.options.cursor == crate::options::CursorStyle::Bar; + let cursor_style = match app.options.cursor { + crate::options::CursorStyle::Underline => Style::default() + .fg(cursor_color) + .add_modifier(Modifier::UNDERLINED), + crate::options::CursorStyle::Block => super::selected_style(&app.theme).bg(cursor_color), + crate::options::CursorStyle::Bar => Style::default().fg(if app.rejected_key && !blind { + app.theme.incorrect + } else { + app.theme.pending + }), + }; + let mut bar_at: Option<(usize, usize)> = None; let mut lines: Vec> = vec![Vec::new(); end_line - first_line]; let first_word = word_starts .partition_point(|&(line, _)| line < first_line) @@ -342,32 +355,48 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { } else { Style::default() }; - (' ', style) + let on_cursor = is_active_word && typed_char_count >= char_states.len(); + (' ', style, on_cursor) }); let styled = char_states .iter() .enumerate() .map(|(char_index, (ch, state))| { - let style = if is_active_word && char_index == typed_char_count { + let on_cursor = is_active_word && char_index == typed_char_count; + // The bar leaves the character's style alone, unless a key + // was just refused. + let style = if on_cursor && (!bar || app.rejected_key) { cursor_style } else { style_for_char(*state, is_zen_mode, blind, &app.theme) }; - (*ch, style) + (*ch, style, on_cursor) }); - for (ch, style) in styled.chain(space) { + for (ch, style, on_cursor) in styled.chain(space) { let cells = ch.width().unwrap_or(0); at = place(at, cells); if (first_line..end_line).contains(&at.0) { lines[at.0 - first_line].push(Span::styled(ch.to_string(), style)); + if on_cursor { + bar_at = Some((at.0 - first_line, at.1)); + } } at.1 += cells; } } - let words_paragraph = Paragraph::new(lines.into_iter().map(Line::from).collect::>()) - .block(words_block(app)); + let block = words_block(app); + let inner = block.inner(area); + let words_paragraph = + Paragraph::new(lines.into_iter().map(Line::from).collect::>()).block(block); f.render_widget(words_paragraph, area); + // The bar: the terminal's cursor, on the character to type next. + if let (true, Some((line, column))) = (bar, bar_at) { + let position = Position::new(inner.x + column as u16, inner.y + line as u16); + if inner.contains(position) { + f.set_cursor_position(position); + } + } } /// Picks a foreground color/modifier for one character based on whether it @@ -539,6 +568,49 @@ mod tests { assert!(!wrong, "a right/wrong color while blind"); } + /// Underline and block style the character to type next; the bar puts + /// the terminal's own cursor on it, leaving its style alone β€” until a + /// key is refused, when it takes the mistake color. + #[test] + fn cursor_styles() { + use crate::options::CursorStyle; + let mut app = App::new(None, builtin::MONOKAI, Mode::Time(15)); + app.start_game(Mode::Words(2)).unwrap(); + app.words = vec![Word::new("abc".into()), Word::new("de".into())]; + app.handle_char('a'); + let theme = builtin::MONOKAI; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let mut cell_of_b = |app: &App| { + terminal.draw(|f| crate::ui::render(f, app)).unwrap(); + let buffer = terminal.backend().buffer().clone(); + let at = (0..24) + .flat_map(|y| (0..80).map(move |x| (x, y))) + .find(|&(x, y)| buffer[(x, y)].symbol() == "b") + .unwrap(); + ( + buffer[at].clone(), + at, + terminal.get_cursor_position().unwrap(), + ) + }; + + let (cell, ..) = cell_of_b(&app); + assert!(cell.modifier.contains(Modifier::UNDERLINED) && cell.fg == theme.accent); + + app.options.cursor = CursorStyle::Block; + let (cell, ..) = cell_of_b(&app); + assert_eq!(cell.bg, theme.accent); + + app.options.cursor = CursorStyle::Bar; + let (cell, at, cursor) = cell_of_b(&app); + assert_eq!((cursor.x, cursor.y), at, "the terminal cursor sits on it"); + assert_eq!(cell.fg, theme.pending); + assert!(!cell.modifier.contains(Modifier::UNDERLINED)); + app.rejected_key = true; + let (cell, ..) = cell_of_b(&app); + assert_eq!(cell.fg, theme.incorrect); + } + /// An emoji with a skin-tone modifier is two characters drawn two cells /// each. Layout and drawing measure it the same way (per character), so /// rows hold exactly what was laid out and nothing is clipped off a row's From 65d70a6a5365e515f190394c911cbce18735d036 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:46:32 +0000 Subject: [PATCH 06/14] feat(typing): a pace cursor at your best or a target WPM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing.pace (Settings, typerush config, --pace): off, best, or a WPM from 1 to 300 (written as a number). While typing, the character the pace has reached β€” the text read as one stream, spaces included β€” is underlined in the secondary color, so you can see whether you're ahead or behind; the real cursor wins where they meet. best is the best for the mode being played, looked up when the run begins (App::history reads stats.json then if no screen has yet β€” never per frame); on a replay it races the original run's speed. Zen keeps no score, so it has no pace, and neither does a mode with no best yet. --- config.example.toml | 4 +- docs/ROADMAP.md | 2 +- src/app.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++ src/config/edit.rs | 30 +++++++++++++-- src/config/load.rs | 39 ++++++++++++++++++- src/config/mod.rs | 22 +++++++++++ src/main.rs | 40 ++++++++++++++++++++ src/options.rs | 67 +++++++++++++++++++++++++++++++++ src/settings.rs | 15 +++++++- src/ui/settings.rs | 1 + src/ui/typing.rs | 68 +++++++++++++++++++++++++++++++++ 11 files changed, 372 insertions(+), 7 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9cee765..7e52f14 100644 --- a/config.example.toml +++ b/config.example.toml @@ -52,7 +52,8 @@ symbol_count = 25 # ----------------------------------------------------------------------------- # Typing β€” how a run behaves and looks while you type. The Settings screen -# changes these too; `--blind` and `--stop-on-error` set them for one launch. +# changes these too; `--blind`, `--stop-on-error` and `--pace` set them for +# one launch. # ----------------------------------------------------------------------------- # [typing] # live_wpm = true # show WPM and accuracy while typing @@ -61,6 +62,7 @@ symbol_count = 25 # # | "word" (a word with a mistake can't be left) # cursor = "underline" # "underline" | "block" | "bar" # sound = "off" # "off" | "errors" | "keys" β€” the terminal's bell +# pace = "off" # "off" | "best" (your best for the mode) | a WPM, e.g. 60 # ----------------------------------------------------------------------------- # Color overrides β€” applied ON TOP of the chosen theme. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7966f7a..eb9bac1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -229,7 +229,7 @@ one pull request: `word` (a word with a mistake can't be left until it's fixed) 5. βœ… Cursor styles β€” underline, block, bar β€” and an optional key sound (the terminal's bell, on every key or only on mistakes) -6. πŸ”² Pace cursor: a second, dimmer cursor moving at your best for the +6. βœ… Pace cursor: a second, dimmer cursor moving at your best for the mode, or at a target WPM 7. πŸ”² Practise missed words: every session keeps the words you got wrong; a `missed` run is built from your most-missed recent words diff --git a/src/app.rs b/src/app.rs index b0449ad..e44ab03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -577,6 +577,9 @@ pub struct App { pub ended_early: bool, /// The last key was refused (stop on error): the cursor shows it. pub rejected_key: bool, + /// The WPM the pace cursor moves at in this run (worked out when the + /// run begins), `None` without one. + pub pace_wpm: Option, /// The key just typed should ring the terminal bell (`typing.sound`); /// the main loop rings it and clears this. pub bell: bool, @@ -590,6 +593,10 @@ pub struct App { /// Results screen needs it and replaced by the updated list on every /// save. `None` until first needed (or after a failed save). pub stats_cache: Option>, + /// Where the history is read from when something needs it before the + /// Stats or Results screen has (`~/.typerush/stats.json`; `None` in + /// tests: then only `stats_cache` is used). + pub stats_file: Option, /// What the Stats screen shows (category, its summary, every mode's /// best), built from `stats_cache` when that screen is entered and when /// the category changes. `None` until the first Stats visit. @@ -700,9 +707,11 @@ impl App { timeline: Vec::new(), ended_early: false, rejected_key: false, + pace_wpm: None, bell: false, missed: 0, stats_cache: None, + stats_file: None, stats_view: None, results_comparison: None, session_just_saved: false, @@ -1199,6 +1208,51 @@ impl App { self.missed = 0; self.top_up_words(); self.screen = Screen::Typing; + self.pace_wpm = self.pace_for_run(); + } + + /// The history: the cache, read from `stats_file` the first time it's + /// needed (outside a frame: at a run's start, on entering a screen). + pub fn history(&mut self) -> &[SessionRecord] { + if self.stats_cache.is_none() { + if let Some(file) = &self.stats_file { + self.stats_cache = + Some(crate::storage::load_sessions_from_path(file).unwrap_or_default()); + } + } + self.stats_cache.as_deref().unwrap_or(&[]) + } + + /// What the pace cursor moves at in the run just begun: the target + /// WPM; or for `best`, the best for this mode β€” on a replay, the + /// original run's speed. Zen keeps no score, so it has none. + fn pace_for_run(&mut self) -> Option { + use crate::options::Pace; + if self.mode == Mode::Zen { + return None; + } + match self.options.pace { + Pace::Off => None, + Pace::Wpm(wpm) => Some(f64::from(wpm)), + Pace::Best => match &self.replay { + Some(replay) => Some(replay.wpm), + None => { + let label = self.session_label(); + crate::storage::personal_best_for_mode(self.history(), &label) + } + }, + } + .filter(|wpm| *wpm > 0.0) + } + + /// Where the pace cursor is: the index of the character it's on, the + /// text read as one stream (each space between words counts one). + /// `None` without a pace, or before the first key. + pub fn pace_position(&self) -> Option { + let wpm = self.pace_wpm?; + self.started_at?; + let minutes = self.elapsed().as_secs_f64() / 60.0; + Some((wpm * 5.0 * minutes) as usize) } /// The text the Results best is limited to: today's words for the daily @@ -1666,6 +1720,43 @@ mod tests { } } + /// The pace cursor moves at the target WPM, at the mode's best, or on + /// a replay at the original's speed; never in zen or without a best. + #[test] + fn pace_cursor_speed_and_position() { + use crate::options::Pace; + let mut app = menu_app(); + app.stats_cache = Some(vec![SessionRecord { + wpm: 72.0, + mode: "words-10".into(), + ..SessionRecord::default() + }]); + app.options.pace = Pace::Wpm(60); + app.start_game(Mode::Words(10)).unwrap(); + assert_eq!(app.pace_wpm, Some(60.0)); + assert_eq!(app.pace_position(), None, "not before the first key"); + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(6)); + assert_eq!(app.pace_position(), Some(30), "60 wpm = 5 chars a second"); + + app.options.pace = Pace::Best; + app.start_game(Mode::Words(10)).unwrap(); + assert_eq!(app.pace_wpm, Some(72.0)); + app.start_game(Mode::Words(25)).unwrap(); + assert_eq!(app.pace_wpm, None, "no best yet for words-25"); + app.start_game(Mode::Zen).unwrap(); + assert_eq!(app.pace_wpm, None); + app.start_replay(&SessionRecord { + wpm: 55.5, + mode: "words-25".into(), + text: "a b".into(), + ..SessionRecord::default() + }) + .unwrap(); + assert_eq!(app.pace_wpm, Some(55.5), "a replay races the original"); + } + // ── deeper results (v0.6.0) ──────────────────────────────────────────── /// Each key lands in the second it was typed (paused time left out): diff --git a/src/config/edit.rs b/src/config/edit.rs index 781797f..c223907 100644 --- a/src/config/edit.rs +++ b/src/config/edit.rs @@ -17,7 +17,7 @@ use super::load::{ }; use super::Config; use crate::app::Mode; -use crate::options::{CursorStyle, KeySound, StopOnError}; +use crate::options::{CursorStyle, KeySound, Pace, StopOnError}; use crate::storage::write_atomic; use crate::text::printable; use crate::theme::{builtin, color, ThemePalette}; @@ -44,6 +44,8 @@ pub enum Kind { Color, /// One of a fixed set of names. Choice(&'static [&'static str]), + /// `off`, `best` or a WPM. + Pace, } /// One setting of `config.toml`, by its dotted name (`defaults.mode`). @@ -112,6 +114,10 @@ pub const KEYS: &[Key] = &[ name: "typing.sound", kind: Kind::Choice(&KeySound::NAMES), }, + Key { + name: "typing.pace", + kind: Kind::Pace, + }, Key { name: "colors.accent", kind: Kind::Color, @@ -239,6 +245,15 @@ pub fn check(key: &Key, raw: &str) -> Result { }, // Written back the one way the parser reads it (`#A6E22E`, // `dark_gray`), so a printed value is always one of those. + // A WPM is written as a number, `off` / `best` as text. + Kind::Pace => match Pace::from_name(raw) { + Some(Pace::Wpm(wpm)) => Ok(Setting::Number(i64::from(wpm))), + Some(pace) => text(&pace.name()), + None => Err(format!( + "not off, best or a WPM from 1 to {}", + Pace::MAX_WPM + )), + }, Kind::Choice(names) => match names.contains(&lower.as_str()) { true => text(&lower), false => Err(format!("not one of {}", names.join(", "))), @@ -316,6 +331,12 @@ fn file_value(config: &Config, name: &str) -> Option { "typing.stop_on_error" => config.typing.as_ref()?.stop_on_error.clone(), "typing.cursor" => config.typing.as_ref()?.cursor.clone(), "typing.sound" => config.typing.as_ref()?.sound.clone(), + "typing.pace" => config + .typing + .as_ref()? + .pace + .as_ref() + .map(|pace| pace.text()), "colors.accent" => colors?.accent.clone(), "colors.secondary" => colors?.secondary.clone(), "colors.correct" => colors?.correct.clone(), @@ -347,6 +368,7 @@ fn default_value(key: &Key, theme: &ThemePalette) -> Setting { "typing.stop_on_error" => text("off"), "typing.cursor" => text("underline"), "typing.sound" => text("off"), + "typing.pace" => text("off"), "colors.accent" => color(theme.accent), "colors.secondary" => color(theme.secondary), "colors.correct" => color(theme.correct), @@ -556,6 +578,7 @@ mod tests { stop_on_error: _, cursor: _, sound: _, + pace: _, } = Typing::default(); let Colors { accent: _, @@ -569,9 +592,9 @@ mod tests { neutral: _, background: _, } = Colors::default(); - // 1 theme + 5 defaults + 3 words + 5 typing + 9 colors (10 minus + // 1 theme + 5 defaults + 3 words + 6 typing + 9 colors (10 minus // `extra`). - assert_eq!(KEYS.len(), 1 + 5 + 3 + 5 + 9); + assert_eq!(KEYS.len(), 1 + 5 + 3 + 6 + 9); } /// One valid value for each kind of setting, as typed by a person. @@ -586,6 +609,7 @@ mod tests { Kind::Switch => "on", Kind::Color => "#A6E22E", Kind::Choice(names) => names[names.len() - 1], + Kind::Pace => "85", } } diff --git a/src/config/load.rs b/src/config/load.rs index 68dfb43..946f294 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use super::{Colors, Config}; use crate::app::Mode; -use crate::options::{CursorStyle, KeySound, StopOnError, TypingOptions}; +use crate::options::{CursorStyle, KeySound, Pace, StopOnError, TypingOptions}; use crate::storage; use crate::theme::{self, builtin, ThemePalette}; use crate::words::{CodeLang, WordDecor, WordPool}; @@ -69,6 +69,8 @@ pub struct CliOverrides<'a> { pub blind: Option, /// `--stop-on-error `. pub stop_on_error: Option, + /// `--pace `. + pub pace: Option, } /// Read and resolve `~/.typerush/config.toml`, then apply CLI overrides on @@ -190,6 +192,9 @@ fn resolve_with(raw: Config, cli: CliOverrides<'_>) -> (ResolvedConfig, Vec, warnings: &mut Vec) -> defaults.sound, warnings, ); + let pace = match &typing.pace { + None => defaults.pace, + Some(value) => Pace::from_name(&value.text()).unwrap_or_else(|| { + warnings.push(format!( + "pace '{}' isn't off, best or a WPM from 1 to {}, using 'off'", + crate::text::printable(&value.text()), + Pace::MAX_WPM + )); + defaults.pace + }), + }; TypingOptions { + pace, live_wpm: typing.live_wpm.unwrap_or(defaults.live_wpm), blind: typing.blind.unwrap_or(defaults.blind), stop_on_error, @@ -534,6 +551,24 @@ mod tests { ); } + /// `pace` is written as a number or as text; out of range warns. + #[test] + fn pace_from_the_config() { + for (text, pace) in [ + ("pace = 60", Pace::Wpm(60)), + ("pace = \"best\"", Pace::Best), + ] { + let raw: Config = toml::from_str(&format!("[typing]\n{text}")).unwrap(); + let (resolved, warnings) = resolve(raw, None); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(resolved.typing.pace, pace); + } + let raw: Config = toml::from_str("[typing]\npace = 9000").unwrap(); + let (resolved, warnings) = resolve(raw, None); + assert_eq!(resolved.typing.pace, Pace::Off); + assert!(warnings[0].contains("pace '9000'"), "{warnings:?}"); + } + #[test] fn daily_default_mode() { let raw = Config { @@ -958,6 +993,7 @@ mod tests { numbers: Some(true), blind: Some(true), stop_on_error: Some(StopOnError::Word), + pace: Some(Pace::Best), }, ); assert!( @@ -965,6 +1001,7 @@ mod tests { "--blind applies on a broken file too" ); assert_eq!(resolved.typing.stop_on_error, StopOnError::Word); + assert_eq!(resolved.typing.pace, Pace::Best); assert_eq!(warnings.len(), 1, "{warnings:?}"); assert!(warnings[0].starts_with("could not parse"), "{warnings:?}"); assert_eq!(resolved.palette, builtin::MONOKAI); diff --git a/src/config/mod.rs b/src/config/mod.rs index fd24032..7c2b36b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -27,6 +27,7 @@ //! stop_on_error = "off" # off | letter | word //! cursor = "underline" # underline | block | bar //! sound = "off" # off | errors | keys (the terminal bell) +//! pace = "off" # off | best | a WPM such as 60 //! //! [colors] # optional β€” overrides slots of the chosen theme //! accent = "#FF00FF" @@ -73,6 +74,27 @@ pub struct Typing { pub cursor: Option, /// `off` | `errors` | `keys`: when the terminal bell sounds. pub sound: Option, + /// `"off"` | `"best"` | a WPM (`60` or `"60"`): the pace cursor. + pub pace: Option, +} + +/// A value written either as text or as a whole number (`pace = 60` and +/// `pace = "best"` are both natural to write). +#[derive(Debug, Deserialize, PartialEq, Clone)] +#[serde(untagged)] +pub enum TextOrNumber { + Number(i64), + Text(String), +} + +impl TextOrNumber { + /// The value as text: `60`, `best`. + pub fn text(&self) -> String { + match self { + TextOrNumber::Number(number) => number.to_string(), + TextOrNumber::Text(text) => text.clone(), + } + } } /// Default mode + per-mode defaults. Pre-selects the matching row in the menu diff --git a/src/main.rs b/src/main.rs index f5ab997..b1ff1bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,6 +60,16 @@ where check_count(count, MAX) } +/// Value parser for `--pace`. +fn pace(value: &str) -> Result { + options::Pace::from_name(value).ok_or_else(|| { + format!( + "not off, best or a WPM from 1 to {}", + options::Pace::MAX_WPM + ) + }) +} + /// Value parser for `--stop-on-error`. fn stop_on_error(value: &str) -> Result { options::StopOnError::from_name(value) @@ -150,6 +160,11 @@ struct Cli { #[arg(long, value_name = "WHEN", value_parser = stop_on_error)] stop_on_error: Option, + /// A pace cursor for this run of TypeRush: `best` (your best for the + /// mode) or a target WPM, 1–300. + #[arg(long, value_name = "PACE", value_parser = pace)] + pace: Option, + /// One-shot theme override: dark | light | monokai | dracula. /// Takes precedence over the `theme` setting in `~/.typerush/config.toml`. #[arg(long)] @@ -195,6 +210,7 @@ impl Cli { numbers: switch(self.numbers, self.no_numbers), blind: self.blind.then_some(true), stop_on_error: self.stop_on_error, + pace: self.pace, } } } @@ -362,6 +378,7 @@ fn run_app(terminal: &mut Tui, cli: Cli) -> Result<()> { } app.export_file = Some(storage::data_dir().join("stats.csv")); app.config_file = Some(config::load::config_path()); + app.stats_file = Some(storage::stats_path()); let stats_file = storage::stats_path(); apply_cli_autostart(&mut app, &cli, &stats_file)?; @@ -2152,6 +2169,29 @@ mod tests { assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(resolved.typing.cursor, options::CursorStyle::Bar); assert_eq!(resolved.typing.sound, options::KeySound::Errors); + + app.settings_select(row(settings::Row::Pace)); + press(&mut app, KeyCode::Right); // off β†’ best + press(&mut app, KeyCode::Right); // β†’ 30 + assert_eq!(app.options.pace, options::Pace::Wpm(30)); + let text = std::fs::read_to_string(&config_file).unwrap(); + assert!( + text.contains("pace = 30\n"), + "a WPM is written as a number: {text}" + ); + let (resolved, warnings) = config::load::load_from(&config_file, CliOverrides::default()); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(resolved.typing.pace, options::Pace::Wpm(30)); + } + + /// `--pace` takes off, best or a WPM in range. + #[test] + fn pace_flag() { + let cli = Cli::try_parse_from(["typerush", "--pace", "75"]).unwrap(); + assert_eq!(cli.overrides().pace, Some(options::Pace::Wpm(75))); + let cli = Cli::try_parse_from(["typerush", "--pace", "best"]).unwrap(); + assert_eq!(cli.overrides().pace, Some(options::Pace::Best)); + assert!(Cli::try_parse_from(["typerush", "--pace", "999"]).is_err()); } /// `--stop-on-error` takes off / letter / word, nothing else. diff --git a/src/options.rs b/src/options.rs index ca0ff27..873d068 100644 --- a/src/options.rs +++ b/src/options.rs @@ -90,6 +90,59 @@ impl KeySound { } } +/// What the pace cursor moves at. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Pace { + /// No pace cursor (the default). + #[default] + Off, + /// Your best for the mode being played (a replay: the original run). + Best, + /// A target WPM. + Wpm(u32), +} + +impl Pace { + /// Fastest target pace. + pub const MAX_WPM: u32 = 300; + /// The values Settings cycles through. + pub const CHOICES: [Pace; 12] = [ + Pace::Off, + Pace::Best, + Pace::Wpm(30), + Pace::Wpm(40), + Pace::Wpm(50), + Pace::Wpm(60), + Pace::Wpm(70), + Pace::Wpm(80), + Pace::Wpm(90), + Pace::Wpm(100), + Pace::Wpm(120), + Pace::Wpm(150), + ]; + + /// `off`, `best` or a number of WPM (1 to [`Pace::MAX_WPM`]). + pub fn from_name(name: &str) -> Option { + match name.trim().to_lowercase().as_str() { + "off" => Some(Pace::Off), + "best" => Some(Pace::Best), + number => match number.parse::() { + Ok(wpm) if (1..=Self::MAX_WPM).contains(&wpm) => Some(Pace::Wpm(wpm)), + _ => None, + }, + } + } + + /// As written in the config and shown on Settings: `off`, `best`, `60`. + pub fn name(self) -> String { + match self { + Pace::Off => "off".into(), + Pace::Best => "best".into(), + Pace::Wpm(wpm) => wpm.to_string(), + } + } +} + /// The resolved `[typing]` settings. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TypingOptions { @@ -102,6 +155,7 @@ pub struct TypingOptions { pub stop_on_error: StopOnError, pub cursor: CursorStyle, pub sound: KeySound, + pub pace: Pace, } impl Default for TypingOptions { @@ -112,6 +166,7 @@ impl Default for TypingOptions { stop_on_error: StopOnError::Off, cursor: CursorStyle::Underline, sound: KeySound::Off, + pace: Pace::Off, } } } @@ -143,4 +198,16 @@ mod tests { assert_eq!(CursorStyle::from_name("BAR"), Some(CursorStyle::Bar)); assert_eq!(KeySound::from_name("click"), None); } + + #[test] + fn pace_names_read_back() { + for pace in Pace::CHOICES { + assert_eq!(Pace::from_name(&pace.name()), Some(pace)); + } + assert_eq!(Pace::from_name(" Best "), Some(Pace::Best)); + assert_eq!(Pace::from_name("85"), Some(Pace::Wpm(85))); + for bad in ["0", "301", "fast", "-5"] { + assert_eq!(Pace::from_name(bad), None, "{bad}"); + } + } } diff --git a/src/settings.rs b/src/settings.rs index b015681..b21094e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -22,13 +22,14 @@ pub enum Row { LiveWpm, Blind, StopOnError, + Pace, Cursor, Sound, Reset, } /// Every row, in order. -pub const ROWS: [Row; 11] = [ +pub const ROWS: [Row; 12] = [ Row::Theme, Row::Start, Row::Punctuation, @@ -37,6 +38,7 @@ pub const ROWS: [Row; 11] = [ Row::LiveWpm, Row::Blind, Row::StopOnError, + Row::Pace, Row::Cursor, Row::Sound, Row::Reset, @@ -216,6 +218,17 @@ impl App { self.options.stop_on_error = StopOnError::from_name(name).unwrap_or_default(); } } + Row::Pace => { + use crate::options::Pace; + let at = Pace::CHOICES.iter().position(|p| *p == view.typing.pace); + let pace = Pace::CHOICES[cycle(at, Pace::CHOICES.len(), forward)]; + if self + .save_settings(&[("typing.pace", pace.name())]) + .is_some() + { + self.options.pace = pace; + } + } Row::Cursor => { use crate::options::CursorStyle; let names = CursorStyle::NAMES; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 3217f0f..64f6e05 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -157,6 +157,7 @@ fn row_line( Row::LiveWpm => ("live wpm", on_off(view.typing.live_wpm)), Row::Blind => ("blind", on_off(view.typing.blind)), Row::StopOnError => ("stop on", view.typing.stop_on_error.name().to_string()), + Row::Pace => ("pace", view.typing.pace.name()), Row::Cursor => ("cursor", view.typing.cursor.name().to_string()), Row::Sound => ("key sound", view.typing.sound.name().to_string()), Row::Reset => unreachable!("drawn above"), diff --git a/src/ui/typing.rs b/src/ui/typing.rs index 16422a5..0d3a130 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -253,6 +253,10 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // character, ~0.2 ms a frame at 10,000 words); cache per word list if a run // ever gets long enough to show up in a profile. let mut word_starts = Vec::with_capacity(app.words.len()); + // Where each word starts in the text read as one stream (each space + // between words one character): what the pace cursor counts in. + let mut word_offsets = Vec::with_capacity(app.words.len()); + let mut offset = 0; let mut cursor_line = 0; let mut at = (0, 0); for (word_index, word) in app.words.iter().enumerate() { @@ -266,6 +270,8 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { + usize::from(has_space(word_index)); at = place(at, width.min(max_width)); word_starts.push(at); + word_offsets.push(offset); + offset += word.text.chars().count() + 1; let is_active = word_index == app.current_word; if at.1 + width <= max_width && !is_active { at.1 += width; @@ -328,6 +334,18 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { }), }; let mut bar_at: Option<(usize, usize)> = None; + // The pace cursor: the character it has reached, underlined in the + // secondary color β€” never over the real cursor, which wins. + let pace = app.pace_position(); + let ghost = |index: usize, on_cursor: bool, style: Style| { + if pace == Some(index) && !on_cursor { + style + .fg(app.theme.secondary) + .add_modifier(Modifier::UNDERLINED) + } else { + style + } + }; let mut lines: Vec> = vec![Vec::new(); end_line - first_line]; let first_word = word_starts .partition_point(|&(line, _)| line < first_line) @@ -356,6 +374,11 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { Style::default() }; let on_cursor = is_active_word && typed_char_count >= char_states.len(); + let style = ghost( + word_offsets[word_index] + char_states.len(), + on_cursor, + style, + ); (' ', style, on_cursor) }); let styled = char_states @@ -370,6 +393,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { } else { style_for_char(*state, is_zen_mode, blind, &app.theme) }; + let style = ghost(word_offsets[word_index] + char_index, on_cursor, style); (*ch, style, on_cursor) }); for (ch, style, on_cursor) in styled.chain(space) { @@ -436,6 +460,7 @@ mod tests { use crate::app::Word; use crate::theme::builtin; use ratatui::{backend::TestBackend, Terminal}; + use std::time::{Duration, Instant}; /// The cell the cursor is drawn on (accent + underline), if on screen. fn cursor_cell(app: &App, width: u16, height: u16) -> Option<(u16, u16)> { @@ -611,6 +636,49 @@ mod tests { assert_eq!(cell.fg, theme.incorrect); } + /// The pace cursor underlines the character it has reached in the + /// secondary color, counting the spaces between words; the real cursor + /// wins where they meet. + #[test] + fn pace_cursor_is_drawn_where_it_has_reached() { + let mut app = App::new(None, builtin::MONOKAI, Mode::Time(15)); + app.start_game(Mode::Words(3)).unwrap(); + app.words = vec![ + Word::new("ab".into()), + Word::new("cd".into()), + Word::new("ef".into()), + ]; + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(6)); + app.pace_wpm = Some(6.0); // 6 wpm Γ— 6 s = 3 characters: "ab c" + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| crate::ui::render(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + let ghosts: Vec<&str> = buffer + .content() + .iter() + .filter(|c| { + c.fg == builtin::MONOKAI.secondary && c.modifier.contains(Modifier::UNDERLINED) + }) + .map(|c| c.symbol()) + .collect(); + assert_eq!(ghosts, ["c"]); + + app.pace_wpm = Some(0.0); // at the start, under the real cursor + terminal.draw(|f| crate::ui::render(f, &app)).unwrap(); + let ghosts = terminal + .backend() + .buffer() + .content() + .iter() + .filter(|c| { + c.fg == builtin::MONOKAI.secondary && c.modifier.contains(Modifier::UNDERLINED) + }) + .count(); + assert_eq!(ghosts, 0); + } + /// An emoji with a skin-tone modifier is two characters drawn two cells /// each. Layout and drawing measure it the same way (per character), so /// rows hold exactly what was laid out and nothing is clipped off a row's From 00773ebc0e2992d039dc1884026d172b36e19046 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:49:28 +0000 Subject: [PATCH 07/14] feat: practise the words you got wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each word now remembers whether a wrong key was pressed in it or on its space β€” even if it was fixed β€” and a saved session keeps those words (SessionRecord::mistyped, at most 200; left out of the file when none). A new `missed` menu row (after daily), --missed, or mode = "missed" as the start mode builds a run from them: from the last 100 sessions, the 50 words missed most often (the most recent first on a tie), shuffled and repeated to at least 25 words. The words come from stats.json, so they are filtered like a custom file's. With none yet, it says so: they're kept from TypeRush 0.7 on. To keep the whole menu on 80x24 with a snippet library, the blank line under the last option now gives way when it is all that doesn't fit β€” after the gaps between categories, before the banner. --- config.example.toml | 2 +- docs/ROADMAP.md | 2 +- src/app.rs | 161 +++++++++++++++++++++++++++++++++++++++++++- src/config/load.rs | 5 +- src/config/mod.rs | 2 +- src/main.rs | 30 +++++++++ src/settings.rs | 1 + src/storage.rs | 10 ++- src/ui/menu.rs | 31 ++++++--- src/ui/mod.rs | 1 + 10 files changed, 228 insertions(+), 17 deletions(-) diff --git a/config.example.toml b/config.example.toml index 7e52f14..67971ba 100644 --- a/config.example.toml +++ b/config.example.toml @@ -26,7 +26,7 @@ theme = "dark" # ----------------------------------------------------------------------------- [defaults] # Which menu row to pre-select: -# "time" | "words" | "quote" | "code" | "zen" | "symbols" | "daily" +# "time" | "words" | "quote" | "code" | "zen" | "symbols" | "daily" | "missed" mode = "time" # Used when mode = "time". Standard menu rows: 15 / 30 / 60 / 120. time_seconds = 15 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index eb9bac1..d11d5ab 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -231,7 +231,7 @@ one pull request: (the terminal's bell, on every key or only on mistakes) 6. βœ… Pace cursor: a second, dimmer cursor moving at your best for the mode, or at a target WPM -7. πŸ”² Practise missed words: every session keeps the words you got wrong; +7. βœ… Practise missed words: every session keeps the words you got wrong; a `missed` run is built from your most-missed recent words 8. πŸ”² Idle detection: a run with a long stretch without a key (10 s by default) is not saved, and Results says why diff --git a/src/app.rs b/src/app.rs index e44ab03..37abc9a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -70,6 +70,8 @@ pub enum Mode { Symbols(usize), /// The daily challenge: the same words for everyone on a given day. Daily, + /// A run built from the words you got wrong lately. + Missed, } impl Mode { @@ -90,6 +92,7 @@ impl Mode { Mode::Custom => "custom".to_string(), Mode::Symbols(count) => format!("symbols-{}", count), Mode::Daily => "daily".to_string(), + Mode::Missed => "missed".to_string(), } } @@ -120,6 +123,7 @@ impl Mode { "quote" => Some(Mode::Quote), "zen" => Some(Mode::Zen), "daily" => Some(Mode::Daily), + "missed" => Some(Mode::Missed), _ if base == "custom" || base.starts_with("custom-") => Some(Mode::Custom), _ => None, } @@ -207,6 +211,9 @@ pub struct Word { /// True when a non-space key was typed where the trailing space belongs β€” /// the space is rendered as an error until backspaced. pub space_missed: bool, + /// A wrong key was pressed somewhere in this word (or on its space), + /// even if it was fixed: the word goes to the missed-words practice. + pub had_error: bool, } impl Word { @@ -215,6 +222,7 @@ impl Word { text, typed: String::new(), space_missed: false, + had_error: false, } } } @@ -296,6 +304,15 @@ pub fn best_menu_match(menu: &[MenuItem], default_mode: Mode) -> usize { .unwrap_or(0) } +/// Most mistyped words saved with one session. +pub const MAX_MISTYPED: usize = 200; +/// How many recent sessions the missed-words practice looks through. +const MISSED_SESSIONS: usize = 100; +/// Most different words in a missed-words practice run. +const MISSED_DISTINCT: usize = 50; +/// Fewest words in a missed-words practice run. +const MISSED_RUN: usize = 25; + /// The menu with no custom sources β€” what tests and a fresh `App` start from. #[cfg(test)] pub fn default_menu() -> Vec { @@ -368,6 +385,7 @@ pub fn build_menu(snippets: &[Snippet], last_custom_file: Option<&Path>) -> Vec< } // The rows with a choice of options come first, then the single runs. menu.push(start("daily", "daily", Mode::Daily)); + menu.push(start("missed", "missed", Mode::Missed)); menu.push(start("quote", "quote", Mode::Quote)); menu.push(start("zen", "zen", Mode::Zen)); @@ -1108,7 +1126,9 @@ impl App { Mode::Words(target) | Mode::Symbols(target) => { Some((self.current_word.min(target), target)) } - Mode::Daily => Some((self.current_word.min(self.words.len()), self.words.len())), + Mode::Daily | Mode::Missed => { + Some((self.current_word.min(self.words.len()), self.words.len())) + } _ => None, } } @@ -1132,6 +1152,7 @@ impl App { Mode::Code(lang) => words::random_code_snippet(lang), Mode::Symbols(count) => words::symbols::random_symbol_tokens(count), Mode::Daily => words::daily_words(chrono::Local::now().date_naive()), + Mode::Missed => self.missed_words_run()?, Mode::Custom => { let Some(path) = &self.custom_file else { return Err(anyhow::anyhow!( @@ -1211,6 +1232,67 @@ impl App { self.pace_wpm = self.pace_for_run(); } + /// The words of this run you got wrong (a wrong key in the word or on + /// its space, fixed or not), in order, as saved for practice: at most + /// [`MAX_MISTYPED`] of them. + pub fn mistyped_words(&self) -> Vec { + let reached = (self.current_word + 1).min(self.words.len()); + self.words[..reached] + .iter() + .filter(|word| word.had_error) + .take(MAX_MISTYPED) + .map(|word| word.text.clone()) + .collect() + } + + /// A practice run of the words you've got wrong lately: from the last + /// [`MISSED_SESSIONS`] sessions, the [`MISSED_DISTINCT`] words missed + /// most often (the most recent first on a tie), shuffled, and repeated + /// to at least [`MISSED_RUN`] words so a short list is still a run. + pub fn missed_words_run(&mut self) -> anyhow::Result> { + use rand::seq::SliceRandom; + // (times missed, how recently: lower is newer) + let mut seen: HashMap = HashMap::new(); + let history = self.history(); + for (age, session) in history.iter().rev().take(MISSED_SESSIONS).enumerate() { + for word in &session.mistyped { + // Held to the custom-file rules: stats.json can be edited. + let word: String = word + .chars() + .filter(|&c| !crate::text::is_untypeable(c)) + .collect(); + if word.is_empty() || word.contains(char::is_whitespace) { + continue; + } + let entry = seen.entry(word).or_insert((0, age)); + entry.0 += 1; + } + } + if seen.is_empty() { + anyhow::bail!( + "no missed words yet: sessions keep the words you get wrong from TypeRush 0.7 on" + ); + } + let mut words: Vec<(String, (usize, usize))> = seen.into_iter().collect(); + words.sort_by(|(a, (count_a, age_a)), (b, (count_b, age_b))| { + count_b.cmp(count_a).then(age_a.cmp(age_b)).then(a.cmp(b)) + }); + let distinct: Vec = words + .into_iter() + .take(MISSED_DISTINCT) + .map(|(word, _)| word) + .collect(); + let mut rng = rand::rng(); + let mut run = Vec::new(); + while run.len() < MISSED_RUN.max(distinct.len()) { + let mut round = distinct.clone(); + round.shuffle(&mut rng); + run.extend(round); + } + run.truncate(MISSED_RUN.max(distinct.len())); + Ok(run) + } + /// The history: the cache, read from `stats_file` the first time it's /// needed (outside a frame: at a run's start, on entering a screen). pub fn history(&mut self) -> &[SessionRecord] { @@ -1380,6 +1462,7 @@ impl App { }; self.rejected_key = blocked; let is_correct = typed_char == target_char && !blocked; + active_word.had_error |= !is_correct; self.bell = match self.options.sound { KeySound::Off => false, KeySound::Errors => !is_correct, @@ -1702,6 +1785,77 @@ mod tests { assert_eq!(app.screen, Screen::Results); } + /// A word counts as missed when a wrong key was pressed in it or on its + /// space β€” even if it was fixed; the run's missed words are saved in + /// order, the word being typed included. + #[test] + fn missed_words_are_marked_even_when_fixed() { + let mut app = custom_app(&["one", "two", "three", "four"]); + for ch in "onx".chars() { + app.handle_char(ch); + } + app.handle_backspace(false); + for ch in "e two ".chars() { + app.handle_char(ch); + } + app.handle_char('x'); // wrong first letter of "three" + assert!(app.words[0].had_error, "fixed, still missed"); + assert!(!app.words[1].had_error); + assert_eq!(app.mistyped_words(), ["one", "three"]); + // A wrong key on a word's space counts for that word. + let mut app = custom_app(&["ab", "cd"]); + for ch in "abx".chars() { + app.handle_char(ch); + } + assert_eq!(app.mistyped_words(), ["ab"]); + } + + /// The practice run: the most-missed recent words first in weight + /// (ties: the most recent), shuffled and repeated to at least 25 words; + /// text from stats.json filtered like a custom file's; none yet is an + /// error that explains. + #[test] + fn missed_words_run_is_built_from_recent_mistakes() { + let mut app = menu_app(); + let session = |words: &[&str]| SessionRecord { + mistyped: words.iter().map(|w| w.to_string()).collect(), + ..SessionRecord::default() + }; + app.stats_cache = Some(vec![]); + let error = app.missed_words_run().unwrap_err().to_string(); + assert!(error.contains("no missed words yet"), "{error}"); + + app.stats_cache = Some(vec![ + session(&["their", "because"]), + session(&["their", "wh\u{1b}ich", "two words"]), + ]); + let run = app.missed_words_run().unwrap(); + assert_eq!(run.len(), 25, "repeated to a full run"); + for word in ["their", "because", "which"] { + assert!(run.contains(&word.to_string()), "{word} missing"); + } + assert!(!run.iter().any(|w| w.contains(' ') || w.contains('\u{1b}'))); + + app.start_game(Mode::Missed).unwrap(); + assert_eq!(app.session_label(), "missed"); + assert_eq!(app.progress(), Some((0, 25))); + + // Many different words: the 50 most missed, each once. + let many: Vec = (0..80).map(|i| format!("w{i}")).collect(); + let mut sessions: Vec = vec![SessionRecord { + mistyped: many.clone(), + ..SessionRecord::default() + }]; + sessions.push(SessionRecord { + mistyped: vec!["w79".into()], + ..SessionRecord::default() + }); + app.stats_cache = Some(sessions); + let run = app.missed_words_run().unwrap(); + assert_eq!(run.len(), 50); + assert!(run.contains(&"w79".to_string()), "missed twice: kept"); + } + /// The bell is asked for on every key, on mistakes only (refused keys /// too), or never. #[test] @@ -2284,7 +2438,10 @@ mod tests { groups.dedup(); assert_eq!( groups, - ["time", "words", "code", "symbols", "custom", "daily", "quote", "zen", "app"] + [ + "time", "words", "code", "symbols", "custom", "daily", "missed", "quote", "zen", + "app" + ] ); assert_eq!(row("daily"), ["daily"]); assert_eq!(row("app"), ["stats", "profile", "settings", "quit"]); diff --git a/src/config/load.rs b/src/config/load.rs index 946f294..4eece75 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -350,7 +350,9 @@ pub fn word_pool(name: &str) -> Option { } /// The `[defaults] mode` names, in menu order. -pub const MODE_NAMES: [&str; 7] = ["time", "words", "quote", "code", "zen", "symbols", "daily"]; +pub const MODE_NAMES: [&str; 8] = [ + "time", "words", "quote", "code", "zen", "symbols", "daily", "missed", +]; /// Built-in defaults for the `[defaults]` counts: what a time, words or /// symbols run starts with when the config doesn't say. @@ -427,6 +429,7 @@ fn resolve_default_mode(defaults: Option<&super::Defaults>, warnings: &mut Vec Mode::Zen, "daily" => Mode::Daily, + "missed" => Mode::Missed, "symbols" => Mode::Symbols(config_count( "symbol_count", defaults.symbol_count, diff --git a/src/config/mod.rs b/src/config/mod.rs index 7c2b36b..de92323 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -10,7 +10,7 @@ //! theme = "monokai" # built-in name: dark | light | monokai | dracula //! //! [defaults] -//! mode = "time" # time | words | quote | code | zen | symbols | daily +//! mode = "time" # time | words | quote | code | zen | symbols | daily | missed //! time_seconds = 15 # default duration for time mode //! word_count = 25 # default count for words mode //! code_lang = "rust" # rust | python | js | go | java | sql | shell diff --git a/src/main.rs b/src/main.rs index b1ff1bd..d16c1d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,6 +116,10 @@ struct Cli { #[arg(long)] daily: bool, + /// Skip the menu and practise the words you've got wrong lately. + #[arg(long)] + missed: bool, + /// Skip the menu and start a programming-symbols drill of N tokens (25/50). #[arg(long, value_parser = count::)] symbols: Option, @@ -468,6 +472,8 @@ fn apply_cli_autostart(app: &mut App, cli: &Cli, stats_file: &Path) -> Result<() app.start_game(Mode::Symbols(count))?; } else if cli.daily { app.start_game(Mode::Daily)?; + } else if cli.missed { + app.start_game(Mode::Missed)?; } else if let Some(back) = cli.replay { let history = storage::load_sessions_from_path(stats_file).unwrap_or_default(); let Some(record) = history.len().checked_sub(back).map(|i| &history[i]) else { @@ -1009,6 +1015,7 @@ fn save_current_session(app: &mut App, stats_file: &Path) { consistency: app.consistency(), missed: app.missed, ended_early: app.ended_early, + mistyped: app.mistyped_words(), }; match storage::save_session_to_path(stats_file, &record) { Ok(history) => { @@ -1893,6 +1900,28 @@ mod tests { assert!(Cli::try_parse_from(["typerush", "--replay", "0"]).is_err()); } + /// A saved session keeps its missed words; `--missed` practises them. + #[test] + fn missed_words_are_saved_and_practised() { + let dir = tempfile::tempdir().unwrap(); + let stats_file = dir.path().join("stats.json"); + let mut app = make_typing_app(); // "hello world" + app.started_at = Some(std::time::Instant::now() - Duration::from_secs(3)); + for ch in "hellp world".chars() { + app.handle_char(ch); + } + after_input(&mut app, Screen::Typing, &mut false, &stats_file); + let saved = storage::load_sessions_from_path(&stats_file).unwrap(); + assert_eq!(saved[0].mistyped, ["hello"]); + + let cli = Cli::try_parse_from(["typerush", "--missed"]).unwrap(); + let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); + app.stats_file = Some(stats_file.clone()); + apply_cli_autostart(&mut app, &cli, &stats_file).unwrap(); + assert_eq!(app.mode, Mode::Missed); + assert!(app.words.iter().all(|w| w.text == "hello")); + } + /// `--daily` starts today's challenge straight away. #[test] fn daily_flag_starts_the_challenge() { @@ -2017,6 +2046,7 @@ mod tests { press(&mut app, KeyCode::Left); // wraps to the last run: zen assert_eq!(app.settings.as_ref().unwrap().start, Mode::Zen); press(&mut app, KeyCode::Left); // β†’ quote + press(&mut app, KeyCode::Left); // β†’ missed press(&mut app, KeyCode::Left); // β†’ daily assert_eq!(app.settings.as_ref().unwrap().start, Mode::Daily); let (resolved, warnings) = config::load::load_from(&config_file, CliOverrides::default()); diff --git a/src/settings.rs b/src/settings.rs index b21094e..bafc42f 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -345,6 +345,7 @@ fn start_settings(mode: Mode) -> Vec<(&'static str, String)> { Mode::Zen => "zen", Mode::Symbols(_) => "symbols", Mode::Daily => "daily", + Mode::Missed => "missed", // Never offered (see `start_modes`). Mode::Custom => "time", }; diff --git a/src/storage.rs b/src/storage.rs index da0fc3c..76c2cb7 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -69,6 +69,10 @@ pub struct SessionRecord { /// completed run. Added in v0.6.0 (older records count as completed). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub ended_early: bool, + /// The words typed wrong (a wrong key in the word, fixed or not), for + /// the missed-words practice; at most 200. Added in v0.7.0. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub mistyped: Vec, } fn is_zero(n: &usize) -> bool { @@ -529,7 +533,7 @@ pub struct ModeBest { } /// Every mode in `sessions` with its best, in menu order: time, words, -/// code, symbols, custom, daily, quote, then anything else; numbers in numeric +/// code, symbols, custom, daily, missed, quote, then anything else; numbers in numeric /// order within a family (`time-15s` before `time-120s`). pub fn bests_by_mode(sessions: &[SessionRecord]) -> Vec { bests_among(sessions, 0..sessions.len()) @@ -570,8 +574,8 @@ pub fn bests_among( /// Sort key for a mode label: menu family first, then the label's first /// number as a number, then the text around it. fn mode_sort_key(mode: &str) -> (usize, String, u64, String) { - const FAMILIES: [&str; 7] = [ - "time-", "words-", "code-", "symbols-", "custom", "daily", "quote", + const FAMILIES: [&str; 8] = [ + "time-", "words-", "code-", "symbols-", "custom", "daily", "missed", "quote", ]; let family = FAMILIES .iter() diff --git a/src/ui/menu.rs b/src/ui/menu.rs index c82bb2c..0c6202b 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -75,6 +75,8 @@ struct MenuLines { selected_line: usize, /// Blank lines between categories in `lines`. gaps: usize, + /// `lines` ends with the blank line under the last option. + trailing_blank: bool, } /// Render the menu screen. `app.menu_index` highlights the active option. @@ -119,12 +121,16 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { { (Banner::Full, spaced) } else { - let banner = - if text_width >= COMPACT_WIDTH as usize && gaps_that_fit(Banner::Compact).is_some() { - Banner::Compact - } else { - Banner::Hidden - }; + // The blank line under the last option is the last thing to go + // before the banner: the compact banner stays if the list fits + // without it. + let fits_tight = + (box_area.height as usize) >= Banner::Compact.rows() as usize + CHROME + dense_rows - 1; + let banner = if text_width >= COMPACT_WIDTH as usize && fits_tight { + Banner::Compact + } else { + Banner::Hidden + }; let fit = gaps_that_fit(banner).unwrap_or(0); // All the gaps between categories, or none: a mix (dense at the // top, spaced below) reads as misaligned. @@ -171,14 +177,22 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { banner_area, ); + // The blank line under the last option goes when it's all that doesn't + // fit (see above). + let mut menu = menu; + let visible = menu_area.height as usize; + if menu.lines.len() > visible && menu.lines.last().is_some_and(|l| l.width() == 0) { + menu.lines.pop(); + menu.trailing_blank = false; + } // Too short even so: scroll so the selected row (and its heading, just // above it) stays visible. - let visible = menu_area.height as usize; let scroll = (menu.selected_line + 1).saturating_sub(visible); let more_above = scroll > 0; // The trailing blank line is padding, not an option: it never counts // as "more". - let more_below = scroll + visible < menu.lines.len() - 1; + let options_end = menu.lines.len() - usize::from(menu.trailing_blank); + let more_below = scroll + visible < options_end; let mut targets = app.click_targets.borrow_mut(); for (line, x, width, index) in menu.chips { if line < scroll || line - scroll >= visible { @@ -399,5 +413,6 @@ fn menu_lines(app: &App, width: usize, gaps_wanted: bool) -> MenuLines { chips, selected_line, gaps, + trailing_blank: true, } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0a0e746..f640834 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -415,6 +415,7 @@ mod tests { consistency: (i % 3 != 0).then_some(70.0 + i as f64), missed: i % 2, ended_early: i % 7 == 0, + mistyped: vec!["fox".into()], } }) .collect(); From 7b1c80f94537ea209021d39da9cd68ecb8c4d231 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:52:03 +0000 Subject: [PATCH 08/14] feat: a run left idle too long isn't saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typing.idle_limit (Settings, typerush config): when a run has a stretch of that many whole seconds or more without a key β€” 10 by default, 0 to turn it off, up to 600 β€” it isn't saved, and Results' title says why ("not saved: 34 s without a key (the limit is 10 s)"). It counts from the per-second tally, so paused time never counts as idle, and walking away from a time run until its clock runs out does. --- config.example.toml | 2 ++ docs/ROADMAP.md | 2 +- src/app.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++ src/config/edit.rs | 30 +++++++++++++++++++++-- src/config/load.rs | 29 ++++++++++++++++++++++ src/config/mod.rs | 3 +++ src/main.rs | 30 +++++++++++++++++++++++ src/options.rs | 14 +++++++++++ src/settings.rs | 16 +++++++++++- src/ui/results.rs | 10 ++++++++ src/ui/settings.rs | 4 +++ 11 files changed, 196 insertions(+), 4 deletions(-) diff --git a/config.example.toml b/config.example.toml index 67971ba..934430a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -63,6 +63,8 @@ symbol_count = 25 # cursor = "underline" # "underline" | "block" | "bar" # sound = "off" # "off" | "errors" | "keys" β€” the terminal's bell # pace = "off" # "off" | "best" (your best for the mode) | a WPM, e.g. 60 +# idle_limit = 10 # a run with this many seconds without a key isn't saved; +# # 0 = never # ----------------------------------------------------------------------------- # Color overrides β€” applied ON TOP of the chosen theme. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d11d5ab..f9f3aaa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -233,7 +233,7 @@ one pull request: mode, or at a target WPM 7. βœ… Practise missed words: every session keeps the words you got wrong; a `missed` run is built from your most-missed recent words -8. πŸ”² Idle detection: a run with a long stretch without a key (10 s by +8. βœ… Idle detection: a run with a long stretch without a key (10 s by default) is not saved, and Results says why 9. πŸ”² Custom themes from files in `~/.typerush/themes/`, on top of a built-in or from scratch, listed and picked like the built-in ones diff --git a/src/app.rs b/src/app.rs index 37abc9a..fea400e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1073,6 +1073,30 @@ impl App { Some((100.0 * (1.0 - variance.sqrt() / mean)).clamp(0.0, 100.0)) } + /// The longest stretch of whole seconds without a key in this run + /// (paused time left out, like everywhere): the idle check. + pub fn longest_idle(&self) -> u64 { + let seconds = self.elapsed().as_secs() as usize; + let (mut longest, mut run) = (0u64, 0u64); + for i in 0..seconds { + if self.timeline.get(i).is_none_or(|second| second.typed == 0) { + run += 1; + longest = longest.max(run); + } else { + run = 0; + } + } + longest + } + + /// `Some(idle seconds)` when the run sat idle for at least the limit + /// (`typing.idle_limit`): it isn't saved, and Results says why. + pub fn too_idle(&self) -> Option { + let limit = self.options.idle_limit?; + let idle = self.longest_idle(); + (idle >= limit).then_some(idle) + } + /// Characters of the text never typed: what was left of a fixed text /// (words, quote, code, symbols, custom, daily) when the run ended /// early. Endless runs (time, zen) miss nothing β€” there is always more. @@ -1856,6 +1880,42 @@ mod tests { assert!(run.contains(&"w79".to_string()), "missed twice: kept"); } + /// The idle check counts the longest stretch of whole seconds with no + /// key β€” at the end too (walking away from a time run) β€” against the + /// limit; off, nothing is too idle. + #[test] + fn idle_runs_are_caught() { + let mut app = custom_app(&["abc"]); + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(20)); + let typing = Second { + typed: 3, + correct: 3, + errors: 0, + }; + // Keys in seconds 0–3 and 16–19: twelve idle seconds between. + app.timeline = (0..20) + .map(|i| { + if !(4..16).contains(&i) { + typing + } else { + Second::default() + } + }) + .collect(); + assert_eq!(app.longest_idle(), 12); + app.options.idle_limit = Some(10); + assert_eq!(app.too_idle(), Some(12)); + app.options.idle_limit = Some(15); + assert_eq!(app.too_idle(), None); + // Walking away at the end counts too: keys only in seconds 0–3. + app.timeline = vec![typing; 4]; + assert_eq!(app.longest_idle(), 16); + app.options.idle_limit = None; + assert_eq!(app.too_idle(), None); + } + /// The bell is asked for on every key, on mistakes only (refused keys /// too), or never. #[test] diff --git a/src/config/edit.rs b/src/config/edit.rs index c223907..6ee23dd 100644 --- a/src/config/edit.rs +++ b/src/config/edit.rs @@ -46,6 +46,8 @@ pub enum Kind { Choice(&'static [&'static str]), /// `off`, `best` or a WPM. Pace, + /// Seconds from 0 (off) to the longest idle limit. + IdleLimit, } /// One setting of `config.toml`, by its dotted name (`defaults.mode`). @@ -118,6 +120,10 @@ pub const KEYS: &[Key] = &[ name: "typing.pace", kind: Kind::Pace, }, + Key { + name: "typing.idle_limit", + kind: Kind::IdleLimit, + }, Key { name: "colors.accent", kind: Kind::Color, @@ -245,6 +251,22 @@ pub fn check(key: &Key, raw: &str) -> Result { }, // Written back the one way the parser reads it (`#A6E22E`, // `dark_gray`), so a printed value is always one of those. + // `off` is 0; a number of seconds may end in `s`. + Kind::IdleLimit => { + let seconds = match lower.trim_end_matches('s') { + "off" => 0, + number => number + .parse::() + .map_err(|_| "not a number of seconds (or off)".to_string())?, + }; + match (0..=crate::options::MAX_IDLE_SECONDS as i64).contains(&seconds) { + true => Ok(Setting::Number(seconds)), + false => Err(format!( + "must be between 0 (off) and {}", + crate::options::MAX_IDLE_SECONDS + )), + } + } // A WPM is written as a number, `off` / `best` as text. Kind::Pace => match Pace::from_name(raw) { Some(Pace::Wpm(wpm)) => Ok(Setting::Number(i64::from(wpm))), @@ -331,6 +353,7 @@ fn file_value(config: &Config, name: &str) -> Option { "typing.stop_on_error" => config.typing.as_ref()?.stop_on_error.clone(), "typing.cursor" => config.typing.as_ref()?.cursor.clone(), "typing.sound" => config.typing.as_ref()?.sound.clone(), + "typing.idle_limit" => number(config.typing.as_ref()?.idle_limit), "typing.pace" => config .typing .as_ref()? @@ -369,6 +392,7 @@ fn default_value(key: &Key, theme: &ThemePalette) -> Setting { "typing.cursor" => text("underline"), "typing.sound" => text("off"), "typing.pace" => text("off"), + "typing.idle_limit" => Setting::Number(10), "colors.accent" => color(theme.accent), "colors.secondary" => color(theme.secondary), "colors.correct" => color(theme.correct), @@ -579,6 +603,7 @@ mod tests { cursor: _, sound: _, pace: _, + idle_limit: _, } = Typing::default(); let Colors { accent: _, @@ -592,9 +617,9 @@ mod tests { neutral: _, background: _, } = Colors::default(); - // 1 theme + 5 defaults + 3 words + 6 typing + 9 colors (10 minus + // 1 theme + 5 defaults + 3 words + 7 typing + 9 colors (10 minus // `extra`). - assert_eq!(KEYS.len(), 1 + 5 + 3 + 6 + 9); + assert_eq!(KEYS.len(), 1 + 5 + 3 + 7 + 9); } /// One valid value for each kind of setting, as typed by a person. @@ -610,6 +635,7 @@ mod tests { Kind::Color => "#A6E22E", Kind::Choice(names) => names[names.len() - 1], Kind::Pace => "85", + Kind::IdleLimit => "30s", } } diff --git a/src/config/load.rs b/src/config/load.rs index 4eece75..8810454 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -250,7 +250,21 @@ fn resolve_typing(typing: Option<&super::Typing>, warnings: &mut Vec) -> defaults.pace }), }; + let idle_limit = match typing.idle_limit { + None => defaults.idle_limit, + Some(0) => None, + Some(seconds) if seconds <= crate::options::MAX_IDLE_SECONDS => Some(seconds), + Some(seconds) => { + warnings.push(format!( + "idle_limit {seconds} is over {}, using {}", + crate::options::MAX_IDLE_SECONDS, + crate::options::idle_name(defaults.idle_limit) + )); + defaults.idle_limit + } + }; TypingOptions { + idle_limit, pace, live_wpm: typing.live_wpm.unwrap_or(defaults.live_wpm), blind: typing.blind.unwrap_or(defaults.blind), @@ -572,6 +586,21 @@ mod tests { assert!(warnings[0].contains("pace '9000'"), "{warnings:?}"); } + /// `idle_limit`: 0 turns it off, up to 600 s, more warns. + #[test] + fn idle_limit_from_the_config() { + for (seconds, limit) in [(0, None), (30, Some(30)), (600, Some(600))] { + let raw: Config = toml::from_str(&format!("[typing]\nidle_limit = {seconds}")).unwrap(); + let (resolved, warnings) = resolve(raw, None); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(resolved.typing.idle_limit, limit); + } + let raw: Config = toml::from_str("[typing]\nidle_limit = 601").unwrap(); + let (resolved, warnings) = resolve(raw, None); + assert_eq!(resolved.typing.idle_limit, Some(10)); + assert!(warnings[0].contains("idle_limit 601"), "{warnings:?}"); + } + #[test] fn daily_default_mode() { let raw = Config { diff --git a/src/config/mod.rs b/src/config/mod.rs index de92323..ee103e8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -28,6 +28,7 @@ //! cursor = "underline" # underline | block | bar //! sound = "off" # off | errors | keys (the terminal bell) //! pace = "off" # off | best | a WPM such as 60 +//! idle_limit = 10 # a run idle this long isn't saved; 0 = never //! //! [colors] # optional β€” overrides slots of the chosen theme //! accent = "#FF00FF" @@ -76,6 +77,8 @@ pub struct Typing { pub sound: Option, /// `"off"` | `"best"` | a WPM (`60` or `"60"`): the pace cursor. pub pace: Option, + /// Seconds without a key after which a run isn't saved; 0 = never. + pub idle_limit: Option, } /// A value written either as text or as a whole number (`pace = 60` and diff --git a/src/main.rs b/src/main.rs index d16c1d7..2a77a93 100644 --- a/src/main.rs +++ b/src/main.rs @@ -978,6 +978,7 @@ fn after_input(app: &mut App, last_screen: Screen, session_saved: &mut bool, sta /// - the mode is Zen (no-stats philosophy) /// - the session is shorter than 1 second /// - the user hasn't typed a single character +/// - it sat idle for the idle limit or longer (`App::too_idle`) /// /// File I/O errors are intentionally swallowed β€” losing a stat row is never a /// good reason to crash on the user. @@ -992,6 +993,10 @@ fn save_current_session(app: &mut App, stats_file: &Path) { if duration < 1.0 || app.total_typed_chars == 0 { return; } + // A run left idle too long isn't a fair result (Results says why). + if app.too_idle().is_some() { + return; + } let record = SessionRecord { wpm: app.wpm(), accuracy: app.accuracy(), @@ -1922,6 +1927,31 @@ mod tests { assert!(app.words.iter().all(|w| w.text == "hello")); } + /// A run left idle past the limit isn't saved, and Results says why; + /// with the check off it is saved. + #[test] + fn idle_runs_are_not_saved() { + let dir = tempfile::tempdir().unwrap(); + let stats_file = dir.path().join("stats.json"); + for (limit, saved) in [(Some(10), false), (None, true)] { + let mut app = make_typing_app(); + app.options.idle_limit = limit; + // Twelve seconds pass before the keys come. + app.started_at = Some(std::time::Instant::now() - Duration::from_secs(12)); + for ch in "hello world".chars() { + app.handle_char(ch); + } + after_input(&mut app, Screen::Typing, &mut false, &stats_file); + assert_eq!(app.session_just_saved, saved, "{limit:?}"); + let title = screen(&app, 100, 24)[0].clone(); + assert_eq!( + title.contains("not saved: 12 s without a key (the limit is 10 s)"), + !saved, + "{title}" + ); + } + } + /// `--daily` starts today's challenge straight away. #[test] fn daily_flag_starts_the_challenge() { diff --git a/src/options.rs b/src/options.rs index 873d068..d6605cb 100644 --- a/src/options.rs +++ b/src/options.rs @@ -143,6 +143,11 @@ impl Pace { } } +/// Longest idle limit, in seconds. +pub const MAX_IDLE_SECONDS: u64 = 600; +/// The idle limits Settings cycles through (0 = off). +pub const IDLE_CHOICES: [u64; 6] = [0, 5, 10, 15, 30, 60]; + /// The resolved `[typing]` settings. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TypingOptions { @@ -156,6 +161,9 @@ pub struct TypingOptions { pub cursor: CursorStyle, pub sound: KeySound, pub pace: Pace, + /// A run with this many seconds or more without a key isn't saved; + /// `None` = never. + pub idle_limit: Option, } impl Default for TypingOptions { @@ -167,10 +175,16 @@ impl Default for TypingOptions { cursor: CursorStyle::Underline, sound: KeySound::Off, pace: Pace::Off, + idle_limit: Some(10), } } } +/// `off` or `10s` β€” an idle limit as Settings shows it. +pub fn idle_name(limit: Option) -> String { + limit.map_or("off".into(), |seconds| format!("{seconds}s")) +} + impl TypingOptions { /// Whether the live WPM and accuracy are drawn: wanted, and not blind. pub fn show_live_numbers(self) -> bool { diff --git a/src/settings.rs b/src/settings.rs index bafc42f..93e7df4 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -25,11 +25,12 @@ pub enum Row { Pace, Cursor, Sound, + IdleLimit, Reset, } /// Every row, in order. -pub const ROWS: [Row; 12] = [ +pub const ROWS: [Row; 13] = [ Row::Theme, Row::Start, Row::Punctuation, @@ -41,6 +42,7 @@ pub const ROWS: [Row; 12] = [ Row::Pace, Row::Cursor, Row::Sound, + Row::IdleLimit, Row::Reset, ]; @@ -253,6 +255,18 @@ impl App { self.options.sound = KeySound::from_name(name).unwrap_or_default(); } } + Row::IdleLimit => { + use crate::options::IDLE_CHOICES; + let current = view.typing.idle_limit.unwrap_or(0); + let at = IDLE_CHOICES.iter().position(|s| *s == current); + let seconds = IDLE_CHOICES[cycle(at, IDLE_CHOICES.len(), forward)]; + if self + .save_settings(&[("typing.idle_limit", seconds.to_string())]) + .is_some() + { + self.options.idle_limit = (seconds > 0).then_some(seconds); + } + } Row::Reset => {} } } diff --git a/src/ui/results.rs b/src/ui/results.rs index b7efd92..6a782b5 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -52,6 +52,16 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { .fg(theme.correct) .add_modifier(Modifier::BOLD), )]; + if let (false, Some(idle), Some(limit)) = ( + app.session_just_saved, + app.too_idle(), + app.options.idle_limit, + ) { + title.push(Span::styled( + format!(" not saved: {idle} s without a key (the limit is {limit} s)"), + Style::default().fg(theme.incorrect), + )); + } if app.session_just_saved { let (before, after) = ( crate::progress::Level::of(comparison.xp_before), diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 64f6e05..5a61012 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -160,6 +160,10 @@ fn row_line( Row::Pace => ("pace", view.typing.pace.name()), Row::Cursor => ("cursor", view.typing.cursor.name().to_string()), Row::Sound => ("key sound", view.typing.sound.name().to_string()), + Row::IdleLimit => ( + "idle limit", + crate::options::idle_name(view.typing.idle_limit), + ), Row::Reset => unreachable!("drawn above"), }; let label = Span::styled( From 6f893c2a499052cc41a2829158c43d4d32b3394c Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:55:31 +0000 Subject: [PATCH 09/14] feat(theme): custom themes from ~/.typerush/themes/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each TOML file in ~/.typerush/themes/ is a theme named after the file (ocean.toml is `ocean`): base = "" (dark when left out) and any of the color slots [colors] takes. They're read once at start and then picked like the built-in ones β€” theme = "ocean", --theme, the Settings screen, typerush config set theme, --list-themes (custom ones after the built-ins). A file is held to the config's limits (regular file, at most 1 MiB), a name of letters, digits, - and _, and can't take a built-in's name; one that can't be read is left out with a warning in the startup modal, and a color that doesn't parse keeps the base's slot. --list-themes also notes custom text colors under 4.5:1 on their background, the level the built-in themes meet. The WCAG contrast function moves from the built-in theme test to theme::color for that. --- config.example.toml | 4 +- docs/ROADMAP.md | 2 +- src/config/load.rs | 5 +- src/main.rs | 29 +++-- src/theme/builtin.rs | 41 ++++--- src/theme/color.rs | 21 ++++ src/theme/custom.rs | 273 +++++++++++++++++++++++++++++++++++++++++++ src/theme/mod.rs | 3 +- 8 files changed, 346 insertions(+), 32 deletions(-) create mode 100644 src/theme/custom.rs diff --git a/config.example.toml b/config.example.toml index 934430a..7a8748b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -14,7 +14,9 @@ # ----------------------------------------------------------------------------- # Theme β€” pick one of the built-in palettes by name. -# Available names: dark, light, monokai, dracula +# Available names: dark, light, monokai, dracula β€” and your own: a TOML file +# in ~/.typerush/themes/ (ocean.toml is "ocean") with `base = "dark"` (or +# another built-in) and any of the color slots listed under [colors] below. # Run `typerush --list-themes` to see what's currently shipped. # ----------------------------------------------------------------------------- theme = "dark" diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f9f3aaa..9e4d42c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -235,7 +235,7 @@ one pull request: a `missed` run is built from your most-missed recent words 8. βœ… Idle detection: a run with a long stretch without a key (10 s by default) is not saved, and Results says why -9. πŸ”² Custom themes from files in `~/.typerush/themes/`, on top of a +9. βœ… Custom themes from files in `~/.typerush/themes/`, on top of a built-in or from scratch, listed and picked like the built-in ones 10. πŸ”² Review, end-to-end test, docs, version 0.7.0 diff --git a/src/config/load.rs b/src/config/load.rs index 8810454..d8e9a15 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -323,7 +323,10 @@ fn resolve_words_section( (pool, decor) } -fn apply_color_overrides( +/// `overrides` applied on top of `palette`; a color that doesn't parse +/// keeps the palette's slot and adds a warning. Custom theme files use it +/// too (`theme::custom`). +pub(crate) fn apply_color_overrides( mut palette: ThemePalette, overrides: &Colors, warnings: &mut Vec, diff --git a/src/main.rs b/src/main.rs index 2a77a93..2f9b771 100644 --- a/src/main.rs +++ b/src/main.rs @@ -221,8 +221,12 @@ impl Cli { fn main() -> Result<()> { let cli = Cli::parse(); + // Custom themes are read first: every theme lookup after this β€” the + // config, --theme, --list-themes, `config set theme` β€” sees them. + let (themes, theme_warnings) = theme::custom::discover_in(&theme::custom::themes_dir()); + theme::custom::install(themes); if cli.list_themes { - print_themes_and_exit(); + print_themes_and_exit(&theme_warnings); } if cli.list_snippets { print_snippets_and_exit(); @@ -251,7 +255,7 @@ fn main() -> Result<()> { } install_panic_hook(); let mut terminal = setup_terminal()?; - let run_result = run_app(&mut terminal, cli); + let run_result = run_app(&mut terminal, cli, theme_warnings); let restored = restore_terminal(&mut terminal); if let Err(error) = run_result { // The run's error is the one that matters; still mention a failed @@ -274,12 +278,21 @@ fn print_error_and_exit(error: &anyhow::Error) -> ! { std::process::exit(1); } -/// Print every built-in theme name on its own line and exit with status 0. -/// Intended for shell completion / discoverability. -fn print_themes_and_exit() -> ! { +/// Print every theme name β€” built-in, then custom β€” on its own line and +/// exit with status 0 (for shell completion and scripts). Problems with +/// theme files, and custom text colors that are hard to read, go to stderr. +fn print_themes_and_exit(warnings: &[String]) -> ! { for theme_name in builtin::names() { println!("{}", theme_name); } + for line in warnings.iter().flat_map(|warning| warning.lines()) { + eprintln!("note: {}", text::printable(line)); + } + for theme in theme::custom::installed() { + for note in theme::custom::contrast_notes(&theme.palette) { + eprintln!("note: theme {}: {note}", theme.name); + } + } std::process::exit(0); } @@ -363,8 +376,10 @@ fn install_panic_hook() { /// `tick_rate` is 100ms β€” small enough that the WPM display feels live, large /// enough that we're not busy-looping. `event::poll` blocks for the remainder /// of the tick interval so keystrokes are still handled instantly. -fn run_app(terminal: &mut Tui, cli: Cli) -> Result<()> { - let (resolved, warnings) = config::load::load_or_default_with(cli.overrides()); +fn run_app(terminal: &mut Tui, cli: Cli, theme_warnings: Vec) -> Result<()> { + let (resolved, config_warnings) = config::load::load_or_default_with(cli.overrides()); + // A broken theme file is a warning like a broken config. + let warnings: Vec = theme_warnings.into_iter().chain(config_warnings).collect(); let mut app = App::new(cli.file.clone(), resolved.palette, resolved.default_mode); app.set_word_source(resolved.word_pool, resolved.word_decor); app.options = resolved.typing; diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index 47fcabb..2e497b0 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -80,17 +80,33 @@ pub const ALL: &[(&str, ThemePalette)] = &[ ("dracula", DRACULA), ]; -/// Look up a theme by name (case-insensitive). Returns `None` for unknown names. +/// Look up a theme by name (case-insensitive): a built-in one, or a custom +/// one from `~/.typerush/themes/` (see `theme::custom`). `None` for unknown +/// names. pub fn by_name(name: &str) -> Option { let lower = name.to_lowercase(); ALL.iter() .find(|(theme_name, _)| *theme_name == lower) .map(|(_, palette)| *palette) + .or_else(|| { + super::custom::installed() + .iter() + .find(|theme| theme.name == lower) + .map(|theme| theme.palette) + }) } -/// All built-in theme names, in registration order. +/// Every theme name: the built-in ones in registration order, then the +/// custom ones in name order. pub fn names() -> Vec<&'static str> { - ALL.iter().map(|(name, _)| *name).collect() + ALL.iter() + .map(|(name, _)| *name) + .chain( + super::custom::installed() + .iter() + .map(|theme| theme.name.as_str()), + ) + .collect() } #[cfg(test)] @@ -142,25 +158,8 @@ mod tests { assert_eq!(DRACULA.background, Color::Rgb(0x28, 0x2A, 0x36)); } - /// WCAG relative luminance of an RGB color. - fn luminance(color: Color) -> f64 { - let Color::Rgb(r, g, b) = color else { - panic!("{color:?} is not an RGB color"); - }; - let channel = |c: u8| { - let c = f64::from(c) / 255.0; - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - }; - 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) - } - fn contrast(a: Color, b: Color) -> f64 { - let (a, b) = (luminance(a), luminance(b)); - (a.max(b) + 0.05) / (a.min(b) + 0.05) + crate::theme::color::contrast(a, b).expect("RGB colors") } /// Every text color of a theme that paints its own background is easy diff --git a/src/theme/color.rs b/src/theme/color.rs index f14140c..4123d58 100644 --- a/src/theme/color.rs +++ b/src/theme/color.rs @@ -72,6 +72,27 @@ fn parse_named(name: &str) -> Result { } } +/// WCAG contrast ratio between two RGB colors (1.0–21.0); `None` when +/// either isn't RGB β€” a terminal palette color has no fixed value. +pub fn contrast(a: Color, b: Color) -> Option { + fn luminance(color: Color) -> Option { + let Color::Rgb(r, g, b) = color else { + return None; + }; + let channel = |c: u8| { + let c = f64::from(c) / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)) + } + let (a, b) = (luminance(a)?, luminance(b)?); + Some((a.max(b) + 0.05) / (a.min(b) + 0.05)) +} + /// `color` written the way [`parse_color`] reads it: `#RRGGBB`, an ANSI /// name (`dark_gray`) or `reset` β€” for printing config values. pub fn color_name(color: Color) -> String { diff --git a/src/theme/custom.rs b/src/theme/custom.rs new file mode 100644 index 0000000..e84ace1 --- /dev/null +++ b/src/theme/custom.rs @@ -0,0 +1,273 @@ +//! Your own themes: one TOML file each in `~/.typerush/themes/`, named after +//! the file (`ocean.toml` is `ocean`). A file sets any of the color slots +//! `[colors]` takes, on top of a built-in theme (`base = "dracula"`; `dark` +//! when it doesn't say): +//! +//! ```toml +//! base = "dark" +//! background = "#0B1D2A" +//! accent = "#4FC3F7" +//! ``` +//! +//! They are read once at start and then picked like the built-in ones β€” +//! `theme = "ocean"`, `--theme ocean`, the Settings screen, `--list-themes`. +//! A file is held to the same limits as the config: a regular file, small, +//! and a name made of letters, digits, `-` and `_`. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use super::{builtin, ThemePalette}; +use crate::config::Colors; + +/// Most custom themes read; more files are left out with a warning. +const MAX_THEMES: usize = 100; +/// Longest theme name, in characters. +const MAX_NAME: usize = 32; + +/// `~/.typerush/themes`. +pub fn themes_dir() -> PathBuf { + crate::storage::data_dir().join("themes") +} + +/// A custom theme read from its file. +#[derive(Clone, Debug, PartialEq)] +pub struct CustomTheme { + /// The name it is picked by: the file name without `.toml`, lower case. + pub name: String, + pub palette: ThemePalette, + pub path: PathBuf, +} + +/// The custom themes installed for this run of TypeRush ([`install`]). +static INSTALLED: OnceLock> = OnceLock::new(); + +/// Make `themes` available to theme lookups for the rest of the run. Only +/// the first call counts (TypeRush calls it once, at start; tests never do, +/// so they only ever see the built-in themes). +pub fn install(themes: Vec) { + let _ = INSTALLED.set(themes); +} + +/// The installed custom themes (none until [`install`]). +pub fn installed() -> &'static [CustomTheme] { + INSTALLED.get().map_or(&[], Vec::as_slice) +} + +/// Every theme file in `dir` (`*.toml`, in name order), and a warning for +/// each one left out or with a color it couldn't use. A missing folder is +/// no themes and no warning. +pub fn discover_in(dir: &Path) -> (Vec, Vec) { + let mut themes = Vec::new(); + let mut warnings = Vec::new(); + let Ok(entries) = std::fs::read_dir(dir) else { + return (themes, warnings); + }; + let mut paths: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) + }) + .collect(); + // Name order, ignoring case, like the snippets. + paths.sort_by_cached_key(|path| path.file_name().map(|n| n.to_string_lossy().to_lowercase())); + for path in paths { + let shown = crate::text::printable(&path.display().to_string()).into_owned(); + let stem = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let valid_name = !stem.is_empty() + && stem.chars().count() <= MAX_NAME + && stem + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'); + if !valid_name { + warnings.push(format!( + "theme {shown} left out: a theme's file name must be letters, digits, - or _ (up to {MAX_NAME})" + )); + continue; + } + if builtin::ALL.iter().any(|(name, _)| *name == stem) { + warnings.push(format!( + "theme {shown} left out: {stem} is a built-in theme's name" + )); + continue; + } + if themes.iter().any(|theme: &CustomTheme| theme.name == stem) { + warnings.push(format!( + "theme {shown} left out: a theme named {stem} is already read" + )); + continue; + } + if themes.len() == MAX_THEMES { + warnings.push(format!( + "more than {MAX_THEMES} themes: {shown} and later ones left out" + )); + break; + } + // Read like the config: a regular file of at most 1 MiB. + let text = match crate::config::load::read_config(&path) { + Ok(Some(text)) => text, + Ok(None) => continue, + Err(error) => { + warnings.push(format!("theme {shown} left out: {error}")); + continue; + } + }; + match parse(&text) { + Ok((palette, problems)) => { + warnings.extend( + problems + .into_iter() + .map(|problem| format!("theme {stem}: {problem}")), + ); + themes.push(CustomTheme { + name: stem, + palette, + path, + }); + } + Err(error) => warnings.push(format!("theme {shown} left out: {error}")), + } + } + (themes, warnings) +} + +/// A theme file's palette, and a note for each color it couldn't use (that +/// slot keeps the base theme's color). An error when it isn't valid TOML, +/// names an unknown base, or has a setting that isn't a color slot. +pub fn parse(text: &str) -> Result<(ThemePalette, Vec), String> { + let mut table: toml::Table = text + .parse() + .map_err(|error| format!("isn't valid TOML: {error}"))?; + let base = match table.remove("base") { + None => builtin::DARK, + Some(toml::Value::String(name)) => builtin::ALL + .iter() + .find(|(builtin, _)| builtin.eq_ignore_ascii_case(&name)) + .map(|(_, palette)| *palette) + .ok_or_else(|| { + format!( + "base '{}' isn't a built-in theme (try {})", + crate::text::printable(&name), + builtin::ALL + .iter() + .map(|(name, _)| *name) + .collect::>() + .join(", ") + ) + })?, + Some(_) => return Err("base must be the name of a built-in theme".into()), + }; + let colors: Colors = table.try_into().map_err(|error| format!("{error}"))?; + let mut problems = Vec::new(); + let palette = crate::config::load::apply_color_overrides(base, &colors, &mut problems); + Ok((palette, problems)) +} + +/// Text slots of `palette` that are hard to read on its background β€” under +/// 4.5:1, the level every built-in theme meets β€” as notes for +/// `--list-themes`. Nothing for a theme on the terminal's own background +/// (its contrast is the terminal's) or with non-RGB colors. +pub fn contrast_notes(palette: &ThemePalette) -> Vec { + let slots = [ + ("accent", palette.accent), + ("secondary", palette.secondary), + ("correct", palette.correct), + ("incorrect", palette.incorrect), + ("pending", palette.pending), + ("mode_tag", palette.mode_tag), + ("error", palette.error), + ("neutral", palette.neutral), + ]; + slots + .into_iter() + .filter_map(|(slot, color)| { + let ratio = super::color::contrast(color, palette.background)?; + (ratio < 4.5) + .then(|| format!("{slot} is {ratio:.1}:1 on the background (4.5:1 reads well)")) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Color; + + fn write(dir: &Path, name: &str, text: &str) { + std::fs::write(dir.join(name), text).unwrap(); + } + + /// A theme starts from its base (dark when it doesn't say) and sets the + /// slots it names; a bad color keeps the base's slot with a note. + #[test] + fn a_theme_file_sets_slots_on_its_base() { + let (palette, problems) = parse("base = \"Dracula\"\naccent = \"#112233\"\n").unwrap(); + assert_eq!(palette.accent, Color::Rgb(0x11, 0x22, 0x33)); + assert_eq!(palette.background, builtin::DRACULA.background); + assert!(problems.is_empty()); + + let (palette, problems) = parse("background = \"#000000\"\nerror = \"nope\"").unwrap(); + assert_eq!(palette.correct, builtin::DARK.correct); + assert_eq!(palette.background, Color::Rgb(0, 0, 0)); + assert_eq!(palette.error, builtin::DARK.error); + assert!(problems[0].contains("'error'"), "{problems:?}"); + + for (bad, why) in [ + ("base = \"solarized\"", "isn't a built-in theme"), + ("base = 3", "base must be"), + ("acent = \"red\"", "unknown field"), + ("accent = ", "isn't valid TOML"), + ] { + let error = parse(bad).unwrap_err(); + assert!(error.contains(why), "{bad}: {error}"); + } + } + + /// The folder's *.toml files become themes named after them, in name + /// order; bad names, built-in names, broken files and non-files are left + /// out with a warning; a missing folder is just no themes. + #[test] + fn themes_are_discovered_with_warnings() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(discover_in(&dir.path().join("none")), (vec![], vec![])); + write(dir.path(), "Ocean.toml", "accent = \"#4FC3F7\""); + write(dir.path(), "forest_2.TOML", "base = \"light\""); + write(dir.path(), "notes.txt", "accent = \"red\""); + write(dir.path(), "has space.toml", ""); + write(dir.path(), "monokai.toml", ""); + write(dir.path(), "broken.toml", "accent = "); + std::fs::create_dir(dir.path().join("folder.toml")).unwrap(); + let (themes, warnings) = discover_in(dir.path()); + let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, ["forest_2", "ocean"]); + assert_eq!(themes[0].palette.background, builtin::LIGHT.background); + assert_eq!(themes[1].palette.accent, Color::Rgb(0x4F, 0xC3, 0xF7)); + let all = warnings.join("\n"); + for reason in [ + "letters, digits", + "built-in theme's name", + "isn't valid TOML", + "not a regular file", + ] { + assert!(all.contains(reason), "{reason}: {all}"); + } + assert_eq!(warnings.len(), 4, "{all}"); + } + + /// Hard-to-read text slots are noted; the built-in themes have none. + #[test] + fn contrast_notes_flag_hard_to_read_slots() { + for (_, palette) in builtin::ALL { + assert!(contrast_notes(palette).is_empty()); + } + let (palette, _) = parse("base = \"light\"\npending = \"#EEEEEE\"").unwrap(); + let notes = contrast_notes(&palette); + assert_eq!(notes.len(), 1); + assert!(notes[0].starts_with("pending is 1.1:1"), "{notes:?}"); + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 0b081d2..9a6fcae 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -3,7 +3,7 @@ //! A `ThemePalette` is a flat struct of named color slots. Built-in palettes //! (dark / light / monokai / dracula) live in [`builtin`]; the user can also //! pick one by name in `~/.typerush/config.toml` and optionally override -//! individual slots. +//! individual slots, or add their own in `~/.typerush/themes/` ([`custom`]). //! //! All UI code reads from the active `ThemePalette` rather than hardcoding //! `Color::*`, so adding a new theme is a matter of dropping a `ThemePalette` @@ -11,6 +11,7 @@ pub mod builtin; pub mod color; +pub mod custom; use ratatui::style::Color; From 39cb76cfe7653a3abb86cd8cb9c01bf575733a23 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 12:56:42 +0000 Subject: [PATCH 10/14] review: Settings scrolls on a short terminal; theme sweep covers v0.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings has thirteen rows now. On a terminal too short for all of them the bottom rows β€” reset to defaults among them β€” were cut off with no way to reach them; the list now scrolls to keep the selected row in view, its clickable parts follow, and the border says there's more. - The every-screen theme test now also draws the block cursor with a pace cursor and a refused key, the bar cursor in blind mode, and a Results screen for a run left idle. --- src/main.rs | 25 ++++++++++++++++++++++++ src/ui/mod.rs | 21 ++++++++++++++++++++ src/ui/settings.rs | 48 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/src/main.rs b/src/main.rs index 2f9b771..a39e659 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2280,6 +2280,31 @@ mod tests { assert!(Cli::try_parse_from(["typerush", "--stop-on-error", "always"]).is_err()); } + /// On a terminal too short for every setting, the list scrolls to keep + /// the selected row in view β€” reset included, still clickable β€” and + /// the border says there's more; at 80Γ—24 everything fits. + #[test] + fn settings_scroll_on_a_short_terminal() { + let dir = tempfile::tempdir().unwrap(); + let (mut app, _) = settings_app(&dir); + assert!(!screen(&app, 80, 24).join("\n").contains("more")); + let reset = settings::ROWS.len() - 1; + app.settings_select(reset); + let rows = screen(&app, 80, 14); + let dump = rows.join("\n"); + assert!(dump.contains("reset to defaults"), "{dump}"); + assert!(dump.contains("β–² more"), "{dump}"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 14)).unwrap(); + terminal.draw(|f| ui::render(f, &app)).unwrap(); + target_cell(&app, ClickAction::Setting(reset, KeyCode::Enter)); // clickable + app.settings_select(0); + let dump = screen(&app, 80, 14).join("\n"); + assert!( + dump.contains("β–Ό more") && !dump.contains("β–² more"), + "{dump}" + ); + } + /// The menu's settings option opens the screen too. #[test] fn menu_settings_option_opens_settings() { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f640834..fafb5dc 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -435,6 +435,27 @@ mod tests { app.start_game(Mode::Time(30)).unwrap(); app.started_at = Some(Instant::now() - Duration::from_secs(12)); }), + ("typing block cursor, pace, refused key", |app| { + app.options.cursor = crate::options::CursorStyle::Block; + app.options.stop_on_error = crate::options::StopOnError::Letter; + app.start_game(Mode::Words(25)).unwrap(); + app.started_at = Some(Instant::now() - Duration::from_secs(4)); + app.pace_wpm = Some(60.0); + app.handle_char('#'); + }), + ("typing bar cursor, blind", |app| { + app.options.cursor = crate::options::CursorStyle::Bar; + app.options.blind = true; + app.start_game(Mode::Words(25)).unwrap(); + app.handle_char('#'); + }), + ("results not saved (idle)", |app| { + app.start_game(Mode::Words(10)).unwrap(); + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(30)); + app.screen = Screen::Results; + }), ("paused", |app| { app.start_game(Mode::Words(25)).unwrap(); app.toggle_pause(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5a61012..2950126 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -54,27 +54,24 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { }, ); let block = block.title_bottom(Span::styled(saved_to, Style::default().fg(theme.pending))); - f.render_widget(block, box_area); // A blank line, the settings, a blank line, reset, then any problem - // with the file. + // with the file. Clickable parts are kept by line until the scroll is + // known. let mut lines = vec![Line::raw("")]; - let mut targets = app.click_targets.borrow_mut(); + let mut chips_by_line = Vec::new(); + let mut selected_line = 0; for (index, row) in ROWS.iter().enumerate() { if *row == Row::Reset { lines.push(Line::raw("")); } - let y = inner.y + lines.len() as u16; let (line, chips) = row_line(app, view, index, *row); - for (x, width, code) in chips { - let cell = Rect::new(inner.x + x, y, width, 1).intersection(inner); - if !cell.is_empty() { - targets.push((cell, ClickAction::Setting(index, code))); - } + if index == view.selected { + selected_line = lines.len(); } + chips_by_line.push((lines.len(), index, chips)); lines.push(line); } - drop(targets); if let Some(problem) = &view.problem { // The first line of the loader's warning: the file is still used // where it can be, and the problem stays until it is fixed. @@ -86,7 +83,36 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { Style::default().fg(theme.error), )); } - f.render_widget(Paragraph::new(lines), inner); + + // Too short for every row: scroll so the selected one is in view, with + // a sign on the border for what's above or below. + let visible = inner.height as usize; + let scroll = (selected_line + 1).saturating_sub(visible); + let hint = Style::default().fg(theme.pending); + let mut block = block; + if scroll > 0 { + block = block.title(Line::styled(" β–² more ", hint).right_aligned()); + } + if scroll + visible < lines.len() { + block = block.title_bottom(Line::styled(" β–Ό more ", hint).right_aligned()); + } + f.render_widget(block, box_area); + + let mut targets = app.click_targets.borrow_mut(); + for (line, index, chips) in chips_by_line { + if line < scroll || line - scroll >= visible { + continue; + } + let y = inner.y + (line - scroll) as u16; + for (x, width, code) in chips { + let cell = Rect::new(inner.x + x, y, width, 1).intersection(inner); + if !cell.is_empty() { + targets.push((cell, ClickAction::Setting(index, code))); + } + } + } + drop(targets); + f.render_widget(Paragraph::new(lines).scroll((scroll as u16, 0)), inner); const HINTS: &[super::Hint] = &[ ("↑/↓ setting", None), From 03269836be2fd1ee566b0b3fdaf111c55fb92fae Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 13:01:18 +0000 Subject: [PATCH 11/14] =?UTF-8?q?docs:=20v0.7.0=20=E2=80=94=20usage,=20arc?= =?UTF-8?q?hitecture,=20learnings,=20changelog;=20version=200.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USAGE: Tab and F6, practising missed words, the typing options (live wpm, blind, stop on error, cursor, key sound, pace, idle limit) with their config names, your own themes, the Settings scroll; the monokai row no longer names other products. ARCHITECTURE: options.rs and theme/custom.rs, how each practice option is built, and the steps for a new typing option. LEARNINGS: what was caught before shipping and why each feature is built the way it is. README, CONTRIBUTING and the contributor skill follow; CHANGELOG gains [0.7.0]; ROADMAP marks v0.7 done and says nothing is planned past it yet. --- .claude/skills/typerush-dev/SKILL.md | 3 +- CHANGELOG.md | 46 ++++++++++- CONTRIBUTING.md | 3 +- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- docs/ARCHITECTURE.md | 73 +++++++++++++++++- docs/LEARNINGS.md | 58 ++++++++++++++ docs/ROADMAP.md | 18 ++++- docs/USAGE.md | 111 ++++++++++++++++++++++++--- 10 files changed, 298 insertions(+), 24 deletions(-) diff --git a/.claude/skills/typerush-dev/SKILL.md b/.claude/skills/typerush-dev/SKILL.md index 0f2dd89..36a41f4 100644 --- a/.claude/skills/typerush-dev/SKILL.md +++ b/.claude/skills/typerush-dev/SKILL.md @@ -53,7 +53,8 @@ with a throwaway `HOME` β€” never the real `~/.typerush` β€” following - Every mouse action has a keyboard equivalent; clicks fire on release. - `config.toml` is only written through `config::edit` (comments kept, values checked by the loader's rules); a new setting needs a `KEYS` - entry. New modes need a label `Mode::from_label` reads back (replay). + entry, and a typing option the steps in ARCHITECTURE's "When you add a + typing option". New modes need a label `Mode::from_label` reads back (replay). - Footer hints must fit 80 columns, and the menu and help overlay must fit 80Γ—24 β€” tests check both; count the cells before adding one. - Anything added up over the history or the whole run (XP, the profile, diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b8833..120339c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,49 @@ _No unreleased changes yet._ --- +## [0.7.0] β€” practice modes + +More ways to practise: drill your mistakes, make every error count, race +your best, type blind β€” and make TypeRush look the way you want. + +### Added + +- **Practise missed words:** every session keeps the words you got wrong + (even ones you fixed). The `missed` menu row (or `--missed`) builds a run + from your most-missed recent words. +- **Repeat a run:** `F6` on Results types the same words again, as a + replay compared with the run you just finished. +- **Quick restart on `Tab`** while typing (like `F5` / `Ctrl+R`). +- **A `[typing]` section** in `config.toml`, all of it also on the Settings + screen and in `typerush config`: + - `live_wpm` β€” hide WPM and accuracy while typing; + - `blind` β€” no right/wrong colors while typing, numbers only at the end + (`--blind`); + - `stop_on_error` β€” `letter` (a wrong key doesn't move the cursor) or + `word` (a word with a mistake can't be left until it's fixed) + (`--stop-on-error`); + - `cursor` β€” `underline`, `block` or `bar`; + - `sound` β€” the terminal's bell on mistakes or on every key; + - `pace` β€” a second cursor moving at your best for the mode (on a + replay, the original run) or at a target WPM (`--pace`); + - `idle_limit` β€” a run with that many seconds without a key (10 by + default) isn't saved, and Results says why. +- **Custom themes:** a TOML file in `~/.typerush/themes/` is a theme named + after the file, on top of a built-in; pick it like any other. + `--list-themes` lists them and notes hard-to-read colors. +- `missed` can be the start mode (`mode = "missed"`). + +### Changed + +- The typing footer says `Tab / F5 restart`; the Results footer gains + `F6 repeat` (`Ctrl+C` still quits there, as everywhere). +- On a short terminal the Settings list scrolls with the selection. +- The blank line under the menu's last option gives way before the banner + on a terminal too short for both. +- The help overlay lists `Tab` and `F6`. + +--- + ## [0.6.0] β€” profile and deeper results See more of every run, and of all of them: a chart and new figures on @@ -572,7 +615,8 @@ keyboard alternates** for accessibility. dedicated docs. - Demo GIF uses absolute GitHub raw URL for correct display on crates.io. -[Unreleased]: https://github.com/withrvr/typerush/compare/v0.6.0...HEAD +[Unreleased]: https://github.com/withrvr/typerush/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/withrvr/typerush/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/withrvr/typerush/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/withrvr/typerush/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/withrvr/typerush/compare/v0.4.0...v0.4.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b188b2..864d70e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,13 +30,14 @@ The TL;DR: - `src/app.rs` β€” application state machine - `src/settings.rs` β€” the Settings screen's state and actions - `src/progress.rs` β€” levels, XP and the Profile screen's summary +- `src/options.rs` β€” the `[typing]` settings (blind, stop on error, cursor, pace, …) - `src/game.rs` β€” character matcher - `src/storage.rs` β€” JSON stats persistence - `src/state.rs` β€” `state.json` (the last custom file) - `src/text.rs` β€” safe display of user-supplied names and text - `src/config/*.rs` β€” `config.toml` loading, CLI overrides, and editing it in place (`edit.rs`, for `typerush config` and Settings) -- `src/theme/*.rs` β€” built-in themes and colour parsing +- `src/theme/*.rs` β€” built-in themes, custom theme files, colour parsing - `src/ui/*.rs` β€” one file per screen - `src/words/*.rs` β€” word sources (pools, quotes, code, symbols, snippets) diff --git a/Cargo.lock b/Cargo.lock index 8b9e1f3..b5f4d16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1771,7 +1771,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typerush" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 735f913..35c5025 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "typerush" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.88" description = "In your terminal β€” a fast, cross-platform WPM typing trainer built with Rust" diff --git a/README.md b/README.md index eaeab28..939e068 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ with a quote β€” TypeRush meets you where you already work: the command line. - ⏸ **Pause** β€” `Ctrl+P` / `F2` stops the clock and hides the text - πŸ“Š **A chart of every run** β€” WPM, raw speed and mistakes second by second, plus raw WPM, consistency and a correct / incorrect / missed breakdown - πŸ… **Levels, XP and a profile** β€” earn XP for time typed, see your bests side by side and a year of practice on an activity calendar +- 🎯 **Practice your way** β€” practise the words you got wrong, stop on errors, blind mode, a pace cursor racing your best, cursor styles +- πŸ–Œ **Your own themes** β€” drop a small TOML file in `~/.typerush/themes/` - πŸ”€ **Seven code languages** β€” Rust, Python, JavaScript, Go, Java, SQL, Shell - πŸ“š **10k-word pool & punctuation/number drills** β€” toggle them with `b` / `p` / `n` in the menu when you want a tougher session - πŸ—‚ **Personal snippet library** β€” drop `.txt` files in `~/.typerush/snippets/`, each with its own personal best @@ -88,6 +90,7 @@ typerush --quote # one programming quote typerush --code rust # rust | python | js | go | java | sql | shell typerush --symbols 25 # programming-punctuation drill typerush --daily # today's daily challenge +typerush --missed # practise the words you've got wrong lately typerush --replay # type your last session again, word for word typerush --zen # zen mode β€” no timer, no pressure typerush --file my_text.txt # type anything you want @@ -132,7 +135,8 @@ typerush --init-config # write a starter ~/.typerush/config.toml | --------------- | ------------------------------- | | `Enter` | start / restart a session | | `Esc` | back to menu / end session | -| `Ctrl+R` / `F5` | restart the current mode | +| `Tab` / `F5` | restart the current mode | +| `F6` | the same words again (Results) | | `Ctrl+P` / `F2` | pause / resume while typing | | `Tab` | jump to your stats history | | `c` | settings (from the menu) | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 23e3f1a..057c5bd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,11 +36,11 @@ The `Screen` enum in `src/app.rs` is the application's high-level state: ```mermaid stateDiagram-v2 [*] --> Menu - [*] --> Typing: mode flag (--time, --words, --quote, --code, --zen, --symbols, --daily, --replay, --file) + [*] --> Typing: mode flag (--time, --words, --quote, --code, --zen, --symbols, --daily, --missed, --replay, --file) Menu --> Typing: Enter / Space / click an option - Typing --> Typing: Ctrl+R / F5 restart, Ctrl+P / F2 pause and resume + Typing --> Typing: Tab / F5 / Ctrl+R restart, Ctrl+P / F2 pause and resume Typing --> Results: mode finishes / Esc - Results --> Typing: Enter / F5 / Ctrl+R restart + Results --> Typing: Enter / F5 / Ctrl+R restart, F6 the same words Results --> Menu: Esc Results --> Stats: Tab Menu --> Stats: Tab / s @@ -115,6 +115,9 @@ flowchart LR src --> settings["settings.rs Settings screen state (SettingsView) and actions: change, save through config::edit, apply at once"] + src --> options["options.rs + TypingOptions: live WPM, blind, stop on error, + cursor style, key sound, pace, idle limit"] src --> progress["progress.rs XP and levels (Level::of), Profile: totals, days typed, streaks, standard bests, calendar day"] @@ -151,7 +154,10 @@ flowchart LR (new theme = one row in ALL)"] theme --> color["color.rs #RRGGBB and 16 ANSI color names: parse_color, - color_name (the other way)"] + color_name (the other way), contrast (WCAG)"] + theme --> custom["custom.rs + ~/.typerush/themes/*.toml: discover_in, parse, + install (by_name / names see them)"] src --> config["config/"] config --> config_mod["mod.rs Config / Defaults / Colors / Words from config.toml (serde)"] @@ -417,6 +423,51 @@ The calendar (`ui::profile::render_calendar`) draws as many weeks as fit share of the busiest day shown in quarters, as glyphs that get denser; each day cell is a `ClickAction::Select(days_back)` target. +### Practice options (v0.7.0) + +`options::TypingOptions` is the resolved `[typing]` section (and the +`--blind`, `--stop-on-error`, `--pace` overrides), kept in `App::options` +and changed live by Settings. Each named setting has `NAMES` / `name` / +`from_name`; the loader resolves them through one `named` helper (unknown +name: a warning and the default), and `config::edit` checks them with +`Kind::Choice(&NAMES)` β€” so a new choice is one enum and one key. + +- **Stop on error** is decided in `handle_char` before anything moves: a + *blocked* key is tallied as a mistake, sets `App::rejected_key` (the + cursor turns the mistake color) and returns. `word` also holds the end of + the last word until it's right. +- **Blind** is a parameter of `style_for_char` (typed text neutral) and of + the header (`show_live_numbers`). +- **Cursor**: underline and block are styles on the character; the bar is + the terminal's cursor (`Frame::set_cursor_position` on the character, its + shape set by the main loop when the option changes and reset on exit and + in the panic hook). +- **Sound** sets `App::bell` in `handle_char`; the main loop writes BEL to + the terminal and clears it β€” no audio library. +- **Pace**: `App::pace_wpm` is worked out when a run begins (`best` reads + the history through `App::history`, which loads `stats_file` once if no + screen has), and `pace_position` turns elapsed time into a character + index in the text read as one stream; the typing renderer keeps each + word's offset in that stream while laying out. +- **Idle**: `longest_idle` walks the per-second timeline; `too_idle` stops + the save and gives Results its reason. +- **Missed words**: `Word::had_error` is set by any wrong key in the word + or on its space; records keep `mistyped` (≀ 200). `missed_words_run` + counts them over the last 100 sessions and builds the `Mode::Missed` run. +- **Repeat** (`F6`) builds a `Replay` from the current run + (`App::repeat_run`) and restarts it. + +### Custom themes (v0.7.0) + +`theme::custom::discover_in` reads `~/.typerush/themes/*.toml` at start +(through `config::load::read_config`, the same limits), parses each with +`parse` β€” a `base` built-in plus a `Colors` table applied with the config's +own `apply_color_overrides` β€” and `install` puts them in a process-wide +`OnceLock`. `builtin::by_name` and `names` look there after the built-ins, +so the config, `--theme`, `typerush config`, Settings and `--list-themes` +all see them without being passed anything. Tests never install, so they +only see the built-ins; the parsing and discovery are tested directly. + ### Config loading `config::load::load_from(path, cli)` never fails: a missing file means @@ -477,6 +528,20 @@ round-trips every menu mode), and if it can be a start mode, a name in `config::load::MODE_NAMES`, `resolve_default_mode` and `settings::start_settings`. +## When you add a typing option + +1. A field (and, for a choice, an enum with `NAMES` / `name` / + `from_name`) in `options::TypingOptions`, its default in `Default`. +2. The `Typing` field in `config/mod.rs`, resolved in + `load::resolve_typing` (a choice through `named`), and a CLI override in + `CliOverrides` if it gets a flag. +3. A `Key` in `config::edit::KEYS` (`Kind::Choice(&NAMES)` for a choice) + with its `file_value` / `default_value` arms. +4. A `settings::Row`, its arm in `settings_change` and its label and value + in `ui::settings::row_line`. +5. The commented default in `config.example.toml`, and USAGE's typing + options. + ## When you add a setting 1. Add the field to the `Config` structs in `src/config/mod.rs` and resolve diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index ccd778e..42ba2f2 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -56,6 +56,64 @@ rule that keeps it fixed). --- +## v0.7.0 β€” practice modes + +### Bugs (caught before they shipped) + +- **Settings outgrew short terminals.** Seven typing rows took Settings + from six rows to thirteen; at 80Γ—24 they fit, but on a shorter window the + last rows β€” reset to defaults among them β€” were cut off with no way to + reach them. Fix: the list scrolls with the selection, with `β–² / β–Ό more` + on the border, like the menu. Guard: + `settings_scroll_on_a_short_terminal`. Lesson: every list that grows + needs a plan for the screen it no longer fits. +- **The menu at 80Γ—24 again.** The `missed` row pushed the menu with a + snippet library past a standard terminal (the compact banner would have + gone). Fix: the blank line under the last option now gives way first β€” + after the gaps between categories, before the banner. + `whole_menu_fits_80x24_at_launch` caught it. +- **Custom themes listed in byte order** (`Ocean` before `forest_2`). + Fix: sorted ignoring case, like snippets. Found by the discovery test. +- **`--list-themes` flattened a TOML error** into one line of `?`s + (`printable` replaces the newlines). Fix: printed line by line, as the + error modal does. Found driving the release binary. + +### Features and changes + +- **One shape for every choice setting.** `stop_on_error`, `cursor` and + `sound` are enums with `NAMES` / `name` / `from_name`; the loader + resolves them through one `named` helper and `typerush config` checks + them with `Kind::Choice(&NAMES)`, so Settings, the CLI, the config and + `config set` can't disagree about the names. `pace` (a word or a number) + and `idle_limit` (seconds, 0 = off) have their own kinds; `pace = 60` and + `pace = "60"` both parse through an untagged `TextOrNumber`, because a + type error in TOML makes the loader drop the whole file. +- **Stop on error is decided before anything moves.** A blocked key is + tallied (it counts against accuracy) and returns early; the last word of + a text has no space, so in `word` mode the run's end waits on it too. +- **The bar cursor is the terminal's own.** A drawn bar would take a cell + and shift the line β€” the jitter the steady cursor was built to avoid. The + real cursor costs no room; its shape is set when the option changes and + reset on exit and in the panic hook. +- **The key sound is a BEL byte.** An audio library would need system + audio headers on Linux to even build; the bell is one byte, and + terminals already let people choose how it sounds (or flashes). +- **Pace counts in one stream** β€” word characters plus one per space β€” + the same way the cursor advances, so the ghost never drifts from the + text. `best` is looked up when the run begins; `App::history` loads + stats.json then if no screen has, never per frame. +- **Missed words are marked as they happen** (`Word::had_error`), not + derived from the final text: a mistake you fixed is still a word worth + practising. Words from stats.json are filtered like a custom file's. +- **Custom themes are installed once, globally.** Passing a theme list to + every lookup (config, CLI, `config set`, Settings, `--list-themes`) + would touch every caller; a `OnceLock` read by `builtin::by_name` and + `names` needs none. Tests never install, so they only see built-ins. +- **F6 repeat is a replay.** The run's words are now seen, so it's saved + as `+replay` and compared with the run β€” the same rule as any replay. + +--- + ## v0.6.0 β€” profile and deeper results ### Bugs (caught before they shipped) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9e4d42c..4af1fe7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -110,7 +110,7 @@ in v0.3, as the category-grouped menu.) ## 🚧 In progress -- 🚧 Release v0.2.1, v0.3.0, v0.4.0, v0.4.1, v0.5.0 and v0.6.0: git tags, GitHub release binaries, +- 🚧 Release v0.2.1, v0.3.0, v0.4.0, v0.4.1, v0.5.0, v0.6.0 and v0.7.0: git tags, GitHub release binaries, crates.io. The latest published release is v0.2.0 β€” pre-built binaries on the [Releases page](https://github.com/withrvr/typerush/releases) and `cargo install typerush` both started with v0.1.1 / v0.2.0. @@ -214,12 +214,12 @@ one pull request: - βœ… The CSV export gains raw WPM, consistency, missed and completed - βœ… `profile` joins `stats`, `settings` and `quit` on the menu's last row -## 🚧 v0.7 β€” Practice modes +## βœ… v0.7 β€” Practice modes Worked in this order, one commit (or a few) per step, on one branch and one pull request: -1. 🚧 Plan (this section) +1. βœ… Plan (this section) 2. βœ… Quick restart on `Tab` while typing, and repeat the run you just finished (the same words) from Results with `F6` 3. βœ… A `[typing]` section in the config and on the Settings screen: @@ -237,12 +237,22 @@ one pull request: default) is not saved, and Results says why 9. βœ… Custom themes from files in `~/.typerush/themes/`, on top of a built-in or from scratch, listed and picked like the built-in ones -10. πŸ”² Review, end-to-end test, docs, version 0.7.0 +10. βœ… Review, end-to-end test, docs, version 0.7.0 + +**Added to v0.7 along the way:** + +- βœ… `missed` can be the start mode; `--missed` +- βœ… The Settings list scrolls on a short terminal +- βœ… The menu's trailing blank line gives way before the banner at 80Γ—24 --- ## πŸ”² Planned +Nothing is planned past v0.7 yet β€” the ideas below are candidates. +[Open an issue](https://github.com/withrvr/typerush/issues/new/choose) to +suggest one. + --- ## πŸ’‘ Ideas (not committed) diff --git a/docs/USAGE.md b/docs/USAGE.md index 1119466..fd58e88 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -22,6 +22,7 @@ typerush --code rust # code: rust | python | js | go | java | sql | she typerush --zen # zen mode (no timer, no stats) typerush --symbols 25 # programming-symbols drill of N tokens typerush --daily # today's daily challenge +typerush --missed # practise the words you've got wrong lately typerush --replay # type the last saved session again, word for word typerush --replay 3 # …or the third most recent one typerush --file # type any text file you have @@ -106,6 +107,7 @@ combined with the run flags above (`typerush --time 30 export` is refused). | Symbols | Programming punctuation: `=>`, `(){};`, `&&`, `?.`, `` … (25 / 50 tokens) | you complete the last token | | Custom | A text file: `--file`, a snippet, or the file you typed last | you complete the last word | | Daily | Today's challenge: 40 words, the same for everyone on a given day | you complete the last word | +| Missed | The words you've got wrong lately, shuffled (at least 25) | you complete the last word | ### The daily challenge @@ -123,6 +125,17 @@ category. (The words depend on the date and on TypeRush's word list, so two different versions of TypeRush could give different words on the same day.) +### Practising missed words + +Every session keeps the words you got wrong β€” a word counts when you +pressed a wrong key in it or on its space, even if you fixed it. The +`missed` menu row (or `--missed`, or `mode = "missed"` to start on it) +builds a run from them: from your last 100 sessions, the 50 words you've +missed most often (the most recent first when two tie), shuffled and +repeated so the run has at least 25 words. It's saved as `missed`, with its +own best. Sessions keep their missed words from TypeRush 0.7 on; before you +have any, it says so. + ### Replaying a session Every session is saved with the words it typed (every word of a words, @@ -185,8 +198,8 @@ needs `Shift` held while dragging. Modes are grouped by category, the ones with a choice of options first: `time`, `words`, `code`, `symbols` and `custom` show a heading with their options on the line below (a row too wide for the menu continues on the next -line). Then come the single runs `daily`, `quote` and `zen`, and `stats`, -`profile`, `settings` and `quit` share the last row: +line). Then come the single runs `daily`, `missed`, `quote` and `zen`, and +`stats`, `profile`, `settings` and `quit` share the last row: ```mermaid flowchart TD @@ -214,7 +227,7 @@ flowchart TD direction LR stats ~~~ profile ~~~ settings ~~~ quit end - time ~~~ words ~~~ code ~~~ symbols ~~~ custom ~~~ daily ~~~ quote ~~~ zen ~~~ app + time ~~~ words ~~~ code ~~~ symbols ~~~ custom ~~~ daily ~~~ missed ~~~ quote ~~~ zen ~~~ app ``` The bottom border of the list shows the three word settings β€” `p @@ -257,7 +270,7 @@ for the whole list does it scroll, and then the border shows `β–² more` / | `Ctrl+Backspace` | Delete the entire current word (`Alt+Backspace` too) | | `Ctrl+W` | Same β€” delete the entire current word | | `Ctrl+H` | Same β€” most terminals send this when you press `Ctrl+Backspace` | -| `Ctrl+R` / `F5` | Restart the same mode with a new word list (a replay: the same words) | +| `Tab` / `F5` / `Ctrl+R` | Restart the same mode with a new word list (a replay: the same words) | | `Ctrl+P` / `F2` | Pause / resume | | `Esc` | End the session and go to the results screen | @@ -272,6 +285,7 @@ or the time saved in your stats. | Key | Action | | --------------- | --------------------------------- | | `Enter` / `F5` / `Ctrl+R` | Restart the same mode | +| `F6` | The same words again (a replay of this run) | | `Tab` | Open the stats history | | `Esc` | Back to the menu | | `F1` | Help | @@ -350,7 +364,10 @@ Open it from the menu (`c`, or the `settings` option on the last row). | `Esc` | Cancel a pending reset, else back to the menu (`m` / `Tab` too) | | `q` | Quit | -See [Changing settings](#changing-settings) for what each one does. +See [Changing settings](#changing-settings) for what each one does, and +[Typing options](#typing-options) for the typing rows. On a terminal too +short for every row, the list scrolls with the selection (`β–² more` / +`β–Ό more` on the border). --- @@ -504,8 +521,8 @@ won't replace a config you already have. Three ways, all writing the same file: - **The Settings screen** (`c` in the menu): theme, start mode (the menu row - highlighted at launch), punctuation, numbers, 10k words, and reset to - defaults. A change is saved straight away and used straight away β€” a + highlighted at launch), punctuation, numbers, 10k words, the + [typing options](#typing-options), and reset to defaults. A change is saved straight away and used straight away β€” a theme shows as you pick it. - **`typerush config set NAME VALUE`** from the shell. `typerush config show` lists every setting with the value in use (`# default` where the @@ -529,13 +546,19 @@ Setting names, as `config show` lists them: | Name | Values | | ---- | ------ | -| `theme` | `dark`, `light`, `monokai`, `dracula` | -| `defaults.mode` | `time`, `words`, `quote`, `code`, `zen`, `symbols`, `daily` | +| `theme` | `dark`, `light`, `monokai`, `dracula`, or [one of your own](#your-own-themes) | +| `defaults.mode` | `time`, `words`, `quote`, `code`, `zen`, `symbols`, `daily`, `missed` | | `defaults.time_seconds` | 1 – 3600 | | `defaults.word_count`, `defaults.symbol_count` | 1 – 10,000 | | `defaults.code_lang` | `rust`, `python`, `js`, `go`, `java`, `sql`, `shell` | | `words.pool` | `common`, `extended` | | `words.punctuation`, `words.numbers` | `true` / `false` (`on` / `off` too) | +| `typing.live_wpm`, `typing.blind` | `true` / `false` | +| `typing.stop_on_error` | `off`, `letter`, `word` | +| `typing.cursor` | `underline`, `block`, `bar` | +| `typing.sound` | `off`, `errors`, `keys` | +| `typing.pace` | `off`, `best`, or a WPM from 1 to 300 | +| `typing.idle_limit` | seconds, 0 (off) to 600 | | `colors.accent`, `colors.secondary`, `colors.correct`, `colors.incorrect`, `colors.pending`, `colors.mode_tag`, `colors.error`, `colors.neutral`, `colors.background` | `#RRGGBB`, an ANSI name, or `reset` | ### Themes @@ -552,9 +575,34 @@ Built-in themes (run `typerush --list-themes` to print them): | --------- | -------------------------------------------------- | | `dark` | Default β€” cyan / green / yellow on a dark terminal | | `light` | Softer palette for light-background terminals | -| `monokai` | Classic Sublime/TextMate β€” pink/green/yellow | +| `monokai` | Warm dark β€” pink/green/yellow on `#272822` | | `dracula` | Purple/pink/cyan on `#282a36` | +### Your own themes + +Drop a TOML file in `~/.typerush/themes/` and it's a theme, named after the +file (`ocean.toml` is `ocean`; letters, digits, `-` and `_`, not a built-in +name). It starts from a built-in theme β€” `base = "dracula"`, or `dark` when +it doesn't say β€” and sets any of the color slots +[`[colors]`](#per-slot-color-overrides) takes: + +```toml +# ~/.typerush/themes/ocean.toml +base = "dark" +background = "#0B1D2A" +neutral = "#D8E6F0" +accent = "#4FC3F7" +pending = "#7A8C99" +``` + +Pick it like a built-in one: `theme = "ocean"`, `--theme ocean`, the +Settings screen, `typerush config set theme ocean`. `typerush +--list-themes` lists your themes after the built-in ones, and notes (on +stderr) any text color under 4.5:1 against the background β€” the contrast +every built-in theme keeps. A file that can't be read, or a color that +doesn't parse, is reported when TypeRush starts; the theme is left out, +or that slot keeps the base's color. + ### Per-slot color overrides Override any slot of the chosen theme: @@ -620,6 +668,49 @@ Runs with any of these settings keep their own personal bests: the mode label gets `+10k`, `+p` and/or `+n` (`time-30s+p`, `words-50+10k+p+n`), so a harder run is only ever compared with runs played the same way. +### Typing options + +How a run behaves and looks while you type β€” the `[typing]` section, all +also on the Settings screen: + +```toml +[typing] +live_wpm = true # show WPM and accuracy while typing +blind = false # no right/wrong colors; numbers only at the end +stop_on_error = "off" # "off" | "letter" | "word" +cursor = "underline" # "underline" | "block" | "bar" +sound = "off" # "off" | "errors" | "keys" +pace = "off" # "off" | "best" | a WPM, e.g. 60 +idle_limit = 10 # seconds without a key before a run isn't saved; 0 = never +``` + +- **live wpm** off hides WPM and accuracy while typing; the clock and mode + stay. +- **blind** shows what you type in one color, right or wrong (a wrong space + isn't marked either), and no numbers until Results. `--blind` for one + launch. +- **stop on error**: `letter` β€” a wrong key doesn't move the cursor; type + the right one. `word` β€” wrong letters are typed, but you can't leave a + word with a mistake in it (nor finish the run on one) until it's fixed. + A refused key counts as a mistake, and the cursor turns the mistake color + until the right key or a backspace. `--stop-on-error letter|word` for one + launch. +- **cursor**: `underline` (the character underlined), `block` (the + character in a solid block) or `bar` (a thin bar before it β€” the + terminal's own cursor, set back to your terminal's shape when TypeRush + exits). +- **key sound**: the terminal's bell on every key (`keys`) or only on + mistakes (`errors`). Some terminals flash instead of beeping, or are set + to stay quiet. +- **pace**: a second cursor, underlined in another color, that moves at + your best for the mode you're playing (`best`; on a replay, the original + run's speed) or at a target WPM β€” so you can see whether you're ahead. + `--pace best|WPM` for one launch. Zen has none, and neither does a mode + you haven't played yet. +- **idle limit**: a run with a stretch of that many seconds without a key + isn't saved β€” Results says so (`not saved: 34 s without a key (the limit + is 10 s)`). Paused time doesn't count; walking away from a time run does. + ### Precedence ```mermaid From 4b5d347e08c812ac570b941b9a65aefbf924c7e5 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 13:30:09 +0000 Subject: [PATCH 12/14] feat(ui): round chart labels, a trend as long as fits, nothing against a border MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #20. - Results chart: both axes labelled on round numbers (1, 2, 2.5 or 5 times a power of ten), at least four labels each and as many as fit β€” one per two rows up the side, one per seven cells along the bottom β€” each axis ending on its last label so every label sits on its value. ratatui puts middle x labels in equal slots, off their values, so the x labels are drawn by hand, each centered on its second's column (found from the axis corner ratatui drew). - Stats WPM trend: as many of the category's latest sessions as the box is wide (it was the last 20), one bar per cell, titled with how many ("all 13", "last 74"). - #20, nothing against a box's side: two cells of padding on the chart, the trend and the Stats tables (the Mode column one cell narrower so the sessions table still fits 80 columns, click targets moved with it), a help box wide enough for its longest line, and no marker in the first cell on Settings β€” the selected row shows by its bold label and its highlighted value. nothing_touches_a_box_border draws every screen, overlays included, at two sizes and fails on anything beside a vertical border; it fails with the old padding and help width. ticks and the even spacing of the chart's labels are tested too. --- CHANGELOG.md | 10 +++ docs/LEARNINGS.md | 22 ++++++ docs/USAGE.md | 6 +- src/ui/help.rs | 4 +- src/ui/mod.rs | 143 +++++++++++++++++++++++++++++++++-- src/ui/results.rs | 180 +++++++++++++++++++++++++++++++++++++++++---- src/ui/settings.rs | 28 +++---- src/ui/stats.rs | 42 +++++++---- 8 files changed, 380 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 120339c..ef6d0f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,15 @@ your best, type blind β€” and make TypeRush look the way you want. - The blank line under the menu's last option gives way before the banner on a terminal too short for both. - The help overlay lists `Tab` and `F6`. +- **The Results chart is labelled on round numbers:** at least four labels + on each axis and as many as fit, every label on its value. +- **The Stats WPM trend shows as many sessions as the box is wide** (it + was the last 20), and says how many. +- **Nothing is drawn against a box's side any more** ([#20]): the chart, + the WPM trend and the Stats tables are inset like the text in every other + box, the help box is wide enough for its longest line, and Settings shows + the selected row by its label and value rather than a marker in the + border's first cell. --- @@ -627,3 +636,4 @@ keyboard alternates** for accessibility. [0.1.1]: https://github.com/withrvr/typerush/releases/tag/v0.1.1 [#8]: https://github.com/withrvr/typerush/issues/8 [#4]: https://github.com/withrvr/typerush/issues/4 +[#20]: https://github.com/withrvr/typerush/issues/20 diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 42ba2f2..20fc074 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -74,6 +74,27 @@ rule that keeps it fixed). `whole_menu_fits_80x24_at_launch` caught it. - **Custom themes listed in byte order** (`Ocean` before `forest_2`). Fix: sorted ignoring case, like snippets. Found by the discovery test. +- **Chart labels off their values.** Asked for more axis labels, the + first version handed ratatui five or more x labels β€” and they drifted: + ratatui spreads the middle x labels over equal slots (label *i* centered + at (i + Β½)Β·width/count), not at their values; its y labels are placed + proportionally and were fine. Found driving the release binary + (`0s … 1s 2s 3s … 4s`). Fix: two blank x labels keep the row, and + `draw_x_labels` writes each label centered on its own column, found from + the axis corner ratatui drew. Guard: `chart_is_drawn_under_the_results` + checks the labels are evenly spaced. Ticks are round (1, 2, 2.5, 5 Γ— + 10ⁿ), at least four, as many as fit, and each axis ends on its last + label so labels sit on values. +- **Content against box borders** ([#20]). The chart's y labels, the WPM + trend's bars and the Stats tables' β˜… column started in a box's first + cell; two help lines filled their box; Settings put its ➀ in the first + cell. Fix: two cells of padding on those boxes (the tables' Mode column + one cell narrower so the sessions table still fits 80 columns), a wider + help box, no marker on Settings (the selected row shows by its bold label + and highlighted value). Guard: `nothing_touches_a_box_border` finds every + vertical border (a `β”‚` run, overlays included) on every screen and fails + on anything drawn beside it β€” checked to fail with the old padding and + help width. - **`--list-themes` flattened a TOML error** into one line of `?`s (`printable` replaces the newlines). Fix: printed line by line, as the error modal does. Found driving the release binary. @@ -420,3 +441,4 @@ rule that keeps it fixed). nothing is left to type. [#8]: https://github.com/withrvr/typerush/issues/8 +[#20]: https://github.com/withrvr/typerush/issues/20 diff --git a/docs/USAGE.md b/docs/USAGE.md index fd58e88..d4a77d7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -309,7 +309,9 @@ What Results shows: - **mode** and its best (`today best` for the daily challenge). - **A chart of the run, second by second**: the WPM so far (the line that ends at your WPM), the raw speed of each second, and a dot on each second - with a mistake. Runs under two seconds, and zen, have no chart. + with a mistake. Both axes are labelled on round numbers β€” at least four + labels each, more on a bigger terminal. Runs under two seconds, and zen, + have no chart. **Consistency** is how even your speed was: 100% means the same number of keys every second. It is 100 minus how far the keys per second spread from @@ -431,7 +433,7 @@ Opened from the menu, it stays on the category you were looking at last. | ------- | ------------- | | **Summary (left)** | Best WPM Β· Average accuracy Β· Session count Β· Last WPM Β· **Daily streak** Β· **7-day avg WPM** Β· **30-day avg WPM** Β· **Time typed** | | **Key accuracy (right)** | Up to 5 of your worst keys (β‰₯ 3 presses). Colour-coded: red < 80%, amber < 93%, green otherwise. Shows `no key data yet` until enough data is collected. | -| **WPM trend** | Sparkline of the category's last 20 sessions | +| **WPM trend** | One bar per session, as many of the category's latest as the box is wide (all of them when they fit); the title says how many | | **Sessions** | Every session in the category, newest first, with date, mode, WPM, raw WPM, accuracy, consistency (`β€”` before v0.6) and time; `↑` / `↓` move the highlighted row and `Enter` replays it. A `β˜…` marks the session holding its mode's best. The title says which rows are showing (`1–5 of 42`). | **Filters and sorting.** The chips on the list's bottom border β€” `d dates diff --git a/src/ui/help.rs b/src/ui/help.rs index cfc7a09..dc46fdc 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -52,7 +52,9 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] dim(" Snippets ~/.typerush/snippets/*.txt"), ]; - const WIDTH: u16 = 50; + // The longest line is 48 cells: two more keep it off the right border, + // as the two in front keep it off the left. + const WIDTH: u16 = 52; // Narrower than the box, lines wrap and need more rows: take the full // height instead of guessing how many. let height = if screen.width < WIDTH { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index fafb5dc..b2cbbe6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -558,6 +558,99 @@ mod tests { } } + /// Issue #20: nothing is drawn against a box's side. On every screen, + /// each cell beside a vertical border β€” a `β”‚` with a border character + /// above or below it, so overlays count too β€” is blank or another + /// border. (The Results chart is drawn short here: its own axis line is + /// a `β”‚` too; its padding is checked in `ui::results`.) + #[test] + fn nothing_touches_a_box_border() { + use crate::app::Screen; + let border = |c: &str| { + c.chars() + .next() + .is_some_and(|c| ('\u{2500}'..='\u{257F}').contains(&c)) + }; + let mut history: Vec<_> = (0..12) + .map(|i| record(40.0 + i as f64, "time-30s")) + .collect(); + history.push(record(99.0, "words-25")); // newest and a best: β˜… on the top row + type Setup = fn(&mut App); + let cases: Vec<(&str, Setup)> = vec![ + ("menu", |app| app.screen = Screen::Menu), + ("typing", |app| { + app.start_game(Mode::Words(25)).unwrap(); + app.handle_char('x'); + }), + ("paused", |app| { + app.start_game(Mode::Words(25)).unwrap(); + app.toggle_pause(); + }), + ("results", |app| { + app.start_game(Mode::Words(5)).unwrap(); + app.finish_game(); + }), + ("stats", |app| app.screen = Screen::Stats), + ("stats bests", |app| { + let history = app.stats_cache.clone().unwrap(); + app.stats_view = Some(crate::storage::StatsView::new( + &history, + Some("bests"), + Default::default(), + )); + app.screen = Screen::Stats; + }), + ("settings", |app| app.screen = Screen::Settings), + ("settings reset", |app| { + let mut view = + crate::settings::SettingsView::load(None, crate::settings::ROWS.len() - 1); + view.confirm_reset = true; + app.settings = Some(view); + app.screen = Screen::Settings; + }), + ("profile", |app| { + let history = app.stats_cache.clone().unwrap(); + app.profile = Some(crate::progress::Profile::new(&history, 20)); + app.screen = Screen::Profile; + }), + ("help", |app| app.screen = Screen::Help), + ("error", |app| app.error_message = Some("x".repeat(120))), + ]; + for (case, setup) in &cases { + for (width, height) in [(80, 24), (130, 45)] { + let mut app = App::new(None, builtin::DARK, Mode::Time(15)); + app.stats_cache = Some(history.clone()); + setup(&mut app); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|f| render(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + let at = |x: u16, y: u16| buffer[(x, y)].symbol(); + for y in 0..height { + for x in 0..width { + if at(x, y) != "β”‚" { + continue; + } + let vertical = (y > 0 && border(at(x, y - 1))) + || (y + 1 < height && border(at(x, y + 1))); + if !vertical { + continue; + } + for nx in [x.checked_sub(1), (x + 1 < width).then_some(x + 1)] + .into_iter() + .flatten() + { + let next = at(nx, y); + assert!( + next.trim().is_empty() || border(next), + "{case} {width}x{height}: {next:?} at ({nx}, {y}) touches a border" + ); + } + } + } + } + } + } + /// The help overlay fits a standard 80Γ—24 terminal whole: every line, /// none wrapped, and the close hint on its bottom border. #[test] @@ -631,7 +724,7 @@ mod tests { .map(|c| c.symbol()) .collect(); assert!(!text.contains('\x1b')); - assert!(text.contains("custom-?…nal-draft "), "{text}"); + assert!(text.contains("custom-?…al-draft "), "{text}"); } /// Draw the Stats screen for `sessions` at 80Γ—24 and return its rows. @@ -701,12 +794,14 @@ mod tests { assert!(dump.contains("40.0"), "{dump}"); let first = app.stats_view.as_ref().unwrap().scroll.get(); assert!(first > 0 && first < 12, "clamped scroll {first}"); - // The selected (oldest) row is drawn as one highlighted bar. + // The selected (oldest) row is drawn as one highlighted bar, kept + // off the borders by the table's padding. let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); terminal.draw(|f| render(f, &app)).unwrap(); let buffer = terminal.backend().buffer(); let highlighted: Vec = (0..24) - .filter(|&y| (1..79).all(|x| buffer[(x, y)].bg == builtin::DARK.accent)) + // Inside the table's padding: two cells from each border. + .filter(|&y| (3..77).all(|x| buffer[(x, y)].bg == builtin::DARK.accent)) .map(|y| (0..80).map(|x| buffer[(x, y)].symbol()).collect()) .collect(); assert_eq!(highlighted.len(), 1, "{highlighted:?}"); @@ -720,6 +815,43 @@ mod tests { assert!(dump.contains("β˜… 55.0"), "{dump}"); } + /// The WPM trend shows as many sessions as its box is wide, one bar a + /// cell inside its padding, and says how many: all of them when they + /// fit. + #[test] + fn wpm_trend_shows_as_many_sessions_as_fit() { + let mut app = App::new(None, builtin::DARK, Mode::Time(15)); + app.screen = crate::app::Screen::Stats; + app.stats_cache = Some( + (0..13) + .map(|i| record(40.0 + i as f64, "time-30s")) + .collect(), + ); + assert!(stats_rows(&app).join("\n").contains("wpm trend (all 13)")); + app.stats_cache = Some( + (0..300) + .map(|i| record(20.0 + (i % 50) as f64, "time-30s")) + .collect(), + ); + let rows = stats_rows(&app); + // 80 wide: 78 inside the borders, 74 inside the padding. + assert!(rows.join("\n").contains("wpm trend (last 74)"), "{rows:?}"); + let bars = |row: &String| { + row.chars() + .skip(3) + .take(74) + .filter(|c| ('▁'..='β–ˆ').contains(c)) + .count() + }; + let lowest = rows + .iter() + .filter(|r| r.starts_with("β”‚ ")) + .map(bars) + .max() + .unwrap(); + assert_eq!(lowest, 74, "a bar in every cell of the bottom row"); + } + /// A window too short for any table row (three rows tall: the table's /// minimum gives way only there) says how many sessions there are instead /// of an impossible range, and keeps the scroll. @@ -852,9 +984,8 @@ mod tests { "30-day avg", "time typed", ], - // Not "theme": the selected row has the ➀ in its gutter; not - // "punctuation": it fills the label column but one cell. - Screen::Settings => &["start mode", "numbers", "10k words"], + // Not "punctuation": it fills the label column but one cell. + Screen::Settings => &["theme", "start mode", "numbers", "10k words"], Screen::Profile => &["level", "xp", "since", "days typed", "streak", "started"], _ => &[], }; diff --git a/src/ui/results.rs b/src/ui/results.rs index 6a782b5..4ee49b7 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -10,7 +10,7 @@ use ratatui::{ prelude::*, symbols::Marker, - widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph}, + widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Padding, Paragraph}, }; use ratatui::crossterm::event::KeyCode; @@ -270,8 +270,11 @@ fn render_chart(f: &mut Frame, app: &App, area: Rect) { // The legend lives in the title: the chart's own hides itself on a // chart this short, and here it never covers a line. Names, not only // colors, tell the lines apart. + // Two cells of padding, as text has in every box: the axis labels and + // the lines keep off the border. let block = Block::default() .borders(Borders::ALL) + .padding(Padding::horizontal(2)) .border_style(Style::default().fg(theme.pending)) .title(Line::from(vec![ Span::raw(" this run, second by second "), @@ -298,9 +301,20 @@ fn render_chart(f: &mut Frame, app: &App, area: Rect) { let [wpm, raw, errors] = chart_points(&app.timeline, seconds); let top = wpm.iter().chain(&raw).map(|&(_, y)| y).fold(0.0, f64::max); - // Room above the highest point, on a round number. - let top = ((top * 1.1 / 10.0).ceil() * 10.0).max(10.0); + // Axis labels on round numbers, at least four on each axis and as many + // as fit: one per two rows up the side, one per seven cells along the + // bottom (room for "120s" and a gap). The plot is the box less its + // borders and padding, the y labels and axis line, the x labels. + let inner = block.inner(area); + let plot_rows = inner.height.saturating_sub(2) as usize; + let plot_cells = inner.width.saturating_sub(5) as usize; + let (y_step, y_count) = ticks(top, plot_rows.div_ceil(2)); + let (x_step, x_count) = ticks(seconds as f64, plot_cells / 7); let label = |text: String| Span::styled(text, Style::default().fg(theme.pending)); + let y_labels: Vec = (0..y_count) + .map(|i| label(format!("{:.0}", i as f64 * y_step))) + .collect(); + let x_top = x_step * (x_count - 1) as f64; let datasets = vec![ Dataset::default() .name("raw") @@ -327,27 +341,91 @@ fn render_chart(f: &mut Frame, app: &App, area: Rect) { .style(super::base_style(theme)) .block(block) .legend_position(None) + // Each axis runs from 0 to its last round label. The y labels are + // spread evenly, so each sits on its value. The x labels ratatui + // would put in equal slots, off their ticks: two blank ones keep + // their row, and they are drawn below, each on its own second. .x_axis( Axis::default() .style(Style::default().fg(theme.pending)) - .bounds([1.0, seconds as f64]) - .labels([ - label("1s".into()), - label(format!("{}s", seconds.div_ceil(2))), - label(format!("{seconds}s")), - ]), + .bounds([0.0, x_top]) + .labels([Span::raw(""), Span::raw("")]), ) .y_axis( Axis::default() .style(Style::default().fg(theme.pending)) - .bounds([0.0, top]) - .labels([ - label("0".into()), - label(format!("{:.0}", top / 2.0)), - label(format!("{top:.0}")), - ]), + .bounds([0.0, y_step * (y_count - 1) as f64]) + .labels(y_labels), ); f.render_widget(chart, area); + let labels: Vec<(f64, String)> = (0..x_count) + .map(|i| (i as f64 * x_step, format!("{:.0}s", i as f64 * x_step))) + .collect(); + draw_x_labels( + f.buffer_mut(), + inner, + &labels, + x_top, + Style::default().fg(theme.pending), + ); +} + +/// Draw `labels` β€” (value, text) β€” under the chart's x axis in `inner`, each +/// centered on its value's column (kept inside `inner`). The axis is found +/// by its corner, `β””`, where ratatui drew it; the plot runs from just right +/// of the corner to the right edge, 0 to `top`. +fn draw_x_labels( + buf: &mut ratatui::buffer::Buffer, + inner: Rect, + labels: &[(f64, String)], + top: f64, + style: Style, +) { + let corner = (inner.top()..inner.bottom()) + .flat_map(|y| (inner.left()..inner.right()).map(move |x| (x, y))) + .find(|&(x, y)| buf[(x, y)].symbol() == "β””"); + let Some((corner_x, corner_y)) = corner else { + return; + }; + let row = corner_y + 1; + if row >= inner.bottom() || top <= 0.0 { + return; + } + let left = corner_x + 1; + let width = inner.right().saturating_sub(left).max(1); + for (value, text) in labels { + let column = left + ((value / top) * f64::from(width - 1)).round() as u16; + let text_width = text.chars().count() as u16; + let start = column + .saturating_sub(text_width / 2) + .clamp(inner.left(), inner.right().saturating_sub(text_width)); + buf.set_string(start, row, text, style); + } +} + +/// Round axis labels for values from 0 to `max`: the step (1, 2, 2.5 or 5 +/// times a power of ten, whole numbers only) and how many labels, 0 included β€” the most that +/// stay within `room`, never fewer than four (an axis may then reach a +/// little past `max`). +fn ticks(max: f64, room: usize) -> (f64, usize) { + // 1, 2, 5, 10, 20, 25, 50, 100, 200, 250, … β€” round every way. + let steps = (0..9).flat_map(|power| { + let scale = 10f64.powi(power); + let quarter = (power > 0).then_some(2.5 * scale); + [Some(scale), Some(2.0 * scale), quarter, Some(5.0 * scale)] + .into_iter() + .flatten() + }); + let room = room.max(4); + let mut chosen = (1.0, 4); + for step in steps { + let count = ((max / step).ceil() as usize + 1).max(4); + chosen = (step, count); + if count <= room { + break; + } + } + chosen } /// (WPM so far, raw speed, mistakes) as chart points for the first @@ -407,6 +485,27 @@ mod tests { assert_eq!(wpm[4], (5.0, 33.6)); // 14 correct in 5 s } + /// Labels land on round numbers, at least four, as many as the room + /// allows; the axis reaches the first label at or past the highest value. + #[test] + fn ticks_are_round_and_at_least_four() { + assert_eq!(ticks(87.0, 5), (25.0, 5), "0 25 50 75 100"); + assert_eq!(ticks(87.0, 10), (10.0, 10)); + assert_eq!(ticks(87.0, 2), (50.0, 4), "never fewer than four"); + assert_eq!(ticks(2.0, 10), (1.0, 4)); + assert_eq!(ticks(30.0, 7), (5.0, 7)); + assert_eq!(ticks(120.0, 9), (20.0, 7)); + assert_eq!(ticks(0.0, 6), (1.0, 4)); + for (max, room) in [(3600.0, 10), (999.0, 4), (61.0, 30)] { + let (step, count) = ticks(max, room); + assert!( + count >= 4 && (count <= room.max(4)), + "{max} {room}: {count}" + ); + assert!(step * (count - 1) as f64 >= max, "{max}: reaches the top"); + } + } + fn screen(app: &App) -> String { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); @@ -441,7 +540,7 @@ mod tests { .collect(); let shown = screen(&app); assert!(shown.contains(" this run, second by second "), "{shown}"); - for text in ["━ wpm ━ raw β€’ errors", "1s", "10s"] { + for text in ["━ wpm ━ raw β€’ errors", "0s", "10s"] { assert!(shown.contains(text), "{text:?} missing:\n{shown}"); } assert!( @@ -456,6 +555,55 @@ mod tests { .unwrap() .starts_with(" Enter / F5 restart")); + // Round labels, at least four per axis; along the bottom of a ten- + // second run at 80 columns, every two seconds. Nothing touches the + // box's left border: the y labels start two cells in. + let rows: Vec<&str> = shown.lines().collect(); + let x_row = rows.iter().find(|r| r.contains("10s")).unwrap(); + // Each label centered on its own second: evenly spaced (ratatui's + // own x labels sit in equal slots, off their values). + let centers: Vec = ["0s", "2s", "4s", "6s", "8s", "10s"] + .iter() + .map(|label| { + let start = x_row + .char_indices() + .find(|(i, _)| { + x_row[*i..].starts_with(label) + && !x_row[..*i].ends_with(|c: char| c.is_ascii_digit()) + }) + .map(|(i, _)| x_row[..i].chars().count()) + .unwrap_or_else(|| panic!("{label}: {x_row}")); + start + label.len() / 2 + }) + .collect(); + let gaps: Vec = centers.windows(2).map(|w| w[1] - w[0]).collect(); + let (low, high) = (gaps.iter().min().unwrap(), gaps.iter().max().unwrap()); + assert!(high - low <= 1, "uneven labels {gaps:?}: {x_row}"); + let y_labels = rows + .iter() + .filter(|r| r.starts_with("β”‚ ") && r[5..].trim_start().starts_with(char::is_numeric)) + .count(); + assert!(y_labels >= 4, "{y_labels} y labels:\n{shown}"); + assert!(rows + .iter() + .all(|r| !r.starts_with("β”‚0") && !r.starts_with("β”‚ 0"))); + + // A taller, wider window fits more labels. + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(130, 45)).unwrap(); + terminal.draw(|f| crate::ui::render(f, &app)).unwrap(); + let big: String = terminal + .backend() + .buffer() + .content() + .iter() + .map(|c| c.symbol()) + .collect(); + assert!( + big.contains("1s") && big.contains("9s"), + "every second at 130 wide" + ); + app.started_at = Some(end - Duration::from_millis(1500)); assert!(screen(&app).contains("too short to chart")); app.mode = Mode::Zen; diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 2950126..03c3b95 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -124,7 +124,9 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { HINTS } -/// One row: the ➀ marker in the two-cell gutter, its label, its value. The +/// One row: its label two cells in, then its value. The selected row +/// shows by its label (bold, in the secondary color) and its value in the +/// highlight β€” nothing is drawn against the border. The /// clickable parts come back as (x offset in the box, width, key): β€Ή and β€Ί /// act like ← and β†’, the value like Enter. fn row_line( @@ -135,12 +137,8 @@ fn row_line( ) -> (Line<'static>, Vec<(u16, u16, KeyCode)>) { let theme = &app.theme; let selected = view.selected == index; - // Text starts two cells in from the border, as in every box; the ➀ - // marker sits in that gutter. - let marker = |cells: usize| { - let text = if selected { "➀" } else { " " }; - Span::styled(format!("{text: = view.indices[view.indices.len().saturating_sub(20)..] + // Two cells of padding, as text has in every box. + let block = Block::default() + .borders(Borders::ALL) + .padding(Padding::horizontal(2)) + .border_style(Style::default().fg(theme.pending)); + let room = block.inner(area).width as usize; + let shown = &view.indices[view.indices.len().saturating_sub(room)..]; + let recent: Vec = shown .iter() .map(|&i| sessions[i].wpm.max(0.0) as u64) .collect(); + let title = match shown.len() { + 0 => " wpm trend ".to_string(), + 1 => " wpm trend (1 session) ".to_string(), + n if n == view.indices.len() => format!(" wpm trend (all {n}) "), + n => format!(" wpm trend (last {n}) "), + }; let spark = Sparkline::default() - .block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.pending)) - .title(" wpm trend (last 20) "), - ) + .block(block.title(title)) .data(&recent) .style(Style::default().fg(theme.accent)); f.render_widget(spark, area); } -/// Width of the Mode column in both tables. -const MODE_WIDTH: usize = 18; +/// Width of the Mode column in both tables: the sessions table's columns +/// then fill 80 columns exactly, padding included. +const MODE_WIDTH: usize = 17; /// Rows of a bordered table with a header that fit in `area`, and the first /// of `total` rows to show: the view's scroll, moved just enough to keep the @@ -371,7 +381,7 @@ fn visible_rows(area: Rect, total: usize, view: &storage::StatsView) -> (usize, /// Make each drawn row of a table in `area` clickable: a click selects it. /// Rows start under the top border and the header. fn register_rows(app: &App, area: Rect, first: usize, shown: usize) { - let inner = area.inner(Margin::new(1, 1)); + let inner = area.inner(Margin::new(1 + TABLE_PADDING, 1)); let mut targets = app.click_targets.borrow_mut(); for row in 0..shown { let y = inner.y + 1 + row as u16; @@ -410,13 +420,19 @@ fn table_block<'a>( } else { format!(" {name} Β· {}–{} of {total} ", first + 1, first + shown) }; + // Two cells of padding, as text has in every box: the β˜… column keeps + // off the border. Block::default() .borders(Borders::ALL) + .padding(Padding::horizontal(TABLE_PADDING)) .border_style(Style::default().fg(app.theme.pending)) .title(title) .title_bottom(chips) } +/// Cells between a table's border and its first column. +const TABLE_PADDING: u16 = 2; + /// The filter chips on a table's bottom border β€” `d dates all time`, /// `w words any`, and on the sessions list `s sort date ↓` and `r reverse` β€” /// each clickable like its key. A chip that filters something out is @@ -569,7 +585,7 @@ fn render_sessions_table( [ Constraint::Length(1), Constraint::Length(16), - Constraint::Length(MODE_WIDTH as u16), // fits "words-100+10k+p+n" + Constraint::Length(MODE_WIDTH as u16), // fits "words-100+10k+p+n" (17) Constraint::Length(7), Constraint::Length(7), Constraint::Length(7), From b8905e0a8b04f1a0991c8a679d43d2edca3e8a21 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 13:52:50 +0000 Subject: [PATCH 13/14] feat(ui): stars under the trend's best runs; padding in the help box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stats WPM trend: a row under the bars with a β˜… under each session that holds its mode's best β€” the same rule as the sessions list's β˜… β€” and a blank under every other bar. The trend box grows a row to keep three rows of bars; the sessions list shows one fewer at 80x24. - Help: a blank line of padding above and below the text. To keep the box whole on 80x24, the two entries that wrapped onto a second line (p n b, Mouse) are one line each now. Docs: ROADMAP's v0.7 section lists everything added beyond the plan β€” what was found while building it and what was asked for after using it β€” and CHANGELOG, USAGE and LEARNINGS cover the stars and the help padding. Tests: wpm_trend_stars_the_bests; help_fits_80x24 checks the padding. --- CHANGELOG.md | 5 ++++- docs/LEARNINGS.md | 10 ++++++++++ docs/ROADMAP.md | 31 +++++++++++++++++++++++----- docs/USAGE.md | 2 +- src/ui/help.rs | 14 ++++++------- src/ui/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++-- src/ui/stats.rs | 29 ++++++++++++++++++++++----- 7 files changed, 121 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6d0f8..a658ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,12 +56,15 @@ your best, type blind β€” and make TypeRush look the way you want. - **The Results chart is labelled on round numbers:** at least four labels on each axis and as many as fit, every label on its value. - **The Stats WPM trend shows as many sessions as the box is wide** (it - was the last 20), and says how many. + was the last 20), and says how many; a β˜… under a bar marks a session + holding its mode's best, as in the sessions list. - **Nothing is drawn against a box's side any more** ([#20]): the chart, the WPM trend and the Stats tables are inset like the text in every other box, the help box is wide enough for its longest line, and Settings shows the selected row by its label and value rather than a marker in the border's first cell. +- **The help box has a blank line above and below its text** (two + two-line entries became one line each, so it still fits 80Γ—24 whole). --- diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index 20fc074..c3089a8 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -95,6 +95,12 @@ rule that keeps it fixed). vertical border (a `β”‚` run, overlays included) on every screen and fails on anything drawn beside it β€” checked to fail with the old padding and help width. +- **The help box had no room to breathe.** Padding above and below meant + two more rows in a box that already used all 23 at 80Γ—24; two entries + that wrapped onto a second line (`p n b`, `Mouse`) were reworded to one + line each β€” and the first rewording ran into the right border, which + `nothing_touches_a_box_border` caught. Guard: `help_fits_80x24` checks + the blank rows above and below. - **`--list-themes` flattened a TOML error** into one line of `?`s (`printable` replaces the newlines). Fix: printed line by line, as the error modal does. Found driving the release binary. @@ -132,6 +138,10 @@ rule that keeps it fixed). `names` needs none. Tests never install, so they only see built-ins. - **F6 repeat is a replay.** The run's words are now seen, so it's saved as `+replay` and compared with the run β€” the same rule as any replay. +- **The trend's stars use the list's β˜… rule** (`StatsView::best_indices`: + the session holding each mode's best), on a row of their own under the + bars, so a best is never hidden behind a tall bar. The trend box grew one + row; the sessions list shows one fewer at 80Γ—24. --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4af1fe7..9c68e75 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -239,11 +239,32 @@ one pull request: built-in or from scratch, listed and picked like the built-in ones 10. βœ… Review, end-to-end test, docs, version 0.7.0 -**Added to v0.7 along the way:** - -- βœ… `missed` can be the start mode; `--missed` -- βœ… The Settings list scrolls on a short terminal -- βœ… The menu's trailing blank line gives way before the banner at 80Γ—24 +**Added to v0.7 beyond the plan:** + +Found while building it: + +- βœ… `missed` can be the start mode (`mode = "missed"`), and `--missed` + starts it from the shell +- βœ… The Settings list scrolls on a short terminal (it has thirteen rows + now), with `β–² / β–Ό more` on the border +- βœ… The menu's trailing blank line gives way before the banner, so the + whole menu still fits 80Γ—24 with the new row + +Asked for after using it: + +- βœ… Results chart labelled on round numbers: at least four labels per + axis, as many as fit, each on its value (the x labels are drawn by hand β€” + ratatui's own sit in equal slots, off their ticks) +- βœ… Stats WPM trend shows as many sessions as the box is wide (it was the + last 20), titled with how many +- βœ… A β˜… under each bar of the trend that holds its mode's best, on a row + of its own +- βœ… Nothing drawn against a box's side + ([#20](https://github.com/withrvr/typerush/issues/20)): two cells of + padding on the chart, the trend and the Stats tables; a help box wide + enough for its longest line; no marker in Settings' first cell (the + selected row shows by its bold label and highlighted value) +- βœ… A blank line of padding above and below the help text --- diff --git a/docs/USAGE.md b/docs/USAGE.md index d4a77d7..2d0fab9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -433,7 +433,7 @@ Opened from the menu, it stays on the category you were looking at last. | ------- | ------------- | | **Summary (left)** | Best WPM Β· Average accuracy Β· Session count Β· Last WPM Β· **Daily streak** Β· **7-day avg WPM** Β· **30-day avg WPM** Β· **Time typed** | | **Key accuracy (right)** | Up to 5 of your worst keys (β‰₯ 3 presses). Colour-coded: red < 80%, amber < 93%, green otherwise. Shows `no key data yet` until enough data is collected. | -| **WPM trend** | One bar per session, as many of the category's latest as the box is wide (all of them when they fit); the title says how many | +| **WPM trend** | One bar per session, as many of the category's latest as the box is wide (all of them when they fit); the title says how many. A `β˜…` under a bar marks a session holding its mode's best | | **Sessions** | Every session in the category, newest first, with date, mode, WPM, raw WPM, accuracy, consistency (`β€”` before v0.6) and time; `↑` / `↓` move the highlighted row and `Enter` replays it. A `β˜…` marks the session holding its mode's best. The title says which rows are showing (`1–5 of 42`). | **Filters and sorting.** The chips on the list's bottom border β€” `d dates diff --git a/src/ui/help.rs b/src/ui/help.rs index dc46fdc..f646be1 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -11,8 +11,8 @@ use ratatui::crossterm::event::KeyCode; use crate::{app::App, theme::ThemePalette}; -/// Render the keybindings overlay. Sized to its content (23 rows, so it fits -/// an 80Γ—24 window whole). On a smaller terminal the box shrinks and the +/// Render the keybindings overlay. Sized to its content and a blank line of +/// padding above and below (23 rows, so it fits an 80Γ—24 window whole). On a smaller terminal the box shrinks and the /// bottom of the list is cut, but the close hint lives in the bottom border /// and is always visible. /// Returns the footer hints. @@ -37,16 +37,14 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] Line::from(" Ctrl+P F2 pause / resume a run"), Line::from(" Esc finish a run / back"), Line::from(" Ctrl+Bksp Ctrl+W delete word"), - Line::from(" p n b menu: punctuation, numbers,"), - Line::from(" 10k words (time & words runs)"), + Line::from(" p n b menu: punctuation/numbers/10k"), Line::from(" Tab s stats (only Tab on results)"), Line::from(" c u menu: settings, profile"), Line::from(" Enter e stats: replay, export CSV"), Line::from(" a d w s r stats: all/dates/words/sort"), Line::from(" ? F1 help (only F1 on results)"), Line::from(" Ctrl+C quit"), - Line::from(" Mouse click options, rows, hints;"), - Line::from(" the wheel scrolls lists"), + Line::from(" Mouse click anything; wheel scrolls"), dim(" Stats ~/.typerush/stats.json (not zen)"), dim(" Config ~/.typerush/config.toml"), dim(" Snippets ~/.typerush/snippets/*.txt"), @@ -57,10 +55,11 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] const WIDTH: u16 = 52; // Narrower than the box, lines wrap and need more rows: take the full // height instead of guessing how many. + // The lines, a blank line of padding above and below, the borders. let height = if screen.width < WIDTH { screen.height } else { - lines.len() as u16 + 2 + lines.len() as u16 + 4 }; let area = screen.centered(Constraint::Length(WIDTH), Constraint::Length(height)); super::clear(f, theme, area); @@ -76,6 +75,7 @@ pub fn render(f: &mut Frame, app: &App, screen: Rect) -> &'static [super::Hint] .block( Block::default() .borders(Borders::ALL) + .padding(Padding::vertical(1)) .border_style(Style::default().fg(theme.accent)) .title(" help ") .title_bottom(Span::styled( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b2cbbe6..77ac677 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -671,8 +671,20 @@ mod tests { .iter() .position(|r| r.contains("TypeRush β€” keybindings")); let last = rows.iter().position(|r| r.contains("Snippets ~/.typerush")); - assert_eq!(last.zip(title).map(|(l, t)| l - t), Some(20), "{dump}"); - assert!(rows[last.unwrap() + 1].contains("click to close"), "{dump}"); + assert_eq!(last.zip(title).map(|(l, t)| l - t), Some(18), "{dump}"); + // A blank line of padding inside the box above the first line and + // below the last. + let (title, last) = (title.unwrap(), last.unwrap()); + let blank_inside = |row: &str| { + let cells: Vec = row.chars().collect(); + let left = cells.iter().position(|c| *c == 'β”‚').unwrap(); + let right = cells.iter().rposition(|c| *c == 'β”‚').unwrap(); + cells[left + 1..right].iter().all(|c| *c == ' ') + }; + assert!(rows[title - 2].contains("β”Œ help"), "{dump}"); + assert!(blank_inside(&rows[title - 1]), "no padding above: {dump}"); + assert!(blank_inside(&rows[last + 1]), "no padding below: {dump}"); + assert!(rows[last + 2].contains("click to close"), "{dump}"); for text in ["Ctrl+P F2", "menu: settings", "replay, export CSV"] { assert!(dump.contains(text), "{text:?} missing:\n{dump}"); } @@ -852,6 +864,41 @@ mod tests { assert_eq!(lowest, 74, "a bar in every cell of the bottom row"); } + /// The trend has a row under its bars with a β˜… under each session that + /// holds its mode's best β€” only those, each under its own bar. + #[test] + fn wpm_trend_stars_the_bests() { + let mut app = App::new(None, builtin::DARK, Mode::Time(15)); + app.screen = crate::app::Screen::Stats; + // time-30s: best is the 3rd session (index 2); words-25: the 5th. + app.stats_cache = Some(vec![ + record(40.0, "time-30s"), + record(45.0, "time-30s"), + record(70.0, "time-30s"), + record(30.0, "words-25"), + record(60.0, "words-25"), + record(50.0, "time-30s"), + ]); + let rows = stats_rows(&app); + let title = rows.iter().position(|r| r.contains("wpm trend")).unwrap(); + // Three rows of bars, then the stars, then the bottom border. + let stars = &rows[title + 4]; + assert!(rows[title + 5].starts_with('β””'), "{rows:#?}"); + let marks: Vec = stars + .chars() + .enumerate() + .filter(|(_, c)| *c == 'β˜…') + .map(|(x, _)| x) + .collect(); + // Bars start three cells in (border, two of padding): sessions 2, 4. + assert_eq!(marks, [3 + 2, 3 + 4], "{stars}"); + let bars_row = &rows[title + 3]; + assert!( + bars_row.chars().nth(3 + 2).is_some_and(|c| c != ' '), + "{bars_row}" + ); + } + /// A window too short for any table row (three rows tall: the table's /// minimum gives way only there) says how many sessions there are instead /// of an impossible range, and keeps the scroll. diff --git a/src/ui/stats.rs b/src/ui/stats.rs index ea926b5..13469ad 100644 --- a/src/ui/stats.rs +++ b/src/ui/stats.rs @@ -30,12 +30,12 @@ pub fn render(f: &mut Frame, app: &App, area: Rect) -> &'static [super::Hint] { // Layout: // 0 β€” title bar with the category (2 rows) // 1 β€” summary (left) + key accuracy (right) (8 rows) - // 2 β€” WPM sparkline (5 rows) + // 2 β€” WPM sparkline: three rows of bars and a row of β˜… (6 rows) // 3 β€” sessions or bests table (fills remaining space) let layout = Layout::vertical([ Constraint::Length(2), Constraint::Length(8), - Constraint::Length(5), + Constraint::Length(6), Constraint::Min(4), ]) .split(area); @@ -321,7 +321,8 @@ fn render_key_heatmap( } /// Render the WPM sparkline: as many of the category's latest sessions as -/// the box has room for β€” one bar per cell β€” titled with how many show. +/// the box has room for β€” one bar per cell β€” titled with how many show, +/// with a β˜… under each bar that is a mode's best. fn render_sparkline( f: &mut Frame, app: &App, @@ -347,11 +348,29 @@ fn render_sparkline( n if n == view.indices.len() => format!(" wpm trend (all {n}) "), n => format!(" wpm trend (last {n}) "), }; + // The bars, and under them a row with a β˜… under each session that + // holds its mode's best (the same β˜… as in the sessions list). + let inner = block.inner(area); + f.render_widget(block.title(title), area); + if inner.height < 2 { + return; + } + let [bars, stars] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner); let spark = Sparkline::default() - .block(block.title(title)) .data(&recent) .style(Style::default().fg(theme.accent)); - f.render_widget(spark, area); + f.render_widget(spark, bars); + let star_style = Style::default() + .fg(theme.secondary) + .add_modifier(Modifier::BOLD); + let marks: Vec = shown + .iter() + .map(|i| match view.best_indices.contains(i) { + true => Span::styled("β˜…", star_style), + false => Span::raw(" "), + }) + .collect(); + f.render_widget(Paragraph::new(Line::from(marks)), stars); } /// Width of the Mode column in both tables: the sessions table's columns From 4c5c65d92f5db11fca454b98921824a5ddb2afb7 Mon Sep 17 00:00:00 2001 From: Raghav Rathi Date: Sun, 11 Oct 2026 14:13:51 +0000 Subject: [PATCH 14/14] review: fix the last review's findings on the v0.7 branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The chart's x labels panicked on a terminal 8-9 columns wide (clamp with min > max): a label wider than the plot isn't drawn, and placing one can't panic. no_screen_panics_at_any_size draws every screen at every width from 1 to 30 and heights from 1 to 24. - F6 after a run over 64 KiB repeated one empty word: it built the words from the saved text, which is empty past the limit. It takes the run's own words (App::run_words, shared with session_text), and the dead +replay strip in repeat_run is gone. - Stopping on words, the right space refused at the end of a wrong word was counted as a Space-key mistake (key heatmap, accuracy, tally): it now only stops. A wrong key there is still a mistake. - Zen runs showed "not saved: idle"; zen is never saved, so the idle check doesn't apply to it. - pace = best on the daily challenge raced any day's best; it races today's best on today's words, as Results counts it. - The Settings typing rows showed the config file's values, not the --blind / --stop-on-error / --pace in use, and changed from those. They now show and change the values in use (SettingsView keeps no copy). Tidy-ups: one source for the stats path β€” after_input, the save and --replay use App::stats_file through App::history, and the parameter that duplicated it is gone; the seven typing rows share one next_typing helper (and from_name is written one way); the typing screen counts word offsets only when there's a pace cursor. --- CHANGELOG.md | 2 + docs/LEARNINGS.md | 14 +++++ src/app.rs | 109 +++++++++++++++++++++++++++++----- src/main.rs | 110 +++++++++++++++++++++++----------- src/options.rs | 10 ++-- src/settings.rs | 145 ++++++++++++++++++++------------------------- src/ui/mod.rs | 78 ++++++++++++++++++++++++ src/ui/results.rs | 36 ++++++++++- src/ui/settings.rs | 14 ++--- src/ui/typing.rs | 26 ++++---- 10 files changed, 387 insertions(+), 157 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a658ad3..886a8fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ your best, type blind β€” and make TypeRush look the way you want. - The blank line under the menu's last option gives way before the banner on a terminal too short for both. - The help overlay lists `Tab` and `F6`. +- The Settings typing rows show the values in use, so `--blind`, + `--stop-on-error` and `--pace` show there for the launch they're given on. - **The Results chart is labelled on round numbers:** at least four labels on each axis and as many as fit, every label on its value. - **The Stats WPM trend shows as many sessions as the box is wide** (it diff --git a/docs/LEARNINGS.md b/docs/LEARNINGS.md index c3089a8..b2b8afc 100644 --- a/docs/LEARNINGS.md +++ b/docs/LEARNINGS.md @@ -95,6 +95,20 @@ rule that keeps it fixed). vertical border (a `β”‚` run, overlays included) on every screen and fails on anything drawn beside it β€” checked to fail with the old padding and help width. +- **Found by the last review of the branch** (fixed with a test each): + the chart's x labels panicked on a terminal 8–9 columns wide (`clamp` + with min > max) β€” `no_screen_panics_at_any_size` now draws every screen + at every width from 1 to 30; `F6` after a run over 64 KiB repeated one + empty word (it took the saved text, which is empty past the limit β€” it + now takes the run's words); stopping on words, the right space refused at + the end of a wrong word counted as a Space-key mistake; zen runs showed + "not saved: idle" though zen is never saved; `pace = best` on the daily + challenge raced any day's best while Results meant today's; the Settings + typing rows showed the file's values, not `--blind` / `--pace` in use. And + three tidy-ups: one source for the stats path (`App::stats_file`, read + through `App::history`), one helper for the typing rows, and word offsets + for the pace cursor counted only when there is one. Lesson: a value + shown on screen and a value used must come from the same place. - **The help box had no room to breathe.** Padding above and below meant two more rows in a box that already used all 23 at 80Γ—24; two entries that wrapped onto a second line (`p n b`, `Mouse`) were reworded to one diff --git a/src/app.rs b/src/app.rs index fea400e..1700988 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1092,6 +1092,10 @@ impl App { /// `Some(idle seconds)` when the run sat idle for at least the limit /// (`typing.idle_limit`): it isn't saved, and Results says why. pub fn too_idle(&self) -> Option { + // Zen is never saved, so being idle can't be why it wasn't. + if self.mode == Mode::Zen { + return None; + } let limit = self.options.idle_limit?; let idle = self.longest_idle(); (idle >= limit).then_some(idle) @@ -1217,14 +1221,13 @@ impl App { /// and compared with it on Results. A replay is simply replayed again. pub fn repeat_run(&mut self) { if self.replay.is_none() { - let mut label = self.session_label(); - while let Some(original) = label.strip_suffix("+replay") { - label = original.to_string(); - } + // Not a replay yet, so the label has no +replay to take off; the + // words straight from the run, however long (the saved text is + // empty past 64 KiB). self.replay = Some(Replay { - label, + label: self.session_label(), mode: self.mode, - words: self.session_text().split(' ').map(str::to_string).collect(), + words: self.run_words().iter().map(|w| w.text.clone()).collect(), pool: self.word_pool, decor: self.word_decor, wpm: self.wpm(), @@ -1344,7 +1347,12 @@ impl App { Some(replay) => Some(replay.wpm), None => { let label = self.session_label(); - crate::storage::personal_best_for_mode(self.history(), &label) + // The daily challenge's best is today's, on today's + // words β€” as Results counts it. + let same_text = self.best_text(); + crate::storage::personal_best(self.history().iter().filter(|s| { + s.mode == label && same_text.as_ref().is_none_or(|text| s.text == *text) + })) } }, } @@ -1371,12 +1379,7 @@ impl App { /// with a fixed text, only the words reached in an endless (time) run. /// Empty when longer than [`crate::storage::MAX_TEXT_BYTES`]. pub fn session_text(&self) -> String { - let words = match self.mode { - Mode::Time(_) | Mode::Zen => { - &self.words[..(self.current_word + 1).min(self.words.len())] - } - _ => &self.words[..], - }; + let words = self.run_words(); let bytes = words.iter().map(|w| w.text.len() + 1).sum::(); if bytes.saturating_sub(1) > crate::storage::MAX_TEXT_BYTES { return String::new(); @@ -1385,6 +1388,17 @@ impl App { words.join(" ") } + /// The run's text, as words: all of a fixed text; in an endless run + /// (time, zen) the words reached β€” the rest were only ever a buffer. + fn run_words(&self) -> &[Word] { + match self.mode { + Mode::Time(_) | Mode::Zen => { + &self.words[..(self.current_word + 1).min(self.words.len())] + } + _ => &self.words[..], + } + } + /// Time and zen runs end on the clock or on Esc, not on the last word: /// keep at least a batch of words ahead of the cursor. fn top_up_words(&mut self) { @@ -1467,7 +1481,6 @@ impl App { let active_word = &mut self.words[self.current_word]; let cursor_position = active_word.typed.chars().count(); let word_len = active_word.text.chars().count(); - self.total_typed_chars += 1; // Past the end of the word the cursor sits on the space after it. let expected_char = active_word.text.chars().nth(cursor_position); @@ -1485,6 +1498,15 @@ impl App { } }; self.rejected_key = blocked; + // The right space, refused because the word still has a mistake: + // the mistake is that earlier letter, already counted β€” this key + // isn't another one (it would mark Space as a weak key). It only + // stops, and sounds if mistakes do. + if blocked && typed_char == target_char { + self.bell = self.options.sound != KeySound::Off; + return; + } + self.total_typed_chars += 1; let is_correct = typed_char == target_char && !blocked; active_word.had_error |= !is_correct; self.bell = match self.options.sound { @@ -1771,6 +1793,28 @@ mod tests { assert_eq!(app.replay.as_ref().unwrap().wpm, wpm, "still the first run"); } + /// Review fix: stopping on words, the right space refused at the end of + /// a wrong word is not another mistake β€” not on the Space key, not in + /// accuracy or the per-second tally; a wrong key there still is. + #[test] + fn refused_right_space_is_not_a_mistake() { + let mut app = custom_app(&["ab", "cd"]); + app.options.stop_on_error = StopOnError::Word; + app.started_at = Some(Instant::now()); + app.handle_char('a'); + app.handle_char('x'); + let (typed, correct) = (app.total_typed_chars, app.correct_chars); + for _ in 0..3 { + app.handle_char(' '); + } + assert!(app.rejected_key); + assert_eq!((app.total_typed_chars, app.correct_chars), (typed, correct)); + assert!(!app.key_misses.contains_key(&' '), "{:?}", app.key_misses); + assert_eq!(app.timeline.iter().map(|s| s.errors).sum::(), 1); + app.handle_char('z'); // a letter where the space belongs: a mistake + assert_eq!(app.key_misses.get(&' '), Some(&1)); + } + /// Stopping on letters, a wrong key is a mistake that doesn't move the /// cursor; stopping on words, a word with a mistake can't be left β€” nor /// the run finished on one β€” until it's fixed. @@ -1912,10 +1956,28 @@ mod tests { // Walking away at the end counts too: keys only in seconds 0–3. app.timeline = vec![typing; 4]; assert_eq!(app.longest_idle(), 16); + // Review fix: zen is never saved, so it is never "too idle". + app.options.idle_limit = Some(10); + app.mode = Mode::Zen; + assert_eq!(app.too_idle(), None); + app.mode = Mode::Custom; app.options.idle_limit = None; assert_eq!(app.too_idle(), None); } + /// Review fix: F6 after a run too long to save its text (a big custom + /// file) repeated one empty word. It repeats the run's own words. + #[test] + fn repeat_of_a_huge_run_repeats_its_words() { + let word = "x".repeat(1000); + let mut app = custom_app(&vec![word.as_str(); 70]); + assert_eq!(app.session_text(), "", "too long to save"); + app.end_early(); + app.repeat_run(); + assert_eq!(app.words.len(), 70); + assert!(app.words.iter().all(|w| w.text == word)); + } + /// The bell is asked for on every key, on mistakes only (refused keys /// too), or never. #[test] @@ -1961,6 +2023,25 @@ mod tests { assert_eq!(app.pace_wpm, None, "no best yet for words-25"); app.start_game(Mode::Zen).unwrap(); assert_eq!(app.pace_wpm, None); + // Review fix: the daily challenge races today's best on today's + // words, not another day's. + let today = crate::words::daily_words(chrono::Local::now().date_naive()).join(" "); + app.stats_cache = Some(vec![ + SessionRecord { + wpm: 95.0, + mode: "daily".into(), + text: "yesterday's words".into(), + ..SessionRecord::default() + }, + SessionRecord { + wpm: 70.0, + mode: "daily".into(), + text: today, + ..SessionRecord::default() + }, + ]); + app.start_game(Mode::Daily).unwrap(); + assert_eq!(app.pace_wpm, Some(70.0)); app.start_replay(&SessionRecord { wpm: 55.5, mode: "words-25".into(), diff --git a/src/main.rs b/src/main.rs index a39e659..cff5862 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,7 @@ mod words; use std::{ io::{stdout, Stdout}, panic, - path::{Path, PathBuf}, + path::PathBuf, time::{Duration, Instant}, }; @@ -398,8 +398,7 @@ fn run_app(terminal: &mut Tui, cli: Cli, theme_warnings: Vec) -> Result< app.export_file = Some(storage::data_dir().join("stats.csv")); app.config_file = Some(config::load::config_path()); app.stats_file = Some(storage::stats_path()); - let stats_file = storage::stats_path(); - apply_cli_autostart(&mut app, &cli, &stats_file)?; + apply_cli_autostart(&mut app, &cli)?; let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); @@ -457,7 +456,7 @@ fn run_app(terminal: &mut Tui, cli: Cli, theme_warnings: Vec) -> Result< last_tick = Instant::now(); } - after_input(&mut app, last_screen, &mut session_saved, &stats_file); + after_input(&mut app, last_screen, &mut session_saved); last_screen = app.screen; if app.should_quit { @@ -471,8 +470,8 @@ fn run_app(terminal: &mut Tui, cli: Cli, theme_warnings: Vec) -> Result< /// If the user passed a mode flag (`--time`, `--words`, `--quote`, `--code`, /// `--zen`, `--symbols`, `--daily`, `--replay`, `--file`) skip the menu and start that -/// mode. `stats_file` is where `--replay` finds its session. -fn apply_cli_autostart(app: &mut App, cli: &Cli, stats_file: &Path) -> Result<()> { +/// mode. `--replay` finds its session in `App::history`. +fn apply_cli_autostart(app: &mut App, cli: &Cli) -> Result<()> { if let Some(seconds) = cli.time { app.start_game(Mode::Time(seconds))?; } else if let Some(word_count) = cli.words { @@ -490,7 +489,7 @@ fn apply_cli_autostart(app: &mut App, cli: &Cli, stats_file: &Path) -> Result<() } else if cli.missed { app.start_game(Mode::Missed)?; } else if let Some(back) = cli.replay { - let history = storage::load_sessions_from_path(stats_file).unwrap_or_default(); + let history = app.history().to_vec(); let Some(record) = history.len().checked_sub(back).map(|i| &history[i]) else { anyhow::bail!( "no session {back} back to replay: {} saved so far", @@ -906,13 +905,13 @@ fn export_stats(app: &mut App) { } /// Bookkeeping after each round of input, driven by which screen we are on. -/// `stats_file` is the history file (a temp file in tests). -fn after_input(app: &mut App, last_screen: Screen, session_saved: &mut bool, stats_file: &Path) { +/// The history is `App::stats_file` (a temp file in tests, or none). +fn after_input(app: &mut App, last_screen: Screen, session_saved: &mut bool) { // Save each finished game exactly once. The flag is cleared only when a // new game starts, so leaving Results for the Help overlay (or an error // modal) and coming back can't save the same session again. if app.screen == Screen::Results && !*session_saved { - save_current_session(app, stats_file); + save_current_session(app); *session_saved = true; } if app.screen == Screen::Typing { @@ -930,7 +929,7 @@ fn after_input(app: &mut App, last_screen: Screen, session_saved: &mut bool, sta Screen::Stats | Screen::Results | Screen::Profile ) && app.stats_cache.is_none() { - app.stats_cache = Some(storage::load_sessions_from_path(stats_file).unwrap_or_default()); + app.history(); } // Results comparisons and the Stats summary (streak, averages, key // heatmap) are computed once per visit instead of on every frame. @@ -997,7 +996,7 @@ fn after_input(app: &mut App, last_screen: Screen, session_saved: &mut bool, sta /// /// File I/O errors are intentionally swallowed β€” losing a stat row is never a /// good reason to crash on the user. -fn save_current_session(app: &mut App, stats_file: &Path) { +fn save_current_session(app: &mut App) { // Assume not saved until the disk write succeeds; the Results screen // reads this to know whether the last history entry is this session. app.session_just_saved = false; @@ -1037,7 +1036,11 @@ fn save_current_session(app: &mut App, stats_file: &Path) { ended_early: app.ended_early, mistyped: app.mistyped_words(), }; - match storage::save_session_to_path(stats_file, &record) { + // Nowhere to save to (a test that didn't set one): not saved. + let Some(stats_file) = app.stats_file.clone() else { + return; + }; + match storage::save_session_to_path(&stats_file, &record) { Ok(history) => { app.stats_cache = Some(history); app.session_just_saved = true; @@ -1052,6 +1055,7 @@ mod tests { use super::*; use crate::app::{Screen, Word}; use crate::theme::{builtin, ThemePalette}; + use std::path::Path; fn make_typing_app() -> App { let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); @@ -1214,7 +1218,7 @@ mod tests { let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); app.stats_cache = Some(Vec::new()); app.screen = Screen::Stats; - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); draw(&app); let next = target_cell(&app, ClickAction::Key(KeyCode::Right, KeyModifiers::NONE)); click(&mut app, next); @@ -1762,7 +1766,8 @@ mod tests { let mut app = make_typing_app(); // words-2 let (mut saved, mut last) = (false, app.screen); let mut step = |app: &mut App| { - after_input(app, last, &mut saved, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(app, last, &mut saved); last = app.screen; }; step(&mut app); @@ -1843,7 +1848,8 @@ mod tests { let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); let (mut session_saved, mut last) = (false, app.screen); let mut step = |app: &mut App| { - after_input(app, last, &mut session_saved, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(app, last, &mut session_saved); last = app.screen; }; handle_key(&mut app, KeyCode::Char('s'), KeyModifiers::NONE); @@ -1890,7 +1896,7 @@ mod tests { ..SessionRecord::default() }]); app.screen = Screen::Stats; - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); handle_key(&mut app, KeyCode::Enter, KeyModifiers::NONE); assert_eq!(app.screen, Screen::Stats); assert!(app @@ -1911,7 +1917,8 @@ mod tests { let start = |args: &[&str]| { let cli = Cli::try_parse_from(args).unwrap(); let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); - apply_cli_autostart(&mut app, &cli, &stats_file).map(|()| app.words[0].text.clone()) + app.stats_file = Some(stats_file.clone()); + apply_cli_autostart(&mut app, &cli).map(|()| app.words[0].text.clone()) }; assert_eq!(start(&["typerush", "--replay"]).unwrap(), "newest"); assert_eq!(start(&["typerush", "--replay", "2"]).unwrap(), "older"); @@ -1930,14 +1937,16 @@ mod tests { for ch in "hellp world".chars() { app.handle_char(ch); } - after_input(&mut app, Screen::Typing, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Typing, &mut false); let saved = storage::load_sessions_from_path(&stats_file).unwrap(); assert_eq!(saved[0].mistyped, ["hello"]); let cli = Cli::try_parse_from(["typerush", "--missed"]).unwrap(); let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); app.stats_file = Some(stats_file.clone()); - apply_cli_autostart(&mut app, &cli, &stats_file).unwrap(); + app.stats_file = Some(stats_file.clone()); + apply_cli_autostart(&mut app, &cli).unwrap(); assert_eq!(app.mode, Mode::Missed); assert!(app.words.iter().all(|w| w.text == "hello")); } @@ -1956,7 +1965,8 @@ mod tests { for ch in "hello world".chars() { app.handle_char(ch); } - after_input(&mut app, Screen::Typing, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Typing, &mut false); assert_eq!(app.session_just_saved, saved, "{limit:?}"); let title = screen(&app, 100, 24)[0].clone(); assert_eq!( @@ -1972,7 +1982,7 @@ mod tests { fn daily_flag_starts_the_challenge() { let cli = Cli::try_parse_from(["typerush", "--daily"]).unwrap(); let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); - apply_cli_autostart(&mut app, &cli, Path::new("unused")).unwrap(); + apply_cli_autostart(&mut app, &cli).unwrap(); assert_eq!((app.screen, app.mode), (Screen::Typing, Mode::Daily)); } @@ -1991,7 +2001,7 @@ mod tests { app.started_at = Some(std::time::Instant::now() - Duration::from_secs(60)); app.handle_char('x'); app.finish_game(); - after_input(&mut app, Screen::Typing, &mut true, Path::new("unused")); + after_input(&mut app, Screen::Typing, &mut true); assert_eq!(app.results_comparison.unwrap().previous_mode_best, None); let rows = screen(&app, 80, 24).join("\n"); assert!(rows.contains("today best β€” wpm"), "{rows}"); @@ -2009,7 +2019,7 @@ mod tests { ..SessionRecord::default() }]); app.screen = Screen::Stats; - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); draw(&app); let export = target_cell( &app, @@ -2038,7 +2048,7 @@ mod tests { app.config_file = Some(config_file.clone()); handle_key(&mut app, KeyCode::Char('c'), KeyModifiers::NONE); assert_eq!(app.screen, Screen::Settings); - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); (app, config_file) } @@ -2053,7 +2063,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (mut app, config_file) = settings_app(&dir); std::fs::write(&config_file, "# mine\n[colors]\nerror = \"#123456\"\n").unwrap(); - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); press(&mut app, KeyCode::Right); // dark β†’ light let text = std::fs::read_to_string(&config_file).unwrap(); // A new top-level key goes above the first section; the comment @@ -2188,7 +2198,7 @@ mod tests { std::fs::write(&config_file, "theme = = 1").unwrap(); press(&mut app, KeyCode::Esc); press(&mut app, KeyCode::Char('c')); - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); let rows = screen(&app, 80, 24).join("\n"); assert!(rows.contains("could not parse"), "{rows}"); press(&mut app, KeyCode::Right); @@ -2305,6 +2315,28 @@ mod tests { ); } + /// Review fix: the typing rows show the values in use β€” `--blind` shows + /// as "blind on" though the file doesn't say so β€” and change from them. + #[test] + fn settings_typing_rows_follow_the_flags_in_use() { + let dir = tempfile::tempdir().unwrap(); + let (mut app, config_file) = settings_app(&dir); + app.options.blind = true; // as --blind sets it + app.options.pace = options::Pace::Wpm(80); // as --pace 80 + let dump = screen(&app, 80, 24).join("\n"); + assert!(dump.contains("blind β€Ή on β€Ί"), "{dump}"); + assert!(dump.contains("pace β€Ή 80 β€Ί"), "{dump}"); + let row = |row| settings::ROWS.iter().position(|r| *r == row).unwrap(); + app.settings_select(row(settings::Row::Blind)); + press(&mut app, KeyCode::Enter); + assert!(!app.options.blind, "toggled from what was in use"); + let text = std::fs::read_to_string(&config_file).unwrap(); + assert!(text.contains("blind = false"), "{text}"); + app.settings_select(row(settings::Row::Pace)); + press(&mut app, KeyCode::Right); + assert_eq!(app.options.pace, options::Pace::Wpm(90)); + } + /// The menu's settings option opens the screen too. #[test] fn menu_settings_option_opens_settings() { @@ -2329,7 +2361,8 @@ mod tests { app.handle_char(ch); } handle_key(&mut app, KeyCode::Esc, KeyModifiers::NONE); - after_input(&mut app, Screen::Typing, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Typing, &mut false); let saved = storage::load_sessions_from_path(&stats_file).unwrap(); let record = &saved[0]; assert!(record.ended_early); @@ -2364,7 +2397,7 @@ mod tests { }, ]); app.screen = Screen::Stats; - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); let filters = |app: &App| app.stats_view.as_ref().unwrap().filters; handle_key(&mut app, KeyCode::Char('d'), KeyModifiers::NONE); assert_eq!(filters(&app).range, storage::DateRange::Today); @@ -2386,9 +2419,9 @@ mod tests { ); handle_key(&mut app, KeyCode::Esc, KeyModifiers::NONE); - after_input(&mut app, Screen::Stats, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Stats, &mut false); handle_key(&mut app, KeyCode::Char('s'), KeyModifiers::NONE); - after_input(&mut app, Screen::Menu, &mut false, Path::new("unused")); + after_input(&mut app, Screen::Menu, &mut false); assert_eq!(filters(&app).words, storage::WordsFilter::Plain, "kept"); handle_key(&mut app, KeyCode::Char('d'), KeyModifiers::NONE); // 7 days @@ -2416,7 +2449,8 @@ mod tests { for ch in "hello world".chars() { app.handle_char(ch); } - after_input(&mut app, Screen::Typing, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Typing, &mut false); let title = screen(&app, 80, 24)[0].clone(); assert!(title.contains("+6 xp Β· β˜… level 2!"), "{title}"); @@ -2426,7 +2460,8 @@ mod tests { for ch in "hello world".chars() { app.handle_char(ch); } - after_input(&mut app, Screen::Typing, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Typing, &mut false); let title = screen(&app, 80, 24)[0].clone(); assert!(title.contains("+4 xp Β· level 2 Β· 1%"), "{title}"); } @@ -2445,7 +2480,8 @@ mod tests { let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); handle_key(&mut app, KeyCode::Char('u'), KeyModifiers::NONE); assert_eq!(app.screen, Screen::Profile); - after_input(&mut app, Screen::Menu, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Menu, &mut false); let profile = app.profile.as_ref().unwrap(); assert_eq!((profile.sessions, profile.started), (3, 3)); let rows = screen(&app, 80, 24); @@ -2490,7 +2526,8 @@ mod tests { } let mut app = App::new(None, ThemePalette::default(), Mode::Time(15)); handle_key(&mut app, KeyCode::Char('u'), KeyModifiers::NONE); - after_input(&mut app, Screen::Menu, &mut false, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(&mut app, Screen::Menu, &mut false); let dump = screen(&app, 80, 24).join("\n"); assert!(dump.contains("activity Β· 2 days typed"), "{dump}"); assert!(dump.contains("1 run Β· 2m Β· best 50.0"), "{dump}"); @@ -2532,7 +2569,8 @@ mod tests { let mut saved = false; let mut last = app.screen; let mut step = |app: &mut App| { - after_input(app, last, &mut saved, &stats_file); + app.stats_file = Some(stats_file.clone()); + after_input(app, last, &mut saved); last = app.screen; }; step(&mut app); diff --git a/src/options.rs b/src/options.rs index d6605cb..028f1f6 100644 --- a/src/options.rs +++ b/src/options.rs @@ -26,12 +26,10 @@ impl StopOnError { /// The setting called `name` (case-insensitive), if there is one. pub fn from_name(name: &str) -> Option { - match name.to_lowercase().as_str() { - "off" => Some(Self::Off), - "letter" => Some(Self::Letter), - "word" => Some(Self::Word), - _ => None, - } + let at = Self::NAMES + .iter() + .position(|n| n.eq_ignore_ascii_case(name))?; + Some([Self::Off, Self::Letter, Self::Word][at]) } } diff --git a/src/settings.rs b/src/settings.rs index 93e7df4..381f436 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -61,8 +61,6 @@ pub struct SettingsView { pub start: Mode, pub pool: WordPool, pub decor: WordDecor, - /// The `[typing]` settings. - pub typing: crate::options::TypingOptions, /// The first problem the loader has with the file (it doesn't parse, a /// value is out of range…), shown on the screen. pub problem: Option, @@ -91,7 +89,6 @@ impl SettingsView { start: resolved.default_mode, pool: resolved.word_pool, decor: resolved.word_decor, - typing: resolved.typing, problem: warnings.into_iter().next(), } } @@ -188,83 +185,19 @@ impl App { self.word_pool = pool; } } - Row::LiveWpm => { - let on = !view.typing.live_wpm; - if self - .save_settings(&[("typing.live_wpm", on.to_string())]) - .is_some() - { - self.options.live_wpm = on; - } - } - Row::Blind => { - let on = !view.typing.blind; - if self - .save_settings(&[("typing.blind", on.to_string())]) - .is_some() - { - self.options.blind = on; - } - } - Row::StopOnError => { - use crate::options::StopOnError; - let names = StopOnError::NAMES; - let at = names - .iter() - .position(|n| *n == view.typing.stop_on_error.name()); - let name = names[cycle(at, names.len(), forward)]; - if self - .save_settings(&[("typing.stop_on_error", name.to_string())]) - .is_some() - { - self.options.stop_on_error = StopOnError::from_name(name).unwrap_or_default(); - } - } - Row::Pace => { - use crate::options::Pace; - let at = Pace::CHOICES.iter().position(|p| *p == view.typing.pace); - let pace = Pace::CHOICES[cycle(at, Pace::CHOICES.len(), forward)]; - if self - .save_settings(&[("typing.pace", pace.name())]) - .is_some() - { - self.options.pace = pace; - } - } - Row::Cursor => { - use crate::options::CursorStyle; - let names = CursorStyle::NAMES; - let at = names.iter().position(|n| *n == view.typing.cursor.name()); - let name = names[cycle(at, names.len(), forward)]; - if self - .save_settings(&[("typing.cursor", name.to_string())]) - .is_some() - { - self.options.cursor = CursorStyle::from_name(name).unwrap_or_default(); - } - } - Row::Sound => { - use crate::options::KeySound; - let names = KeySound::NAMES; - let at = names.iter().position(|n| *n == view.typing.sound.name()); - let name = names[cycle(at, names.len(), forward)]; - if self - .save_settings(&[("typing.sound", name.to_string())]) - .is_some() - { - self.options.sound = KeySound::from_name(name).unwrap_or_default(); - } - } - Row::IdleLimit => { - use crate::options::IDLE_CHOICES; - let current = view.typing.idle_limit.unwrap_or(0); - let at = IDLE_CHOICES.iter().position(|s| *s == current); - let seconds = IDLE_CHOICES[cycle(at, IDLE_CHOICES.len(), forward)]; - if self - .save_settings(&[("typing.idle_limit", seconds.to_string())]) - .is_some() - { - self.options.idle_limit = (seconds > 0).then_some(seconds); + // The typing rows change the values in use (`--blind` and the + // other flags included), then save the new one. + row @ (Row::LiveWpm + | Row::Blind + | Row::StopOnError + | Row::Pace + | Row::Cursor + | Row::Sound + | Row::IdleLimit) => { + let mut next = self.options; + let (key, value) = next_typing(&mut next, row, forward); + if self.save_settings(&[(key, value)]).is_some() { + self.options = next; } } Row::Reset => {} @@ -376,6 +309,58 @@ fn start_settings(mode: Mode) -> Vec<(&'static str, String)> { settings } +/// Move typing setting `row` of `options` to its next (or previous) value; +/// the config key and the value as written. +fn next_typing( + options: &mut crate::options::TypingOptions, + row: Row, + forward: bool, +) -> (&'static str, String) { + use crate::options::{CursorStyle, KeySound, Pace, StopOnError, IDLE_CHOICES}; + match row { + Row::LiveWpm => { + options.live_wpm = !options.live_wpm; + ("typing.live_wpm", options.live_wpm.to_string()) + } + Row::Blind => { + options.blind = !options.blind; + ("typing.blind", options.blind.to_string()) + } + Row::StopOnError => { + let name = step(&StopOnError::NAMES, options.stop_on_error.name(), forward); + options.stop_on_error = StopOnError::from_name(name).unwrap_or_default(); + ("typing.stop_on_error", name.to_string()) + } + Row::Cursor => { + let name = step(&CursorStyle::NAMES, options.cursor.name(), forward); + options.cursor = CursorStyle::from_name(name).unwrap_or_default(); + ("typing.cursor", name.to_string()) + } + Row::Sound => { + let name = step(&KeySound::NAMES, options.sound.name(), forward); + options.sound = KeySound::from_name(name).unwrap_or_default(); + ("typing.sound", name.to_string()) + } + Row::Pace => { + options.pace = step(&Pace::CHOICES, options.pace, forward); + ("typing.pace", options.pace.name()) + } + Row::IdleLimit => { + let seconds = step(&IDLE_CHOICES, options.idle_limit.unwrap_or(0), forward); + options.idle_limit = (seconds > 0).then_some(seconds); + ("typing.idle_limit", seconds.to_string()) + } + _ => unreachable!("not a typing row: {row:?}"), + } +} + +/// The item after (or before) `current` in `choices`, wrapping; the first +/// (or last) when `current` isn't one of them. +fn step(choices: &[T], current: T, forward: bool) -> T { + let at = choices.iter().position(|choice| *choice == current); + choices[cycle(at, choices.len(), forward)] +} + /// Index after (or before) `at` in a list of `len`, wrapping; the first /// (or last) when `at` isn't in the list. fn cycle(at: Option, len: usize, forward: bool) -> usize { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 77ac677..5a80b28 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -558,6 +558,84 @@ mod tests { } } + /// No screen panics at any size: every screen (with a charted run, a + /// pace and a pending reset among them) at every width from 1 to 30 and + /// heights from 1 to 24 β€” the review found the chart panicking at 8 + /// columns. + #[test] + fn no_screen_panics_at_any_size() { + use crate::app::{Screen, Second}; + use std::time::{Duration, Instant}; + let history: Vec<_> = (0..20) + .map(|i| record(40.0 + i as f64, "time-30s")) + .collect(); + type Setup = fn(&mut App); + let cases: Vec = vec![ + |app| app.screen = Screen::Menu, + |app| { + app.start_game(Mode::Words(25)).unwrap(); + app.started_at = Some(Instant::now() - Duration::from_secs(5)); + app.pace_wpm = Some(60.0); + app.options.cursor = crate::options::CursorStyle::Bar; + app.handle_char('x'); + }, + |app| { + app.start_game(Mode::Words(25)).unwrap(); + app.toggle_pause(); + }, + |app| { + app.start_game(Mode::Words(10)).unwrap(); + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(30)); + app.timeline = vec![ + Second { + typed: 5, + correct: 4, + errors: 1 + }; + 30 + ]; + app.screen = Screen::Results; + }, + |app| app.screen = Screen::Stats, + |app| { + let history = app.stats_cache.clone().unwrap(); + app.stats_view = Some(crate::storage::StatsView::new( + &history, + Some("bests"), + Default::default(), + )); + app.screen = Screen::Stats; + }, + |app| { + let mut view = + crate::settings::SettingsView::load(None, crate::settings::ROWS.len() - 1); + view.confirm_reset = true; + app.settings = Some(view); + app.screen = Screen::Settings; + }, + |app| { + let history = app.stats_cache.clone().unwrap(); + app.profile = Some(crate::progress::Profile::new(&history, 30)); + app.screen = Screen::Profile; + }, + |app| app.screen = Screen::Help, + |app| app.error_message = Some("a long error message ".repeat(8)), + ]; + for setup in &cases { + let mut app = App::new(None, builtin::MONOKAI, Mode::Time(15)); + app.stats_cache = Some(history.clone()); + setup(&mut app); + for width in 1..=30 { + for height in [1, 2, 3, 4, 6, 9, 13, 18, 24] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|f| render(f, &app)).unwrap(); + } + } + } + } + /// Issue #20: nothing is drawn against a box's side. On every screen, /// each cell beside a vertical border β€” a `β”‚` with a border character /// above or below it, so overlays count too β€” is blank or another diff --git a/src/ui/results.rs b/src/ui/results.rs index 4ee49b7..4c0378c 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -396,9 +396,14 @@ fn draw_x_labels( for (value, text) in labels { let column = left + ((value / top) * f64::from(width - 1)).round() as u16; let text_width = text.chars().count() as u16; + // Wider than the chart (a tiny terminal): no room for it at all. + if text_width > inner.width { + continue; + } let start = column .saturating_sub(text_width / 2) - .clamp(inner.left(), inner.right().saturating_sub(text_width)); + .max(inner.left()) + .min(inner.right() - text_width); buf.set_string(start, row, text, style); } } @@ -506,6 +511,35 @@ mod tests { } } + /// Review fix: on a terminal narrower than an x label (8–9 columns), + /// placing the label panicked (`clamp` with min > max). Every width + /// from 1 to 30 draws a charted run without panicking. + #[test] + fn chart_survives_any_width() { + use crate::app::{Mode, Screen}; + use std::time::{Duration, Instant}; + let mut app = App::new(None, crate::theme::builtin::DARK, Mode::Time(15)); + app.start_game(Mode::Words(10)).unwrap(); + app.screen = Screen::Results; + app.stats_cache = Some(Vec::new()); + let end = Instant::now(); + app.ended_at = Some(end); + app.started_at = Some(end - Duration::from_secs(12)); + app.timeline = vec![ + Second { + typed: 5, + correct: 5, + errors: 0 + }; + 12 + ]; + for width in 1..=30 { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap(); + terminal.draw(|f| crate::ui::render(f, &app)).unwrap(); + } + } + fn screen(app: &App) -> String { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 24)).unwrap(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 03c3b95..8c1e2c6 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -174,15 +174,15 @@ fn row_line( Row::Punctuation => ("punctuation", on_off(view.decor.punctuation)), Row::Numbers => ("numbers", on_off(view.decor.numbers)), Row::Pool => ("10k words", on_off(view.pool == WordPool::Extended)), - Row::LiveWpm => ("live wpm", on_off(view.typing.live_wpm)), - Row::Blind => ("blind", on_off(view.typing.blind)), - Row::StopOnError => ("stop on", view.typing.stop_on_error.name().to_string()), - Row::Pace => ("pace", view.typing.pace.name()), - Row::Cursor => ("cursor", view.typing.cursor.name().to_string()), - Row::Sound => ("key sound", view.typing.sound.name().to_string()), + Row::LiveWpm => ("live wpm", on_off(app.options.live_wpm)), + Row::Blind => ("blind", on_off(app.options.blind)), + Row::StopOnError => ("stop on", app.options.stop_on_error.name().to_string()), + Row::Pace => ("pace", app.options.pace.name()), + Row::Cursor => ("cursor", app.options.cursor.name().to_string()), + Row::Sound => ("key sound", app.options.sound.name().to_string()), Row::IdleLimit => ( "idle limit", - crate::options::idle_name(view.typing.idle_limit), + crate::options::idle_name(app.options.idle_limit), ), Row::Reset => unreachable!("drawn above"), }; diff --git a/src/ui/typing.rs b/src/ui/typing.rs index 0d3a130..35ceb1c 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -254,8 +254,10 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // ever gets long enough to show up in a profile. let mut word_starts = Vec::with_capacity(app.words.len()); // Where each word starts in the text read as one stream (each space - // between words one character): what the pace cursor counts in. - let mut word_offsets = Vec::with_capacity(app.words.len()); + // between words one character): what the pace cursor counts in. Only + // kept when there is a pace cursor. + let pace = app.pace_position(); + let mut word_offsets = Vec::new(); let mut offset = 0; let mut cursor_line = 0; let mut at = (0, 0); @@ -270,8 +272,10 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { + usize::from(has_space(word_index)); at = place(at, width.min(max_width)); word_starts.push(at); - word_offsets.push(offset); - offset += word.text.chars().count() + 1; + if pace.is_some() { + word_offsets.push(offset); + offset += word.text.chars().count() + 1; + } let is_active = word_index == app.current_word; if at.1 + width <= max_width && !is_active { at.1 += width; @@ -336,9 +340,9 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { let mut bar_at: Option<(usize, usize)> = None; // The pace cursor: the character it has reached, underlined in the // secondary color β€” never over the real cursor, which wins. - let pace = app.pace_position(); - let ghost = |index: usize, on_cursor: bool, style: Style| { - if pace == Some(index) && !on_cursor { + let ghost = |word_index: usize, char_index: usize, on_cursor: bool, style: Style| { + let index = word_offsets.get(word_index).map(|start| start + char_index); + if pace.is_some() && pace == index && !on_cursor { style .fg(app.theme.secondary) .add_modifier(Modifier::UNDERLINED) @@ -374,11 +378,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { Style::default() }; let on_cursor = is_active_word && typed_char_count >= char_states.len(); - let style = ghost( - word_offsets[word_index] + char_states.len(), - on_cursor, - style, - ); + let style = ghost(word_index, char_states.len(), on_cursor, style); (' ', style, on_cursor) }); let styled = char_states @@ -393,7 +393,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { } else { style_for_char(*state, is_zen_mode, blind, &app.theme) }; - let style = ghost(word_offsets[word_index] + char_index, on_cursor, style); + let style = ghost(word_index, char_index, on_cursor, style); (*ch, style, on_cursor) }); for (ch, style, on_cursor) in styled.chain(space) {