First improvement pass

This commit is contained in:
2026-02-10 23:32:57 -05:00
parent f65e3d8413
commit c78a8a90a3
26 changed files with 13200 additions and 207 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target
/clones/

1090
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,3 +17,8 @@ rust-embed = "8.5"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
thiserror = "2.0"
reqwest = { version = "0.12", features = ["blocking"], optional = true }
[features]
default = ["network"]
network = ["reqwest"]

View File

@@ -6,7 +6,7 @@ fg = "#4c4f69"
text_correct = "#40a02b"
text_incorrect = "#d20f39"
text_incorrect_bg = "#f5c2cf"
text_pending = "#9ca0b0"
text_pending = "#7c7f93"
text_cursor_bg = "#dc8a78"
text_cursor_fg = "#eff1f5"
focused_key = "#df8e1d"

View File

@@ -6,7 +6,7 @@ fg = "#cdd6f4"
text_correct = "#a6e3a1"
text_incorrect = "#f38ba8"
text_incorrect_bg = "#45273a"
text_pending = "#585b70"
text_pending = "#a6adc8"
text_cursor_bg = "#f5e0dc"
text_cursor_fg = "#1e1e2e"
focused_key = "#f9e2af"

View File

@@ -6,7 +6,7 @@ fg = "#f8f8f2"
text_correct = "#50fa7b"
text_incorrect = "#ff5555"
text_incorrect_bg = "#44242a"
text_pending = "#6272a4"
text_pending = "#9aadce"
text_cursor_bg = "#f1fa8c"
text_cursor_fg = "#282a36"
focused_key = "#f1fa8c"

View File

@@ -6,7 +6,7 @@ fg = "#ebdbb2"
text_correct = "#b8bb26"
text_incorrect = "#fb4934"
text_incorrect_bg = "#462726"
text_pending = "#665c54"
text_pending = "#a89984"
text_cursor_bg = "#fabd2f"
text_cursor_fg = "#282828"
focused_key = "#fabd2f"

View File

@@ -6,7 +6,7 @@ fg = "#eceff4"
text_correct = "#a3be8c"
text_incorrect = "#bf616a"
text_incorrect_bg = "#3f2e31"
text_pending = "#4c566a"
text_pending = "#8fbcbb"
text_cursor_bg = "#ebcb8b"
text_cursor_fg = "#2e3440"
focused_key = "#ebcb8b"

View File

@@ -6,7 +6,7 @@ fg = "#abb2bf"
text_correct = "#98c379"
text_incorrect = "#e06c75"
text_incorrect_bg = "#3e2a2d"
text_pending = "#5c6370"
text_pending = "#848b98"
text_cursor_bg = "#e5c07b"
text_cursor_fg = "#282c34"
focused_key = "#e5c07b"

View File

@@ -6,7 +6,7 @@ fg = "#839496"
text_correct = "#859900"
text_incorrect = "#dc322f"
text_incorrect_bg = "#2a1a1a"
text_pending = "#586e75"
text_pending = "#839496"
text_cursor_bg = "#b58900"
text_cursor_fg = "#002b36"
focused_key = "#b58900"

View File

@@ -6,7 +6,7 @@ fg = "#c0caf5"
text_correct = "#9ece6a"
text_incorrect = "#f7768e"
text_incorrect_bg = "#3b2232"
text_pending = "#565f89"
text_pending = "#9aa5ce"
text_cursor_bg = "#e0af68"
text_cursor_fg = "#1a1b26"
focused_key = "#e0af68"

10002
assets/words-en.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,290 @@
# keydr - Terminal Typing Tutor Architecture Plan
## Context
**Problem**: No terminal-based typing tutor exists that combines keybr.com's adaptive learning algorithm (gradual letter unlocking, per-key confidence tracking, phonetic pseudo-word generation) with code syntax training. Existing tools either lack adaptive learning entirely (ttyper, smassh, typr) or have incomplete implementations (gokeybr intentionally ignores error stats, ivan-volnov/keybr is focused on Anki integration).
**Goal**: Build a full-featured Rust TUI typing tutor that clones keybr.com's core algorithm, extends it to code syntax training, and provides a polished statistics dashboard - all in the terminal.
---
## Research Summary
### keybr.com Algorithm (from reading source: `packages/keybr-lesson/lib/guided.ts`, `keybr-phonetic-model/lib/phoneticmodel.ts`, `keybr-result/lib/keystats.ts`)
**Letter Unlocking**: Letters sorted by frequency. Starts with minimum 6. New letter unlocked only when ALL included keys have `confidence >= 1.0`. Weakest key (lowest confidence) gets "focused" - drills bias heavily toward it.
**Confidence Model**: `confidence = target_time_ms / filtered_time_to_type`, where `target_time_ms = 60000 / target_speed_cpm` (default target: 175 CPM ~ 35 WPM). `filtered_time_to_type` is an exponential moving average (alpha=0.1) of raw per-key typing times.
**Phonetic Word Generation**: Markov chain transition table maps character bigrams to next-character probability distributions. Chain is walked with a `Filter` that restricts to unlocked characters only. Focused letter gets prefix biasing - the generator searches for chain states containing the focused letter and starts from there. Words are 3-10 chars; space probability boosted by `1.3^word_length` to keep words short.
**Scoring**: `score = (speed_cpm * complexity) / (errors + 1) * (length / 50)`
**Learning Rate**: Polynomial regression (degree 1-3 based on sample count) on last 30 per-key time samples, with R^2 threshold of 0.5 for meaningful predictions.
### Key Insights from Prior Art
- **gokeybr**: Trigram-based scoring with `frequency * effort(speed)` is a good complementary approach. Its Bellman-Ford shortest-path for drill generation is clever but complex.
- **ttyper**: Clean Rust/Ratatui architecture to reference. Uses `crossterm` events, `State::Test | State::Results` enum, `Config` from TOML. Dependencies: `ratatui ^0.25`, `crossterm ^0.27`, `clap`, `serde`, `toml`, `rand`, `rust-embed`.
- **keybr-code**: Uses PEG grammars to generate code snippets for 12+ languages. Each grammar produces realistic syntax patterns.
---
## Architecture
### Technology Stack
- **TUI**: Ratatui + Crossterm (the standard Rust TUI stack, battle-tested by ttyper and many others)
- **CLI**: Clap (derive)
- **Serialization**: Serde + serde_json + toml
- **HTTP**: Reqwest (blocking, for GitHub API)
- **Persistence**: JSON files via `dirs` crate (XDG paths)
- **Embedded Assets**: rust-embed
- **Error Handling**: anyhow + thiserror
- **Time**: chrono
### Project Structure
```
src/
main.rs # CLI parsing, terminal init, main event loop
app.rs # App state machine (TEA pattern), message dispatch
event.rs # Crossterm event polling thread -> AppMessage channel
config.rs # Config loading (~/.config/keydr/config.toml)
engine/
mod.rs
letter_unlock.rs # Letter ordering, unlock logic, focus selection
key_stats.rs # Per-key EMA, confidence, best-time tracking
scoring.rs # Lesson score formula, gamification (levels, streaks)
learning_rate.rs # Polynomial regression for speed prediction
filter.rs # Active character set filter
generator/
mod.rs # TextGenerator trait
phonetic.rs # Markov chain pseudo-word generator
transition_table.rs # Binary transition table (de)serialization
code_syntax.rs # PEG grammar interpreter for code snippets
passage.rs # Book passage loading
github_code.rs # GitHub API code fetching + caching
session/
mod.rs
lesson.rs # LessonState: target text, cursor, timing
input.rs # Keystroke processing, match/mismatch, backspace
result.rs # LessonResult computation from raw events
store/
mod.rs # StorageBackend trait
json_store.rs # JSON file persistence with atomic writes
schema.rs # Serializable data models
ui/
mod.rs # Root render dispatcher
theme.rs # Theme TOML parsing, color resolution
layout.rs # Responsive screen layout (ratatui Rect splitting)
components/
mod.rs
typing_area.rs # Main typing widget (correct/incorrect/pending coloring)
stats_sidebar.rs # Live WPM, accuracy, key confidence bars
keyboard_diagram.rs # Visual keyboard with finger colors + focus highlight
progress_bar.rs # Letter unlock progress
chart.rs # WPM-over-time line charts (ratatui Chart widget)
menu.rs # Mode selection menu
dashboard.rs # Post-lesson results view
stats_dashboard.rs # Historical statistics with graphs
keyboard/
mod.rs
layout.rs # KeyboardLayout, key positions, finger assignments
finger.rs # Finger enum, hand assignment
assets/
models/en.bin # Pre-built English phonetic transition table
themes/*.toml # Built-in themes (catppuccin, dracula, gruvbox, nord, etc.)
grammars/*.toml # Code syntax grammars (rust, python, js, go, etc.)
layouts/*.toml # Keyboard layouts (qwerty, dvorak, colemak)
```
### Core Data Flow
```
┌─────────────┐
│ Event Loop │
└──────┬──────┘
│ AppMessage
┌──────────┐ ┌─────────────────┐ ┌───────────┐
│Generator │────▶│ App State │────▶│ UI Layer │
│(phonetic,│ │ (TEA pattern) │ │ (ratatui) │
│ code, │ │ │ │ │
│ passage) │ │ ┌─────────────┐ │ └───────────┘
└──────────┘ │ │ Engine │ │
│ │ (key_stats, │ │ ┌───────────┐
│ │ unlock, │ │────▶│ Store │
│ │ scoring) │ │ │ (JSON) │
│ └─────────────┘ │ └───────────┘
└─────────────────┘
```
### App State Machine
```
Start → Menu
Menu → Lesson (on mode select)
Menu → StatsDashboard (on 's')
Menu → Settings (on 'c')
Lesson → LessonResult (on completion or ESC)
LessonResult → Lesson (on 'r' retry)
LessonResult → Menu (on 'q'/ESC)
LessonResult → StatsDashboard (on 's')
StatsDashboard → Menu (on ESC)
Settings → Menu (on ESC, saves config)
Any → Quit (on Ctrl+C)
```
### The Adaptive Algorithm
**Step 1 - Letter Order**: English frequency order: `e t a o i n s h r d l c u m w f g y p b v k j x q z`
**Step 2 - Unlock Logic** (after each lesson):
```
min_letters = 6
for each letter in frequency_order:
if included.len() < min_letters:
include(letter)
elif all included keys have confidence >= 1.0:
include(letter)
else:
break
```
**Step 3 - Focus Selection**:
```
focused = included_keys
.filter(|k| k.confidence < 1.0)
.min_by(|a, b| a.confidence.cmp(&b.confidence))
```
**Step 4 - Stats Update** (per key, after each lesson):
```
alpha = 0.1
stat.filtered_time = alpha * new_time + (1 - alpha) * stat.filtered_time
stat.best_time = min(stat.best_time, stat.filtered_time)
stat.confidence = (60000.0 / target_speed_cpm) / stat.filtered_time
```
**Step 5 - Text Generation Biasing**:
- Only allow characters in the unlocked set (Filter)
- When a focused letter exists, find Markov chain prefixes containing it and start words from those prefixes
- This naturally creates words heavy in the weak letter
### Code Syntax Extension
After all 26 prose letters are unlocked, the system transitions to code syntax training:
- Introduces code-relevant characters: `{ } [ ] ( ) < > ; : . , = + - * / & | ! ? _ " ' # @ \ ~ ^ %`
- Uses PEG grammars per language to generate realistic code snippets
- Gradual character unlocking continues for syntax characters
- Users select their target programming languages in config
### Theme System
Themes are TOML files with semantic color names:
```toml
[colors]
bg = "#1e1e2e"
text_correct = "#a6e3a1"
text_incorrect = "#f38ba8"
text_pending = "#585b70"
text_cursor_bg = "#f5e0dc"
focused_key = "#f9e2af"
# ... etc
```
Resolution order: CLI flag → config → user themes dir → bundled → default fallback.
Built-in themes: Catppuccin Mocha, Catppuccin Latte, Dracula, Gruvbox Dark, Nord, Tokyo Night, Solarized Dark, One Dark, plus an ANSI-safe default.
### Persistence
JSON files in `~/.local/share/keydr/`:
- `key_stats.json` - Per-key EMA, confidence, sample history
- `lesson_history.json` - Last 500 lesson results
- `profile.json` - Unlock state, settings, gamification data
Atomic writes (temp file → fsync → rename) to prevent corruption. Schema version field for forward-compatible migrations.
---
## Implementation Phases
### Phase 1: Foundation (Core Loop + Basic Typing)
Create the terminal init/restore with crossterm, event polling thread, TEA-based App state machine, basic typing against a hardcoded word list with correct/incorrect coloring.
**Key files**: `main.rs`, `app.rs`, `event.rs`, `session/lesson.rs`, `session/input.rs`, `ui/components/typing_area.rs`, `ui/layout.rs`
### Phase 2: Adaptive Engine + Statistics
Implement per-key stats (EMA, confidence), letter unlocking, focus selection, scoring, live stats sidebar, and progress bar.
**Key files**: `engine/key_stats.rs`, `engine/letter_unlock.rs`, `engine/scoring.rs`, `engine/filter.rs`, `session/result.rs`, `ui/components/stats_sidebar.rs`, `ui/components/progress_bar.rs`
### Phase 3: Phonetic Text Generation
Build the English transition table (offline tool or build script), implement the Markov chain walker with filter and focus biasing, integrate with the lesson system.
**Key files**: `generator/transition_table.rs`, `generator/phonetic.rs`, `generator/mod.rs`, a `build.rs` or `tools/` script for table generation
### Phase 4: Persistence + Theming
JSON storage backend, atomic writes, config loading, theme parsing, built-in theme files, apply themes throughout all UI components.
**Key files**: `store/json_store.rs`, `store/schema.rs`, `config.rs`, `ui/theme.rs`, `assets/themes/*.toml`
### Phase 5: Results + Dashboard
Post-lesson results screen, historical stats dashboard with charts (ratatui Chart widget), learning rate prediction.
**Key files**: `ui/components/dashboard.rs`, `ui/components/stats_dashboard.rs`, `ui/components/chart.rs`, `engine/learning_rate.rs`
### Phase 6: Code Practice + Passages
PEG grammar interpreter for code syntax generation, book passage mode, GitHub code fetching + caching.
**Key files**: `generator/code_syntax.rs`, `generator/passage.rs`, `generator/github_code.rs`, `assets/grammars/*.toml`
### Phase 7: Keyboard Diagram + Layouts
Visual keyboard widget with finger color coding, multiple layout support (QWERTY, Dvorak, Colemak).
**Key files**: `keyboard/layout.rs`, `keyboard/finger.rs`, `ui/components/keyboard_diagram.rs`, `assets/layouts/*.toml`
### Phase 8: Polish + Gamification
Level system, streaks, badges, CLI completeness, error handling, performance, testing, documentation.
---
## Verification
After each phase, verify by:
1. `cargo build` compiles without errors
2. `cargo test` passes all unit tests
3. Manual testing: launch `cargo run`, exercise the new features, verify UI rendering
4. For Phase 2+: verify letter unlocking by typing accurately and watching new letters appear
5. For Phase 3+: verify generated words only contain unlocked letters and bias toward the focused key
6. For Phase 4+: verify stats persist across app restarts
7. For Phase 5+: verify charts render correctly with historical data
---
## Dependencies (Cargo.toml)
```toml
[dependencies]
ratatui = "0.30"
crossterm = "0.28"
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.8"
rand = { version = "0.8", features = ["small_rng"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
dirs = "6.0"
rust-embed = "8.5"
chrono = { version = "0.4", features = ["serde"] }
anyhow = "1.0"
thiserror = "2.0"
```

