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