diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de9a24..af6d0c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,75 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +_No unreleased changes yet._ + +--- + +## [0.2.0] — customization + +### Added +- **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. + 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`, `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. +- **`--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. +- **`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 48 — color parsing, config deserialization, + theme resolution, override application, CLI precedence, menu-row + matching, render-level background paint, custom-mode auto-finish, + Ctrl+H/W/Backspace dispatch. + +### 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 +- **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. +- **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`, + `--file`) are unchanged. +- `~/.typerush/stats.json` format is unchanged — old session history loads + exactly as before. + --- ## [0.1.1] — initial release @@ -41,5 +110,6 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm dedicated docs. - Demo GIF uses absolute GitHub raw URL for correct display on crates.io. -[Unreleased]: https://github.com/withrvr/typerush/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/withrvr/typerush/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/withrvr/typerush/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/withrvr/typerush/releases/tag/v0.1.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8186ffe..7658524 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 ``` --- @@ -174,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/Cargo.lock b/Cargo.lock index 3ff1594..22e6e78 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,9 +889,50 @@ 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.1" +version = "0.2.0" dependencies = [ "anyhow", "chrono", @@ -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 ce9c3a7..47b3e3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "typerush" -version = "0.1.1" +version = "0.2.0" edition = "2021" description = "In your terminal — a fast, cross-platform WPM typing trainer built with Rust" license = "MIT" @@ -27,6 +27,7 @@ rand = "0.8" chrono = { version = "0.4", features = ["serde"] } anyhow = "1" dirs = "5" +toml = "0.8" [profile.release] lto = true diff --git a/README.md b/README.md index b687103..99ad199 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,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 @@ -69,6 +69,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 ``` --- @@ -109,10 +111,29 @@ 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 - 🏛 [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/config.example.toml b/config.example.toml new file mode 100644 index 0000000..fcf0b85 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,49 @@ +# 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) +# background = "#272822" # full-screen background fill +# # set to "reset" to use the terminal's native bg 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`. 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 03147dd..6e32212 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,29 +68,29 @@ 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) - 🔲 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 - 🔲 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/` --- @@ -88,7 +100,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). diff --git a/docs/USAGE.md b/docs/USAGE.md index 8014580..8b862b8 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. @@ -66,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 | @@ -111,6 +115,77 @@ 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 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 + +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 | diff --git a/src/app.rs b/src/app.rs index 014f019..1b9f6a1 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, } } @@ -488,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(); } @@ -511,3 +565,65 @@ 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"); + } + + /// 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()); + } +} diff --git a/src/config/load.rs b/src/config/load.rs new file mode 100644 index 0000000..470b996 --- /dev/null +++ b/src/config/load.rs @@ -0,0 +1,347 @@ +//! 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); + try_push("neutral", &overrides.neutral, warnings); + try_push("background", &overrides.background, 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 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 { + 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..a1552b9 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,149 @@ +//! 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, + pub neutral: Option, + pub background: 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()); + } + + /// 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/main.rs b/src/main.rs index 81a459e..3202f92 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,10 @@ //! 4. Restore the terminal on exit (and on panic, via a hook). mod app; +mod config; mod game; mod storage; +mod theme; mod ui; mod words; @@ -35,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. @@ -69,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); @@ -80,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>; @@ -124,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); @@ -284,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(); @@ -300,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; } @@ -365,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"); + } +} diff --git a/src/theme/builtin.rs b/src/theme/builtin.rs new file mode 100644 index 0000000..19e4ad4 --- /dev/null +++ b/src/theme/builtin.rs @@ -0,0 +1,148 @@ +//! 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. +/// +/// `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, + correct: Color::Green, + incorrect: Color::Red, + pending: Color::DarkGray, + extra: Color::Red, + 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. 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, 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 +}; + +/// 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 + 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), + neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // monokai foreground + background: Color::Rgb(0x27, 0x28, 0x22), // canonical monokai background +}; + +/// 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 + 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), + neutral: Color::Rgb(0xF8, 0xF8, 0xF2), // dracula foreground + background: Color::Rgb(0x28, 0x2A, 0x36), // canonical dracula background +}; + +/// `(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); + } + + /// 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/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..4ed0861 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,83 @@ +//! 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, + /// 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, + /// 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 { + /// 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, + "neutral" => self.neutral = *color, + "background" => self.background = *color, + _ => {} + } + } + self + } +} + +impl Default for ThemePalette { + /// The default theme is `dark` — the original TypeRush look. + fn default() -> Self { + builtin::DARK + } +} diff --git a/src/ui/help.rs b/src/ui/help.rs index 3930c05..05cc6ca 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,32 @@ 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)) - .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); } /// 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 +79,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..7e1afc1 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -11,69 +11,73 @@ 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); - // 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]); + f.render_widget(banner, layout[1]); - // Centered list - let inner = centered_rect(60, 100, layout[1]); + let inner = centered_rect(60, 100, layout[2]); let items: Vec = app .menu .iter() .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 ", Style::default() - .fg(Color::Yellow) + .fg(app.theme.secondary) .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) - .bg(Color::Cyan) + .bg(app.theme.accent) .add_modifier(Modifier::BOLD), ) .highlight_symbol("➤ "); @@ -82,11 +86,10 @@ 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]); + 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 581d35f..04f4e3d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -4,6 +4,19 @@ //! 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 **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; @@ -11,12 +24,19 @@ pub mod results; pub mod stats; pub mod typing; -use ratatui::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 every cell in the frame with the theme background. + let area = frame.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 { Screen::Menu => menu::render(frame, app), Screen::Typing => typing::render(frame, app), @@ -24,8 +44,60 @@ 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, message); + 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)); } } 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..e339458 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 @@ -95,19 +95,23 @@ 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()), - 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)), + // 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)), 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 +129,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..7fc0ea0 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,14 @@ 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; + // 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 @@ -93,18 +108,21 @@ 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)); + .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(); 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)); + .label(Span::styled( + format!("{:.0}s / {}s", elapsed, total), + label_style, + )); f.render_widget(gauge, area); } } @@ -115,10 +133,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 +145,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 +158,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 +177,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 +216,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 +229,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); }