View File

@@ -0,0 +1,188 @@
# keydr Improvement Plan
## Context
The app was built in a single first-pass implementation. Six issues need addressing: a broken settings menu, low-contrast pending text, poor phonetic word quality, a bare-bones stats dashboard, an undersized keyboard visualization, and hardcoded passage/code content.
---
## Issue 1: Settings Menu Not Working
**Root cause**: In `main.rs:handle_menu_key`, the `Enter` match handles `0..=3` but Settings is menu item index `4` — it falls through to `_ => {}`. Also no `KeyCode::Char('c')` shortcut handler exists.
**Fix** (`src/main.rs:124-158`):
- Add `4 => app.screen = AppScreen::Settings` in the Enter match arm
- Add `KeyCode::Char('c') => app.screen = AppScreen::Settings` handler
**Additionally**, make the Settings screen functional instead of a stub:
- Make it an interactive form with arrow keys to select fields, Enter to cycle values
- Fields: Target WPM (adjustable ±5), Theme (cycle through available), Word Count, Code Languages
- Save config on ESC via existing `Config::save()`
- New file: no new files needed; extend `render_settings` in `main.rs` and add `handle_settings_key` logic
- Add `settings_selected: usize` and `settings_editing: bool` fields to `App`
---
## Issue 2: Low Contrast Pending Text
**Root cause**: `text_pending` = `#585b70` (Catppuccin overlay0) on bg `#1e1e2e` is too dim for readable upcoming text.
**Fix**: Change `text_pending` in the default theme and all bundled theme files:
- `src/ui/theme.rs:92` default: `#585b70``#a6adc8` (Catppuccin subtext0, much brighter)
- Update all 8 theme TOML files in `assets/themes/` with appropriate brighter pending text colors for each theme
---
## Issue 3: Better Phonetic Word Generation
**Root cause**: Our current approach uses a hand-built order-2 trigram table from ~50 English patterns. keybr.com uses:
1. **Order-4 Markov chain** trained on top 10,000 words from a real frequency dictionary
2. **Pre-built binary model** (~47KB for English)
3. **Real word dictionary** — the `naturalWords` mode (keybr's default) primarily uses real English words filtered by unlocked letters, falling back to phonetic pseudo-words only when <15 words match
**Implementation plan**:
### Step A: Build a proper transition table from a word frequency list
- Create `tools/build_model.rs` (a build-time binary) that:
1. Reads an English word frequency list (we'll embed a curated 10K-word list as `assets/wordfreq-en.csv`)
2. Uses order-4 chain (matching keybr)
3. Appends each word weighted by frequency (like keybr's `builder.append()` loop)
4. Outputs binary `.data` file matching keybr's format
- **OR simpler approach**: Embed the word list directly and build the table at startup (it's fast enough)
### Step B: Upgrade TransitionTable to order-4
- Modify `TransitionTable` to support variable-order chains
- Change the key from `(char, char)` → a `Vec<char>` prefix of length `order - 1`
- Implement `segment(prefix: &[char])` matching keybr's approach
### Step C: Add a word dictionary for "natural words" mode
- Create `src/generator/dictionary.rs` with a `Dictionary` struct
- Embed a 10K English word list (JSON or plain text) via rust-embed
- `Dictionary::find(filter: &CharFilter, focused: Option<char>) -> Vec<&str>` returns real words where all characters are in the allowed set
- If focused letter exists, prefer words containing it
### Step D: Update PhoneticGenerator to use combined approach (like keybr's GuidedLesson)
- When `naturalWords` is enabled (default):
1. Get real words matching the filter from Dictionary
2. If >= 15 real words available, randomly pick from them
3. Otherwise, supplement with phonetic pseudo-words from the Markov chain
- This is what makes keybr's output "feel like real words" — because they mostly ARE real words
**Key files to modify**:
- `src/generator/transition_table.rs` — upgrade to order-4
- `src/generator/phonetic.rs` — update word generation loop
- New: `src/generator/dictionary.rs` — real word dictionary
- New: `assets/words-en.json` — embedded 10K word list (we can extract from keybr's `clones/keybr.com/packages/keybr-content-words/lib/data/words-en.json`)
- `src/app.rs` — wire up dictionary
---
## Issue 4: Comprehensive Statistics Dashboard
**Current state**: Single screen with 4 summary numbers and 1 unlabeled WPM line chart.
**Target** (inspired by typr's three-tab layout):
### Tab navigation
- Add tab state to the stats dashboard: `Dashboard | History | Keystrokes`
- Keyboard: `D`, `H`, `K` to switch tabs, or `Tab` to cycle
- Render tabs as a header row with active tab highlighted
### Dashboard Tab
1. **Summary stats row**: Total lessons, Avg WPM, Best WPM, Avg Accuracy, Total time, Streak
2. **Progress bars** (3 columns): WPM vs goal, Accuracy vs 100%, Level progress
3. **WPM over time chart** (line chart, last 50 lessons) — already exists, add axis labels
4. **Accuracy over time chart** (line chart, last 50 lessons) — new chart
### History Tab
1. **Recent tests table**: Last 20 lessons with columns: #, WPM, Raw WPM, Accuracy, Time, Date
2. **Per-key average speed chart**: Bar chart of all 26 letters by avg typing time
### Keystrokes Tab
1. **Keyboard accuracy heatmap**: Render keyboard layout with per-key accuracy coloring (green=100%, yellow=90-100%, red=<90%)
2. **Slowest/Fastest keys tables**: Top 5 each with average time in ms
3. **Word/Character stats**: Total correct/wrong counts
**Key files to modify/create**:
- `src/ui/components/stats_dashboard.rs` — complete rewrite with tabs
- `src/ui/components/chart.rs` — add AccuracyChart, BarChart widgets
- New: `src/ui/components/keyboard_heatmap.rs` — per-key accuracy visualization
- `src/engine/key_stats.rs` — ensure per-key accuracy tracking exists (not just timing)
- `src/session/result.rs` — ensure per-key accuracy data is persisted
- `src/store/schema.rs` — may need to add per-key accuracy to KeyStatsData
---
## Issue 5: Keyboard Visualization Too Small
**Current state**: Keyboard diagram IS rendered in `render_lesson` (`main.rs:330-335`) but given only `Constraint::Length(4)` — with borders that's 2 inner rows, but QWERTY needs 3 rows.
**Fix**:
- Change keyboard constraint from `Length(4)` to `Length(5)` in `main.rs:316`
- Improve the keyboard rendering in `keyboard_diagram.rs`:
- Use wider keys (5 chars instead of 4) for readability
- Add finger-color coding (reuse existing `keyboard/finger.rs`)
- Show the next key to type highlighted (pass current target char)
- Improve spacing/centering
**Files**: `src/main.rs:311-318`, `src/ui/components/keyboard_diagram.rs`
---
## Issue 6: Embedded + Internet Content (Both Approaches)
### Embedded Baseline (always available, no network)
- Bundle ~50 passages from public domain literature directly in binary (via rust-embed)
- Bundle ~100 code snippets per language (Rust, Python, JS, Go) in embedded assets
- These replace the current ~15 hardcoded passages and ~12 code snippets per language
### Internet Fetching (on top of embedded, with caching)
**Passages: Project Gutenberg**
- Fetch from `https://www.gutenberg.org/cache/epub/{id}/pg{id}.txt`
- Curate ~20 popular book IDs (Pride and Prejudice, Alice in Wonderland, etc.)
- Extract random paragraphs (skip Gutenberg header/footer boilerplate)
- Cache fetched books to `~/.local/share/keydr/passages/`
- Gracefully fall back to embedded passages on network failure
**Code: GitHub Raw Files**
- Fetch raw files from curated popular repos (e.g., `tokio-rs/tokio`, `python/cpython`)
- Use direct raw.githubusercontent.com URLs for specific files (no API auth needed)
- Extract function-length snippets (20-50 lines)
- Cache to `~/.local/share/keydr/code_cache/`
- Gracefully fall back to embedded snippets on failure
**New dependency**: `reqwest = { version = "0.12", features = ["json", "blocking"] }`
**Files to modify**:
- `src/generator/passage.rs` — expand embedded + add Gutenberg fetching
- `src/generator/code_syntax.rs` — expand embedded + add GitHub fetching
- New: `src/generator/cache.rs` — shared disk caching logic
- New: `assets/passages/*.txt` — embedded passage files
- New: `assets/code/*.rs`, `*.py`, etc. — embedded code snippet files
- `Cargo.toml` — add reqwest dependency
---
## Implementation Order
1. **Issue 1** (Settings menu fix) — quick fix, unblocks testing
2. **Issue 2** (Text contrast) — quick theme change
3. **Issue 5** (Keyboard size) — quick layout fix
4. **Issue 3** (Word generation) — medium complexity, core improvement
5. **Issue 4** (Stats dashboard) — large UI rewrite
6. **Issue 6** (Internet content) — medium complexity, requires new dependency
---
## Verification
1. `cargo build` — compiles without errors
2. `cargo test` — all tests pass
3. Manual testing for each issue:
- Settings: navigate to Settings in menu, change target WPM, verify it saves/loads
- Contrast: verify pending text is readable in the typing area
- Keyboard: verify all 3 QWERTY rows visible during lesson
- Words: start adaptive mode, verify words look like real English
- Stats: complete 2-3 lessons, check all three stats tabs render correctly
- Passages: start passage mode, verify it fetches new content (with network), and falls back gracefully (without)

View File

@@ -7,6 +7,7 @@ use crate::engine::key_stats::KeyStatsStore;
use crate::engine::letter_unlock::LetterUnlock;
use crate::engine::scoring;
use crate::generator::code_syntax::CodeSyntaxGenerator;
use crate::generator::dictionary::Dictionary;
use crate::generator::passage::PassageGenerator;
use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::TextGenerator;
@@ -26,7 +27,6 @@ pub enum AppScreen {
Lesson,
LessonResult,
StatsDashboard,
#[allow(dead_code)]
Settings,
}
@@ -52,8 +52,12 @@ pub struct App {
pub profile: ProfileData,
pub store: Option<JsonStore>,
pub should_quit: bool,
pub settings_selected: usize,
pub stats_tab: usize,
rng: SmallRng,
transition_table: TransitionTable,
#[allow(dead_code)]
dictionary: Dictionary,
}
impl App {
@@ -89,7 +93,8 @@ impl App {
let mut key_stats_with_target = key_stats;
key_stats_with_target.target_cpm = config.target_cpm();
let transition_table = TransitionTable::build_english();
let dictionary = Dictionary::load();
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
Self {
screen: AppScreen::Menu,
@@ -106,8 +111,11 @@ impl App {
profile,
store,
should_quit: false,
settings_selected: 0,
stats_tab: 0,
rng: SmallRng::from_entropy(),
transition_table,
dictionary,
}
}
@@ -127,8 +135,9 @@ impl App {
let filter = CharFilter::new(self.letter_unlock.included.clone());
let focused = self.letter_unlock.focused;
let table = self.transition_table.clone();
let dict = Dictionary::load();
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PhoneticGenerator::new(table, rng);
let mut generator = PhoneticGenerator::new(table, dict, rng);
generator.generate(&filter, focused, word_count)
}
LessonMode::Code => {
@@ -145,7 +154,8 @@ impl App {
}
LessonMode::Passage => {
let filter = CharFilter::new(('a'..='z').collect());
let mut generator = PassageGenerator::new();
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PassageGenerator::new(rng);
generator.generate(&filter, None, word_count)
}
}
@@ -244,6 +254,90 @@ impl App {
}
pub fn go_to_stats(&mut self) {
self.stats_tab = 0;
self.screen = AppScreen::StatsDashboard;
}
pub fn go_to_settings(&mut self) {
self.settings_selected = 0;
self.screen = AppScreen::Settings;
}
pub fn settings_cycle_forward(&mut self) {
match self.settings_selected {
0 => {
self.config.target_wpm = (self.config.target_wpm + 5).min(200);
self.key_stats.target_cpm = self.config.target_cpm();
}
1 => {
let themes = Theme::available_themes();
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
let next = (idx + 1) % themes.len();
self.config.theme = themes[next].clone();
} else if let Some(first) = themes.first() {
self.config.theme = first.clone();
}
if let Some(new_theme) = Theme::load(&self.config.theme) {
let theme: &'static Theme = Box::leak(Box::new(new_theme));
self.theme = theme;
self.menu.theme = theme;
}
}
2 => {
self.config.word_count = (self.config.word_count + 5).min(100);
}
3 => {
let langs = ["rust", "python", "javascript", "go"];
let current = self
.config
.code_languages
.first()
.map(|s| s.as_str())
.unwrap_or("rust");
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
let next = (idx + 1) % langs.len();
self.config.code_languages = vec![langs[next].to_string()];
}
_ => {}
}
}
pub fn settings_cycle_backward(&mut self) {
match self.settings_selected {
0 => {
self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
self.key_stats.target_cpm = self.config.target_cpm();
}
1 => {
let themes = Theme::available_themes();
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
let next = if idx == 0 { themes.len() - 1 } else { idx - 1 };
self.config.theme = themes[next].clone();
} else if let Some(first) = themes.first() {
self.config.theme = first.clone();
}
if let Some(new_theme) = Theme::load(&self.config.theme) {
let theme: &'static Theme = Box::leak(Box::new(new_theme));
self.theme = theme;
self.menu.theme = theme;
}
}
2 => {
self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
}
3 => {
let langs = ["rust", "python", "javascript", "go"];
let current = self
.config
.code_languages
.first()
.map(|s| s.as_str())
.unwrap_or("rust");
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
self.config.code_languages = vec![langs[next].to_string()];
}
_ => {}
}
}
}

49
src/generator/cache.rs Normal file
View File

@@ -0,0 +1,49 @@
use std::fs;
use std::path::PathBuf;
pub struct DiskCache {
base_dir: PathBuf,
}
impl DiskCache {
pub fn new(subdir: &str) -> Option<Self> {
let base = dirs::data_dir()?.join("keydr").join(subdir);
fs::create_dir_all(&base).ok()?;
Some(Self { base_dir: base })
}
pub fn get(&self, key: &str) -> Option<String> {
let path = self.base_dir.join(Self::sanitize_key(key));
fs::read_to_string(path).ok()
}
pub fn put(&self, key: &str, content: &str) -> bool {
let path = self.base_dir.join(Self::sanitize_key(key));
fs::write(path, content).is_ok()
}
fn sanitize_key(key: &str) -> String {
key.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.collect()
}
}
#[cfg(feature = "network")]
pub fn fetch_url(url: &str) -> Option<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok()?;
let response = client.get(url).send().ok()?;
if response.status().is_success() {
response.text().ok()
} else {
None
}
}
#[cfg(not(feature = "network"))]
pub fn fetch_url(_url: &str) -> Option<String> {
None
}

View File

@@ -2,18 +2,81 @@ use rand::rngs::SmallRng;
use rand::Rng;
use crate::engine::filter::CharFilter;
use crate::generator::cache::{DiskCache, fetch_url};
use crate::generator::TextGenerator;
pub struct CodeSyntaxGenerator {
rng: SmallRng,
language: String,
fetched_snippets: Vec<String>,
}
impl CodeSyntaxGenerator {
pub fn new(rng: SmallRng, language: &str) -> Self {
Self {
let mut generator = Self {
rng,
language: language.to_string(),
fetched_snippets: Vec::new(),
};
generator.load_cached_snippets();
generator
}
fn load_cached_snippets(&mut self) {
if let Some(cache) = DiskCache::new("code_cache") {
let key = format!("{}_snippets", self.language);
if let Some(content) = cache.get(&key) {
self.fetched_snippets = content
.split("\n---SNIPPET---\n")
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.collect();
}
}
}
fn try_fetch_code(&mut self) {
let urls = match self.language.as_str() {
"rust" => vec![
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/sync/mutex.rs",
"https://raw.githubusercontent.com/serde-rs/serde/master/serde/src/ser/mod.rs",
],
"python" => vec![
"https://raw.githubusercontent.com/python/cpython/main/Lib/json/encoder.py",
"https://raw.githubusercontent.com/python/cpython/main/Lib/pathlib/__init__.py",
],
"javascript" | "js" => vec![
"https://raw.githubusercontent.com/lodash/lodash/main/src/chunk.ts",
"https://raw.githubusercontent.com/expressjs/express/master/lib/router/index.js",
],
"go" => vec![
"https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go",
],
_ => vec![],
};
let cache = match DiskCache::new("code_cache") {
Some(c) => c,
None => return,
};
let key = format!("{}_snippets", self.language);
if cache.get(&key).is_some() {
return;
}
let mut all_snippets = Vec::new();
for url in urls {
if let Some(content) = fetch_url(url) {
let snippets = extract_code_snippets(&content);
all_snippets.extend(snippets);
}
}
if !all_snippets.is_empty() {
let combined = all_snippets.join("\n---SNIPPET---\n");
cache.put(&key, &combined);
self.fetched_snippets = all_snippets;
}
}
@@ -35,6 +98,20 @@ impl CodeSyntaxGenerator {
"trait Display { fn show(&self) -> String; }",
"while let Some(item) = stack.pop() { process(item); }",
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }",
"let handle = std::thread::spawn(|| { println!(\"thread\"); });",
"let mut map = HashMap::new(); map.insert(\"key\", 42);",
"fn factorial(n: u64) -> u64 { if n <= 1 { 1 } else { n * factorial(n - 1) } }",
"impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { None } }",
"async fn fetch(url: &str) -> Result<String> { let body = reqwest::get(url).await?.text().await?; Ok(body) }",
"let closure = |x: i32, y: i32| -> i32 { x + y };",
"mod tests { use super::*; #[test] fn it_works() { assert_eq!(2 + 2, 4); } }",
"pub struct Builder { name: Option<String> } impl Builder { pub fn name(mut self, n: &str) -> Self { self.name = Some(n.into()); self } }",
"use std::sync::{Arc, Mutex}; let data = Arc::new(Mutex::new(vec![1, 2, 3]));",
"if let Ok(value) = \"42\".parse::<i32>() { println!(\"parsed: {}\", value); }",
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }",
"type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;",
"macro_rules! vec_of_strings { ($($x:expr),*) => { vec![$($x.to_string()),*] }; }",
"let (tx, rx) = std::sync::mpsc::channel(); tx.send(42).unwrap();",
]
}
@@ -52,6 +129,19 @@ impl CodeSyntaxGenerator {
"from collections import defaultdict",
"lambda x: x * 2 + 1",
"dict_comp = {k: v for k, v in pairs.items()}",
"async def fetch(url): async with aiohttp.ClientSession() as session: return await session.get(url)",
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
"@property def name(self): return self._name",
"from dataclasses import dataclass; @dataclass class Config: name: str; value: int = 0",
"yield from range(10)",
"sorted(items, key=lambda x: x.name, reverse=True)",
"from typing import Optional, List, Dict",
"with contextlib.suppress(FileNotFoundError): os.remove(\"temp.txt\")",
"class Meta(type): def __new__(cls, name, bases, attrs): return super().__new__(cls, name, bases, attrs)",
"from functools import lru_cache; @lru_cache(maxsize=128) def expensive(n): return sum(range(n))",
"from pathlib import Path; files = list(Path(\".\").glob(\"**/*.py\"))",
"assert isinstance(result, dict), f\"Expected dict, got {type(result)}\"",
"values = {*set_a, *set_b}; merged = {**dict_a, **dict_b}",
]
}
@@ -69,6 +159,18 @@ impl CodeSyntaxGenerator {
"try { parse(data); } catch (e) { console.error(e); }",
"export default function handler(req, res) { res.send(\"ok\"); }",
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);",
"const promise = new Promise((resolve, reject) => { setTimeout(resolve, 1000); });",
"const [first, ...rest] = array;",
"class EventEmitter { constructor() { this.listeners = new Map(); } }",
"const proxy = new Proxy(target, { get(obj, prop) { return obj[prop]; } });",
"for await (const chunk of stream) { process(chunk); }",
"const memoize = (fn) => { const cache = new Map(); return (...args) => cache.get(args) ?? fn(...args); };",
"import { useState, useEffect } from 'react'; const [state, setState] = useState(null);",
"const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);",
"Object.entries(obj).forEach(([key, value]) => { console.log(key, value); });",
"const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; };",
"const observable = new Observable(subscriber => { subscriber.next(1); subscriber.complete(); });",
"Symbol.iterator",
]
}
@@ -84,6 +186,16 @@ impl CodeSyntaxGenerator {
"switch val { case 1: return \"one\" default: return \"other\" }",
"go func() { ch <- result }()",
"defer file.Close()",
"type Reader interface { Read(p []byte) (n int, err error) }",
"ctx, cancel := context.WithTimeout(context.Background(), time.Second)",
"var wg sync.WaitGroup; wg.Add(1); go func() { defer wg.Done() }()",
"func (p *Point) Distance() float64 { return math.Sqrt(p.X*p.X + p.Y*p.Y) }",
"select { case msg := <-ch: process(msg) case <-time.After(time.Second): timeout() }",
"json.NewEncoder(w).Encode(response)",
"http.HandleFunc(\"/api\", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\"ok\")) })",
"func Map[T, U any](s []T, f func(T) U) []U { r := make([]U, len(s)); for i, v := range s { r[i] = f(v) }; return r }",
"var once sync.Once; once.Do(func() { initialize() })",
"buf := bytes.NewBuffer(nil); buf.WriteString(\"hello\")",
]
}
@@ -105,18 +217,88 @@ impl TextGenerator for CodeSyntaxGenerator {
_focused: Option<char>,
word_count: usize,
) -> String {
let snippets = self.get_snippets();
// Try to fetch from GitHub on first use
if self.fetched_snippets.is_empty() {
self.try_fetch_code();
}
let embedded = self.get_snippets();
let mut result = Vec::new();
let target_words = word_count;
let mut current_words = 0;
let total_available = embedded.len() + self.fetched_snippets.len();
while current_words < target_words {
let idx = self.rng.gen_range(0..snippets.len());
let snippet = snippets[idx];
let idx = self.rng.gen_range(0..total_available.max(1));
let snippet = if idx < embedded.len() {
embedded[idx]
} else if !self.fetched_snippets.is_empty() {
let f_idx = (idx - embedded.len()) % self.fetched_snippets.len();
&self.fetched_snippets[f_idx]
} else {
embedded[idx % embedded.len()]
};
current_words += snippet.split_whitespace().count();
result.push(snippet);
result.push(snippet.to_string());
}
result.join(" ")
}
}
/// Extract function-length snippets from raw source code
fn extract_code_snippets(source: &str) -> Vec<String> {
let mut snippets = Vec::new();
let lines: Vec<&str> = source.lines().collect();
let mut i = 0;
while i < lines.len() {
// Look for function/method starts
let line = lines[i].trim();
let is_func_start = line.starts_with("fn ")
|| line.starts_with("pub fn ")
|| line.starts_with("def ")
|| line.starts_with("func ")
|| line.starts_with("function ")
|| line.starts_with("async fn ")
|| line.starts_with("pub async fn ");
if is_func_start {
let mut snippet_lines = Vec::new();
let mut depth = 0i32;
let mut j = i;
while j < lines.len() && snippet_lines.len() < 30 {
let l = lines[j];
snippet_lines.push(l);
depth += l.chars().filter(|&c| c == '{' || c == '(').count() as i32;
depth -= l.chars().filter(|&c| c == '}' || c == ')').count() as i32;
if depth <= 0 && j > i {
break;
}
j += 1;
}
if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 {
let snippet = snippet_lines.join(" ");
// Normalize whitespace
let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.len() >= 20 && normalized.len() <= 500 {
snippets.push(normalized);
}
}
i = j + 1;
} else {
i += 1;
}
}
snippets.truncate(50);
snippets
}

