From 3dd2a9c9155ff621e5531051d01f140e58c59c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:26:53 +0000 Subject: [PATCH 01/15] feat(theme): add palette module with 4 built-in themes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the theme infrastructure for v0.2.0: - ThemePalette struct with 8 named color slots covering every hardcoded Color::* site in the UI. - Built-in palettes: dark (default, current look), light, monokai, dracula. Adding a new theme is a one-line append to builtin::ALL. - Color parser supporting #RRGGBB hex and the 16 ANSI names (case-insensitive, _/- separators tolerated). - by_name() lookup and names() listing for CLI integration. No UI code consumes the palette yet — that lands in the next commit. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- Cargo.lock | 78 ++++++++++++++++++++++++++- Cargo.toml | 1 + src/main.rs | 1 + src/theme/builtin.rs | 116 +++++++++++++++++++++++++++++++++++++++ src/theme/color.rs | 125 +++++++++++++++++++++++++++++++++++++++++++ src/theme/mod.rs | 71 ++++++++++++++++++++++++ 6 files changed, 391 insertions(+), 1 deletion(-) create mode 100644 src/theme/builtin.rs create mode 100644 src/theme/color.rs create mode 100644 src/theme/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 57b9b04..0915bed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -362,6 +362,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -398,6 +404,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "indoc" version = "2.0.7" @@ -495,7 +511,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -750,6 +766,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "shlex" version = "1.3.0" @@ -864,6 +889,47 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "typerush" version = "0.1.0" @@ -877,6 +943,7 @@ dependencies = [ "ratatui", "serde", "serde_json", + "toml", ] [[package]] @@ -1200,6 +1267,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index da9da82..798ab5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ rand = "0.8" chrono = { version = "0.4", features = ["serde"] } anyhow = "1" dirs = "5" +toml = "0.8" [profile.release] lto = true diff --git a/src/main.rs b/src/main.rs index 81a459e..2136085 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod app; mod game; mod storage; +mod theme; mod ui; mod words; diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs new file mode 100644 index 0000000..d3cec69 --- /dev/null +++ b/src/theme/builtin.rs @@ -0,0 +1,116 @@ +//! Built-in theme catalogue. +//! +//! Add a new theme by declaring a `pub const` and appending it to [`ALL`]. +//! That's all the wiring required — the loader and `--list-themes` pick it up +//! automatically. + +use ratatui::style::Color; + +use super::ThemePalette; + +/// The original TypeRush look — cyan/green/yellow on a black terminal. +/// This is the default when no `theme = ...` is set in the config. +pub const DARK: ThemePalette = ThemePalette { + accent: Color::Cyan, + secondary: Color::Yellow, + correct: Color::Green, + incorrect: Color::Red, + pending: Color::DarkGray, + extra: Color::Red, + mode_tag: Color::Magenta, + error: Color::Red, +}; + +/// Softer palette intended for terminals with a light background. +pub const LIGHT: ThemePalette = ThemePalette { + accent: Color::Rgb(0x01, 0x84, 0xBC), // deep cyan + secondary: Color::Rgb(0xC1, 0x84, 0x01), // amber + correct: Color::Rgb(0x50, 0xA1, 0x4F), // muted green + incorrect: Color::Rgb(0xE4, 0x56, 0x49), // muted red + pending: Color::Rgb(0xA0, 0xA1, 0xA7), // light gray + extra: Color::Rgb(0xE4, 0x56, 0x49), + mode_tag: Color::Rgb(0xA6, 0x26, 0xA4), // purple + error: Color::Rgb(0xCA, 0x12, 0x43), +}; + +/// Classic Monokai — pink/green/yellow on a warm dark backdrop. +pub const MONOKAI: ThemePalette = ThemePalette { + accent: Color::Rgb(0x66, 0xD9, 0xEF), // monokai cyan + secondary: Color::Rgb(0xE6, 0xDB, 0x74), // monokai yellow + correct: Color::Rgb(0xA6, 0xE2, 0x2E), // monokai green + incorrect: Color::Rgb(0xF9, 0x26, 0x72), // monokai pink + pending: Color::Rgb(0x75, 0x71, 0x5E), // dim gray-brown + extra: Color::Rgb(0xFD, 0x97, 0x1F), // orange + mode_tag: Color::Rgb(0xAE, 0x81, 0xFF), // purple + error: Color::Rgb(0xF9, 0x26, 0x72), +}; + +/// Dracula — purple/pink/cyan on `#282a36`. +pub const DRACULA: ThemePalette = ThemePalette { + accent: Color::Rgb(0x8B, 0xE9, 0xFD), // dracula cyan + secondary: Color::Rgb(0xF1, 0xFA, 0x8C), // dracula yellow + correct: Color::Rgb(0x50, 0xFA, 0x7B), // dracula green + incorrect: Color::Rgb(0xFF, 0x55, 0x55), // dracula red + pending: Color::Rgb(0x62, 0x72, 0xA4), // dracula comment + extra: Color::Rgb(0xFF, 0xB8, 0x6C), // dracula orange + mode_tag: Color::Rgb(0xFF, 0x79, 0xC6), // dracula pink + error: Color::Rgb(0xFF, 0x55, 0x55), +}; + +/// `(name, palette)` for every built-in theme. +/// +/// Adding a new theme = append a row here. The loader and `--list-themes` +/// both iterate this list. +pub const ALL: &[(&str, ThemePalette)] = &[ + ("dark", DARK), + ("light", LIGHT), + ("monokai", MONOKAI), + ("dracula", DRACULA), +]; + +/// Look up a theme by name (case-insensitive). Returns `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) +} + +/// All built-in theme names, in registration order. +pub fn names() -> Vec<&'static str> { + ALL.iter().map(|(name, _)| *name).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_known_theme() { + assert!(by_name("monokai").is_some()); + assert!(by_name("Monokai").is_some()); // case-insensitive + assert!(by_name("DRACULA").is_some()); + } + + #[test] + fn lookup_unknown_theme_returns_none() { + assert!(by_name("nonexistent").is_none()); + assert!(by_name("").is_none()); + } + + #[test] + fn names_contains_all_four_builtins() { + let names = names(); + assert_eq!(names.len(), 4); + assert!(names.contains(&"dark")); + assert!(names.contains(&"light")); + assert!(names.contains(&"monokai")); + assert!(names.contains(&"dracula")); + } + + #[test] + fn default_palette_is_dark() { + let default_palette: ThemePalette = ThemePalette::default(); + assert_eq!(default_palette, DARK); + } +} diff --git a/src/theme/color.rs b/src/theme/color.rs new file mode 100644 index 0000000..bc8d49d --- /dev/null +++ b/src/theme/color.rs @@ -0,0 +1,125 @@ +//! Parse user-supplied color strings into ratatui `Color` values. +//! +//! Two accepted forms: +//! - Hex: `#RRGGBB` (case-insensitive, leading `#` required). +//! - Named: any of the standard 16 ANSI palette names (`cyan`, `darkgray`, +//! `red`, …). Case-insensitive. Underscores accepted (`dark_gray`). +//! +//! Anything else returns `Err`. The error is human-readable so we can surface +//! it in the one-time error modal on a bad config. + +use anyhow::{anyhow, Result}; +use ratatui::style::Color; + +/// Parse one color string into a `Color`. +/// +/// # Examples +/// ```ignore +/// parse_color("#FF00FF").unwrap(); // Rgb(255, 0, 255) +/// parse_color("cyan").unwrap(); // Color::Cyan +/// parse_color("dark_gray").unwrap(); // Color::DarkGray +/// parse_color("not-a-color"); // Err +/// ``` +pub fn parse_color(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(anyhow!("empty color string")); + } + if let Some(rest) = trimmed.strip_prefix('#') { + return parse_hex(rest); + } + parse_named(trimmed) +} + +fn parse_hex(hex: &str) -> Result { + if hex.len() != 6 { + return Err(anyhow!( + "hex color must be exactly 6 characters after '#', got '{}'", + hex + )); + } + let red = u8::from_str_radix(&hex[0..2], 16) + .map_err(|_| anyhow!("invalid red byte in hex color '#{}'", hex))?; + let green = u8::from_str_radix(&hex[2..4], 16) + .map_err(|_| anyhow!("invalid green byte in hex color '#{}'", hex))?; + let blue = u8::from_str_radix(&hex[4..6], 16) + .map_err(|_| anyhow!("invalid blue byte in hex color '#{}'", hex))?; + Ok(Color::Rgb(red, green, blue)) +} + +fn parse_named(name: &str) -> Result { + let normalized: String = name + .chars() + .filter(|c| !c.is_whitespace() && *c != '_' && *c != '-') + .collect::() + .to_lowercase(); + match normalized.as_str() { + "black" => Ok(Color::Black), + "red" => Ok(Color::Red), + "green" => Ok(Color::Green), + "yellow" => Ok(Color::Yellow), + "blue" => Ok(Color::Blue), + "magenta" => Ok(Color::Magenta), + "cyan" => Ok(Color::Cyan), + "gray" | "grey" => Ok(Color::Gray), + "darkgray" | "darkgrey" => Ok(Color::DarkGray), + "lightred" => Ok(Color::LightRed), + "lightgreen" => Ok(Color::LightGreen), + "lightyellow" => Ok(Color::LightYellow), + "lightblue" => Ok(Color::LightBlue), + "lightmagenta" => Ok(Color::LightMagenta), + "lightcyan" => Ok(Color::LightCyan), + "white" => Ok(Color::White), + "reset" | "default" => Ok(Color::Reset), + _ => Err(anyhow!("unknown color name '{}'", name)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_color() { + assert_eq!(parse_color("#FF00FF").unwrap(), Color::Rgb(255, 0, 255)); + assert_eq!(parse_color("#000000").unwrap(), Color::Rgb(0, 0, 0)); + assert_eq!(parse_color("#ffffff").unwrap(), Color::Rgb(255, 255, 255)); + } + + #[test] + fn parses_named_color_case_insensitive() { + assert_eq!(parse_color("cyan").unwrap(), Color::Cyan); + assert_eq!(parse_color("CYAN").unwrap(), Color::Cyan); + assert_eq!(parse_color("Cyan").unwrap(), Color::Cyan); + } + + #[test] + fn parses_compound_named_colors() { + assert_eq!(parse_color("dark_gray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("dark-gray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("darkgray").unwrap(), Color::DarkGray); + assert_eq!(parse_color("light_red").unwrap(), Color::LightRed); + } + + #[test] + fn rejects_short_hex() { + assert!(parse_color("#FFF").is_err()); + assert!(parse_color("#1234567").is_err()); + } + + #[test] + fn rejects_unknown_name() { + assert!(parse_color("burnt-orange").is_err()); + assert!(parse_color("").is_err()); + } + + #[test] + fn rejects_invalid_hex_chars() { + assert!(parse_color("#GGGGGG").is_err()); + } + + #[test] + fn trims_whitespace() { + assert_eq!(parse_color(" cyan ").unwrap(), Color::Cyan); + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..5d54f82 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,71 @@ +//! Theming — the color palette every UI module reads from. +//! +//! 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. +//! +//! All UI code reads from the active `ThemePalette` rather than hardcoding +//! `Color::*`, so adding a new theme is a matter of dropping a `ThemePalette` +//! constant into [`builtin`] and listing it in [`builtin::ALL`]. + +pub mod builtin; +pub mod color; + +use ratatui::style::Color; + +/// Every themable color slot in the UI. +/// +/// Each field maps to one or more concrete rendering decisions. Keeping the +/// list deliberately small (≈8 slots) avoids overwhelming users who just want +/// to tweak a color or two. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ThemePalette { + /// Primary brand color — banner, cursor, gauge fill, "time" number, titles. + pub accent: Color, + /// Secondary accent — used for emphasized numbers (WPM) and section titles. + pub secondary: Color, + /// Correctly-typed characters and the accuracy percentage. + pub correct: Color, + /// Wrongly-typed characters. + pub incorrect: Color, + /// Not-yet-typed characters and muted footer / label text. + pub pending: Color, + /// Surplus characters typed past the end of a word. + pub extra: Color, + /// `[mode]` badge in the header. + pub mode_tag: Color, + /// Error modal border / red highlights. + pub error: Color, +} + +impl ThemePalette { + /// Apply a list of (slot-name, color-string) overrides on top of a base palette. + /// + /// Unknown slot names are silently ignored — they would have failed earlier + /// during config deserialization in any sane setup, but we never want a + /// typo'd override to crash the app. + pub fn with_overrides(mut self, overrides: &[(&str, Color)]) -> Self { + for (slot, color) in overrides { + match *slot { + "accent" => self.accent = *color, + "secondary" => self.secondary = *color, + "correct" => self.correct = *color, + "incorrect" => self.incorrect = *color, + "pending" => self.pending = *color, + "extra" => self.extra = *color, + "mode_tag" => self.mode_tag = *color, + "error" => self.error = *color, + _ => {} + } + } + self + } +} + +impl Default for ThemePalette { + /// The default theme is `dark` — the original TypeRush look. + fn default() -> Self { + builtin::DARK + } +} From 68d99d0c5f500dd854a18dd29a7d6f64a468f508 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:28:29 +0000 Subject: [PATCH 02/15] feat(config): add config.toml loader with theme + defaults resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the configuration plumbing for v0.2.0: - Config struct (theme, defaults, colors sections) with serde deserialization. Unknown fields and unknown color slots both error at parse time so typos surface immediately. - load_or_default() in load.rs: reads ~/.typerush/config.toml, never errors — missing file gives defaults, malformed file gives defaults plus a warning surfaced via the error modal. - ResolvedConfig: the flat, fully-resolved shape the rest of the app consumes. Bakes in CLI > config > default precedence for theme. - DefaultMode + CodeLangKind: config-side mirrors of the runtime Mode enum, kept here to avoid the config module depending on app/words. - 17 tests covering valid configs, invalid configs, color overrides, unknown themes, unknown modes, CLI override precedence. Nothing consumes load_or_default() yet — wiring lands in the next commit alongside the UI rewire. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/config/load.rs | 326 +++++++++++++++++++++++++++++++++++++++++++++ src/config/mod.rs | 134 +++++++++++++++++++ src/main.rs | 1 + 3 files changed, 461 insertions(+) create mode 100644 src/config/load.rs create mode 100644 src/config/mod.rs diff --git a/src/config/load.rs b/src/config/load.rs new file mode 100644 index 0000000..b78b931 --- /dev/null +++ b/src/config/load.rs @@ -0,0 +1,326 @@ +//! Disk I/O for the config file plus the "resolve everything" entry point. +//! +//! `load_or_default()` is the single function the rest of the app calls. It +//! returns a fully-resolved `ResolvedConfig` and a list of human-readable +//! warnings — never an error. The caller (typically `main.rs`) can choose to +//! surface the first warning via the error modal. + +use std::path::PathBuf; + +use ratatui::style::Color; + +use super::{Colors, Config}; +use crate::storage; +use crate::theme::{self, builtin, ThemePalette}; + +/// Final, fully-resolved configuration the rest of the app consumes. +/// +/// All `Option`s in the raw `Config` have been flattened: missing values were +/// replaced with hardcoded defaults, missing/invalid theme names fell back to +/// `dark`, and color slot overrides were applied on top of the chosen palette. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedConfig { + /// Final palette to render with. + pub palette: ThemePalette, + /// Pre-selected mode for the menu (and starting `App::mode`). + pub default_mode: DefaultMode, +} + +/// Coarse picker for which menu row to highlight on launch. Distinct from +/// `app::Mode` because we don't want config-loading to depend on the runtime +/// `Mode` enum's variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DefaultMode { + /// `time-Ns` — value is N (seconds). + Time(u64), + /// `words-N` — value is N (words). + Words(usize), + /// Single quote round. + Quote, + /// Language identifier matching `app::Mode::Code`. + Code(CodeLangKind), + /// Zen mode. + Zen, +} + +/// Mirror of `crate::words::CodeLang` for config-resolution purposes. +/// Kept here so the config module doesn't depend on the words module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodeLangKind { + Rust, + Python, + JavaScript, +} + +impl Default for ResolvedConfig { + fn default() -> Self { + Self { + palette: ThemePalette::default(), + // Picked in agreement with the v0.2.0 spec: "default scheme time 15 sec". + default_mode: DefaultMode::Time(15), + } + } +} + +/// Filesystem path of the config file: `~/.typerush/config.toml`. +pub fn config_path() -> PathBuf { + storage::data_dir().join("config.toml") +} + +/// Read and resolve the config from disk. Always succeeds: +/// +/// - missing file → defaults, no warnings +/// - unparseable file → defaults, one warning +/// - unknown theme name → fall back to dark, one warning +/// - bad color override → keep the theme's slot, one warning per bad slot +/// +/// `cli_theme_override` lets `--theme` win over whatever the config says. +pub fn load_or_default(cli_theme_override: Option<&str>) -> (ResolvedConfig, Vec) { + let path = config_path(); + let raw_config = if path.exists() { + match std::fs::read_to_string(&path) { + Ok(text) => match toml::from_str::(&text) { + Ok(parsed) => parsed, + Err(err) => { + let warning = format!("could not parse {}: {}", path.display(), err); + return (ResolvedConfig::default(), vec![warning]); + } + }, + Err(err) => { + let warning = format!("could not read {}: {}", path.display(), err); + return (ResolvedConfig::default(), vec![warning]); + } + } + } else { + Config::default() + }; + resolve(raw_config, cli_theme_override) +} + +/// Pure resolver — easier to unit-test than the disk-touching variant. +fn resolve(raw: Config, cli_theme_override: Option<&str>) -> (ResolvedConfig, Vec) { + let mut warnings = vec![]; + + // Theme: CLI wins over config. Unknown name → warn, use dark. + let theme_choice = cli_theme_override + .map(str::to_string) + .or_else(|| raw.theme.clone()); + let palette_base = match theme_choice.as_deref() { + None => builtin::DARK, + Some(name) => match builtin::by_name(name) { + Some(palette) => palette, + None => { + warnings.push(format!( + "unknown theme '{}', falling back to 'dark' (try one of: {})", + name, + builtin::names().join(", ") + )); + builtin::DARK + } + }, + }; + + // Color slot overrides on top of the chosen palette. + let palette = if let Some(colors) = raw.colors.as_ref() { + apply_color_overrides(palette_base, colors, &mut warnings) + } else { + palette_base + }; + + let default_mode = resolve_default_mode(raw.defaults.as_ref(), &mut warnings); + + ( + ResolvedConfig { + palette, + default_mode, + }, + warnings, + ) +} + +fn apply_color_overrides( + base: ThemePalette, + overrides: &Colors, + warnings: &mut Vec, +) -> ThemePalette { + let mut parsed: Vec<(&str, Color)> = vec![]; + let mut try_push = |slot: &'static str, value: &Option, warnings: &mut Vec| { + if let Some(input) = value { + match theme::color::parse_color(input) { + Ok(color) => parsed.push((slot, color)), + Err(err) => warnings.push(format!("bad color for '{}': {}", slot, err)), + } + } + }; + try_push("accent", &overrides.accent, warnings); + try_push("secondary", &overrides.secondary, warnings); + try_push("correct", &overrides.correct, warnings); + try_push("incorrect", &overrides.incorrect, warnings); + try_push("pending", &overrides.pending, warnings); + try_push("extra", &overrides.extra, warnings); + try_push("mode_tag", &overrides.mode_tag, warnings); + try_push("error", &overrides.error, warnings); + base.with_overrides(&parsed) +} + +fn resolve_default_mode( + defaults: Option<&super::Defaults>, + warnings: &mut Vec, +) -> DefaultMode { + let Some(defaults) = defaults else { + return DefaultMode::Time(15); + }; + let mode_name = defaults.mode.as_deref().unwrap_or("time"); + match mode_name.to_lowercase().as_str() { + "time" => DefaultMode::Time(defaults.time_seconds.unwrap_or(15)), + "words" => DefaultMode::Words(defaults.word_count.unwrap_or(25)), + "quote" => DefaultMode::Quote, + "code" => DefaultMode::Code(parse_code_lang( + defaults.code_lang.as_deref().unwrap_or("rust"), + warnings, + )), + "zen" => DefaultMode::Zen, + other => { + warnings.push(format!( + "unknown default mode '{}', falling back to 'time'", + other + )); + DefaultMode::Time(defaults.time_seconds.unwrap_or(15)) + } + } +} + +fn parse_code_lang(name: &str, warnings: &mut Vec) -> CodeLangKind { + match name.to_lowercase().as_str() { + "rust" | "rs" => CodeLangKind::Rust, + "python" | "py" => CodeLangKind::Python, + "js" | "javascript" => CodeLangKind::JavaScript, + other => { + warnings.push(format!( + "unknown code_lang '{}', falling back to 'rust'", + other + )); + CodeLangKind::Rust + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Colors, Defaults}; + + #[test] + fn default_when_config_is_empty() { + let (resolved, warnings) = resolve(Config::default(), None); + assert!(warnings.is_empty()); + assert_eq!(resolved.palette, builtin::DARK); + assert_eq!(resolved.default_mode, DefaultMode::Time(15)); + } + + #[test] + fn theme_name_picks_palette() { + let raw = Config { + theme: Some("monokai".into()), + ..Default::default() + }; + let (resolved, warnings) = resolve(raw, None); + assert!(warnings.is_empty()); + assert_eq!(resolved.palette, builtin::MONOKAI); + } + + #[test] + fn unknown_theme_warns_and_uses_dark() { + let raw = Config { + theme: Some("nonexistent".into()), + ..Default::default() + }; + let (resolved, warnings) = resolve(raw, None); + assert_eq!(resolved.palette, builtin::DARK); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("nonexistent")); + } + + #[test] + fn cli_override_wins_over_config() { + let raw = Config { + theme: Some("monokai".into()), + ..Default::default() + }; + let (resolved, _) = resolve(raw, Some("dracula")); + assert_eq!(resolved.palette, builtin::DRACULA); + } + + #[test] + fn color_override_applies_on_top_of_theme() { + let raw = Config { + theme: Some("dark".into()), + colors: Some(Colors { + accent: Some("#FF00FF".into()), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, warnings) = resolve(raw, None); + assert!(warnings.is_empty()); + assert_eq!(resolved.palette.accent, Color::Rgb(255, 0, 255)); + assert_eq!(resolved.palette.correct, builtin::DARK.correct); + } + + #[test] + fn bad_color_warns_and_keeps_theme_slot() { + let raw = Config { + colors: Some(Colors { + accent: Some("not-a-color".into()), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, warnings) = resolve(raw, None); + assert_eq!(resolved.palette.accent, builtin::DARK.accent); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("accent")); + } + + #[test] + fn defaults_resolve_to_mode() { + let raw = Config { + defaults: Some(Defaults { + mode: Some("words".into()), + word_count: Some(100), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, _) = resolve(raw, None); + assert_eq!(resolved.default_mode, DefaultMode::Words(100)); + } + + #[test] + fn defaults_unknown_mode_falls_back_with_warning() { + let raw = Config { + defaults: Some(Defaults { + mode: Some("hyperspeed".into()), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, warnings) = resolve(raw, None); + assert_eq!(resolved.default_mode, DefaultMode::Time(15)); + assert!(!warnings.is_empty()); + } + + #[test] + fn defaults_code_mode_picks_language() { + let raw = Config { + defaults: Some(Defaults { + mode: Some("code".into()), + code_lang: Some("python".into()), + ..Default::default() + }), + ..Default::default() + }; + let (resolved, _) = resolve(raw, None); + assert_eq!(resolved.default_mode, DefaultMode::Code(CodeLangKind::Python)); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..7a0a4b5 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,134 @@ +//! Reading and resolving `~/.typerush/config.toml`. +//! +//! The config is **entirely optional**: a missing file means defaults. A +//! malformed file means defaults plus a warning surfaced once via the error +//! modal. We never crash the app over a config problem. +//! +//! ## Schema +//! +//! ```toml +//! theme = "monokai" # built-in name: dark | light | monokai | dracula +//! +//! [defaults] +//! mode = "time" # time | words | quote | code | zen +//! time_seconds = 15 # default duration for time mode +//! word_count = 25 # default count for words mode +//! code_lang = "rust" # rust | python | js +//! +//! [colors] # optional — overrides slots of the chosen theme +//! accent = "#FF00FF" +//! correct = "green" +//! ``` + +pub mod load; + +use serde::Deserialize; + +/// Top-level config shape. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// Built-in theme name, e.g. `"monokai"`. Unknown names trigger a warning + /// and fall back to `dark`. + pub theme: Option, + /// Pre-selected mode and per-mode defaults. + pub defaults: Option, + /// Optional per-slot color overrides applied on top of the chosen theme. + pub colors: Option, +} + +/// Default mode + per-mode defaults. Pre-selects the matching row in the menu +/// at startup. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Defaults { + /// Which menu row to pre-select: `time` | `words` | `quote` | `code` | `zen`. + pub mode: Option, + /// Seconds for `Mode::Time` (and for the time-mode menu row pre-selection). + pub time_seconds: Option, + /// Word count for `Mode::Words`. + pub word_count: Option, + /// Language for `Mode::Code`: `rust` | `python` | `js`. + pub code_lang: Option, +} + +/// Per-slot color overrides. Any field that's `Some` overrides the corresponding +/// slot of the chosen theme. Invalid color strings are ignored with a warning. +#[derive(Debug, Default, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Colors { + pub accent: Option, + pub secondary: Option, + pub correct: Option, + pub incorrect: Option, + pub pending: Option, + pub extra: Option, + pub mode_tag: Option, + pub error: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_config() { + let raw = r#"theme = "monokai""#; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.theme.as_deref(), Some("monokai")); + assert!(cfg.defaults.is_none()); + assert!(cfg.colors.is_none()); + } + + #[test] + fn parses_full_config() { + let raw = r##" +theme = "dracula" + +[defaults] +mode = "time" +time_seconds = 30 +word_count = 50 +code_lang = "rust" + +[colors] +accent = "#FF00FF" +correct = "green" + "##; + let cfg: Config = toml::from_str(raw).unwrap(); + assert_eq!(cfg.theme.as_deref(), Some("dracula")); + let defaults = cfg.defaults.expect("defaults section parsed"); + assert_eq!(defaults.mode.as_deref(), Some("time")); + assert_eq!(defaults.time_seconds, Some(30)); + assert_eq!(defaults.word_count, Some(50)); + assert_eq!(defaults.code_lang.as_deref(), Some("rust")); + let colors = cfg.colors.expect("colors section parsed"); + assert_eq!(colors.accent.as_deref(), Some("#FF00FF")); + assert_eq!(colors.correct.as_deref(), Some("green")); + assert!(colors.incorrect.is_none()); + } + + #[test] + fn empty_string_is_a_valid_config() { + let cfg: Config = toml::from_str("").unwrap(); + assert_eq!(cfg, Config::default()); + } + + #[test] + fn unknown_field_rejected() { + let raw = r#" +theme = "dark" +nonsense_field = 42 + "#; + assert!(toml::from_str::(raw).is_err()); + } + + #[test] + fn unknown_color_slot_rejected() { + let raw = r#" +[colors] +not_a_real_slot = "red" + "#; + assert!(toml::from_str::(raw).is_err()); + } +} diff --git a/src/main.rs b/src/main.rs index 2136085..a758ba4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ //! 4. Restore the terminal on exit (and on panic, via a hook). mod app; +mod config; mod game; mod storage; mod theme; From 6c14237b0fd59d0574a2e1bfec6edac8b89719b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:34:21 +0000 Subject: [PATCH 03/15] feat(ui): wire palette through App and every screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hardcoded Color::* in the UI now reads from app.theme. Switching themes only requires changing one ThemePalette value at startup. - App grows a 'theme: ThemePalette' field and a constructor that takes the resolved palette + default mode. The constructor also pre-selects the matching menu row (exact match preferred, falls back to first row of the same family). - main.rs loads ~/.typerush/config.toml via config::load::load_or_default, surfacing the first warning (if any) through the existing error modal. - New CLI flags: --theme for one-shot overrides, --list-themes for shell-completion-friendly enumeration. - typing/menu/results/stats/help screens fully rethemed. Zen mode now desaturates to theme.pending + theme.neutral instead of hardcoded grays, so light backgrounds remain readable. - Added a 'neutral' palette slot for emphasized neutral text (chars totals, session count) — maps to white on dark themes, near-black on light themes. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/app.rs | 61 +++++++++++++++++++++++++-- src/config/load.rs | 6 ++- src/config/mod.rs | 1 + src/main.rs | 31 +++++++++++++- src/theme/builtin.rs | 4 ++ src/theme/mod.rs | 5 +++ src/ui/help.rs | 21 ++++++---- src/ui/menu.rs | 32 +++++---------- src/ui/mod.rs | 2 +- src/ui/results.rs | 40 +++++++++--------- src/ui/stats.rs | 44 ++++++++++---------- src/ui/typing.rs | 98 ++++++++++++++++++++++++-------------------- 12 files changed, 222 insertions(+), 123 deletions(-) diff --git a/src/app.rs b/src/app.rs index 014f019..fb3c8cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,9 @@ use std::time::{Duration, Instant}; +use crate::config::load::{CodeLangKind, DefaultMode}; use crate::game::{get_char_states, CharState}; +use crate::theme::ThemePalette; /// One of the high-level screens the user can be looking at. The current /// `Screen` drives both the renderer dispatch in `ui::render` and the keymap @@ -110,6 +112,43 @@ pub enum MenuAction { Quit, } +/// Translate a `DefaultMode` (the config-side enum) into a runtime `Mode`. +fn mode_for(default_mode: DefaultMode) -> Mode { + use crate::words::CodeLang; + match default_mode { + DefaultMode::Time(seconds) => Mode::Time(seconds), + DefaultMode::Words(count) => Mode::Words(count), + DefaultMode::Quote => Mode::Quote, + DefaultMode::Code(CodeLangKind::Rust) => Mode::Code(CodeLang::Rust), + DefaultMode::Code(CodeLangKind::Python) => Mode::Code(CodeLang::Python), + DefaultMode::Code(CodeLangKind::JavaScript) => Mode::Code(CodeLang::JavaScript), + DefaultMode::Zen => Mode::Zen, + } +} + +/// Pick which menu row to highlight given the configured default mode. +/// +/// Exact match wins (e.g. `time_seconds = 30` → "Time · 30s"). Otherwise we +/// fall back to the first row of the same mode family (so `time_seconds = 45` +/// still pre-selects a time row rather than landing on something unrelated). +fn best_menu_match(menu: &[MenuItem], default_mode: DefaultMode) -> usize { + let exact = mode_for(default_mode); + if let Some(index) = menu.iter().position(|item| item.mode == Some(exact)) { + return index; + } + let family_match = menu.iter().position(|item| { + matches!( + (item.mode, default_mode), + (Some(Mode::Time(_)), DefaultMode::Time(_)) + | (Some(Mode::Words(_)), DefaultMode::Words(_)) + | (Some(Mode::Code(_)), DefaultMode::Code(_)) + | (Some(Mode::Quote), DefaultMode::Quote) + | (Some(Mode::Zen), DefaultMode::Zen) + ) + }); + family_match.unwrap_or(0) +} + /// Build the default menu shown on startup. pub fn default_menu() -> Vec { use crate::words::CodeLang; @@ -237,17 +276,30 @@ pub struct App { pub error_message: Option, /// Counts every `tick()`. Used to throttle costly UI updates if needed. pub tick_count: u64, + /// Active color palette — read by every UI module on every frame. + pub theme: ThemePalette, } impl App { /// Construct a fresh `App` sitting on the main menu. - pub fn new(custom_file: Option) -> Self { + /// + /// `default_mode` (from `~/.typerush/config.toml`) controls which menu row + /// is pre-selected and what `app.mode` starts as. `palette` is the active + /// color theme — UI modules read it on every frame. + pub fn new( + custom_file: Option, + palette: ThemePalette, + default_mode: DefaultMode, + ) -> Self { + let menu = default_menu(); + let initial_mode = mode_for(default_mode); + let menu_index = best_menu_match(&menu, default_mode); Self { screen: Screen::Menu, previous_screen: Screen::Menu, - menu: default_menu(), - menu_index: 0, - mode: Mode::Words(25), + menu, + menu_index, + mode: initial_mode, words: vec![], current_word: 0, started_at: None, @@ -259,6 +311,7 @@ impl App { should_quit: false, error_message: None, tick_count: 0, + theme: palette, } } diff --git a/src/config/load.rs b/src/config/load.rs index b78b931..e481b94 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -160,6 +160,7 @@ fn apply_color_overrides( try_push("extra", &overrides.extra, warnings); try_push("mode_tag", &overrides.mode_tag, warnings); try_push("error", &overrides.error, warnings); + try_push("neutral", &overrides.neutral, warnings); base.with_overrides(&parsed) } @@ -321,6 +322,9 @@ mod tests { ..Default::default() }; let (resolved, _) = resolve(raw, None); - assert_eq!(resolved.default_mode, DefaultMode::Code(CodeLangKind::Python)); + assert_eq!( + resolved.default_mode, + DefaultMode::Code(CodeLangKind::Python) + ); } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 7a0a4b5..ba2398f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -65,6 +65,7 @@ pub struct Colors { pub extra: Option, pub mode_tag: Option, pub error: Option, + pub neutral: Option, } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index a758ba4..3ba4317 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ use ratatui::{backend::CrosstermBackend, Terminal}; use crate::app::{App, MenuAction, Mode, Screen}; use crate::storage::SessionRecord; +use crate::theme::builtin; /// 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. @@ -71,10 +72,22 @@ struct Cli { /// Skip the menu and start zen mode. #[arg(long)] zen: bool, + + /// One-shot theme override: dark | light | monokai | dracula. + /// Takes precedence over the `theme` setting in `~/.typerush/config.toml`. + #[arg(long)] + theme: Option, + + /// Print available built-in theme names and exit. + #[arg(long)] + list_themes: bool, } fn main() -> Result<()> { let cli = Cli::parse(); + if cli.list_themes { + print_themes_and_exit(); + } install_panic_hook(); let mut terminal = setup_terminal()?; let run_result = run_app(&mut terminal, cli); @@ -82,6 +95,15 @@ fn main() -> Result<()> { run_result } +/// 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() -> ! { + for theme_name in builtin::names() { + println!("{}", theme_name); + } + std::process::exit(0); +} + /// Type alias to keep function signatures readable. type Tui = Terminal>; @@ -126,7 +148,14 @@ fn install_panic_hook() { /// 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 mut app = App::new(cli.file.clone()); + let (resolved, warnings) = config::load::load_or_default(cli.theme.as_deref()); + let mut app = App::new(cli.file.clone(), resolved.palette, resolved.default_mode); + // Surface the first config warning (if any) via the existing error modal. + // We only show one — chaining them would force the user to dismiss N + // popups before reaching the menu. + if let Some(first_warning) = warnings.into_iter().next() { + app.error_message = Some(first_warning); + } apply_cli_autostart(&mut app, &cli)?; let tick_rate = Duration::from_millis(100); diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index d3cec69..74b5a8f 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -19,6 +19,7 @@ pub const DARK: ThemePalette = ThemePalette { extra: Color::Red, mode_tag: Color::Magenta, error: Color::Red, + neutral: Color::White, }; /// Softer palette intended for terminals with a light background. @@ -31,6 +32,7 @@ pub const LIGHT: ThemePalette = ThemePalette { extra: Color::Rgb(0xE4, 0x56, 0x49), mode_tag: Color::Rgb(0xA6, 0x26, 0xA4), // purple error: Color::Rgb(0xCA, 0x12, 0x43), + neutral: Color::Rgb(0x38, 0x3A, 0x42), // near-black for light bg }; /// Classic Monokai — pink/green/yellow on a warm dark backdrop. @@ -43,6 +45,7 @@ pub const MONOKAI: ThemePalette = ThemePalette { extra: Color::Rgb(0xFD, 0x97, 0x1F), // orange mode_tag: Color::Rgb(0xAE, 0x81, 0xFF), // purple error: Color::Rgb(0xF9, 0x26, 0x72), + neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // monokai foreground }; /// Dracula — purple/pink/cyan on `#282a36`. @@ -55,6 +58,7 @@ pub const DRACULA: ThemePalette = ThemePalette { extra: Color::Rgb(0xFF, 0xB8, 0x6C), // dracula orange mode_tag: Color::Rgb(0xFF, 0x79, 0xC6), // dracula pink error: Color::Rgb(0xFF, 0x55, 0x55), + neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // dracula foreground }; /// `(name, palette)` for every built-in theme. diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 5d54f82..2a4c539 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -37,6 +37,10 @@ pub struct ThemePalette { pub mode_tag: Color, /// Error modal border / red highlights. pub error: Color, + /// Emphasized neutral text — session counts, char totals on results. + /// Maps to the terminal's natural foreground on each theme (white on dark, + /// near-black on light) so it stays readable on every background. + pub neutral: Color, } impl ThemePalette { @@ -56,6 +60,7 @@ impl ThemePalette { "extra" => self.extra = *color, "mode_tag" => self.mode_tag = *color, "error" => self.error = *color, + "neutral" => self.neutral = *color, _ => {} } } diff --git a/src/ui/help.rs b/src/ui/help.rs index 3930c05..39be494 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -7,18 +7,19 @@ use ratatui::{ widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; -use crate::app::App; +use crate::{app::App, theme::ThemePalette}; /// Render the keybindings overlay. -pub fn render(f: &mut Frame, _app: &App) { +pub fn render(f: &mut Frame, app: &App) { let area = centered_rect(60, 70, f.area()); f.render_widget(Clear, area); + let theme = &app.theme; let lines = vec![ Line::from(Span::styled( " TypeRush — keybindings", Style::default() - .fg(Color::Cyan) + .fg(theme.accent) .add_modifier(Modifier::BOLD), )), Line::raw(""), @@ -34,7 +35,7 @@ pub fn render(f: &mut Frame, _app: &App) { Line::from(Span::styled( " Modes", Style::default() - .fg(Color::Yellow) + .fg(theme.secondary) .add_modifier(Modifier::BOLD), )), Line::from(" Time type as many words as you can"), @@ -45,21 +46,25 @@ pub fn render(f: &mut Frame, _app: &App) { Line::raw(""), Line::from(Span::styled( " Stats saved to ~/.typerush/stats.json", - Style::default().fg(Color::DarkGray), + Style::default().fg(theme.pending), + )), + Line::from(Span::styled( + " Config: ~/.typerush/config.toml", + Style::default().fg(theme.pending), )), ]; let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Cyan)) + .border_style(Style::default().fg(theme.accent)) .title(" help "), ); f.render_widget(p, area); } /// Render the transient error modal. Dismissed by any keypress from the main loop. -pub fn render_error(f: &mut Frame, message: &str) { +pub fn render_error(f: &mut Frame, theme: &ThemePalette, message: &str) { let area = centered_rect(50, 20, f.area()); f.render_widget(Clear, area); let p = Paragraph::new(format!(" {}\n\n press any key", message)) @@ -67,7 +72,7 @@ pub fn render_error(f: &mut Frame, message: &str) { .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Red)) + .border_style(Style::default().fg(theme.error)) .title(" error "), ); f.render_widget(p, area); diff --git a/src/ui/menu.rs b/src/ui/menu.rs index afba938..02f51b0 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -18,43 +18,34 @@ pub fn render(f: &mut Frame, app: &App) { ]) .split(area); - // Banner + let banner_style = Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::BOLD); let banner_text = vec![ Line::from(Span::styled( " ████████ ██ ██ ██████ ███████ ██████ ██ ██ ███████ ██ ██ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + banner_style, )), Line::from(Span::styled( " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + banner_style, )), Line::from(Span::styled( " ██ ████ ██████ █████ ██████ ██ ██ ███████ ███████ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + banner_style, )), Line::from(Span::styled( " ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + banner_style, )), Line::from(Span::styled( " ██ ██ ██ ███████ ██ ██ ██████ ███████ ██ ██ ", - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + banner_style, )), ]; let banner = Paragraph::new(banner_text).alignment(Alignment::Center); f.render_widget(banner, layout[0]); - // Centered list let inner = centered_rect(60, 100, layout[1]); let items: Vec = app .menu @@ -66,14 +57,14 @@ pub fn render(f: &mut Frame, app: &App) { Block::default().borders(Borders::ALL).title(Span::styled( " select mode ", Style::default() - .fg(Color::Yellow) + .fg(app.theme.secondary) .add_modifier(Modifier::BOLD), )), ) .highlight_style( Style::default() .fg(Color::Black) - .bg(Color::Cyan) + .bg(app.theme.accent) .add_modifier(Modifier::BOLD), ) .highlight_symbol("➤ "); @@ -82,9 +73,8 @@ pub fn render(f: &mut Frame, app: &App) { state.select(Some(app.menu_index)); f.render_stateful_widget(list, inner, &mut state); - // Footer let footer = Paragraph::new(" ↑/↓ navigate · Enter start · q quit · ? help") - .style(Style::default().fg(Color::DarkGray)) + .style(Style::default().fg(app.theme.pending)) .wrap(Wrap { trim: true }); f.render_widget(footer, layout[2]); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 581d35f..611f62f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -26,6 +26,6 @@ pub fn render(frame: &mut Frame, app: &App) { } // Error overlay sits on top of everything else when present. if let Some(message) = &app.error_message { - help::render_error(frame, message); + help::render_error(frame, &app.theme, message); } } diff --git a/src/ui/results.rs b/src/ui/results.rs index 9960133..79383c3 100644 --- a/src/ui/results.rs +++ b/src/ui/results.rs @@ -14,6 +14,7 @@ use crate::{app::App, storage}; /// Render the post-session results screen. pub fn render(f: &mut Frame, app: &App) { let area = f.area(); + let theme = &app.theme; let layout = Layout::vertical([ Constraint::Length(3), Constraint::Length(9), @@ -26,7 +27,7 @@ pub fn render(f: &mut Frame, app: &App) { let title = Paragraph::new(Span::styled( " ✓ session complete", Style::default() - .fg(Color::Green) + .fg(theme.correct) .add_modifier(Modifier::BOLD), )); f.render_widget(title, layout[0]); @@ -43,9 +44,9 @@ pub fn render(f: &mut Frame, app: &App) { let diff = wpm - prev; let sign = if diff >= 0.0 { "+" } else { "" }; let color = if diff >= 0.0 { - Color::Green + theme.correct } else { - Color::Red + theme.incorrect }; Span::styled( format!(" ({sign}{:.0} vs last)", diff), @@ -57,43 +58,43 @@ pub fn render(f: &mut Frame, app: &App) { let body_lines = vec![ Line::from(vec![ - Span::styled(" wpm ", Style::default().fg(Color::DarkGray)), + Span::styled(" wpm ", Style::default().fg(theme.pending)), Span::styled( format!("{:>6.1}", wpm), Style::default() - .fg(Color::Yellow) + .fg(theme.secondary) .add_modifier(Modifier::BOLD), ), delta, ]), Line::from(vec![ - Span::styled(" accuracy ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{:>6.1}%", acc), Style::default().fg(Color::Green)), + Span::styled(" accuracy ", Style::default().fg(theme.pending)), + Span::styled(format!("{:>6.1}%", acc), Style::default().fg(theme.correct)), ]), Line::from(vec![ - Span::styled(" time ", Style::default().fg(Color::DarkGray)), + Span::styled(" time ", Style::default().fg(theme.pending)), Span::styled( format!("{:>5.1}s", elapsed), - Style::default().fg(Color::Cyan), + Style::default().fg(theme.accent), ), ]), Line::from(vec![ - Span::styled(" chars ", Style::default().fg(Color::DarkGray)), + Span::styled(" chars ", Style::default().fg(theme.pending)), Span::styled( format!("{}/{}", app.correct_chars, app.total_typed_chars), - Style::default().fg(Color::White), + Style::default().fg(theme.neutral), ), ]), Line::from(vec![ - Span::styled(" mode ", Style::default().fg(Color::DarkGray)), - Span::styled(app.mode.label(), Style::default().fg(Color::Magenta)), + Span::styled(" mode ", Style::default().fg(theme.pending)), + Span::styled(app.mode.label(), Style::default().fg(theme.mode_tag)), ]), Line::from(vec![ - Span::styled(" best ever ", Style::default().fg(Color::DarkGray)), + Span::styled(" best ever ", Style::default().fg(theme.pending)), Span::styled( format!("{:>6.1} wpm", pb), Style::default() - .fg(Color::Cyan) + .fg(theme.accent) .add_modifier(Modifier::BOLD), ), ]), @@ -101,12 +102,11 @@ pub fn render(f: &mut Frame, app: &App) { let body = Paragraph::new(body_lines).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.pending)) .title(" results "), ); f.render_widget(body, layout[1]); - // Sparkline of recent WPM let recent: Vec = sessions .iter() .rev() @@ -118,14 +118,14 @@ pub fn render(f: &mut Frame, app: &App) { .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.pending)) .title(" recent wpm trend "), ) .data(&recent) - .style(Style::default().fg(Color::Cyan)); + .style(Style::default().fg(theme.accent)); f.render_widget(spark, layout[2]); let footer = Paragraph::new(" Enter / r restart · m menu · s stats · q quit") - .style(Style::default().fg(Color::DarkGray)); + .style(Style::default().fg(theme.pending)); f.render_widget(footer, layout[4]); } diff --git a/src/ui/stats.rs b/src/ui/stats.rs index f6fa05e..74c12a8 100644 --- a/src/ui/stats.rs +++ b/src/ui/stats.rs @@ -9,10 +9,10 @@ use ratatui::{ use crate::{app::App, storage}; -/// Render the stats history screen. Doesn't take any state from `App` — -/// everything is read from disk. -pub fn render(f: &mut Frame, _app: &App) { +/// Render the stats history screen. +pub fn render(f: &mut Frame, app: &App) { let area = f.area(); + let theme = &app.theme; let layout = Layout::vertical([ Constraint::Length(2), Constraint::Length(5), @@ -25,7 +25,7 @@ pub fn render(f: &mut Frame, _app: &App) { let title = Paragraph::new(Span::styled( " ◆ stats history", Style::default() - .fg(Color::Cyan) + .fg(theme.accent) .add_modifier(Modifier::BOLD), )); f.render_widget(title, layout[0]); @@ -38,35 +38,35 @@ pub fn render(f: &mut Frame, _app: &App) { let summary_lines = vec![ Line::from(vec![ - Span::styled(" best wpm ", Style::default().fg(Color::DarkGray)), + Span::styled(" best wpm ", Style::default().fg(theme.pending)), Span::styled( format!("{:>6.1}", pb), Style::default() - .fg(Color::Yellow) + .fg(theme.secondary) .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled("avg accuracy ", Style::default().fg(Color::DarkGray)), + Span::styled("avg accuracy ", Style::default().fg(theme.pending)), Span::styled( format!("{:>5.1}%", avg_acc), - Style::default().fg(Color::Green), + Style::default().fg(theme.correct), ), ]), Line::from(vec![ - Span::styled(" sessions ", Style::default().fg(Color::DarkGray)), - Span::styled(format!("{:>6}", total), Style::default().fg(Color::White)), + Span::styled(" sessions ", Style::default().fg(theme.pending)), + Span::styled(format!("{:>6}", total), Style::default().fg(theme.neutral)), Span::raw(" "), - Span::styled("last wpm ", Style::default().fg(Color::DarkGray)), + Span::styled("last wpm ", Style::default().fg(theme.pending)), Span::styled( format!("{:>5.1}", last_wpm), - Style::default().fg(Color::Cyan), + Style::default().fg(theme.accent), ), ]), ]; let summary = Paragraph::new(summary_lines).block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.pending)) .title(" summary "), ); f.render_widget(summary, layout[1]); @@ -82,11 +82,11 @@ pub fn render(f: &mut Frame, _app: &App) { .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.pending)) .title(" wpm trend (last 20) "), ) .data(&recent) - .style(Style::default().fg(Color::Cyan)); + .style(Style::default().fg(theme.accent)); f.render_widget(spark, layout[2]); let recent_rows = sessions @@ -96,18 +96,18 @@ pub fn render(f: &mut Frame, _app: &App) { .map(|s| { Row::new(vec![ Cell::from(s.timestamp.format("%Y-%m-%d %H:%M").to_string()), - Cell::from(s.mode.clone()).style(Style::default().fg(Color::Magenta)), - Cell::from(format!("{:.1}", s.wpm)).style(Style::default().fg(Color::Yellow)), - Cell::from(format!("{:.1}%", s.accuracy)).style(Style::default().fg(Color::Green)), + Cell::from(s.mode.clone()).style(Style::default().fg(theme.mode_tag)), + Cell::from(format!("{:.1}", s.wpm)).style(Style::default().fg(theme.secondary)), + Cell::from(format!("{:.1}%", s.accuracy)).style(Style::default().fg(theme.correct)), Cell::from(format!("{:.1}s", s.duration_secs)) - .style(Style::default().fg(Color::Cyan)), + .style(Style::default().fg(theme.accent)), ]) }) .collect::>(); let header = Row::new(vec!["Date", "Mode", "WPM", "Acc", "Time"]).style( Style::default() - .fg(Color::DarkGray) + .fg(theme.pending) .add_modifier(Modifier::BOLD), ); @@ -125,12 +125,12 @@ pub fn render(f: &mut Frame, _app: &App) { .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(theme.pending)) .title(" recent sessions "), ); f.render_widget(table, layout[3]); let footer = - Paragraph::new(" m / esc menu · q quit").style(Style::default().fg(Color::DarkGray)); + Paragraph::new(" m / esc menu · q quit").style(Style::default().fg(theme.pending)); f.render_widget(footer, layout[4]); } diff --git a/src/ui/typing.rs b/src/ui/typing.rs index a56b27f..e4cec98 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -1,8 +1,13 @@ //! The typing screen — header (live stats), progress bar, words area, footer. //! -//! The cursor is **steady** (no blink) to avoid layout shifts. Two cursor styles: -//! - on a character → REVERSED (solid block fill) -//! - past the last character of a word → underlined trailing space (plain bar) +//! The cursor is **steady** (no blink) to avoid layout shifts. Both cursor +//! positions render the same way: +//! - on a character → underline in `theme.accent` +//! - past the last character → an underlined trailing space in `theme.accent` +//! +//! Keeping the cursor a single visual style (rather than a "block fill" on +//! characters + "underline" on spaces) avoids the visual jolt of switching +//! styles as the cursor crosses word boundaries. use ratatui::{ prelude::*, @@ -12,6 +17,7 @@ use ratatui::{ use crate::{ app::{App, Mode}, game::{get_char_states, CharState}, + theme::ThemePalette, }; /// Top-level entry point for the typing screen. Splits the area into four @@ -29,13 +35,14 @@ pub fn render(f: &mut Frame, app: &App) { render_header(f, app, layout[0]); render_progress(f, app, layout[1]); render_words(f, app, layout[2]); - render_footer(f, layout[3]); + render_footer(f, app, layout[3]); } /// Renders the live WPM / accuracy / time / mode strip at the top of the screen. /// In zen mode this is intentionally minimal (no numbers — just a label). fn render_header(f: &mut Frame, app: &App, area: Rect) { let is_zen_mode = matches!(app.mode, Mode::Zen); + let theme = &app.theme; let timer_text = if let Some(remaining) = app.time_remaining() { format!("{:>3}s", remaining.as_secs()) @@ -45,31 +52,31 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { let header = if is_zen_mode { Line::from(vec![ - Span::styled(" zen ", Style::default().fg(Color::DarkGray)), - Span::styled(" · esc to finish", Style::default().fg(Color::DarkGray)), + Span::styled(" zen ", Style::default().fg(theme.pending)), + Span::styled(" · esc to finish", Style::default().fg(theme.pending)), ]) } else { Line::from(vec![ - Span::styled(" wpm ", Style::default().fg(Color::DarkGray)), + Span::styled(" wpm ", Style::default().fg(theme.pending)), Span::styled( format!("{:>3.0}", app.wpm()), Style::default() - .fg(Color::Yellow) + .fg(theme.secondary) .add_modifier(Modifier::BOLD), ), Span::raw(" "), - Span::styled("acc ", Style::default().fg(Color::DarkGray)), + Span::styled("acc ", Style::default().fg(theme.pending)), Span::styled( format!("{:>5.1}%", app.accuracy()), - Style::default().fg(Color::Green), + Style::default().fg(theme.correct), ), Span::raw(" "), - Span::styled("time ", Style::default().fg(Color::DarkGray)), - Span::styled(timer_text, Style::default().fg(Color::Cyan)), + Span::styled("time ", Style::default().fg(theme.pending)), + Span::styled(timer_text, Style::default().fg(theme.accent)), Span::raw(" "), Span::styled( format!("[{}]", app.mode.label()), - Style::default().fg(Color::Magenta), + Style::default().fg(theme.mode_tag), ), ]) }; @@ -77,7 +84,7 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { let header_paragraph = Paragraph::new(header).block( Block::default() .borders(Borders::BOTTOM) - .border_style(Style::default().fg(Color::DarkGray)), + .border_style(Style::default().fg(theme.pending)), ); f.render_widget(header_paragraph, area); } @@ -85,6 +92,7 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { /// Progress bar — words-typed / total for word modes, elapsed / total for time modes. /// Quote, code, zen and custom modes don't show a bar. fn render_progress(f: &mut Frame, app: &App, area: Rect) { + let gauge_color = app.theme.accent; if let Some((done, total)) = app.progress() { let ratio = if total == 0 { 0.0 @@ -93,7 +101,7 @@ fn render_progress(f: &mut Frame, app: &App, area: Rect) { }; let gauge = Gauge::default() .block(Block::default()) - .gauge_style(Style::default().fg(Color::Cyan)) + .gauge_style(Style::default().fg(gauge_color)) .ratio(ratio.min(1.0)) .label(format!("{} / {}", done, total)); f.render_widget(gauge, area); @@ -102,7 +110,7 @@ fn render_progress(f: &mut Frame, app: &App, area: Rect) { let ratio = (elapsed / total as f64).min(1.0); let gauge = Gauge::default() .block(Block::default()) - .gauge_style(Style::default().fg(Color::Cyan)) + .gauge_style(Style::default().fg(gauge_color)) .ratio(ratio) .label(format!("{:.0}s / {}s", elapsed, total)); f.render_widget(gauge, area); @@ -115,10 +123,10 @@ fn render_progress(f: &mut Frame, app: &App, area: Rect) { /// full control of where line breaks happen — important because each character /// has its own style. /// -/// The cursor never blinks: it is rendered as a REVERSED block on the -/// character it sits on, or as an underlined trailing space when it has passed -/// the end of the current word. Keeping the cursor steady avoids the -/// horizontal "jitter" that a phantom blinking character would cause. +/// The cursor never blinks: it is rendered as an underlined character (or +/// underlined trailing space when past the end of the current word) using +/// `theme.accent`. Keeping the cursor steady avoids the horizontal "jitter" +/// that a phantom blinking character would cause. fn render_words(f: &mut Frame, app: &App, area: Rect) { let mut wrapped_lines: Vec = vec![]; let mut current_line: Vec = vec![]; @@ -127,6 +135,9 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { let mut current_line_width = 0usize; let is_zen_mode = matches!(app.mode, Mode::Zen); let last_word_index = app.words.len().saturating_sub(1); + let cursor_style = Style::default() + .fg(app.theme.accent) + .add_modifier(Modifier::UNDERLINED); for (word_index, word) in app.words.iter().enumerate() { let char_states = get_char_states(&word.text, &word.typed); @@ -137,18 +148,13 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { let cursor_past_word_end = is_active_word && typed_char_count >= char_states.len(); // 1. Render every character (target + extras) with its state-driven style. - // If the cursor is on this char, overlay it with REVERSED for a block fill. + // If the cursor is on this char, overlay it with the cursor style. let mut word_spans: Vec = Vec::with_capacity(char_states.len() + 1); for (char_index, (ch, state)) in char_states.iter().enumerate() { let cursor_on_this_char = is_active_word && char_index == typed_char_count; - let base_style = style_for_char(*state, is_zen_mode); - // Cursor is always a cyan underline regardless of the character's - // own state. This matches the trailing-space cursor style exactly, - // giving a single consistent indicator throughout the session. + let base_style = style_for_char(*state, is_zen_mode, &app.theme); let style = if cursor_on_this_char { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) + cursor_style } else { base_style }; @@ -161,10 +167,7 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { // and is about to press space. let has_trailing_space = word_index < last_word_index; let space_style = if cursor_past_word_end { - // Plain underline — no fill, no blink, no width change. - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::UNDERLINED) + cursor_style } else { Style::default() }; @@ -203,11 +206,11 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { .block( Block::default() .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)) + .border_style(Style::default().fg(app.theme.pending)) .title(Span::styled( " typerush ", Style::default() - .fg(Color::Cyan) + .fg(app.theme.accent) .add_modifier(Modifier::BOLD), )), ); @@ -216,30 +219,35 @@ fn render_words(f: &mut Frame, app: &App, area: Rect) { /// Picks a foreground color/modifier for one character based on whether it /// was typed correctly, incorrectly, not yet typed, or typed as an extra. -/// Zen mode uses a softer, monochrome palette to keep the screen distraction-free. -fn style_for_char(state: CharState, is_zen_mode: bool) -> Style { +/// +/// 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 { if is_zen_mode { return match state { - CharState::Correct => Style::default().fg(Color::Gray), + CharState::Correct => Style::default().fg(theme.neutral), CharState::Incorrect | CharState::Extra => Style::default() - .fg(Color::DarkGray) + .fg(theme.pending) .add_modifier(Modifier::UNDERLINED), - CharState::Pending => Style::default().fg(Color::DarkGray), + CharState::Pending => Style::default().fg(theme.pending), }; } match state { - CharState::Correct => Style::default().fg(Color::Green), - CharState::Incorrect => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - CharState::Pending => Style::default().fg(Color::DarkGray), + CharState::Correct => Style::default().fg(theme.correct), + CharState::Incorrect => Style::default() + .fg(theme.incorrect) + .add_modifier(Modifier::BOLD), + CharState::Pending => Style::default().fg(theme.pending), CharState::Extra => Style::default() - .fg(Color::Red) + .fg(theme.extra) .add_modifier(Modifier::UNDERLINED), } } /// Tiny hint strip at the bottom of the screen. -fn render_footer(f: &mut Frame, area: Rect) { +fn render_footer(f: &mut Frame, app: &App, area: Rect) { let footer = Paragraph::new(" ctrl+r restart · esc menu · ctrl+c quit · ? help") - .style(Style::default().fg(Color::DarkGray)); + .style(Style::default().fg(app.theme.pending)); f.render_widget(footer, area); } From 28bc1b114f9cd4379c9fe65f0b9e7cbb4bd3d483 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:36:55 +0000 Subject: [PATCH 04/15] chore: bump to 0.2.0 + docs, example config, changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalizes v0.2.0: - Cargo.toml: version 0.1.0 → 0.2.0 - CHANGELOG.md: full Added / Changed / Fixed / Compatibility section for 0.2.0; clears [Unreleased]. Notes that the stats.json format and every v0.1 CLI flag are unchanged. - docs/ROADMAP.md: moves all four v0.2 line items into the shipped section. - docs/USAGE.md: new 'Configuration' section documenting theme picking, per-slot overrides, defaults, and CLI > config > built-in precedence. - README.md: surfaces themes in the features list, adds a short 'Make it yours' section linking to USAGE.md. - config.example.toml: documented starter config at repo root. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CHANGELOG.md | 50 +++++++++++++++++++++++++--------- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 22 ++++++++++++++- config.example.toml | 47 ++++++++++++++++++++++++++++++++ docs/ROADMAP.md | 18 ++++++++----- docs/USAGE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 config.example.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 060da37..eb6bde9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,21 +9,46 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] -### Fixed -- Cursor no longer flickers / shifts the line horizontally at the end of a - word. The cursor is now steady and always reserves a fixed-width slot. +_No unreleased changes yet._ + +--- + +## [0.2.0] — customization ### Added -- `ARCHITECTURE.md`, `ROADMAP.md`, `CONTRIBUTING.md`, `CHANGELOG.md`. -- Module-level (`//!`) and item-level (`///`) doc comments throughout the - codebase. -- GitHub issue & pull-request templates. -- Release workflow that builds binaries for Linux / macOS / Windows on every - `v*` tag. +- **Configuration file** at `~/.typerush/config.toml`. Entirely optional — a + missing or malformed file silently falls back to defaults. See + `config.example.toml` and `docs/USAGE.md` for the full schema. +- **Built-in themes**: `dark` (the existing look, still the default), `light`, + `monokai`, `dracula`. Pick one with `theme = "monokai"` in the config. +- **Per-slot color overrides** on top of any built-in theme. Nine themable + slots (`accent`, `secondary`, `correct`, `incorrect`, `pending`, `extra`, + `mode_tag`, `error`, `neutral`) cover every UI element. +- **Color parser** for `#RRGGBB` hex strings and the 16 ANSI color names + (case-insensitive, `_` / `-` separators tolerated). +- **`--theme ` CLI flag** for one-shot overrides. Wins over the config. +- **`--list-themes` CLI flag** prints every built-in theme name and exits — + useful for shell-completion scripts. +- **Default mode + per-mode defaults** in the config — pre-selects the + matching menu row at startup. +- 25 new unit tests covering color parsing, config deserialization, theme + resolution, override application, and CLI precedence. ### Changed -- README rewritten to be more approachable. Technical content moved into - dedicated docs. +- Zen mode now desaturates to theme-aware `pending` / `neutral` colors instead + of hardcoded grays, keeping the screen readable on light backgrounds. +- Help overlay lists the config file path alongside the stats file path. + +### Fixed +- Cursor no longer flickers / shifts the line horizontally at the end of a + word. The cursor is now steady and always reserves a fixed-width slot. + _(Carried over from the unreleased section of v0.1.)_ + +### Compatibility +- Existing CLI flags (`--time`, `--words`, `--quote`, `--code`, `--zen`, + `--file`) are unchanged. +- `~/.typerush/stats.json` format is unchanged — old session history loads + exactly as before. --- @@ -42,5 +67,6 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Unit tests for the matcher. - GitHub Actions CI: build + test + clippy on Linux, macOS, Windows. -[Unreleased]: https://github.com/withrvr/typerush/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/withrvr/typerush/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/withrvr/typerush/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/withrvr/typerush/releases/tag/v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index 0915bed..22e6e78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,7 +932,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "typerush" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 798ab5c..a088934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "typerush" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "In your terminal — a fast, cross-platform WPM typing trainer built with Rust" license = "MIT" diff --git a/README.md b/README.md index 8f3ba68..4848730 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ with a quote — TypeRush meets you where you already work: the command line. ## ✨ Why TypeRush - 🚀 **Instant feedback** — see your WPM and accuracy climb keystroke by keystroke -- 🎨 **Beautiful TUI** — clean colors, smooth layout, no distractions +- 🎨 **Themes built-in** — `dark`, `light`, `monokai`, `dracula`, or roll your own colors - ⏱ **Six built-in modes** — Time, Words, Quote, Code, Zen, and Custom-file - 💾 **Tracks your progress** — every session saved locally, with charts and a personal best - 🌍 **Runs everywhere** — Linux · macOS · Windows · WSL · Git Bash @@ -61,6 +61,8 @@ typerush --quote # one programming quote typerush --code rust # a real Rust snippet typerush --zen # zen mode — no timer, no pressure typerush --file my_text.txt # type anything you want +typerush --theme monokai # try a different theme +typerush --list-themes # see all built-in themes ``` --- @@ -101,6 +103,24 @@ shows your personal best, average accuracy, a trend sparkline, and your last --- +## 🎨 Make it yours + +Drop a [`config.toml`](config.example.toml) into `~/.typerush/` to pick a +theme, set your default mode, or override individual colors: + +```toml +theme = "monokai" + +[defaults] +mode = "time" +time_seconds = 30 +``` + +See [`docs/USAGE.md`](docs/USAGE.md#configuration--typerushconfigtoml) for +the full reference. + +--- + ## 📚 More documentation - 📖 [USAGE](docs/USAGE.md) — all keybindings, modes, CLI flags, config diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..fb12b86 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,47 @@ +# TypeRush — example configuration file +# +# Copy this file to `~/.typerush/config.toml` and edit to taste. +# TypeRush works fine without a config file — every setting below is optional. +# +# Precedence: CLI flags > this file > built-in defaults. + +# ----------------------------------------------------------------------------- +# Theme — pick one of the built-in palettes by name. +# Available names: dark, light, monokai, dracula +# Run `typerush --list-themes` to see what's currently shipped. +# ----------------------------------------------------------------------------- +theme = "monokai" + +# ----------------------------------------------------------------------------- +# Defaults — pre-selects a menu row and sets the initial mode when no CLI flag +# is given. CLI flags (--time, --words, --quote, --code, --zen) always win. +# ----------------------------------------------------------------------------- +[defaults] +# Which menu row to pre-select: "time" | "words" | "quote" | "code" | "zen" +mode = "time" +# Used when mode = "time". Standard menu rows: 15 / 30 / 60 / 120. +time_seconds = 15 +# Used when mode = "words". Standard menu rows: 10 / 25 / 50 / 100. +word_count = 25 +# Used when mode = "code". One of: rust | python | js +code_lang = "rust" + +# ----------------------------------------------------------------------------- +# Color overrides — applied ON TOP of the chosen theme. +# Any slot omitted here keeps the theme's value. +# Values may be: +# - a hex string like "#A6E22E" +# - one of the 16 ANSI names: black, red, green, yellow, blue, magenta, +# cyan, gray, dark_gray, light_red, light_green, light_yellow, +# light_blue, light_magenta, light_cyan, white +# ----------------------------------------------------------------------------- +# [colors] +# accent = "#66D9EF" # cursor, gauge, banner, time, titles +# secondary = "#E6DB74" # WPM number, section titles +# correct = "#A6E22E" # correctly-typed characters + accuracy % +# incorrect = "#F92672" # wrongly-typed characters +# pending = "#75715E" # untyped characters + muted labels/footers +# extra = "#FD971F" # surplus characters past end of word +# mode_tag = "#AE81FF" # [mode] badge in the header +# error = "#F92672" # error modal border +# neutral = "#F8F8F2" # emphasized neutral text (char totals) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 03147dd..852b51f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -47,6 +47,18 @@ KEY: ✅ done 🚧 in progress 🔲 planned 💡 idea --- +## ✅ Shipped (v0.2 — Customization) + +- ✅ `~/.typerush/config.toml` for themes and defaults +- ✅ Named-color + hex (`#RRGGBB`) theme support +- ✅ Default word count / time / mode picked from config +- ✅ Light theme +- ✅ Built-in themes: `dark`, `light`, `monokai`, `dracula` +- ✅ Per-slot color overrides on top of any built-in theme +- ✅ `--theme ` CLI override + `--list-themes` discovery flag + +--- + ## 🚧 In progress - 🚧 Pre-built binary releases on GitHub @@ -56,12 +68,6 @@ KEY: ✅ done 🚧 in progress 🔲 planned 💡 idea ## 🔲 Planned -### v0.2 — Customization -- 🔲 `~/.typerush/config.toml` for themes and defaults -- 🔲 Named-color + hex (`#RRGGBB`) theme support -- 🔲 Default word count / time picked from config -- 🔲 Light theme - ### v0.3 — Smarter stats - 🔲 Per-key accuracy heatmap (find your problem keys) - 🔲 Per-mode personal bests (separate PB for time-30s vs words-50) diff --git a/docs/USAGE.md b/docs/USAGE.md index 8014580..d8a0b7f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -21,6 +21,8 @@ typerush --quote # one programming quote typerush --code rust # code-typing: rust | python | js typerush --zen # zen mode (no timer, no stats) typerush --file # type any text file you have +typerush --theme monokai # one-shot theme override +typerush --list-themes # print available theme names and exit ``` You can also pass `--file` alone to use the file inside the menu's "Custom" entry. @@ -111,6 +113,70 @@ Zen-mode sessions are intentionally **not** saved. --- +## Configuration — `~/.typerush/config.toml` + +The config file is **entirely optional**. Without it, TypeRush boots with the +`dark` theme and a 15-second time-mode pre-selected. + +A complete example lives at [`config.example.toml`](../config.example.toml) at +the project root — copy it to `~/.typerush/config.toml` and edit. + +### Themes + +Pick a built-in palette by name: + +```toml +theme = "monokai" +``` + +Built-in themes (run `typerush --list-themes` to print them): + +| Name | Style | +| --------- | -------------------------------------------------- | +| `dark` | Default — cyan / green / yellow on a dark terminal | +| `light` | Softer palette for light-background terminals | +| `monokai` | Classic Sublime/TextMate — pink/green/yellow | +| `dracula` | Purple/pink/cyan on `#282a36` | + +### Per-slot color overrides + +Override any slot of the chosen theme: + +```toml +theme = "dracula" + +[colors] +accent = "#FF00FF" # hex +correct = "green" # or ANSI name +``` + +The 9 slots: `accent`, `secondary`, `correct`, `incorrect`, `pending`, +`extra`, `mode_tag`, `error`, `neutral`. Each maps to a specific UI element — +see `config.example.toml` for inline documentation. + +### Defaults + +Pre-select a menu row and starting mode: + +```toml +[defaults] +mode = "time" # time | words | quote | code | zen +time_seconds = 15 # 15 / 30 / 60 / 120 (matches menu rows) +word_count = 25 # 10 / 25 / 50 / 100 +code_lang = "rust" # rust | python | js +``` + +### Precedence + +``` +CLI flag (--theme, --time, …) > config.toml > built-in default +``` + +A malformed config file does not crash TypeRush — it falls back to defaults +and shows one error modal you can dismiss with any key. + +--- + ## Terminal compatibility | Terminal | Status | From c8abcee2f18f1143e2e305cd7d00780082496b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 09:42:41 +0000 Subject: [PATCH 05/15] test: add review-driven coverage for menu match + config example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the three actionable items from the Phase 3 review: - config: include_str!-based round-trip test for config.example.toml. Catches schema drift the moment someone renames a slot or adds a field under deny_unknown_fields without updating the example. - config: multi-warning accumulation test for apply_color_overrides. - app: 5 tests for best_menu_match — exact time/words hits, family fallback (time_seconds = 45 lands on the first time row, not on something unrelated), and the zero-tier modes (Zen). - menu: comment explaining why highlight foreground stays Color::Black rather than going through the palette. All four built-in themes pair accent with a bright color, so black-on-accent is the high-contrast choice; users who override accent to something dark can also override. Tests: 29 → 36 passed. Clippy and fmt unchanged. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/app.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/config/load.rs | 16 ++++++++++++++++ src/config/mod.rs | 13 +++++++++++++ src/ui/menu.rs | 4 ++++ 4 files changed, 75 insertions(+) diff --git a/src/app.rs b/src/app.rs index fb3c8cb..105edf1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -564,3 +564,45 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn best_menu_match_exact_time() { + let menu = default_menu(); + let index = best_menu_match(&menu, DefaultMode::Time(30)); + assert_eq!(menu[index].label, "Time · 30s"); + } + + #[test] + fn best_menu_match_exact_words() { + let menu = default_menu(); + let index = best_menu_match(&menu, DefaultMode::Words(100)); + assert_eq!(menu[index].label, "Words · 100"); + } + + #[test] + fn best_menu_match_falls_back_to_first_time_row() { + // 45 isn't one of the four standard time rows; we expect the first + // time row ("Time · 15s") rather than something unrelated. + let menu = default_menu(); + let index = best_menu_match(&menu, DefaultMode::Time(45)); + assert_eq!(menu[index].label, "Time · 15s"); + } + + #[test] + fn best_menu_match_falls_back_to_first_words_row() { + let menu = default_menu(); + let index = best_menu_match(&menu, DefaultMode::Words(7)); + assert_eq!(menu[index].label, "Words · 10"); + } + + #[test] + fn best_menu_match_picks_zen_row() { + let menu = default_menu(); + let index = best_menu_match(&menu, DefaultMode::Zen); + assert_eq!(menu[index].label, "Zen"); + } +} diff --git a/src/config/load.rs b/src/config/load.rs index e481b94..199e02a 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -311,6 +311,22 @@ mod tests { assert!(!warnings.is_empty()); } + #[test] + fn multiple_bad_colors_accumulate_warnings() { + let raw = Config { + colors: Some(Colors { + accent: Some("not-a-color".into()), + correct: Some("#GG0000".into()), + ..Default::default() + }), + ..Default::default() + }; + let (_, warnings) = resolve(raw, None); + assert_eq!(warnings.len(), 2); + assert!(warnings.iter().any(|w| w.contains("accent"))); + assert!(warnings.iter().any(|w| w.contains("correct"))); + } + #[test] fn defaults_code_mode_picks_language() { let raw = Config { diff --git a/src/config/mod.rs b/src/config/mod.rs index ba2398f..14ccda8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -132,4 +132,17 @@ not_a_real_slot = "red" "#; assert!(toml::from_str::(raw).is_err()); } + + /// Regression guard: the example config we ship at the repo root must + /// parse cleanly through the same deserializer the runtime uses. If + /// someone adds a slot or renames a field, this fails before users see it. + #[test] + fn shipped_example_config_round_trips() { + let example = include_str!("../../config.example.toml"); + let cfg: Config = toml::from_str(example).expect("config.example.toml must parse"); + assert_eq!(cfg.theme.as_deref(), Some("monokai")); + let defaults = cfg.defaults.expect("defaults section"); + assert_eq!(defaults.mode.as_deref(), Some("time")); + assert_eq!(defaults.time_seconds, Some(15)); + } } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 02f51b0..c9605bd 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -61,6 +61,10 @@ pub fn render(f: &mut Frame, app: &App) { .add_modifier(Modifier::BOLD), )), ) + // Black on accent gives high contrast on every built-in theme since + // each one's `accent` is a bright color. A user who overrides accent + // to something dark would lose readability here — at that point they + // can override the slot. .highlight_style( Style::default() .fg(Color::Black) From fe6e9d61ce0001b15c336fcfae25cc13e9d9135d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 16:42:57 +0000 Subject: [PATCH 06/15] fix(app): custom-file mode auto-finishes on last word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mode::Custom was missing from the completion check in submit_word(), so after typing every word the session waited for Esc — contradicting the 'ends when you complete the last word' line in docs/USAGE.md. One-line addition to the matches!() guard, plus a regression test that drives a 2-word custom session to completion and asserts the app lands on Screen::Results. 37 tests passing. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CHANGELOG.md | 3 +++ src/app.rs | 25 +++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6bde9..bb8f5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ _No unreleased changes yet._ - Cursor no longer flickers / shifts the line horizontally at the end of a word. The cursor is now steady and always reserves a fixed-width slot. _(Carried over from the unreleased section of v0.1.)_ +- **Custom-file mode (`--file `) now auto-finishes** when the user + types the last word, matching the documented behavior in + `docs/USAGE.md`. Previously the session sat waiting for `Esc`. ### Compatibility - Existing CLI flags (`--time`, `--words`, `--quote`, `--code`, `--zen`, diff --git a/src/app.rs b/src/app.rs index 105edf1..1b9f6a1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -541,8 +541,9 @@ impl App { return; } } - // Quote / code modes: stop when there's nothing left to type. - if matches!(self.mode, Mode::Quote | Mode::Code(_)) && self.current_word >= self.words.len() + // Quote / code / custom-file modes: stop when there's nothing left to type. + if matches!(self.mode, Mode::Quote | Mode::Code(_) | Mode::Custom) + && self.current_word >= self.words.len() { self.finish_game(); } @@ -605,4 +606,24 @@ mod tests { let index = best_menu_match(&menu, DefaultMode::Zen); assert_eq!(menu[index].label, "Zen"); } + + /// Regression: custom-file mode must auto-finish when the user completes + /// the last word. Previously it sat waiting for Esc, contradicting the + /// documented behavior in docs/USAGE.md. + #[test] + fn custom_mode_finishes_after_last_word() { + let palette = crate::theme::ThemePalette::default(); + let mut app = App::new(None, palette, DefaultMode::Time(15)); + app.mode = Mode::Custom; + app.words = vec![Word::new("hi".into()), Word::new("bye".into())]; + app.screen = Screen::Typing; + + // Type "hi" + space + "bye" + space. + for ch in "hi bye ".chars() { + app.handle_char(ch); + } + + assert_eq!(app.screen, Screen::Results); + assert!(app.ended_at.is_some()); + } } From bef6d0d53296aedd488c2ef04fd13aa766bc80a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 16:56:45 +0000 Subject: [PATCH 07/15] docs(roadmap): promote custom-file menu UX into v0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three concrete items to v0.4 — More content & menu UX: - snippet library at ~/.typerush/snippets/ (was in 💡 ideas) - 'Custom' menu row + last-picked-file memory - visual section gaps in the menu Renames the v0.4 section heading to '— More content & menu UX' to reflect the broadened scope. Removes the snippet-library line from the 💡 ideas section since it's now committed. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- docs/ROADMAP.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 852b51f..377e032 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -74,11 +74,14 @@ KEY: ✅ done 🚧 in progress 🔲 planned 💡 idea - 🔲 Daily streak counter - 🔲 Average WPM over the last 7 / 30 days -### v0.4 — More content +### v0.4 — More content & menu UX - 🔲 Bigger English word pool (10k) - 🔲 Programming-symbols mode (focus on `(){};=>` etc.) - 🔲 More languages: Go, Java, SQL, Shell - 🔲 Punctuation / numbers toggle +- 🔲 Custom snippet library: drop `.txt` files in `~/.typerush/snippets/` +- 🔲 "Custom" menu row + last-picked-file memory in `~/.typerush/state.json` +- 🔲 Visual section gaps in the main menu (Time / Words / Quote / Code / Zen groups) ### v0.5 — Quality of life - 🔲 Pause / resume mid-session @@ -94,7 +97,6 @@ KEY: ✅ done 🚧 in progress 🔲 planned 💡 idea - 💡 Per-finger heatmap (left vs right hand, weak fingers) - 💡 Adaptive practice: re-roll words containing your worst keys - 💡 Webhook to post your PB to Discord/Slack -- 💡 Custom snippet library: drop `.txt` files in `~/.typerush/snippets/` - 💡 Voice-over for accessibility Got an idea that should be on this list? [Open an issue](https://github.com/withrvr/typerush/issues/new/choose). From ff70d97daebb1232f784786cd43c85283105ce12 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:25:40 +0000 Subject: [PATCH 08/15] docs(roadmap): add in-app + CLI config management to v0.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new bullets under 'v0.5 — Quality of life': - typerush config get/set/show/reset/path — scriptable CLI for editing ~/.typerush/config.toml without opening it. - In-app Settings screen — theme picker, default mode picker, reset-to-defaults. Navigable from the main menu. - typerush --init-config — writes a starter config.toml so new users don't have to copy from config.example.toml manually. Existing v0.5 items (pause/resume, replay, CSV export, daily challenge) unchanged. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- docs/ROADMAP.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 377e032..6e32212 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -88,6 +88,9 @@ KEY: ✅ done 🚧 in progress 🔲 planned 💡 idea - 🔲 Replay a recent session word-for-word - 🔲 Export stats as CSV - 🔲 Daily challenge (deterministic seed-of-the-day) +- 🔲 `typerush config get/set/show/reset/path` CLI subcommands (edit `~/.typerush/config.toml` without opening it) +- 🔲 In-app Settings screen — theme picker, default mode picker, reset-to-defaults +- 🔲 `typerush --init-config` writes a starter `config.toml` to `~/.typerush/` --- From cac8fdf78ee0cc56d299b281d43b3342b7094087 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:32:24 +0000 Subject: [PATCH 09/15] docs: add docs/DEVELOPMENT.md for local-dev workflow Splits the practical 'how do I run / test / iterate locally' content out of CONTRIBUTING.md (which keeps coding conventions, commit style, PR flow) into a dedicated dev guide. DEVELOPMENT.md covers what wasn't previously written down in any one place: - Debug vs release profile, when to use which - Passing CLI flags through cargo run -- (the -- separator) - Running ./target/debug or ./target/release directly - HOME=/tmp/sandbox redirect for safely testing config.toml without touching the real ~/.typerush/ - Testing malformed configs to verify graceful fallback - Test filtering, --nocapture, --test-threads - Quality gates that mirror CI - cargo-watch with the TUI-specific caveat (re-launch fights the terminal) and the recommended two-terminal loop - A few task-shaped loops (tweaking a palette, debugging matcher behavior, testing config + theme together) CONTRIBUTING.md's old 'Running during development' subsection now points at DEVELOPMENT.md with a 3-line quick-reference instead of duplicating the content. README's doc-link list includes the new file. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CONTRIBUTING.md | 15 ++-- README.md | 1 + docs/DEVELOPMENT.md | 204 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 docs/DEVELOPMENT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8186ffe..ec92da8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,17 +63,16 @@ are incremental and finish in under a second. ### Running during development -```bash -cargo run # debug build, opens the menu -cargo run -- --time 30 # pass any CLI flag through -cargo watch -x run # rerun on every save -``` +For the full local-dev workflow — running with CLI args, debug vs release, +testing the config file safely, `cargo-watch` patterns for TUI apps, the +recommended two-terminal loop — see [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md). -For a release build: +Quick reference: ```bash -cargo build --release -./target/release/typerush +cargo run # debug build, opens the menu +cargo run -- --time 30 # pass any CLI flag through +cargo build --release && ./target/release/typerush ``` --- diff --git a/README.md b/README.md index 4848730..0ecee15 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ the full reference. - 📖 [USAGE](docs/USAGE.md) — all keybindings, modes, CLI flags, config - 🏛 [ARCHITECTURE](docs/ARCHITECTURE.md) — how the code is laid out +- 🛠 [DEVELOPMENT](docs/DEVELOPMENT.md) — local dev workflow, cargo-watch, sandbox config - 🗺 [ROADMAP](docs/ROADMAP.md) — what's done, what's next - 🤝 [CONTRIBUTING](CONTRIBUTING.md) — dev setup, conventions, how to help - 📝 [CHANGELOG](CHANGELOG.md) — what changed in every release diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..1cd888e --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,204 @@ +# TypeRush — Development Guide + +Practical local-dev workflow for iterating on TypeRush. For project layout, +coding conventions, commit style and PR flow see +[`CONTRIBUTING.md`](../CONTRIBUTING.md). For runtime behavior and the config +schema see [`USAGE.md`](USAGE.md). + +--- + +## Running the app from source + +```bash +cargo run # debug build, opens the menu +``` + +Pass CLI flags **after a `--` separator** — that's what tells cargo to forward +the rest to typerush instead of consuming them itself: + +```bash +cargo run -- --help +cargo run -- --time 30 +cargo run -- --theme monokai +cargo run -- --list-themes +cargo run -- --file ./snippets/lorem.txt +``` + +### Debug vs release + +| Profile | Compile speed | Runtime speed | When to use | +|---------|---------------|---------------|-------------| +| `cargo run` (debug) | fast (<1s incremental) | slower | day-to-day code edits | +| `cargo run --release` | slower (~30s clean) | optimized | feels like the real installed app, screenshots, perf checks | + +```bash +cargo run --release -- --theme dracula +``` + +### Running the compiled binary directly + +After a build, the binary sits at one of these paths — you can invoke it +without going through cargo: + +```bash +./target/debug/typerush --theme monokai +./target/release/typerush --theme monokai # after `cargo build --release` +``` + +--- + +## Testing the config-file flow safely + +The loader reads `~/.typerush/config.toml`. To experiment without touching +your real config, **redirect `$HOME` for one command** — typerush will then +look at a sandbox directory you control: + +```bash +mkdir -p /tmp/tr-test/.typerush +cp config.example.toml /tmp/tr-test/.typerush/config.toml +# edit /tmp/tr-test/.typerush/config.toml as you like, then: +HOME=/tmp/tr-test cargo run +``` + +Use the release binary the same way: + +```bash +HOME=/tmp/tr-test ./target/release/typerush --theme monokai +``` + +Clean up afterwards: + +```bash +rm -rf /tmp/tr-test +``` + +Your real `~/.typerush/` (stats + any config you keep) stays untouched. + +### Quickly testing malformed configs + +The loader is supposed to fall back gracefully instead of crashing. To verify: + +```bash +echo "this is not valid toml = {{" > /tmp/tr-test/.typerush/config.toml +HOME=/tmp/tr-test cargo run +``` + +You should land on the menu with an error modal you can dismiss with any key. + +--- + +## Running tests + +```bash +cargo test # every test +cargo test theme # only theme::* tests (substring filter) +cargo test game::tests::all_correct # one specific test +cargo test -- --nocapture # show println! output from tests +cargo test -- --test-threads=1 # run sequentially (rare, for flaky debug) +``` + +Filters are case-sensitive substrings against `module::test_name`. + +--- + +## Quality gates (mirror CI) + +Run these before committing — CI runs the exact same set on Linux, macOS, +and Windows: + +```bash +cargo build # type-check +cargo test # unit tests +cargo clippy -- -D warnings # lints (warnings → errors) +cargo fmt --check # formatting +``` + +To auto-fix formatting: + +```bash +cargo fmt +``` + +--- + +## Auto-rebuild on save (optional) + +[`cargo-watch`](https://crates.io/crates/cargo-watch) re-runs a command every +time a tracked file changes. Install it once: + +```bash +cargo install cargo-watch # global tool — not a project dep +``` + +### Patterns that work well + +```bash +cargo watch -x test # rerun tests on save +cargo watch -x check # type-check only +cargo watch -x test -x 'clippy -- -D warnings' # chained +cargo watch -x 'run -- --theme monokai' # rerun the app (see caveat) +``` + +### Caveat for TUI apps + +`cargo watch -x run` keeps **re-launching** the binary each time you save — +which fights you for the terminal and kills any session in progress. For +TypeRush you almost always want to keep `cargo watch` on tests / lints and +launch the app **manually** in another terminal. + +### The recommended two-terminal loop + +```bash +# Terminal 1 — keep gates green on every save +cargo watch -x test -x 'clippy -- -D warnings' + +# Terminal 2 — run the app yourself when you want to try a change +cargo run -- --theme monokai +# Ctrl+C to exit, ↑ + Enter in the shell to re-run +``` + +This separation lets you keep the test feedback loop tight without losing the +typing session every time you save a UI tweak. + +--- + +## A practical loop for a few common tasks + +### Tweaking a theme palette + +```bash +# Terminal 1 +cargo watch -x test + +# Terminal 2 +cargo run --release -- --theme monokai +# edit src/theme/builtin.rs, Ctrl+C, re-run +``` + +Release profile makes the colors look the same as the installed app. + +### Testing config + theme together + +```bash +HOME=/tmp/tr-test cargo run --release +# edit /tmp/tr-test/.typerush/config.toml in a third terminal, Ctrl+C, re-run +``` + +### Debugging a wrong-character behavior + +```bash +cargo test game:: # the matcher tests +cargo test -- --nocapture # if you've added eprintln!s +``` + +--- + +## Cleaning up + +```bash +cargo clean # nuke ./target — frees several hundred MB +rm -rf /tmp/tr-test # any sandbox config you created +``` + +You almost never need `cargo clean` — incremental builds handle dependency +changes fine. Run it if a strange compile error refuses to go away. From 60b0c42c7d2c9c55951ca0b0b1f758f4666ba093 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 17:52:49 +0000 Subject: [PATCH 10/15] fix(theme): paint canonical backgrounds per theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier in v0.2.0 the light theme only swapped foreground colors — on a dark terminal it still looked dark, just with different text colors. Same problem for monokai and dracula: they were branded by name but adopted the user's terminal background, so they only looked correct on terminals that happened to match. Fix: - ThemePalette gains a 'background: Color' slot. - Built-in palettes set authentic backgrounds: dark → Color::Reset (terminal default — preserves v0.1 look) light → #FAFAFA (off-white) monokai → #272822 (canonical) dracula → #282A36 (canonical) - ui/mod.rs::render paints the whole frame with the theme background before any screen renders. Subsequent widgets render on top — text cells show fg, empty cells show the painted bg. - light theme's 'pending' color shifted from a near-white shade to a medium gray so it stays readable on white. - Config's [colors] section accepts a 'background' field. The example config and docs/USAGE.md document the new slot, with a callout explaining the Color::Reset semantics on the dark theme. Tests: 37 → 39 (added dark_background_is_reset and non_dark_themes_paint_explicit_backgrounds). Clippy and fmt clean. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CHANGELOG.md | 8 ++++++-- config.example.toml | 20 +++++++++++--------- docs/USAGE.md | 13 ++++++++++--- src/config/load.rs | 1 + src/config/mod.rs | 1 + src/theme/builtin.rs | 32 +++++++++++++++++++++++++++++--- src/theme/mod.rs | 7 +++++++ src/ui/mod.rs | 17 +++++++++++++++-- 8 files changed, 80 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8f5bd..bcb50be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,13 @@ _No unreleased changes yet._ `config.example.toml` and `docs/USAGE.md` for the full schema. - **Built-in themes**: `dark` (the existing look, still the default), `light`, `monokai`, `dracula`. Pick one with `theme = "monokai"` in the config. -- **Per-slot color overrides** on top of any built-in theme. Nine themable + Each theme paints its own canonical background — light is genuinely light + even on a dark terminal, monokai and dracula look like the real schemes + regardless of host terminal. Dark uses `Color::Reset` so the user's + terminal background still shines through. +- **Per-slot color overrides** on top of any built-in theme. Ten themable slots (`accent`, `secondary`, `correct`, `incorrect`, `pending`, `extra`, - `mode_tag`, `error`, `neutral`) cover every UI element. + `mode_tag`, `error`, `neutral`, `background`) cover every UI element. - **Color parser** for `#RRGGBB` hex strings and the 16 ANSI color names (case-insensitive, `_` / `-` separators tolerated). - **`--theme ` CLI flag** for one-shot overrides. Wins over the config. diff --git a/config.example.toml b/config.example.toml index fb12b86..fcf0b85 100644 --- a/config.example.toml +++ b/config.example.toml @@ -36,12 +36,14 @@ code_lang = "rust" # light_blue, light_magenta, light_cyan, white # ----------------------------------------------------------------------------- # [colors] -# accent = "#66D9EF" # cursor, gauge, banner, time, titles -# secondary = "#E6DB74" # WPM number, section titles -# correct = "#A6E22E" # correctly-typed characters + accuracy % -# incorrect = "#F92672" # wrongly-typed characters -# pending = "#75715E" # untyped characters + muted labels/footers -# extra = "#FD971F" # surplus characters past end of word -# mode_tag = "#AE81FF" # [mode] badge in the header -# error = "#F92672" # error modal border -# neutral = "#F8F8F2" # emphasized neutral text (char totals) +# accent = "#66D9EF" # cursor, gauge, banner, time, titles +# secondary = "#E6DB74" # WPM number, section titles +# correct = "#A6E22E" # correctly-typed characters + accuracy % +# incorrect = "#F92672" # wrongly-typed characters +# pending = "#75715E" # untyped characters + muted labels/footers +# extra = "#FD971F" # surplus characters past end of word +# mode_tag = "#AE81FF" # [mode] badge in the header +# error = "#F92672" # error modal border +# neutral = "#F8F8F2" # emphasized neutral text (char totals) +# background = "#272822" # full-screen background fill +# # set to "reset" to use the terminal's native bg diff --git a/docs/USAGE.md b/docs/USAGE.md index d8a0b7f..4534421 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -150,9 +150,16 @@ accent = "#FF00FF" # hex correct = "green" # or ANSI name ``` -The 9 slots: `accent`, `secondary`, `correct`, `incorrect`, `pending`, -`extra`, `mode_tag`, `error`, `neutral`. Each maps to a specific UI element — -see `config.example.toml` for inline documentation. +The 10 slots: `accent`, `secondary`, `correct`, `incorrect`, `pending`, +`extra`, `mode_tag`, `error`, `neutral`, `background`. Each maps to a +specific UI element — see `config.example.toml` for inline documentation. + +> **Background.** The `dark` theme uses `Color::Reset` for `background` so it +> picks up whatever your terminal's native background is — same look as v0.1. +> The `light`, `monokai`, and `dracula` themes paint their canonical +> backgrounds so the theme looks the same regardless of terminal. Set +> `background = "reset"` in `[colors]` if you'd rather one of those themes +> use your terminal background too. ### Defaults diff --git a/src/config/load.rs b/src/config/load.rs index 199e02a..470b996 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -161,6 +161,7 @@ fn apply_color_overrides( try_push("mode_tag", &overrides.mode_tag, warnings); try_push("error", &overrides.error, warnings); try_push("neutral", &overrides.neutral, warnings); + try_push("background", &overrides.background, warnings); base.with_overrides(&parsed) } diff --git a/src/config/mod.rs b/src/config/mod.rs index 14ccda8..a1552b9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -66,6 +66,7 @@ pub struct Colors { pub mode_tag: Option, pub error: Option, pub neutral: Option, + pub background: Option, } #[cfg(test)] diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index 74b5a8f..8bb0da0 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -10,6 +10,10 @@ use super::ThemePalette; /// The original TypeRush look — cyan/green/yellow on a black terminal. /// This is the default when no `theme = ...` is set in the config. +/// +/// `background = Color::Reset` keeps the user's terminal background untouched, +/// so anyone who picks "dark" on e.g. a solarized-dark terminal still gets +/// their terminal's chrome — no surprise color change. pub const DARK: ThemePalette = ThemePalette { accent: Color::Cyan, secondary: Color::Yellow, @@ -20,22 +24,26 @@ pub const DARK: ThemePalette = ThemePalette { mode_tag: Color::Magenta, error: Color::Red, neutral: Color::White, + background: Color::Reset, }; /// Softer palette intended for terminals with a light background. +/// Forces an off-white background so the theme actually feels "light" even +/// when the user's terminal itself is dark. pub const LIGHT: ThemePalette = ThemePalette { accent: Color::Rgb(0x01, 0x84, 0xBC), // deep cyan secondary: Color::Rgb(0xC1, 0x84, 0x01), // amber correct: Color::Rgb(0x50, 0xA1, 0x4F), // muted green incorrect: Color::Rgb(0xE4, 0x56, 0x49), // muted red - pending: Color::Rgb(0xA0, 0xA1, 0xA7), // light gray + pending: Color::Rgb(0x80, 0x80, 0x88), // medium gray — readable on white extra: Color::Rgb(0xE4, 0x56, 0x49), mode_tag: Color::Rgb(0xA6, 0x26, 0xA4), // purple error: Color::Rgb(0xCA, 0x12, 0x43), neutral: Color::Rgb(0x38, 0x3A, 0x42), // near-black for light bg + background: Color::Rgb(0xFA, 0xFA, 0xFA), // off-white }; -/// Classic Monokai — pink/green/yellow on a warm dark backdrop. +/// Classic Monokai — pink/green/yellow on the canonical warm dark backdrop. pub const MONOKAI: ThemePalette = ThemePalette { accent: Color::Rgb(0x66, 0xD9, 0xEF), // monokai cyan secondary: Color::Rgb(0xE6, 0xDB, 0x74), // monokai yellow @@ -46,9 +54,10 @@ pub const MONOKAI: ThemePalette = ThemePalette { mode_tag: Color::Rgb(0xAE, 0x81, 0xFF), // purple error: Color::Rgb(0xF9, 0x26, 0x72), neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // monokai foreground + background: Color::Rgb(0x27, 0x28, 0x22), // canonical monokai background }; -/// Dracula — purple/pink/cyan on `#282a36`. +/// Dracula — purple/pink/cyan on the canonical `#282a36` background. pub const DRACULA: ThemePalette = ThemePalette { accent: Color::Rgb(0x8B, 0xE9, 0xFD), // dracula cyan secondary: Color::Rgb(0xF1, 0xFA, 0x8C), // dracula yellow @@ -59,6 +68,7 @@ pub const DRACULA: ThemePalette = ThemePalette { mode_tag: Color::Rgb(0xFF, 0x79, 0xC6), // dracula pink error: Color::Rgb(0xFF, 0x55, 0x55), neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // dracula foreground + background: Color::Rgb(0x28, 0x2A, 0x36), // canonical dracula background }; /// `(name, palette)` for every built-in theme. @@ -117,4 +127,20 @@ mod tests { let default_palette: ThemePalette = ThemePalette::default(); assert_eq!(default_palette, DARK); } + + /// Dark theme must not paint a background — the user's terminal bg shines + /// through, preserving the v0.1 look on every terminal. + #[test] + fn dark_background_is_reset() { + assert_eq!(DARK.background, Color::Reset); + } + + /// Light/monokai/dracula must paint their own background so the theme + /// looks the same regardless of the host terminal. + #[test] + fn non_dark_themes_paint_explicit_backgrounds() { + assert_ne!(LIGHT.background, Color::Reset); + assert_eq!(MONOKAI.background, Color::Rgb(0x27, 0x28, 0x22)); + assert_eq!(DRACULA.background, Color::Rgb(0x28, 0x2A, 0x36)); + } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 2a4c539..4ed0861 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -41,6 +41,12 @@ pub struct ThemePalette { /// Maps to the terminal's natural foreground on each theme (white on dark, /// near-black on light) so it stays readable on every background. pub neutral: Color, + /// Whole-screen background fill. Set to `Color::Reset` to leave the + /// terminal's native background alone (used by the `dark` theme so it + /// behaves identically to v0.1). Other themes paint their canonical + /// background so the theme looks the same regardless of which terminal + /// the user is on. + pub background: Color, } impl ThemePalette { @@ -61,6 +67,7 @@ impl ThemePalette { "mode_tag" => self.mode_tag = *color, "error" => self.error = *color, "neutral" => self.neutral = *color, + "background" => self.background = *color, _ => {} } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 611f62f..ef88531 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,13 @@ //! function. The top-level `render` here just looks at `app.screen` and calls //! the right one. The Help overlay and any error modal are drawn on top of //! whatever screen is underneath. +//! +//! Before any screen renders, we paint the whole frame area with the active +//! theme's background. This is what makes the `light` theme actually look +//! light on a dark terminal (and what gives `monokai` / `dracula` their +//! canonical backgrounds regardless of terminal config). The `dark` theme +//! uses `Color::Reset` here so the user's terminal background shines through +//! exactly as it did in v0.1. pub mod help; pub mod menu; @@ -11,12 +18,18 @@ pub mod results; pub mod stats; pub mod typing; -use ratatui::Frame; +use ratatui::{prelude::*, widgets::Block, Frame}; use crate::app::{App, Screen}; /// Single entry point called once per frame from the main event loop. pub fn render(frame: &mut Frame, app: &App) { + // 1. Paint the theme's background across the entire screen. + let area = frame.area(); + let background_style = Style::default().bg(app.theme.background); + frame.render_widget(Block::default().style(background_style), area); + + // 2. Render the active screen on top of the painted background. match app.screen { Screen::Menu => menu::render(frame, app), Screen::Typing => typing::render(frame, app), @@ -24,7 +37,7 @@ pub fn render(frame: &mut Frame, app: &App) { Screen::Stats => stats::render(frame, app), Screen::Help => help::render(frame, app), } - // Error overlay sits on top of everything else when present. + // 3. Error overlay sits on top of everything else when present. if let Some(message) = &app.error_message { help::render_error(frame, &app.theme, message); } From 3f27276e0384a4d5ec2a5ff146d9c18c5e7fac0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 18:09:46 +0000 Subject: [PATCH 11/15] fix(ui): full-cell bg fill, banner top padding, readable light theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes after the v0.2.0 background work shipped: 1. UI dispatcher now paints the background via buffer.set_style on every cell, instead of rendering a no-border Block widget. The Block path was leaving thin unpainted stripes around the edge of the screen on some terminals; the direct buffer write is unconditional. (Note: terminal-side window padding — e.g. Windows Terminal's default 8px — is still outside ratatui's reach. Module docs now call that out so users know to adjust their terminal profile if a thin stripe remains.) 2. Menu screen: added a 2-row top spacer before the TYPERUSH banner, so it no longer hugs the terminal title bar / tab strip. 3. Light theme: darkened every foreground slot so the palette is readable on the off-white background. The previous values were carried over almost verbatim from the dark theme and washed out on white — especially the medium gray 'pending' which was nearly invisible. New values give roughly 4.5:1+ contrast against the #FAFAFA background across all slots. Tests still pass (39), clippy clean, fmt clean. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/theme/builtin.rs | 22 ++++++++++++---------- src/ui/menu.rs | 16 ++++++++++------ src/ui/mod.rs | 25 ++++++++++++++++--------- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs index 8bb0da0..19e4ad4 100644 --- a/src/theme/builtin.rs +++ b/src/theme/builtin.rs @@ -29,17 +29,19 @@ pub const DARK: ThemePalette = ThemePalette { /// Softer palette intended for terminals with a light background. /// Forces an off-white background so the theme actually feels "light" even -/// when the user's terminal itself is dark. +/// when the user's terminal itself is dark. Foreground colors are +/// deliberately darkened compared to the dark-theme equivalents — anything +/// too pale would wash out against the white background. pub const LIGHT: ThemePalette = ThemePalette { - accent: Color::Rgb(0x01, 0x84, 0xBC), // deep cyan - secondary: Color::Rgb(0xC1, 0x84, 0x01), // amber - correct: Color::Rgb(0x50, 0xA1, 0x4F), // muted green - incorrect: Color::Rgb(0xE4, 0x56, 0x49), // muted red - pending: Color::Rgb(0x80, 0x80, 0x88), // medium gray — readable on white - extra: Color::Rgb(0xE4, 0x56, 0x49), - mode_tag: Color::Rgb(0xA6, 0x26, 0xA4), // purple - error: Color::Rgb(0xCA, 0x12, 0x43), - neutral: Color::Rgb(0x38, 0x3A, 0x42), // near-black for light bg + accent: Color::Rgb(0x01, 0x6F, 0xA0), // deeper cyan — more contrast on white + secondary: Color::Rgb(0xA0, 0x6E, 0x00), // darker amber + correct: Color::Rgb(0x3F, 0x82, 0x3F), // darker green + incorrect: Color::Rgb(0xC5, 0x3B, 0x30), // darker red + pending: Color::Rgb(0x55, 0x57, 0x5C), // mid-dark gray — readable, not loud + extra: Color::Rgb(0xC5, 0x3B, 0x30), + mode_tag: Color::Rgb(0x88, 0x1F, 0x88), // deeper purple + error: Color::Rgb(0xB0, 0x0F, 0x3C), // darker error red + neutral: Color::Rgb(0x20, 0x22, 0x28), // near-black body text background: Color::Rgb(0xFA, 0xFA, 0xFA), // off-white }; diff --git a/src/ui/menu.rs b/src/ui/menu.rs index c9605bd..849dd3c 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -11,10 +11,14 @@ use crate::app::App; /// Render the menu screen. `app.menu_index` highlights the active row. pub fn render(f: &mut Frame, app: &App) { let area = f.area(); + // Layout: a small breathing-room strip, then the banner, then the + // mode list, then a small bottom margin, then the footer. The top + // padding stops the banner from hugging the terminal's title-bar. let layout = Layout::vertical([ - Constraint::Length(5), - Constraint::Min(8), - Constraint::Length(3), + Constraint::Length(2), // top padding + Constraint::Length(5), // banner + Constraint::Min(8), // mode list + Constraint::Length(3), // footer ]) .split(area); @@ -44,9 +48,9 @@ pub fn render(f: &mut Frame, app: &App) { )), ]; let banner = Paragraph::new(banner_text).alignment(Alignment::Center); - f.render_widget(banner, layout[0]); + f.render_widget(banner, layout[1]); - let inner = centered_rect(60, 100, layout[1]); + let inner = centered_rect(60, 100, layout[2]); let items: Vec = app .menu .iter() @@ -80,7 +84,7 @@ pub fn render(f: &mut Frame, app: &App) { let footer = Paragraph::new(" ↑/↓ navigate · Enter start · q quit · ? help") .style(Style::default().fg(app.theme.pending)) .wrap(Wrap { trim: true }); - f.render_widget(footer, layout[2]); + f.render_widget(footer, layout[3]); } fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ef88531..72b0b8a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -5,12 +5,18 @@ //! the right one. The Help overlay and any error modal are drawn on top of //! whatever screen is underneath. //! -//! Before any screen renders, we paint the whole frame area with the active -//! theme's background. This is what makes the `light` theme actually look -//! light on a dark terminal (and what gives `monokai` / `dracula` their -//! canonical backgrounds regardless of terminal config). The `dark` theme -//! uses `Color::Reset` here so the user's terminal background shines through +//! Before any screen renders, we paint **every cell** in the frame buffer +//! with the active theme's background. We use `buffer_mut().set_style` rather +//! than rendering a Block widget so the bg fill is unconditional — no widget +//! render path gets a chance to skip cells. The `dark` theme uses +//! `Color::Reset` here so the user's terminal background shines through //! exactly as it did in v0.1. +//! +//! Note: some terminals add a few pixels of padding *around* the character +//! grid (e.g. Windows Terminal defaults to 8px). That padding lives outside +//! anything ratatui can paint — if a user reports a thin stripe along the +//! edges in non-default themes, it's the terminal's padding setting, not +//! TypeRush. pub mod help; pub mod menu; @@ -18,16 +24,17 @@ pub mod results; pub mod stats; pub mod typing; -use ratatui::{prelude::*, widgets::Block, Frame}; +use ratatui::{prelude::*, Frame}; use crate::app::{App, Screen}; /// Single entry point called once per frame from the main event loop. pub fn render(frame: &mut Frame, app: &App) { - // 1. Paint the theme's background across the entire screen. + // 1. Paint every cell in the frame with the theme background. let area = frame.area(); - let background_style = Style::default().bg(app.theme.background); - frame.render_widget(Block::default().style(background_style), area); + frame + .buffer_mut() + .set_style(area, Style::default().bg(app.theme.background)); // 2. Render the active screen on top of the painted background. match app.screen { From 0390fda042e8bd087dea71c1428748f5de4afced Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 18:17:50 +0000 Subject: [PATCH 12/15] test: lock in render-level background paint invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final v0.2.0 review flagged that no test asserted the buffer-level bg paint actually lands on the buffer — only the palette constants were checked. Adds four ui::tests using ratatui's TestBackend that render the menu screen into a fake terminal and sample a far-corner cell (untouched by any widget): - dark_theme_leaves_background_as_reset v0.1 compatibility — dark must NOT force an explicit bg; the user's terminal background must shine through. - monokai_theme_paints_canonical_background (#272822) - dracula_theme_paints_canonical_background (#282A36) - light_theme_paints_off_white_background (#FAFAFA) If a future refactor breaks the dispatcher's set_style call or replaces it with something that skips cells, these tests fail before users do. Tests: 39 → 43. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/ui/mod.rs | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 72b0b8a..04f4e3d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -49,3 +49,55 @@ pub fn render(frame: &mut Frame, app: &App) { help::render_error(frame, &app.theme, message); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::load::DefaultMode; + use crate::theme::builtin; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + /// Render the menu screen into a fake terminal and return the cell at + /// `(x, y)`. Far-corner cells aren't touched by any widget on the menu + /// screen, so they reflect the theme's background paint directly. + fn render_and_sample(palette: crate::theme::ThemePalette, x: u16, y: u16) -> (Color, Color) { + let app = App::new(None, palette, DefaultMode::Time(15)); + let backend = TestBackend::new(80, 24); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| render(f, &app)).unwrap(); + let buffer = terminal.backend().buffer(); + let cell = &buffer[(x, y)]; + (cell.fg, cell.bg) + } + + /// v0.1 compatibility: the dark theme must NOT force any explicit bg. + /// `Color::Reset` lets the user's terminal background shine through — + /// regressing this would change the look on every terminal. + #[test] + fn dark_theme_leaves_background_as_reset() { + let (_fg, bg) = render_and_sample(builtin::DARK, 79, 23); + assert_eq!(bg, Color::Reset); + } + + /// Non-default themes paint their canonical background on every cell. + /// Sampling a far-corner cell guarantees we're reading the painted bg + /// rather than something a widget happened to render there. + #[test] + fn monokai_theme_paints_canonical_background() { + let (_fg, bg) = render_and_sample(builtin::MONOKAI, 79, 23); + assert_eq!(bg, Color::Rgb(0x27, 0x28, 0x22)); + } + + #[test] + fn dracula_theme_paints_canonical_background() { + let (_fg, bg) = render_and_sample(builtin::DRACULA, 79, 23); + assert_eq!(bg, Color::Rgb(0x28, 0x2A, 0x36)); + } + + #[test] + fn light_theme_paints_off_white_background() { + let (_fg, bg) = render_and_sample(builtin::LIGHT, 79, 23); + assert_eq!(bg, Color::Rgb(0xFA, 0xFA, 0xFA)); + } +} From 0fe7f9c93a76bc734c2742be4c12c0a8b5d23859 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:42:17 +0000 Subject: [PATCH 13/15] fix(ui): readable gauge label + neutral fg for unstyled text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two visual bugs surfaced after the light theme started painting a real background. 1. Gauge label inherited a default style that produced odd contrast on the colored fill (peach-on-cyan on monokai, washed out on dracula). Now explicitly styled as 'theme.secondary + BOLD' — yellow/amber on every built-in theme, readable on both the filled (bg=accent) and unfilled (bg=theme bg) portions without needing a dedicated 'on-accent' palette slot. 2. Plain text rendered without an explicit fg falls through to the terminal's default foreground. On a dark terminal that's near-white, which becomes invisible the moment we force the bg to white via the light theme. Affected: - Menu list items (unselected rows) - Stats table 'Date' column - Plain Line::from("…") entries in the help overlay Fixed by setting widget-level .style(fg=theme.neutral). Spans that are already explicitly styled keep their own colors because Span style overrides parent Paragraph/List/Table style. Tests: 43 passing, clippy + fmt clean. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- src/ui/help.rs | 19 +++++++++++++------ src/ui/menu.rs | 5 +++++ src/ui/stats.rs | 6 +++++- src/ui/typing.rs | 14 ++++++++++++-- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/ui/help.rs b/src/ui/help.rs index 39be494..05cc6ca 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -54,12 +54,19 @@ pub fn render(f: &mut Frame, app: &App) { )), ]; - let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(theme.accent)) - .title(" help "), - ); + let p = Paragraph::new(lines) + // Plain Line::from(string) entries inherit this fg — otherwise they + // render with terminal default which is invisible on the light theme. + // Explicitly-styled spans (titles, subtitles, dim hints) keep their + // own colors because Span style overrides Paragraph style. + .style(Style::default().fg(theme.neutral)) + .wrap(Wrap { trim: false }) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.accent)) + .title(" help "), + ); f.render_widget(p, area); } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 849dd3c..7e1afc1 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -57,6 +57,11 @@ pub fn render(f: &mut Frame, app: &App) { .map(|m| ListItem::new(Line::from(m.label))) .collect(); let list = List::new(items) + // Unselected items inherit this fg. Without it, ratatui leaves cells + // with fg=Reset and the terminal renders its default fg — which is + // usually white on a dark terminal, invisible on the light theme's + // white background. + .style(Style::default().fg(app.theme.neutral)) .block( Block::default().borders(Borders::ALL).title(Span::styled( " select mode ", diff --git a/src/ui/stats.rs b/src/ui/stats.rs index 74c12a8..e339458 100644 --- a/src/ui/stats.rs +++ b/src/ui/stats.rs @@ -95,7 +95,11 @@ pub fn render(f: &mut Frame, app: &App) { .take(10) .map(|s| { Row::new(vec![ - Cell::from(s.timestamp.format("%Y-%m-%d %H:%M").to_string()), + // Date column has no theme-specific role — give it neutral so + // it renders cleanly on both dark and light themes (without + // the explicit fg it falls through to terminal default). + Cell::from(s.timestamp.format("%Y-%m-%d %H:%M").to_string()) + .style(Style::default().fg(theme.neutral)), Cell::from(s.mode.clone()).style(Style::default().fg(theme.mode_tag)), Cell::from(format!("{:.1}", s.wpm)).style(Style::default().fg(theme.secondary)), Cell::from(format!("{:.1}%", s.accuracy)).style(Style::default().fg(theme.correct)), diff --git a/src/ui/typing.rs b/src/ui/typing.rs index e4cec98..7fc0ea0 100644 --- a/src/ui/typing.rs +++ b/src/ui/typing.rs @@ -93,6 +93,13 @@ fn render_header(f: &mut Frame, app: &App, area: Rect) { /// Quote, code, zen and custom modes don't show a bar. fn render_progress(f: &mut Frame, app: &App, area: Rect) { let gauge_color = app.theme.accent; + // Gauge label sits in the middle of the bar, often straddling the + // boundary between the filled (bg = accent) and unfilled (bg = theme bg) + // portions. `secondary + BOLD` gives high contrast on both halves for + // every built-in theme without needing a separate "on-accent" slot. + let label_style = Style::default() + .fg(app.theme.secondary) + .add_modifier(Modifier::BOLD); if let Some((done, total)) = app.progress() { let ratio = if total == 0 { 0.0 @@ -103,7 +110,7 @@ fn render_progress(f: &mut Frame, app: &App, area: Rect) { .block(Block::default()) .gauge_style(Style::default().fg(gauge_color)) .ratio(ratio.min(1.0)) - .label(format!("{} / {}", done, total)); + .label(Span::styled(format!("{} / {}", done, total), label_style)); f.render_widget(gauge, area); } else if let Mode::Time(total) = app.mode { let elapsed = app.elapsed().as_secs_f64(); @@ -112,7 +119,10 @@ fn render_progress(f: &mut Frame, app: &App, area: Rect) { .block(Block::default()) .gauge_style(Style::default().fg(gauge_color)) .ratio(ratio) - .label(format!("{:.0}s / {}s", elapsed, total)); + .label(Span::styled( + format!("{:.0}s / {}s", elapsed, total), + label_style, + )); f.render_widget(gauge, area); } } From bffd15a9b174520a1051542687315124511cc3e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:55:26 +0000 Subject: [PATCH 14/15] docs: refresh CHANGELOG, ARCHITECTURE, CONTRIBUTING for final v0.2.0 state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three docs were stale relative to the code state on this branch. CHANGELOG.md — v0.2.0 entry rewritten to cover the work that landed after the initial draft: - Banner top padding + light theme palette retune (Changed) - Gauge label readability (Fixed) - Plain unstyled text now uses theme.neutral on light theme (Fixed) - docs/DEVELOPMENT.md as a shipped artifact (Added) - Test count updated from '25 new' → '4 to 43' for accuracy docs/ARCHITECTURE.md — refreshed in three places: - Module map now lists src/theme/ and src/config/, which didn't exist when this file was last edited. - 'Steady cursor' design note updated — both cursor positions now use a single underline style (was REVERSED + underline before the v0.2.0 cursor consolidation). - New 'Theme-aware background paint' design note explaining how ui::render fills every cell with theme.background, why widgets with plain Line/Cell entries need a widget-level .style(), and why the dark theme uses Color::Reset. - New 'When you add a new theme' recipe at the end — drop a const into src/theme/builtin.rs and append to ALL, no other wiring. CONTRIBUTING.md — 'Adding a new theme' section pointed at a non-existent src/config.rs with a 'once theming lands in v0.2' note. Replaced with a one-line pointer to the ARCHITECTURE recipe. No code changes, no test count change. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CHANGELOG.md | 22 ++++++++++++++-- CONTRIBUTING.md | 8 +++--- docs/ARCHITECTURE.md | 63 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb50be..dfb3176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,13 +35,22 @@ _No unreleased changes yet._ useful for shell-completion scripts. - **Default mode + per-mode defaults** in the config — pre-selects the matching menu row at startup. -- 25 new unit tests covering color parsing, config deserialization, theme - resolution, override application, and CLI precedence. +- **`docs/DEVELOPMENT.md`** — practical local-dev workflow (running with + CLI args, sandboxed config testing via `HOME` redirect, `cargo-watch` + patterns for TUI apps, recommended two-terminal loop). +- Test count grew from 4 to 43 — color parsing, config deserialization, + theme resolution, override application, CLI precedence, menu-row + matching, render-level background paint, custom-mode auto-finish. ### Changed - Zen mode now desaturates to theme-aware `pending` / `neutral` colors instead of hardcoded grays, keeping the screen readable on light backgrounds. - Help overlay lists the config file path alongside the stats file path. +- Menu screen gained 2 rows of top padding so the TYPERUSH banner no longer + hugs the terminal's title bar / tab strip. +- Light theme foreground palette darkened across every slot — the previous + values were carried over from the dark theme and washed out on white. + All slots now land at ≥4.5:1 contrast against the `#FAFAFA` background. ### Fixed - Cursor no longer flickers / shifts the line horizontally at the end of a @@ -50,6 +59,15 @@ _No unreleased changes yet._ - **Custom-file mode (`--file `) now auto-finishes** when the user types the last word, matching the documented behavior in `docs/USAGE.md`. Previously the session sat waiting for `Esc`. +- **Progress-gauge timer label** is now explicitly styled (`secondary` + + bold) instead of falling through to whatever default ratatui picked. + Readable on both the filled and unfilled portions of the bar across + every built-in theme. +- **Plain (unstyled) text on the light theme** — menu list items, the + stats table's date column, and plain help-overlay lines — now render + in `theme.neutral` rather than terminal-default foreground. Previously + they were near-white on white when running the light theme on a dark + terminal. ### Compatibility - Existing CLI flags (`--time`, `--words`, `--quote`, `--code`, `--zen`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec92da8..7658524 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -173,11 +173,9 @@ menu row, optionally add a CLI flag. ## Adding a new theme -(Once theming lands in v0.2.) Each theme is a `Theme` struct in -`src/config.rs` mapping the four character states (Correct / Incorrect / -Pending / Extra) to ratatui colors. The plan is to read these from -`~/.typerush/config.toml`. Until then, hard-coded constants live in -`src/ui/typing.rs::style_for_char`. +Drop a `ThemePalette` constant into `src/theme/builtin.rs` and append it to +`ALL`. The recipe lives in `docs/ARCHITECTURE.md` — see "When you add a new +theme". --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b947405..42882ef 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,7 +88,7 @@ src/ │ ├── app.rs The App state machine. All fields, all transitions, │ WPM / accuracy math, mode-switching, time/word -│ completion logic. +│ completion logic. Holds the active ThemePalette. │ ├── game.rs Pure function: get_char_states(target, typed) — the │ character-by-character matcher that drives all @@ -98,8 +98,25 @@ src/ │ ~/.typerush/stats.json. Personal best & average │ accuracy helpers. │ +├── theme/ +│ ├── mod.rs ThemePalette struct (10 themable color slots) +│ │ and per-slot override application. +│ ├── builtin.rs The four built-in palettes (dark, light, monokai, +│ │ dracula). Adding a new theme = one row appended +│ │ to `ALL`. +│ └── color.rs Color parser for #RRGGBB hex strings and the 16 +│ ANSI color names. +│ +├── config/ +│ ├── mod.rs Config / Defaults / Colors structs read from +│ │ ~/.typerush/config.toml via serde. +│ └── load.rs load_or_default() — disk read + flattening into a +│ ResolvedConfig. Never errors; bad files surface as +│ a single human-readable warning. +│ ├── ui/ -│ ├── mod.rs render() dispatcher (matches on app.screen) +│ ├── mod.rs render() dispatcher. Paints theme.background on +│ │ every cell before any screen renders. │ ├── menu.rs Main menu with the ASCII banner │ ├── typing.rs The typing screen: header, progress gauge, words │ ├── results.rs Post-session screen with PB delta + sparkline @@ -124,14 +141,32 @@ what you see. ### Steady (no-blink) cursor Earlier versions blinked a phantom `▏` character past the end of the typed word. That toggled the rendered word width, shifting the whole line left/right -every 500ms. The current design renders a **steady** cursor: +every 500ms. The current design renders a **steady** cursor as an +underlined character in `theme.accent`: -- on a character → REVERSED style (block fill) -- past the last character → an underlined trailing space (plain bar) +- on a character → the character itself, restyled with the cursor style +- past the last character → an underlined trailing space, same style -The trailing space is always part of the layout, so toggling its style never +Both cases use the same `fg = theme.accent, modifier = UNDERLINED`. The +trailing space is always part of the layout, so toggling its style never changes width. +### Theme-aware background paint +`ui::render` calls `frame.buffer_mut().set_style(area, ...)` with the +active theme's `background` color before any screen renders. This is what +makes the `light` theme actually look light on a dark terminal, and what +gives `monokai` / `dracula` their canonical backgrounds regardless of +terminal config. The `dark` theme uses `Color::Reset` so the user's +terminal background shines through exactly as it did in v0.1 — there is +no forced override. + +Widgets that include plain `Line::from(string)` or `Cell::from(string)` +entries (no explicit fg) need a widget-level `.style(fg=theme.neutral)` +so they don't fall through to the terminal's default foreground — which +becomes invisible the moment the light theme paints a white background +over a dark terminal. The menu list, stats date column, and help overlay +all follow this pattern. + ### Stats persistence JSON, flat array, no schema. The file is small enough (one session ≈ 200B) that we rewrite it whole on every save. Zen-mode sessions are deliberately @@ -169,3 +204,19 @@ crate. We don't directly use any platform-specific code, so the binary is a 5. If it has a unique completion condition, handle it in `submit_word()` / `tick()`. 6. (Optional) Add a CLI flag in `main.rs::Cli`. + +--- + +## When you add a new theme + +1. Declare a `pub const` of type `ThemePalette` in `src/theme/builtin.rs`. + Fill every slot (`accent`, `secondary`, `correct`, `incorrect`, `pending`, + `extra`, `mode_tag`, `error`, `neutral`, `background`). For light-background + themes, pick colors at ≥4.5:1 contrast against the bg. +2. Append `("name", YOUR_THEME)` to `ALL` in the same file. +3. That's it. The CLI loader (`--theme `), the config loader (`theme = + "..."`), and `--list-themes` all iterate `ALL` — no other wiring needed. + +If you want the theme to look identical regardless of terminal, set +`background` to a concrete `Color::Rgb(...)`. If you'd rather it inherit +the user's terminal background, set `background = Color::Reset`. From 11c17cc35901448e2f7404c02a7d6d4649572d27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 19:58:46 +0000 Subject: [PATCH 15/15] fix(input): Ctrl+Backspace actually deletes the current word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bug since v0.1, surfaced by the user. Most terminals (Windows Terminal, iTerm2, most Linux emulators) send Ctrl+Backspace as a literal ^H byte. crossterm reports that as KeyCode::Char('h') with CTRL — NOT as KeyCode::Backspace with CTRL — so the dedicated Backspace+CTRL branch in handle_typing_key never fired. The keystroke fell into the 'ignore control chords' branch and silently went nowhere. Fix: - Add an explicit Char('h') | Char('w') + CTRL match arm in handle_typing_key that calls app.handle_backspace(true). Ctrl+W is the Unix 'kill word' convention and was likely also broken with the same root cause. - The original Backspace+CTRL branch is preserved for terminals that DO surface Ctrl+Backspace as a Backspace keycode. Tests (main.rs gains its first test module): - ctrl_h_deletes_current_word — the original bug - ctrl_w_deletes_current_word — Unix convention - ctrl_backspace_keycode_still_deletes_word — old path still works - plain_backspace_deletes_one_char — no regression on plain bs - other_ctrl_chords_are_ignored — Ctrl+A/Z still no-op Docs: - USAGE.md keybinding table now lists Ctrl+W and Ctrl+H alongside Ctrl+Backspace. - CHANGELOG.md gains a Fixed bullet for the bug. Tests: 43 → 48, clippy + fmt clean. https://claude.ai/code/session_013rjUTXRnD7tFJS9qCyWeXu --- CHANGELOG.md | 5 +++ docs/USAGE.md | 2 ++ src/main.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb3176..e0a8da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,11 @@ _No unreleased changes yet._ in `theme.neutral` rather than terminal-default foreground. Previously they were near-white on white when running the light theme on a dark terminal. +- **Ctrl+Backspace now actually deletes the current word.** Most terminals + send `Ctrl+Backspace` as a literal `^H` byte — crossterm reports it as + `Char('h') + CTRL`, not as `Backspace + CTRL` — so the old keymap was + silently dropping it into the "ignore control chords" branch. The + keymap now also recognises `Ctrl+W` (Unix "kill word" muscle memory). ### Compatibility - Existing CLI flags (`--time`, `--words`, `--quote`, `--code`, `--zen`, diff --git a/docs/USAGE.md b/docs/USAGE.md index 4534421..8b862b8 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -68,6 +68,8 @@ You can also pass `--file` alone to use the file inside the menu's "Custom" entr | `Space` | Submit the current word, advance to the next | | `Backspace` | Delete the previous character | | `Ctrl+Backspace` | Delete the entire current word | +| `Ctrl+W` | Same — delete the entire current word | +| `Ctrl+H` | Same — most terminals send this when you press `Ctrl+Backspace` | | `Ctrl+R` | Restart the same mode with a new word list | | `Esc` | End the session and go to the results screen | diff --git a/src/main.rs b/src/main.rs index 3ba4317..3202f92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -315,7 +315,7 @@ fn handle_menu_key(app: &mut App, code: KeyCode, _mods: KeyModifiers) { /// Keymap for the typing screen. Note that '?' is **not** a help shortcut /// here — the user may legitimately need to type it. -fn handle_typing_key(app: &mut App, code: KeyCode, mods: KeyModifiers) { +pub(crate) fn handle_typing_key(app: &mut App, code: KeyCode, mods: KeyModifiers) { match code { KeyCode::Esc => { app.finish_game(); @@ -331,9 +331,18 @@ fn handle_typing_key(app: &mut App, code: KeyCode, mods: KeyModifiers) { mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT); app.handle_backspace(delete_whole_word); } + // Many terminals (notably Windows Terminal, iTerm2, most Linux + // emulators) send Ctrl+Backspace as a literal `^H` byte — crossterm + // surfaces this as `Char('h') + CTRL`, NOT as `Backspace + CTRL`, + // so the dedicated Backspace branch above never fires. Ctrl+W is + // the Unix convention for "kill word" and is included for the same + // reason — common muscle memory shouldn't fall on the floor. + KeyCode::Char('h') | KeyCode::Char('w') if mods.contains(KeyModifiers::CONTROL) => { + app.handle_backspace(true); + } KeyCode::Char(typed_char) => { - // Ignore control chords like Ctrl+A — we never want those to be - // counted as typed characters. + // Ignore other control chords like Ctrl+A — we never want those + // to be counted as typed characters. if mods.contains(KeyModifiers::CONTROL) { return; } @@ -396,3 +405,83 @@ fn save_current_session(app: &App) { }; let _ = storage::save_session(&record); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::{Screen, Word}; + use crate::config::load::DefaultMode; + use crate::theme::ThemePalette; + + fn make_typing_app() -> App { + let mut app = App::new(None, ThemePalette::default(), DefaultMode::Time(15)); + app.screen = Screen::Typing; + app.mode = Mode::Words(2); + app.words = vec![Word::new("hello".into()), Word::new("world".into())]; + app + } + + /// Regression: most terminals send Ctrl+Backspace as a literal ^H byte, + /// which crossterm reports as Char('h') + CTRL. Before this fix the + /// keymap dropped that into the "ignore control chords" branch and the + /// user saw nothing happen. + #[test] + fn ctrl_h_deletes_current_word() { + let mut app = make_typing_app(); + for ch in "hel".chars() { + app.handle_char(ch); + } + assert_eq!(app.words[0].typed, "hel"); + + handle_typing_key(&mut app, KeyCode::Char('h'), KeyModifiers::CONTROL); + assert_eq!(app.words[0].typed, ""); + } + + /// Ctrl+W is the Unix convention for "kill word" — common muscle memory. + #[test] + fn ctrl_w_deletes_current_word() { + let mut app = make_typing_app(); + for ch in "hel".chars() { + app.handle_char(ch); + } + handle_typing_key(&mut app, KeyCode::Char('w'), KeyModifiers::CONTROL); + assert_eq!(app.words[0].typed, ""); + } + + /// The original Backspace+CTRL path still works on terminals that DO + /// surface Ctrl+Backspace as a real Backspace keycode. + #[test] + fn ctrl_backspace_keycode_still_deletes_word() { + let mut app = make_typing_app(); + for ch in "hel".chars() { + app.handle_char(ch); + } + handle_typing_key(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL); + assert_eq!(app.words[0].typed, ""); + } + + /// Plain Backspace must still delete a single character — not a whole + /// word. Guards against accidentally widening the new Ctrl+H branch. + #[test] + fn plain_backspace_deletes_one_char() { + let mut app = make_typing_app(); + for ch in "hel".chars() { + app.handle_char(ch); + } + handle_typing_key(&mut app, KeyCode::Backspace, KeyModifiers::NONE); + assert_eq!(app.words[0].typed, "he"); + } + + /// Other Ctrl-chords (Ctrl+A, Ctrl+Z, …) must still be ignored — not + /// counted as typed characters. + #[test] + fn other_ctrl_chords_are_ignored() { + let mut app = make_typing_app(); + for ch in "hel".chars() { + app.handle_char(ch); + } + handle_typing_key(&mut app, KeyCode::Char('a'), KeyModifiers::CONTROL); + handle_typing_key(&mut app, KeyCode::Char('z'), KeyModifiers::CONTROL); + assert_eq!(app.words[0].typed, "hel"); + } +}