View File

@@ -0,0 +1,45 @@
use crate::engine::filter::CharFilter;
const WORDS_EN: &str = include_str!("../../assets/words-en.json");
pub struct Dictionary {
words: Vec<String>,
}
impl Dictionary {
pub fn load() -> Self {
let words: Vec<String> = serde_json::from_str(WORDS_EN).unwrap_or_default();
// Filter to words of length >= 3 (matching keybr)
let words = words
.into_iter()
.filter(|w| w.len() >= 3 && w.chars().all(|c| c.is_ascii_lowercase()))
.collect();
Self { words }
}
pub fn words_list(&self) -> Vec<String> {
self.words.clone()
}
pub fn find_matching(
&self,
filter: &CharFilter,
focused: Option<char>,
) -> Vec<&str> {
let mut matching: Vec<&str> = self
.words
.iter()
.filter(|w| w.chars().all(|c| filter.is_allowed(c)))
.map(|s| s.as_str())
.collect();
// If there's a focused letter, prioritize words containing it
if let Some(focus) = focused {
matching.sort_by_key(|w| if w.contains(focus) { 0 } else { 1 });
}
matching
}
}

View File

@@ -1,4 +1,6 @@
pub mod cache;
pub mod code_syntax;
pub mod dictionary;
pub mod github_code;
pub mod passage;
pub mod phonetic;

View File

@@ -1,7 +1,12 @@
use rand::rngs::SmallRng;
use rand::Rng;
use crate::engine::filter::CharFilter;
use crate::generator::cache::{DiskCache, fetch_url};
use crate::generator::TextGenerator;
const PASSAGES: &[&str] = &[
// Classic literature & speeches
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
@@ -17,21 +22,132 @@ const PASSAGES: &[&str] = &[
"he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world",
"the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below",
"they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door",
// Pride and Prejudice
"it is a truth universally acknowledged that a single man in possession of a good fortune must be in want of a wife",
"there is a stubbornness about me that never can bear to be frightened at the will of others my courage always rises at every attempt to intimidate me",
"i could easily forgive his pride if he had not mortified mine but vanity not love has been my folly",
// Alice in Wonderland
"alice was beginning to get very tired of sitting by her sister on the bank and of having nothing to do",
"who in the world am i that is the great puzzle she said as she looked around the strange room with wonder",
"but i dont want to go among mad people alice remarked oh you cant help that said the cat were all mad here",
// Great Gatsby
"in my younger and more vulnerable years my father gave me some advice that i have been turning over in my mind ever since",
"so we beat on boats against the current borne back ceaselessly into the past dreaming of that green light",
// Sherlock Holmes
"when you have eliminated the impossible whatever remains however improbable must be the truth my dear watson",
"the world is full of obvious things which nobody by any chance ever observes but which are perfectly visible",
// Moby Dick
"call me ishmael some years ago having little or no money in my purse and nothing particular to interest me on shore",
"it is not down on any map because true places never are and the voyage was long and the sea was deep",
// 1984
"it was a bright cold day in april and the clocks were striking thirteen winston smith his chin nuzzled into his breast",
"who controls the past controls the future and who controls the present controls the past said the voice from the screen",
// Walden
"i went to the woods because i wished to live deliberately to front only the essential facts of life",
"the mass of men lead lives of quiet desperation and go to the grave with the song still in them",
// Science & philosophy
"the only way to do great work is to love what you do and if you have not found it yet keep looking and do not settle",
"imagination is more important than knowledge for while knowledge defines all we currently know imagination points to what we might discover",
"the important thing is not to stop questioning for curiosity has its own reason for existing in this wonderful universe",
"we are all in the gutter but some of us are looking at the stars and dreaming of worlds beyond our own",
"the greatest glory in living lies not in never falling but in rising every time we fall and trying once more",
// Nature & observation
"the autumn wind scattered golden leaves across the garden as the last rays of sunlight painted the clouds in shades of orange and pink",
"deep in the forest where the ancient trees stood tall and silent a small stream wound its way through moss covered stones",
"the ocean stretched endlessly before them its surface catching the light of the setting sun in a thousand shimmering reflections",
"morning mist hung low over the meadow as the first birds began their chorus and dew drops sparkled like diamonds on every blade of grass",
"the mountain peak stood above the clouds its snow covered summit glowing pink and gold in the light of the early morning sun",
// Everyday wisdom
"the best time to plant a tree was twenty years ago and the second best time is now so do not wait any longer to begin",
"a journey of a thousand miles begins with a single step and every great achievement started with the decision to try",
"the more that you read the more things you will know and the more that you learn the more places you will go",
"in three words i can sum up everything i have learned about life it goes on and so must we with hope",
"happiness is not something ready made it comes from your own actions and your choices shape the life you live",
"do not go where the path may lead but go instead where there is no path and leave a trail for others to follow",
"success is not final failure is not fatal it is the courage to continue that counts in the end",
"be yourself because everyone else is already taken and the world needs what only you can bring to it",
"life is what happens when you are busy making other plans so enjoy the journey along the way",
"the secret of getting ahead is getting started and the secret of getting started is breaking your tasks into small steps",
];
/// Gutenberg book IDs for popular public domain works
const GUTENBERG_IDS: &[(u32, &str)] = &[
(1342, "pride_and_prejudice"),
(11, "alice_in_wonderland"),
(1661, "sherlock_holmes"),
(84, "frankenstein"),
(1952, "yellow_wallpaper"),
(2701, "moby_dick"),
(74, "tom_sawyer"),
(345, "dracula"),
(1232, "prince"),
(76, "huckleberry_finn"),
(5200, "metamorphosis"),
(2542, "aesop_fables"),
(174, "dorian_gray"),
(98, "tale_two_cities"),
(1080, "modest_proposal"),
(219, "heart_of_darkness"),
(4300, "ulysses"),
(28054, "brothers_karamazov"),
(2554, "crime_and_punishment"),
(55, "oz"),
];
pub struct PassageGenerator {
current_idx: usize,
fetched_passages: Vec<String>,
rng: SmallRng,
}
impl PassageGenerator {
pub fn new() -> Self {
Self { current_idx: 0 }
pub fn new(rng: SmallRng) -> Self {
let mut generator = Self {
current_idx: 0,
fetched_passages: Vec::new(),
rng,
};
generator.load_cached_passages();
generator
}
}
impl Default for PassageGenerator {
fn default() -> Self {
Self::new()
fn load_cached_passages(&mut self) {
if let Some(cache) = DiskCache::new("passages") {
for &(_, name) in GUTENBERG_IDS {
if let Some(content) = cache.get(name) {
let paragraphs = extract_paragraphs(&content);
self.fetched_passages.extend(paragraphs);
}
}
}
}
fn try_fetch_gutenberg(&mut self) {
let cache = match DiskCache::new("passages") {
Some(c) => c,
None => return,
};
// Pick a random book that we haven't cached yet
let uncached: Vec<(u32, &str)> = GUTENBERG_IDS
.iter()
.filter(|(_, name)| cache.get(name).is_none())
.copied()
.collect();
if uncached.is_empty() {
return;
}
let idx = self.rng.gen_range(0..uncached.len());
let (book_id, name) = uncached[idx];
let url = format!("https://www.gutenberg.org/cache/epub/{book_id}/pg{book_id}.txt");
if let Some(content) = fetch_url(&url) {
cache.put(name, &content);
let paragraphs = extract_paragraphs(&content);
self.fetched_passages.extend(paragraphs);
}
}
}
@@ -42,8 +158,87 @@ impl TextGenerator for PassageGenerator {
_focused: Option<char>,
_word_count: usize,
) -> String {
let passage = PASSAGES[self.current_idx % PASSAGES.len()];
// Try to fetch a new Gutenberg book in the background (first few calls)
if self.fetched_passages.len() < 50 && self.current_idx < 3 {
self.try_fetch_gutenberg();
}
let total_passages = PASSAGES.len() + self.fetched_passages.len();
if total_passages == 0 {
self.current_idx += 1;
passage.to_string()
return PASSAGES[0].to_string();
}
// Mix embedded and fetched passages
let idx = self.current_idx % total_passages;
self.current_idx += 1;
if idx < PASSAGES.len() {
PASSAGES[idx].to_string()
} else {
let fetched_idx = idx - PASSAGES.len();
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
}
}
}
/// Extract readable paragraphs from Gutenberg text, skipping header/footer
fn extract_paragraphs(text: &str) -> Vec<String> {
let mut paragraphs = Vec::new();
// Find the start of actual content (after Gutenberg header)
let start_markers = ["*** START OF", "***START OF"];
let end_markers = ["*** END OF", "***END OF"];
let content_start = start_markers
.iter()
.filter_map(|marker| text.find(marker))
.min()
.map(|pos| {
// Find the end of the header line
text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)
})
.unwrap_or(0);
let content_end = end_markers
.iter()
.filter_map(|marker| text.find(marker))
.min()
.unwrap_or(text.len());
let content = &text[content_start..content_end];
// Split into paragraphs (double newline separated)
for para in content.split("\r\n\r\n").chain(content.split("\n\n")) {
let cleaned: String = para
.lines()
.map(|l| l.trim())
.collect::<Vec<_>>()
.join(" ")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
.collect::<String>()
.to_lowercase();
let word_count = cleaned.split_whitespace().count();
if word_count >= 15 && word_count <= 60 {
// Keep only the alpha/space portions for typing
let typing_text: String = cleaned
.chars()
.filter(|c| c.is_ascii_lowercase() || *c == ' ')
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if typing_text.split_whitespace().count() >= 10 {
paragraphs.push(typing_text);
}
}
}
// Take at most 100 paragraphs per book
paragraphs.truncate(100);
paragraphs
}

View File

@@ -2,17 +2,27 @@ use rand::rngs::SmallRng;
use rand::Rng;
use crate::engine::filter::CharFilter;
use crate::generator::dictionary::Dictionary;
use crate::generator::transition_table::TransitionTable;
use crate::generator::TextGenerator;
const MIN_WORD_LEN: usize = 3;
const MAX_WORD_LEN: usize = 10;
const MIN_REAL_WORDS: usize = 15;
pub struct PhoneticGenerator {
table: TransitionTable,
dictionary: Dictionary,
rng: SmallRng,
}
impl PhoneticGenerator {
pub fn new(table: TransitionTable, rng: SmallRng) -> Self {
Self { table, rng }
pub fn new(table: TransitionTable, dictionary: Dictionary, rng: SmallRng) -> Self {
Self {
table,
dictionary,
rng,
}
}
fn pick_weighted_from(
@@ -46,29 +56,31 @@ impl PhoneticGenerator {
Some(filtered.last().unwrap().0)
}
fn generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
let min_len = 3;
let max_len = 10;
let mut word = String::new();
fn generate_phonetic_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
for _attempt in 0..5 {
let word = self.try_generate_word(filter, focused);
if word.len() >= MIN_WORD_LEN {
return word;
}
}
// Fallback
"the".to_string()
}
fn try_generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
let mut word = Vec::new();
// Start with space prefix
let start_char = if let Some(focus) = focused {
if self.rng.gen_bool(0.4) {
let probs = self.table.get_next_probs(' ', focus).cloned();
if let Some(probs) = probs {
let filtered: Vec<(char, f64)> = probs
.iter()
.filter(|(ch, _)| filter.is_allowed(*ch))
.copied()
.collect();
if !filtered.is_empty() {
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
word.push(focus);
Self::pick_weighted_from(&mut self.rng, &filtered, filter)
// Get next char from transition table
let prefix = vec![' ', ' ', focus];
if let Some(probs) = self.table.segment(&prefix) {
Self::pick_weighted_from(&mut self.rng, probs, filter)
} else {
None
}
} else {
Some(focus)
}
} else {
None
}
@@ -76,60 +88,94 @@ impl PhoneticGenerator {
None
};
if word.is_empty() {
// Pick a start from transition table
let prefix = vec![' ', ' ', ' '];
if let Some(probs) = self.table.segment(&prefix) {
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, probs, filter) {
word.push(ch);
}
}
// Fallback: weighted random start
if word.is_empty() {
let starters: Vec<(char, f64)> = filter
.allowed
.iter()
.map(|&ch| {
(
ch,
if ch == 'e' || ch == 't' || ch == 'a' {
3.0
} else {
1.0
},
)
let w = match ch {
'e' | 't' | 'a' => 3.0,
'o' | 'i' | 'n' | 's' => 2.0,
_ => 1.0,
};
(ch, w)
})
.collect();
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
word.push(ch);
} else {
return "the".to_string();
}
}
}
if let Some(ch) = start_char {
word.push(ch);
}
while word.len() < max_len {
let chars: Vec<char> = word.chars().collect();
let len = chars.len();
let (prev, curr) = if len >= 2 {
(chars[len - 2], chars[len - 1])
while word.len() < MAX_WORD_LEN {
// Build prefix from recent chars, padded with spaces
let prefix_len = self.table.order - 1;
let mut prefix = Vec::new();
let start = if word.len() >= prefix_len {
word.len() - prefix_len
} else {
(' ', chars[len - 1])
0
};
let space_prob = 1.3f64.powi(word.len() as i32 - min_len as i32);
if word.len() >= min_len
&& self
.rng
.gen_bool((space_prob / (space_prob + 5.0)).min(0.8))
{
break;
for _ in 0..(prefix_len.saturating_sub(word.len())) {
prefix.push(' ');
}
for i in start..word.len() {
prefix.push(word[i]);
}
let probs = self.table.get_next_probs(prev, curr).cloned();
if let Some(probs) = probs {
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &probs, filter) {
// Check for word ending (space probability increases with length)
if word.len() >= MIN_WORD_LEN {
if let Some(probs) = self.table.segment(&prefix) {
let space_weight: f64 = probs
.iter()
.filter(|(ch, _)| *ch == ' ')
.map(|(_, w)| w)
.sum();
if space_weight > 0.0 {
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
let total: f64 = probs.iter().map(|(_, w)| w).sum();
let space_prob = (space_weight * boost) / (total + space_weight * (boost - 1.0));
if self.rng.gen_bool(space_prob.min(0.85)) {
break;
}
}
}
// Even without space in table, use length-based ending
let end_prob = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
if self.rng.gen_bool((end_prob / (end_prob + 5.0)).min(0.8)) {
break;
}
}
// Get next character from transition table
if let Some(probs) = self.table.segment(&prefix) {
let non_space: Vec<(char, f64)> = probs
.iter()
.filter(|(ch, _)| *ch != ' ')
.copied()
.collect();
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
word.push(next);
} else {
break;
}
} else {
// Fallback to vowel
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
.iter()
.filter(|&&v| filter.is_allowed(v))
@@ -143,11 +189,7 @@ impl PhoneticGenerator {
}
}
if word.is_empty() {
"the".to_string()
} else {
word
}
word.iter().collect()
}
}
@@ -158,10 +200,42 @@ impl TextGenerator for PhoneticGenerator {
focused: Option<char>,
word_count: usize,
) -> String {
// keybr's approach: prefer real words when enough match the filter
// Collect matching words into owned Vec to avoid borrow conflict
let matching_words: Vec<String> = self
.dictionary
.find_matching(filter, focused)
.iter()
.map(|s| s.to_string())
.collect();
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
let mut words: Vec<String> = Vec::new();
let mut last_word = String::new();
for _ in 0..word_count {
words.push(self.generate_word(filter, focused));
if use_real_words {
// Pick a real word (avoid consecutive duplicates)
let mut picked = None;
for _ in 0..3 {
let idx = self.rng.gen_range(0..matching_words.len());
let word = matching_words[idx].clone();
if word != last_word {
picked = Some(word);
break;
}
}
let word = match picked {
Some(w) => w,
None => self.generate_phonetic_word(filter, focused),
};
last_word.clone_from(&word);
words.push(word);
} else {
// Fall back to phonetic pseudo-words
let word = self.generate_phonetic_word(filter, focused);
words.push(word);
}
}
words.join(" ")

View File

@@ -4,29 +4,108 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransitionTable {
pub transitions: HashMap<(char, char), Vec<(char, f64)>>,
pub order: usize,
transitions: HashMap<Vec<char>, Vec<(char, f64)>>,
}
impl TransitionTable {
pub fn new() -> Self {
pub fn new(order: usize) -> Self {
Self {
order,
transitions: HashMap::new(),
}
}
pub fn add(&mut self, prev: char, curr: char, next: char, weight: f64) {
pub fn add(&mut self, prefix: &[char], next: char, weight: f64) {
self.transitions
.entry((prev, curr))
.entry(prefix.to_vec())
.or_default()
.push((next, weight));
}
pub fn get_next_probs(&self, prev: char, curr: char) -> Option<&Vec<(char, f64)>> {
self.transitions.get(&(prev, curr))
pub fn segment(&self, prefix: &[char]) -> Option<&Vec<(char, f64)>> {
// Try exact prefix match first, then fall back to shorter prefixes
let key_len = self.order - 1;
let prefix = if prefix.len() >= key_len {
&prefix[prefix.len() - key_len..]
} else {
prefix
};
// Try progressively shorter prefixes for backoff
for start in 0..prefix.len() {
let key = prefix[start..].to_vec();
if let Some(entries) = self.transitions.get(&key) {
return Some(entries);
}
}
None
}
/// Build an order-4 transition table from a word frequency list.
/// Words earlier in the list are higher frequency and get more weight.
pub fn build_from_words(words: &[String]) -> Self {
let mut table = Self::new(4);
let prefix_len = 3; // order - 1
for (rank, word) in words.iter().enumerate() {
if word.len() < 3 {
continue;
}
if !word.chars().all(|c| c.is_ascii_lowercase()) {
continue;
}
// Weight decreases with rank (frequency-based)
let weight = 1.0 / (1.0 + (rank as f64 / 500.0));
// Add word start transitions (space prefix -> first chars)
let chars: Vec<char> = word.chars().collect();
// Start of word: ' ' prefix
for i in 0..chars.len() {
let mut prefix = Vec::new();
// Build prefix from space + preceding chars
let start = if i >= prefix_len { i - prefix_len } else { 0 };
if i < prefix_len {
// Pad with spaces
for _ in 0..(prefix_len - i) {
prefix.push(' ');
}
}
for j in start..i {
prefix.push(chars[j]);
}
let next = chars[i];
table.add(&prefix, next, weight);
}
// End of word: last chars -> space
let end_start = if chars.len() >= prefix_len {
chars.len() - prefix_len
} else {
0
};
let mut end_prefix: Vec<char> = Vec::new();
if chars.len() < prefix_len {
for _ in 0..(prefix_len - chars.len()) {
end_prefix.push(' ');
}
}
for j in end_start..chars.len() {
end_prefix.push(chars[j]);
}
table.add(&end_prefix, ' ', weight);
}
table
}
/// Legacy order-2 table for fallback
#[allow(dead_code)]
pub fn build_english() -> Self {
let mut table = Self::new();
let mut table = Self::new(4);
let common_patterns: &[(&str, f64)] = &[
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
@@ -40,25 +119,24 @@ impl TransitionTable {
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
("out", 2.0), ("ind", 2.0), ("ith", 2.0), ("ber", 2.0), ("der", 2.0),
("out", 2.0), ("ind", 2.0), ("ber", 2.0), ("der", 2.0),
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
("igh", 2.0), ("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
("anc", 1.5), ("enc", 1.5), ("inc", 1.5), ("onc", 1.5), ("unc", 1.5),
("unt", 1.5), ("int", 1.5), ("ont", 1.5), ("ent", 1.5), ("ment", 1.5),
("ness", 1.5), ("less", 1.5), ("able", 1.5), ("ible", 1.5), ("ting", 1.5),
("ring", 1.5), ("sing", 1.5), ("king", 1.5), ("ning", 1.5), ("ling", 1.5),
("wing", 1.5), ("ding", 1.5), ("ping", 1.5), ("ging", 1.5), ("ving", 1.5),
("bing", 1.5), ("ming", 1.5), ("fing", 1.0), ("hing", 1.0), ("cing", 1.0),
];
for &(pattern, weight) in common_patterns {
let chars: Vec<char> = pattern.chars().collect();
for window in chars.windows(3) {
table.add(window[0], window[1], window[2], weight);
let prefix = vec![window[0], window[1]];
table.add(&prefix, window[2], weight);
}
// Also add shorter prefix entries for the start of patterns
if chars.len() >= 2 {
table.add(&[' ', chars[0]], chars[1], weight * 0.5);
}
}
@@ -70,20 +148,14 @@ impl TransitionTable {
for &c in &consonants {
for &v in &vowels {
table.add(' ', c, v, 1.0);
table.add(v, c, 'e', 0.5);
for &v2 in &vowels {
table.add(c, v, v2.to_ascii_lowercase(), 0.3);
}
for &c2 in &consonants {
table.add(v, c, c2, 0.2);
}
table.add(&[' ', c], v, 1.0);
table.add(&[v, c], 'e', 0.5);
}
}
for &v in &vowels {
for &c in &consonants {
table.add(' ', v, c, 0.5);
table.add(&[' ', v], c, 0.5);
}
}
@@ -93,6 +165,6 @@ impl TransitionTable {
impl Default for TransitionTable {
fn default() -> Self {
Self::new()
Self::new(4)
}
}

View File

@@ -22,7 +22,7 @@ use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Terminal;
use app::{App, AppScreen, LessonMode};
@@ -137,6 +137,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
app.start_lesson();
}
KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(),
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
KeyCode::Enter => match app.menu.selected {
@@ -153,6 +154,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
app.start_lesson();
}
3 => app.go_to_stats(),
4 => app.go_to_settings(),
_ => {}
},
_ => {}
@@ -191,13 +193,37 @@ fn handle_result_key(app: &mut App, key: KeyEvent) {
fn handle_stats_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('h') | KeyCode::Char('2') => app.stats_tab = 1,
KeyCode::Char('k') | KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 },
_ => {}
}
}
fn handle_settings_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.go_to_menu(),
KeyCode::Esc => {
let _ = app.config.save();
app.go_to_menu();
}
KeyCode::Up | KeyCode::Char('k') => {
if app.settings_selected > 0 {
app.settings_selected -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') => {
if app.settings_selected < 3 {
app.settings_selected += 1;
}
}
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
app.settings_cycle_forward();
}
KeyCode::Left | KeyCode::Char('h') => {
app.settings_cycle_backward();
}
_ => {}
}
}
@@ -313,7 +339,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
.constraints([
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(4),
Constraint::Length(5),
])
.split(app_layout.main);
@@ -327,8 +353,13 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
);
frame.render_widget(progress, main_layout[1]);
let next_char = lesson
.target
.get(lesson.cursor)
.copied();
let kbd = KeyboardDiagram::new(
app.letter_unlock.focused,
next_char,
&app.letter_unlock.included,
app.theme,
);
@@ -357,7 +388,13 @@ fn render_result(frame: &mut ratatui::Frame, app: &App) {
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let dashboard = StatsDashboard::new(&app.lesson_history, app.theme);
let dashboard = StatsDashboard::new(
&app.lesson_history,
&app.key_stats,
app.stats_tab,
app.config.target_wpm,
app.theme,
);
frame.render_widget(dashboard, area);
}
@@ -365,45 +402,83 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
let centered = ui::layout::centered_rect(60, 80, area);
let block = Block::bordered()
.title(" Settings ")
.border_style(Style::default().fg(colors.accent()));
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(centered);
block.render(centered, frame.buffer_mut());
let target_wpm = format!(" Target WPM: {}", app.config.target_wpm);
let theme_name = format!(" Theme: {}", app.config.theme);
let layout_name = format!(" Layout: {}", app.config.keyboard_layout);
let languages = format!(" Languages: {}", app.config.code_languages.join(", "));
let available_themes = ui::theme::Theme::available_themes();
let languages_all = ["rust", "python", "javascript", "go"];
let current_lang = app
.config
.code_languages
.first()
.map(|s| s.as_str())
.unwrap_or("rust");
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" Settings coming soon...",
Style::default().fg(colors.text_pending()),
)),
Line::from(""),
Line::from(Span::styled(
&*target_wpm,
Style::default().fg(colors.fg()),
)),
Line::from(Span::styled(
&*theme_name,
Style::default().fg(colors.fg()),
)),
Line::from(Span::styled(
&*layout_name,
Style::default().fg(colors.fg()),
)),
Line::from(Span::styled(
&*languages,
Style::default().fg(colors.fg()),
)),
Line::from(""),
Line::from(Span::styled(
" [ESC] Back",
Style::default().fg(colors.accent()),
)),
let fields: Vec<(String, String)> = vec![
("Target WPM".to_string(), format!("{}", app.config.target_wpm)),
("Theme".to_string(), app.config.theme.clone()),
("Word Count".to_string(), format!("{}", app.config.word_count)),
("Code Language".to_string(), current_lang.to_string()),
];
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, area);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(fields.len() as u16 * 3),
Constraint::Min(0),
Constraint::Length(2),
])
.split(inner);
let header = Paragraph::new(Line::from(Span::styled(
" Use arrows to navigate, Enter/Right to change, ESC to save & exit",
Style::default().fg(colors.text_pending()),
)));
header.render(layout[0], frame.buffer_mut());
let field_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(fields.iter().map(|_| Constraint::Length(3)).collect::<Vec<_>>())
.split(layout[1]);
for (i, (label, value)) in fields.iter().enumerate() {
let is_selected = i == app.settings_selected;
let indicator = if is_selected { " > " } else { " " };
let label_text = format!("{indicator}{label}:");
let value_text = format!(" < {value} >");
let label_style = Style::default().fg(if is_selected {
colors.accent()
} else {
colors.fg()
}).add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() });
let value_style = Style::default().fg(if is_selected {
colors.focused_key()
} else {
colors.text_pending()
});
let lines = vec![
Line::from(Span::styled(label_text, label_style)),
Line::from(Span::styled(value_text, value_style)),
];
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
}
let _ = (available_themes, languages_all);
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] Save & back [Enter/arrows] Change value",
Style::default().fg(colors.accent()),
)));
footer.render(layout[3], frame.buffer_mut());
}

View File

@@ -1,12 +1,14 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{self, Finger, Hand};
use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub next_key: Option<char>,
pub unlocked_keys: &'a [char],
pub theme: &'a Theme,
}
@@ -14,11 +16,13 @@ pub struct KeyboardDiagram<'a> {
impl<'a> KeyboardDiagram<'a> {
pub fn new(
focused_key: Option<char>,
next_key: Option<char>,
unlocked_keys: &'a [char],
theme: &'a Theme,
) -> Self {
Self {
focused_key,
next_key,
unlocked_keys,
theme,
}
@@ -31,6 +35,21 @@ const ROWS: &[&[char]] = &[
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
fn finger_color(ch: char) -> Color {
let assignment = finger::qwerty_finger(ch);
match (assignment.hand, assignment.finger) {
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
_ => Color::Rgb(120, 120, 120),
}
}
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -42,12 +61,12 @@ impl Widget for KeyboardDiagram<'_> {
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 20 {
if inner.height < 3 || inner.width < 30 {
return;
}
let key_width: u16 = 4;
let offsets: &[u16] = &[1, 2, 4];
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
for (row_idx, row) in ROWS.iter().enumerate() {
let y = inner.y + row_idx as u16;
@@ -59,26 +78,33 @@ impl Widget for KeyboardDiagram<'_> {
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
if x + key_width > inner.x + inner.width {
break;
}
let is_unlocked = self.unlocked_keys.contains(&key);
let is_focused = self.focused_key == Some(key);
let is_next = self.next_key == Some(key);
let style = if is_focused {
let style = if is_next {
Style::default()
.fg(colors.bg())
.bg(colors.accent())
} else if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
} else if is_unlocked {
Style::default().fg(colors.fg()).bg(colors.accent_dim())
Style::default()
.fg(colors.fg())
.bg(finger_color(key))
} else {
Style::default()
.fg(colors.text_pending())
.bg(colors.bg())
};
let display = format!("[{key}]");
let display = format!("[ {key} ]");
buf.set_string(x, y, &display, style);
}
}

View File

@@ -4,18 +4,34 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult;
use crate::ui::components::chart::WpmChart;
use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> {
pub history: &'a [LessonResult],
pub key_stats: &'a KeyStatsStore,
pub active_tab: usize,
pub target_wpm: u32,
pub theme: &'a Theme,
}
impl<'a> StatsDashboard<'a> {
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
Self { history, theme }
pub fn new(
history: &'a [LessonResult],
key_stats: &'a KeyStatsStore,
active_tab: usize,
target_wpm: u32,
theme: &'a Theme,
) -> Self {
Self {
history,
key_stats,
active_tab,
target_wpm,
theme,
}
}
}
@@ -42,12 +58,64 @@ impl Widget for StatsDashboard<'_> {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(8),
Constraint::Length(2),
Constraint::Min(10),
Constraint::Length(2),
])
.split(inner);
// Tab header
let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"];
let tab_spans: Vec<Span> = tabs
.iter()
.enumerate()
.flat_map(|(i, &label)| {
let style = if i == self.active_tab {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(colors.text_pending())
};
vec![
Span::styled(format!(" {label} "), style),
Span::raw(" "),
]
})
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
// Tab content
match self.active_tab {
0 => self.render_dashboard_tab(layout[1], buf),
1 => self.render_history_tab(layout[1], buf),
2 => self.render_keystrokes_tab(layout[1], buf),
_ => {}
}
// Footer
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
Style::default().fg(colors.accent()),
)));
footer.render(layout[2], buf);
}
}
impl StatsDashboard<'_> {
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(4),
Constraint::Length(3),
Constraint::Min(8),
])
.split(area);
// Summary stats
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
@@ -57,63 +125,616 @@ impl Widget for StatsDashboard<'_> {
.fold(0.0f64, f64::max);
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
let total_lessons = self.history.len();
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
let total_str = format!("{total_lessons}");
let total_str = format!("{}", self.history.len());
let avg_wpm_str = format!("{avg_wpm:.0}");
let best_wpm_str = format!("{best_wpm:.0}");
let avg_acc_str = format!("{avg_accuracy:.1}%");
let time_str = format_duration(total_time);
let summary = vec![
Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
Span::styled(
&*total_str,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(vec![
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 {
colors.success()
} else {
} else if avg_accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
]),
];
Paragraph::new(summary).render(layout[0], buf);
// Progress bars
self.render_progress_bars(layout[1], buf);
// Charts
let chart_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[2]);
// WPM chart
let wpm_data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.collect();
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
// Accuracy chart
let acc_data: Vec<(f64, f64)> = self
.history
.iter()
.rev()
.take(50)
.enumerate()
.map(|(i, r)| (i as f64, r.accuracy))
.collect();
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
}
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(area);
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
// WPM progress
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
// Accuracy progress
let acc_pct = avg_accuracy.min(100.0);
let acc_label = format!(" Acc: {acc_pct:.1}%");
let acc_color = if acc_pct >= 95.0 {
colors.success()
} else if acc_pct >= 85.0 {
colors.warning()
} else {
colors.error()
};
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
// Level progress
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
let level = ((total_score / 100.0).sqrt() as u32).max(1);
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
let current_level_score = (level as f64).powi(2) * 100.0;
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
}
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10),
Constraint::Length(8),
])
.split(area);
// Recent tests table
let header = Line::from(vec![
Span::styled(
" # WPM Raw Acc% Time Date",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
]);
let mut lines = vec![header, Line::from(Span::styled(
" ─────────────────────────────────────────────",
Style::default().fg(colors.border()),
))];
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
let total = self.history.len();
for (i, result) in recent.iter().enumerate() {
let idx = total - i;
let raw_wpm = result.cpm / 5.0;
let time_str = format!("{:.1}s", result.elapsed_secs);
let date_str = result.timestamp.format("%m/%d %H:%M").to_string();
let idx_str = format!("{idx:>3}");
let wpm_str = format!("{:>6.0}", result.wpm);
let raw_str = format!("{:>6.0}", raw_wpm);
let acc_str = format!("{:>6.1}%", result.accuracy);
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
let acc_color = if result.accuracy >= 95.0 {
colors.success()
} else if result.accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
};
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
}
Paragraph::new(lines).render(layout[0], buf);
// Per-key speed
self.render_per_key_speed(layout[1], buf);
}
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Per-Key Average Speed (ms) ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width < 52 || inner.height < 2 {
return;
}
let letters: Vec<char> = ('a'..='z').collect();
let max_time = letters
.iter()
.filter_map(|&ch| self.key_stats.stats.get(&ch))
.map(|s| s.filtered_time_ms)
.fold(0.0f64, f64::max)
.max(1.0);
// Render bar chart: letter label on row 0, bar on row 1
let bar_width = (inner.width as usize).min(52) / 26;
let bar_width = bar_width.max(1) as u16;
for (i, &ch) in letters.iter().enumerate() {
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
if x >= inner.x + inner.width {
break;
}
let time = self
.key_stats
.stats
.get(&ch)
.map(|s| s.filtered_time_ms)
.unwrap_or(0.0);
let ratio = time / max_time;
let color = if ratio < 0.3 {
colors.success()
} else if ratio < 0.6 {
colors.accent()
} else {
colors.error()
};
// Letter label
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
// Simple bar indicator
if inner.height >= 2 {
let bar_char = if time > 0.0 {
match (ratio * 8.0) as u8 {
0 => '▁',
1 => '▂',
2 => '▃',
3 => '▄',
4 => '▅',
5 => '▆',
6 => '▇',
_ => '█',
}
} else {
' '
};
buf.set_string(
x,
inner.y + 1,
&bar_char.to_string(),
Style::default().fg(color),
);
}
}
let _ = bar_width;
}
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(7),
Constraint::Min(5),
Constraint::Length(6),
])
.split(area);
// Keyboard accuracy heatmap
self.render_keyboard_heatmap(layout[0], buf);
// Slowest/Fastest keys
let key_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(layout[1]);
self.render_slowest_keys(key_layout[0], buf);
self.render_fastest_keys(key_layout[1], buf);
self.render_char_stats(key_layout[2], buf);
// Word/Character stats summary
self.render_overall_stats(layout[2], buf);
}
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Keyboard Accuracy ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 40 {
return;
}
let rows: &[&[char]] = &[
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
let offsets: &[u16] = &[1, 3, 5];
let key_width: u16 = 4;
for (row_idx, row) in rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height {
break;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, &key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + 3 > inner.x + inner.width {
break;
}
let accuracy = self.get_key_accuracy(key);
let color = if accuracy >= 100.0 {
colors.text_pending()
} else if accuracy >= 90.0 {
colors.warning()
} else if accuracy > 0.0 {
colors.error()
} else {
colors.text_pending()
};
let display = format!("[{key}]");
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
}
}
}
fn get_key_accuracy(&self, key: char) -> f64 {
let mut correct = 0usize;
let mut total = 0usize;
for result in self.history {
for kt in &result.per_key_times {
if kt.key == key {
total += 1;
if kt.correct {
correct += 1;
}
}
}
}
if total == 0 {
return 0.0;
}
correct as f64 / total as f64 * 100.0
}
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Slowest ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut key_times: Vec<(char, f64)> = self
.key_stats
.stats
.iter()
.filter(|(_, s)| s.sample_count > 0)
.map(|(&ch, s)| (ch, s.filtered_time_ms))
.collect();
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.error()),
);
}
}
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Fastest ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut key_times: Vec<(char, f64)> = self
.key_stats
.stats
.iter()
.filter(|(_, s)| s.sample_count > 0)
.map(|(&ch, s)| (ch, s.filtered_time_ms))
.collect();
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.success()),
);
}
}
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Key Stats ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let mut total_correct = 0usize;
let mut total_incorrect = 0usize;
for result in self.history {
total_correct += result.correct;
total_incorrect += result.incorrect;
}
let total = total_correct + total_incorrect;
let overall_acc = if total > 0 {
total_correct as f64 / total as f64 * 100.0
} else {
0.0
};
let lines = [
format!(" Total: {total}"),
format!(" Correct: {total_correct}"),
format!(" Wrong: {total_incorrect}"),
format!(" Acc: {overall_acc:.1}%"),
];
for (i, line) in lines.iter().enumerate() {
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
}
}
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Overall ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
let lines = vec![
Line::from(vec![
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_chars}"),
Style::default().fg(colors.accent()),
),
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_correct}"),
Style::default().fg(colors.success()),
),
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
format!("{total_incorrect}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()),
),
]),
];
Paragraph::new(summary).render(layout[0], buf);
let chart_data: Vec<(f64, f64)> = self
.history
.iter()
.enumerate()
.map(|(i, r)| (i as f64, r.wpm))
.collect();
WpmChart::new(&chart_data, self.theme).render(layout[1], buf);
let help = Paragraph::new(Line::from(Span::styled(
" [ESC] Back to menu",
Style::default().fg(colors.accent()),
)));
help.render(layout[2], buf);
Paragraph::new(lines).render(inner, buf);
}
}
fn render_text_bar(
label: &str,
ratio: f64,
fill_color: ratatui::style::Color,
empty_color: ratatui::style::Color,
area: Rect,
buf: &mut Buffer,
) {
if area.height < 2 || area.width < 10 {
return;
}
// Label on first line
buf.set_string(
area.x,
area.y,
label,
Style::default().fg(fill_color),
);
// Bar on second line
let bar_width = (area.width as usize).saturating_sub(4);
let filled = (ratio * bar_width as f64) as usize;
let bar_y = area.y + 1;
buf.set_string(area.x, bar_y, " ", Style::default());
for i in 0..bar_width {
let x = area.x + 2 + i as u16;
if x >= area.x + area.width {
break;
}
let (ch, color) = if i < filled {
('█', fill_color)
} else {
('░', empty_color)
};
buf.set_string(x, bar_y, &ch.to_string(), Style::default().fg(color));
}
}
fn render_accuracy_chart(
data: &[(f64, f64)],
theme: &Theme,
area: Rect,
buf: &mut Buffer,
) {
use ratatui::symbols;
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
let colors = &theme.colors;
if data.is_empty() {
let block = Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.success()))
.data(data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" Accuracy Over Time ")
.border_style(Style::default().fg(colors.border())),
)
.x_axis(
Axis::default()
.title("Lesson")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]),
)
.y_axis(
Axis::default()
.title("%")
.style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]),
);
chart.render(area, buf);
}
fn format_duration(secs: f64) -> String {
let total = secs as u64;
let hours = total / 3600;
let mins = (total % 3600) / 60;
let s = total % 60;
if hours > 0 {
format!("{hours}h {mins}m {s}s")
} else if mins > 0 {
format!("{mins}m {s}s")
} else {
format!("{s}s")
}
}

View File

@@ -89,7 +89,7 @@ impl Default for ThemeColors {
text_correct: "#a6e3a1".to_string(),
text_incorrect: "#f38ba8".to_string(),
text_incorrect_bg: "#45273a".to_string(),
text_pending: "#585b70".to_string(),
text_pending: "#a6adc8".to_string(),
text_cursor_bg: "#f5e0dc".to_string(),
text_cursor_fg: "#1e1e2e".to_string(),
focused_key: "#f9e2af".to_string(),