Compare commits

...

2 Commits

Author SHA1 Message Date
c78a8a90a3 First improvement pass 2026-02-10 23:32:57 -05:00
f65e3d8413 First one-shot pass 2026-02-10 14:29:23 -05:00
54 changed files with 18402 additions and 2 deletions

1
.gitignore vendored
View File

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

3191
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,5 +2,23 @@
name = "keydr"
version = "0.1.0"
edition = "2024"
description = "Terminal typing tutor with adaptive learning"
[dependencies]
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
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"] }
dirs = "6.0"
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

@@ -0,0 +1,23 @@
name = "catppuccin-latte"
[colors]
bg = "#eff1f5"
fg = "#4c4f69"
text_correct = "#40a02b"
text_incorrect = "#d20f39"
text_incorrect_bg = "#f5c2cf"
text_pending = "#7c7f93"
text_cursor_bg = "#dc8a78"
text_cursor_fg = "#eff1f5"
focused_key = "#df8e1d"
accent = "#1e66f5"
accent_dim = "#ccd0da"
border = "#ccd0da"
border_focused = "#1e66f5"
header_bg = "#e6e9ef"
header_fg = "#4c4f69"
bar_filled = "#1e66f5"
bar_empty = "#e6e9ef"
error = "#d20f39"
warning = "#df8e1d"
success = "#40a02b"

View File

@@ -0,0 +1,23 @@
name = "catppuccin-mocha"
[colors]
bg = "#1e1e2e"
fg = "#cdd6f4"
text_correct = "#a6e3a1"
text_incorrect = "#f38ba8"
text_incorrect_bg = "#45273a"
text_pending = "#a6adc8"
text_cursor_bg = "#f5e0dc"
text_cursor_fg = "#1e1e2e"
focused_key = "#f9e2af"
accent = "#89b4fa"
accent_dim = "#45475a"
border = "#45475a"
border_focused = "#89b4fa"
header_bg = "#313244"
header_fg = "#cdd6f4"
bar_filled = "#89b4fa"
bar_empty = "#313244"
error = "#f38ba8"
warning = "#f9e2af"
success = "#a6e3a1"

View File

@@ -0,0 +1,23 @@
name = "dracula"
[colors]
bg = "#282a36"
fg = "#f8f8f2"
text_correct = "#50fa7b"
text_incorrect = "#ff5555"
text_incorrect_bg = "#44242a"
text_pending = "#9aadce"
text_cursor_bg = "#f1fa8c"
text_cursor_fg = "#282a36"
focused_key = "#f1fa8c"
accent = "#bd93f9"
accent_dim = "#44475a"
border = "#44475a"
border_focused = "#bd93f9"
header_bg = "#44475a"
header_fg = "#f8f8f2"
bar_filled = "#bd93f9"
bar_empty = "#44475a"
error = "#ff5555"
warning = "#f1fa8c"
success = "#50fa7b"

View File

@@ -0,0 +1,23 @@
name = "gruvbox-dark"
[colors]
bg = "#282828"
fg = "#ebdbb2"
text_correct = "#b8bb26"
text_incorrect = "#fb4934"
text_incorrect_bg = "#462726"
text_pending = "#a89984"
text_cursor_bg = "#fabd2f"
text_cursor_fg = "#282828"
focused_key = "#fabd2f"
accent = "#83a598"
accent_dim = "#3c3836"
border = "#504945"
border_focused = "#83a598"
header_bg = "#3c3836"
header_fg = "#ebdbb2"
bar_filled = "#83a598"
bar_empty = "#3c3836"
error = "#fb4934"
warning = "#fabd2f"
success = "#b8bb26"

23
assets/themes/nord.toml Normal file
View File

@@ -0,0 +1,23 @@
name = "nord"
[colors]
bg = "#2e3440"
fg = "#eceff4"
text_correct = "#a3be8c"
text_incorrect = "#bf616a"
text_incorrect_bg = "#3f2e31"
text_pending = "#8fbcbb"
text_cursor_bg = "#ebcb8b"
text_cursor_fg = "#2e3440"
focused_key = "#ebcb8b"
accent = "#88c0d0"
accent_dim = "#3b4252"
border = "#4c566a"
border_focused = "#88c0d0"
header_bg = "#3b4252"
header_fg = "#eceff4"
bar_filled = "#88c0d0"
bar_empty = "#3b4252"
error = "#bf616a"
warning = "#ebcb8b"
success = "#a3be8c"

View File

@@ -0,0 +1,23 @@
name = "one-dark"
[colors]
bg = "#282c34"
fg = "#abb2bf"
text_correct = "#98c379"
text_incorrect = "#e06c75"
text_incorrect_bg = "#3e2a2d"
text_pending = "#848b98"
text_cursor_bg = "#e5c07b"
text_cursor_fg = "#282c34"
focused_key = "#e5c07b"
accent = "#61afef"
accent_dim = "#3e4451"
border = "#3e4451"
border_focused = "#61afef"
header_bg = "#21252b"
header_fg = "#abb2bf"
bar_filled = "#61afef"
bar_empty = "#21252b"
error = "#e06c75"
warning = "#e5c07b"
success = "#98c379"

View File

@@ -0,0 +1,23 @@
name = "solarized-dark"
[colors]
bg = "#002b36"
fg = "#839496"
text_correct = "#859900"
text_incorrect = "#dc322f"
text_incorrect_bg = "#2a1a1a"
text_pending = "#839496"
text_cursor_bg = "#b58900"
text_cursor_fg = "#002b36"
focused_key = "#b58900"
accent = "#268bd2"
accent_dim = "#073642"
border = "#586e75"
border_focused = "#268bd2"
header_bg = "#073642"
header_fg = "#93a1a1"
bar_filled = "#268bd2"
bar_empty = "#073642"
error = "#dc322f"
warning = "#b58900"
success = "#859900"

View File

@@ -0,0 +1,23 @@
name = "tokyo-night"
[colors]
bg = "#1a1b26"
fg = "#c0caf5"
text_correct = "#9ece6a"
text_incorrect = "#f7768e"
text_incorrect_bg = "#3b2232"
text_pending = "#9aa5ce"
text_cursor_bg = "#e0af68"
text_cursor_fg = "#1a1b26"
focused_key = "#e0af68"
accent = "#7aa2f7"
accent_dim = "#292e42"
border = "#3b4261"
border_focused = "#7aa2f7"
header_bg = "#24283b"
header_fg = "#c0caf5"
bar_filled = "#7aa2f7"
bar_empty = "#24283b"
error = "#f7768e"
warning = "#e0af68"
success = "#9ece6a"

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)

343
src/app.rs Normal file
View File

@@ -0,0 +1,343 @@
use rand::rngs::SmallRng;
use rand::SeedableRng;
use crate::config::Config;
use crate::engine::filter::CharFilter;
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;
use crate::generator::transition_table::TransitionTable;
use crate::session::input::{self, KeystrokeEvent};
use crate::session::lesson::LessonState;
use crate::session::result::LessonResult;
use crate::store::json_store::JsonStore;
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
use crate::ui::components::menu::Menu;
use crate::ui::theme::Theme;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppScreen {
Menu,
Lesson,
LessonResult,
StatsDashboard,
Settings,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LessonMode {
Adaptive,
Code,
Passage,
}
pub struct App {
pub screen: AppScreen,
pub lesson_mode: LessonMode,
pub lesson: Option<LessonState>,
pub lesson_events: Vec<KeystrokeEvent>,
pub last_result: Option<LessonResult>,
pub lesson_history: Vec<LessonResult>,
pub menu: Menu<'static>,
pub theme: &'static Theme,
pub config: Config,
pub key_stats: KeyStatsStore,
pub letter_unlock: LetterUnlock,
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 {
pub fn new() -> Self {
let config = Config::load().unwrap_or_default();
let loaded_theme = Theme::load(&config.theme).unwrap_or_default();
let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
let menu = Menu::new(theme);
let store = JsonStore::new().ok();
let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store {
let ksd = s.load_key_stats();
let pd = s.load_profile();
let lhd = s.load_lesson_history();
let lu = if pd.unlocked_letters.is_empty() {
LetterUnlock::new()
} else {
LetterUnlock::from_included(pd.unlocked_letters.clone())
};
(ksd.stats, lu, pd, lhd.lessons)
} else {
(
KeyStatsStore::default(),
LetterUnlock::new(),
ProfileData::default(),
Vec::new(),
)
};
let mut key_stats_with_target = key_stats;
key_stats_with_target.target_cpm = config.target_cpm();
let dictionary = Dictionary::load();
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
Self {
screen: AppScreen::Menu,
lesson_mode: LessonMode::Adaptive,
lesson: None,
lesson_events: Vec::new(),
last_result: None,
lesson_history,
menu,
theme,
config,
key_stats: key_stats_with_target,
letter_unlock,
profile,
store,
should_quit: false,
settings_selected: 0,
stats_tab: 0,
rng: SmallRng::from_entropy(),
transition_table,
dictionary,
}
}
pub fn start_lesson(&mut self) {
let text = self.generate_text();
self.lesson = Some(LessonState::new(&text));
self.lesson_events.clear();
self.screen = AppScreen::Lesson;
}
fn generate_text(&mut self) -> String {
let word_count = self.config.word_count;
let mode = self.lesson_mode;
match mode {
LessonMode::Adaptive => {
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, dict, rng);
generator.generate(&filter, focused, word_count)
}
LessonMode::Code => {
let filter = CharFilter::new(('a'..='z').collect());
let lang = self
.config
.code_languages
.first()
.cloned()
.unwrap_or_else(|| "rust".to_string());
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = CodeSyntaxGenerator::new(rng, &lang);
generator.generate(&filter, None, word_count)
}
LessonMode::Passage => {
let filter = CharFilter::new(('a'..='z').collect());
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PassageGenerator::new(rng);
generator.generate(&filter, None, word_count)
}
}
}
pub fn type_char(&mut self, ch: char) {
if let Some(ref mut lesson) = self.lesson {
if let Some(event) = input::process_char(lesson, ch) {
self.lesson_events.push(event);
}
if lesson.is_complete() {
self.finish_lesson();
}
}
}
pub fn backspace(&mut self) {
if let Some(ref mut lesson) = self.lesson {
input::process_backspace(lesson);
}
}
fn finish_lesson(&mut self) {
if let Some(ref lesson) = self.lesson {
let result = LessonResult::from_lesson(lesson, &self.lesson_events);
if self.lesson_mode == LessonMode::Adaptive {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
self.letter_unlock.update(&self.key_stats);
}
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let score = scoring::compute_score(&result, complexity);
self.profile.total_score += score;
self.profile.total_lessons += 1;
self.profile.unlocked_letters = self.letter_unlock.included.clone();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
if self.profile.last_practice_date.as_deref() != Some(&today) {
if let Some(ref last) = self.profile.last_practice_date {
let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
.format("%Y-%m-%d")
.to_string();
if last == &yesterday {
self.profile.streak_days += 1;
} else {
self.profile.streak_days = 1;
}
} else {
self.profile.streak_days = 1;
}
self.profile.best_streak =
self.profile.best_streak.max(self.profile.streak_days);
self.profile.last_practice_date = Some(today);
}
self.lesson_history.push(result.clone());
if self.lesson_history.len() > 500 {
self.lesson_history.remove(0);
}
self.last_result = Some(result);
self.screen = AppScreen::LessonResult;
self.save_data();
}
}
fn save_data(&self) {
if let Some(ref store) = self.store {
let _ = store.save_profile(&self.profile);
let _ = store.save_key_stats(&KeyStatsData {
schema_version: 1,
stats: self.key_stats.clone(),
});
let _ = store.save_lesson_history(&LessonHistoryData {
schema_version: 1,
lessons: self.lesson_history.clone(),
});
}
}
pub fn retry_lesson(&mut self) {
self.start_lesson();
}
pub fn go_to_menu(&mut self) {
self.screen = AppScreen::Menu;
self.lesson = None;
self.lesson_events.clear();
}
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()];
}
_ => {}
}
}
}

82
src/config.rs Normal file
View File

@@ -0,0 +1,82 @@
use std::fs;
use std::path::PathBuf;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_target_wpm")]
pub target_wpm: u32,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_keyboard_layout")]
pub keyboard_layout: String,
#[serde(default = "default_code_languages")]
pub code_languages: Vec<String>,
#[serde(default = "default_word_count")]
pub word_count: usize,
}
fn default_target_wpm() -> u32 {
35
}
fn default_theme() -> String {
"catppuccin-mocha".to_string()
}
fn default_keyboard_layout() -> String {
"qwerty".to_string()
}
fn default_code_languages() -> Vec<String> {
vec!["rust".to_string()]
}
fn default_word_count() -> usize {
20
}
impl Default for Config {
fn default() -> Self {
Self {
target_wpm: default_target_wpm(),
theme: default_theme(),
keyboard_layout: default_keyboard_layout(),
code_languages: default_code_languages(),
word_count: default_word_count(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = Self::config_path();
if path.exists() {
let content = fs::read_to_string(&path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
} else {
Ok(Config::default())
}
}
#[allow(dead_code)]
pub fn save(&self) -> Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
fs::write(&path, content)?;
Ok(())
}
fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("keydr")
.join("config.toml")
}
pub fn target_cpm(&self) -> f64 {
self.target_wpm as f64 * 5.0
}
}

20
src/engine/filter.rs Normal file
View File

@@ -0,0 +1,20 @@
pub struct CharFilter {
pub allowed: Vec<char>,
}
impl CharFilter {
pub fn new(allowed: Vec<char>) -> Self {
Self { allowed }
}
pub fn is_allowed(&self, ch: char) -> bool {
self.allowed.contains(&ch) || ch == ' '
}
#[allow(dead_code)]
pub fn filter_text(&self, text: &str) -> String {
text.chars()
.filter(|&ch| self.is_allowed(ch))
.collect()
}
}

120
src/engine/key_stats.rs Normal file
View File

@@ -0,0 +1,120 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
const EMA_ALPHA: f64 = 0.1;
const DEFAULT_TARGET_CPM: f64 = 175.0;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStat {
pub filtered_time_ms: f64,
pub best_time_ms: f64,
pub confidence: f64,
pub sample_count: usize,
pub recent_times: Vec<f64>,
}
impl Default for KeyStat {
fn default() -> Self {
Self {
filtered_time_ms: 1000.0,
best_time_ms: f64::MAX,
confidence: 0.0,
sample_count: 0,
recent_times: Vec::new(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStatsStore {
pub stats: HashMap<char, KeyStat>,
pub target_cpm: f64,
}
impl Default for KeyStatsStore {
fn default() -> Self {
Self {
stats: HashMap::new(),
target_cpm: DEFAULT_TARGET_CPM,
}
}
}
impl KeyStatsStore {
pub fn update_key(&mut self, key: char, time_ms: f64) {
let stat = self.stats.entry(key).or_default();
stat.sample_count += 1;
if stat.sample_count == 1 {
stat.filtered_time_ms = time_ms;
} else {
stat.filtered_time_ms =
EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
}
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
let target_time_ms = 60000.0 / self.target_cpm;
stat.confidence = target_time_ms / stat.filtered_time_ms;
stat.recent_times.push(time_ms);
if stat.recent_times.len() > 30 {
stat.recent_times.remove(0);
}
}
pub fn get_confidence(&self, key: char) -> f64 {
self.stats
.get(&key)
.map(|s| s.confidence)
.unwrap_or(0.0)
}
#[allow(dead_code)]
pub fn get_stat(&self, key: char) -> Option<&KeyStat> {
self.stats.get(&key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_initial_confidence_is_zero() {
let store = KeyStatsStore::default();
assert_eq!(store.get_confidence('a'), 0.0);
}
#[test]
fn test_update_key_creates_stat() {
let mut store = KeyStatsStore::default();
store.update_key('e', 300.0);
assert!(store.get_confidence('e') > 0.0);
assert_eq!(store.stats.get(&'e').unwrap().sample_count, 1);
}
#[test]
fn test_ema_converges() {
let mut store = KeyStatsStore::default();
// Type key fast many times - confidence should increase
for _ in 0..50 {
store.update_key('t', 200.0);
}
let conf = store.get_confidence('t');
// At 175 CPM target, target_time = 60000/175 = 342.8ms
// With 200ms typing time, confidence = 342.8/200 = 1.71
assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}");
}
#[test]
fn test_slow_typing_low_confidence() {
let mut store = KeyStatsStore::default();
for _ in 0..50 {
store.update_key('a', 1000.0);
}
let conf = store.get_confidence('a');
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}");
}
}

View File

@@ -0,0 +1,59 @@
#[allow(dead_code)]
pub fn polynomial_regression(times: &[f64]) -> Option<f64> {
if times.len() < 3 {
return None;
}
let n = times.len();
let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
let x_mean: f64 = xs.iter().sum::<f64>() / n as f64;
let y_mean: f64 = times.iter().sum::<f64>() / n as f64;
let mut ss_xy = 0.0;
let mut ss_xx = 0.0;
let mut ss_yy = 0.0;
for i in 0..n {
let dx = xs[i] - x_mean;
let dy = times[i] - y_mean;
ss_xy += dx * dy;
ss_xx += dx * dx;
ss_yy += dy * dy;
}
if ss_xx < 1e-10 || ss_yy < 1e-10 {
return None;
}
let slope = ss_xy / ss_xx;
let r_squared = (ss_xy * ss_xy) / (ss_xx * ss_yy);
if r_squared < 0.5 {
return None;
}
let predicted_next = y_mean + slope * (n as f64 - x_mean);
Some(predicted_next.max(0.0))
}
#[allow(dead_code)]
pub fn learning_rate_description(times: &[f64]) -> &'static str {
match polynomial_regression(times) {
Some(predicted) => {
if times.is_empty() {
return "No data";
}
let current = times.last().unwrap();
let improvement = (current - predicted) / current * 100.0;
if improvement > 5.0 {
"Improving"
} else if improvement < -5.0 {
"Slowing down"
} else {
"Steady"
}
}
None => "Not enough data",
}
}

151
src/engine/letter_unlock.rs Normal file
View File

@@ -0,0 +1,151 @@
use crate::engine::key_stats::KeyStatsStore;
pub const FREQUENCY_ORDER: &[char] = &[
'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',
];
const MIN_LETTERS: usize = 6;
#[derive(Clone, Debug)]
pub struct LetterUnlock {
pub included: Vec<char>,
pub focused: Option<char>,
}
impl LetterUnlock {
pub fn new() -> Self {
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
Self {
included,
focused: None,
}
}
pub fn from_included(included: Vec<char>) -> Self {
let mut lu = Self {
included,
focused: None,
};
lu.focused = None;
lu
}
pub fn update(&mut self, stats: &KeyStatsStore) {
let all_confident = self
.included
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
while self.included.len() < MIN_LETTERS {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
self.focused = self
.included
.iter()
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
.min_by(|&&a, &&b| {
stats
.get_confidence(a)
.partial_cmp(&stats.get_confidence(b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied();
}
#[allow(dead_code)]
pub fn is_unlocked(&self, ch: char) -> bool {
self.included.contains(&ch)
}
pub fn unlocked_count(&self) -> usize {
self.included.len()
}
pub fn total_letters(&self) -> usize {
FREQUENCY_ORDER.len()
}
pub fn progress(&self) -> f64 {
self.unlocked_count() as f64 / self.total_letters() as f64
}
}
impl Default for LetterUnlock {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::key_stats::KeyStatsStore;
#[test]
fn test_initial_unlock_has_min_letters() {
let lu = LetterUnlock::new();
assert_eq!(lu.unlocked_count(), 6);
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
}
#[test]
fn test_no_unlock_without_confidence() {
let mut lu = LetterUnlock::new();
let stats = KeyStatsStore::default();
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 6);
}
#[test]
fn test_unlock_when_all_confident() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make all included keys confident by typing fast
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 7);
assert!(lu.included.contains(&'s'));
}
#[test]
fn test_focused_key_is_weakest() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make most keys confident except 'o'
for &ch in &['e', 't', 'a', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
stats.update_key('o', 1000.0); // slow on 'o'
lu.update(&stats);
assert_eq!(lu.focused, Some('o'));
}
#[test]
fn test_progress_ratio() {
let lu = LetterUnlock::new();
let expected = 6.0 / 26.0;
assert!((lu.progress() - expected).abs() < 0.001);
}
}

5
src/engine/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod filter;
pub mod key_stats;
pub mod learning_rate;
pub mod letter_unlock;
pub mod scoring;

45
src/engine/scoring.rs Normal file
View File

@@ -0,0 +1,45 @@
use crate::session::result::LessonResult;
pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 {
let speed = result.cpm;
let errors = result.incorrect as f64;
let length = result.total_chars as f64;
(speed * complexity) / (errors + 1.0) * (length / 50.0)
}
pub fn compute_complexity(unlocked_count: usize) -> f64 {
(unlocked_count as f64 / 26.0).max(0.1)
}
pub fn level_from_score(total_score: f64) -> u32 {
let level = (total_score / 100.0).sqrt() as u32;
level.max(1)
}
#[allow(dead_code)]
pub fn score_to_next_level(total_score: f64) -> f64 {
let current_level = level_from_score(total_score);
let next_level_score = ((current_level + 1) as f64).powi(2) * 100.0;
next_level_score - total_score
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_level_starts_at_one() {
assert_eq!(level_from_score(0.0), 1);
}
#[test]
fn test_level_increases_with_score() {
assert!(level_from_score(1000.0) > level_from_score(100.0));
}
#[test]
fn test_complexity_scales_with_letters() {
assert!(compute_complexity(26) > compute_complexity(6));
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
}
}

49
src/event.rs Normal file
View File

@@ -0,0 +1,49 @@
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEvent};
pub enum AppEvent {
Key(KeyEvent),
Tick,
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
}
pub struct EventHandler {
rx: mpsc::Receiver<AppEvent>,
_tx: mpsc::Sender<AppEvent>,
}
impl EventHandler {
pub fn new(tick_rate: Duration) -> Self {
let (tx, rx) = mpsc::channel();
let _tx = tx.clone();
thread::spawn(move || loop {
if event::poll(tick_rate).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) => {
if tx.send(AppEvent::Key(key)).is_err() {
return;
}
}
Ok(Event::Resize(w, h)) => {
if tx.send(AppEvent::Resize(w, h)).is_err() {
return;
}
}
_ => {}
}
} else if tx.send(AppEvent::Tick).is_err() {
return;
}
});
Self { rx, _tx }
}
pub fn next(&self) -> anyhow::Result<AppEvent> {
Ok(self.rx.recv()?)
}
}

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

@@ -0,0 +1,304 @@
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 {
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;
}
}
fn rust_snippets() -> Vec<&'static str> {
vec![
"fn main() { println!(\"hello\"); }",
"let mut x = 0; x += 1;",
"for i in 0..10 { println!(\"{}\", i); }",
"if x > 0 { return true; }",
"match val { Some(x) => x, None => 0 }",
"struct Point { x: f64, y: f64 }",
"impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }",
"let v: Vec<i32> = vec![1, 2, 3];",
"fn add(a: i32, b: i32) -> i32 { a + b }",
"use std::collections::HashMap;",
"pub fn process(input: &str) -> Result<String, Error> { Ok(input.to_string()) }",
"let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::<Vec<_>>();",
"enum Color { Red, Green, Blue }",
"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();",
]
}
fn python_snippets() -> Vec<&'static str> {
vec![
"def main(): print(\"hello\")",
"for i in range(10): print(i)",
"if x > 0: return True",
"class Point: def __init__(self, x, y): self.x = x",
"import os; path = os.path.join(\"a\", \"b\")",
"result = [x * 2 for x in items if x > 0]",
"with open(\"file.txt\") as f: data = f.read()",
"def add(a: int, b: int) -> int: return a + b",
"try: result = process(data) except ValueError as e: print(e)",
"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}",
]
}
fn javascript_snippets() -> Vec<&'static str> {
vec![
"const x = 42; console.log(x);",
"function add(a, b) { return a + b; }",
"const arr = [1, 2, 3].map(x => x * 2);",
"if (x > 0) { return true; }",
"for (let i = 0; i < 10; i++) { console.log(i); }",
"class Point { constructor(x, y) { this.x = x; this.y = y; } }",
"const { name, age } = person;",
"async function fetch(url) { const res = await get(url); return res.json(); }",
"const obj = { ...defaults, ...overrides };",
"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",
]
}
fn go_snippets() -> Vec<&'static str> {
vec![
"func main() { fmt.Println(\"hello\") }",
"for i := 0; i < 10; i++ { fmt.Println(i) }",
"if err != nil { return err }",
"type Point struct { X float64; Y float64 }",
"func add(a, b int) int { return a + b }",
"import \"fmt\"",
"result := make([]int, 0, 10)",
"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\")",
]
}
fn get_snippets(&self) -> Vec<&'static str> {
match self.language.as_str() {
"rust" => Self::rust_snippets(),
"python" => Self::python_snippets(),
"javascript" | "js" => Self::javascript_snippets(),
"go" => Self::go_snippets(),
_ => Self::rust_snippets(),
}
}
}
impl TextGenerator for CodeSyntaxGenerator {
fn generate(
&mut self,
_filter: &CharFilter,
_focused: Option<char>,
word_count: usize,
) -> String {
// 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..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.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

@@ -0,0 +1,41 @@
use crate::engine::filter::CharFilter;
use crate::generator::TextGenerator;
#[allow(dead_code)]
pub struct GitHubCodeGenerator {
cached_snippets: Vec<String>,
current_idx: usize,
}
impl GitHubCodeGenerator {
#[allow(dead_code)]
pub fn new() -> Self {
Self {
cached_snippets: Vec::new(),
current_idx: 0,
}
}
}
impl Default for GitHubCodeGenerator {
fn default() -> Self {
Self::new()
}
}
impl TextGenerator for GitHubCodeGenerator {
fn generate(
&mut self,
_filter: &CharFilter,
_focused: Option<char>,
_word_count: usize,
) -> String {
if self.cached_snippets.is_empty() {
return "// GitHub code fetching not yet configured. Use settings to add a repository."
.to_string();
}
let snippet = self.cached_snippets[self.current_idx % self.cached_snippets.len()].clone();
self.current_idx += 1;
snippet
}
}

14
src/generator/mod.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod cache;
pub mod code_syntax;
pub mod dictionary;
pub mod github_code;
pub mod passage;
pub mod phonetic;
pub mod transition_table;
use crate::engine::filter::CharFilter;
pub trait TextGenerator {
fn generate(&mut self, filter: &CharFilter, focused: Option<char>, word_count: usize)
-> String;
}

244
src/generator/passage.rs Normal file
View File

@@ -0,0 +1,244 @@
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",
"she walked along the narrow path through the forest listening to the birds singing in the trees above her head",
"the old man sat on the bench watching the children play in the park while the autumn leaves fell softly around him",
"there is nothing either good or bad but thinking makes it so for the mind is its own place and in itself can make a heaven of hell",
"to be or not to be that is the question whether it is nobler in the mind to suffer the slings and arrows of outrageous fortune",
"all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither",
"the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow",
"a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice",
"the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm",
"she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared",
"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(rng: SmallRng) -> Self {
let mut generator = Self {
current_idx: 0,
fetched_passages: Vec::new(),
rng,
};
generator.load_cached_passages();
generator
}
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);
}
}
}
impl TextGenerator for PassageGenerator {
fn generate(
&mut self,
_filter: &CharFilter,
_focused: Option<char>,
_word_count: usize,
) -> String {
// 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;
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
}

243
src/generator/phonetic.rs Normal file
View File

@@ -0,0 +1,243 @@
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, dictionary: Dictionary, rng: SmallRng) -> Self {
Self {
table,
dictionary,
rng,
}
}
fn pick_weighted_from(
rng: &mut SmallRng,
options: &[(char, f64)],
filter: &CharFilter,
) -> Option<char> {
let filtered: Vec<(char, f64)> = options
.iter()
.filter(|(ch, _)| filter.is_allowed(*ch))
.copied()
.collect();
if filtered.is_empty() {
return None;
}
let total: f64 = filtered.iter().map(|(_, w)| w).sum();
if total <= 0.0 {
return None;
}
let mut roll = rng.gen_range(0.0..total);
for (ch, weight) in &filtered {
roll -= weight;
if roll <= 0.0 {
return Some(*ch);
}
}
Some(filtered.last().unwrap().0)
}
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) && filter.is_allowed(focus) {
word.push(focus);
// 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 {
None
}
} else {
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| {
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_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 {
0
};
for _ in 0..(prefix_len.saturating_sub(word.len())) {
prefix.push(' ');
}
for i in start..word.len() {
prefix.push(word[i]);
}
// 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))
.map(|&v| (v, 1.0))
.collect();
if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) {
word.push(v);
} else {
break;
}
}
}
word.iter().collect()
}
}
impl TextGenerator for PhoneticGenerator {
fn generate(
&mut self,
filter: &CharFilter,
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 {
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

@@ -0,0 +1,170 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TransitionTable {
pub order: usize,
transitions: HashMap<Vec<char>, Vec<(char, f64)>>,
}
impl TransitionTable {
pub fn new(order: usize) -> Self {
Self {
order,
transitions: HashMap::new(),
}
}
pub fn add(&mut self, prefix: &[char], next: char, weight: f64) {
self.transitions
.entry(prefix.to_vec())
.or_default()
.push((next, weight));
}
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(4);
let common_patterns: &[(&str, f64)] = &[
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0),
("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0),
("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0),
("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0),
("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5),
("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5),
("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0),
("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), ("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),
("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),
];
for &(pattern, weight) in common_patterns {
let chars: Vec<char> = pattern.chars().collect();
for window in chars.windows(3) {
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);
}
}
let vowels = ['a', 'e', 'i', 'o', 'u'];
let consonants = [
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v',
'w', 'x', 'y', 'z',
];
for &c in &consonants {
for &v in &vowels {
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
}
}
impl Default for TransitionTable {
fn default() -> Self {
Self::new(4)
}
}

50
src/keyboard/finger.rs Normal file
View File

@@ -0,0 +1,50 @@
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Hand {
Left,
Right,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Finger {
Pinky,
Ring,
Middle,
Index,
Thumb,
}
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FingerAssignment {
pub hand: Hand,
pub finger: Finger,
}
impl FingerAssignment {
pub fn new(hand: Hand, finger: Finger) -> Self {
Self { hand, finger }
}
}
#[allow(dead_code)]
pub fn qwerty_finger(ch: char) -> FingerAssignment {
use Finger::*;
use Hand::*;
match ch {
'q' | 'a' | 'z' | '1' => FingerAssignment::new(Left, Pinky),
'w' | 's' | 'x' | '2' => FingerAssignment::new(Left, Ring),
'e' | 'd' | 'c' | '3' => FingerAssignment::new(Left, Middle),
'r' | 'f' | 'v' | 't' | 'g' | 'b' | '4' | '5' => FingerAssignment::new(Left, Index),
'y' | 'h' | 'n' | 'u' | 'j' | 'm' | '6' | '7' => FingerAssignment::new(Right, Index),
'i' | 'k' | ',' | '8' => FingerAssignment::new(Right, Middle),
'o' | 'l' | '.' | '9' => FingerAssignment::new(Right, Ring),
'p' | ';' | '/' | '0' | '-' | '=' | '[' | ']' | '\'' | '\\' => {
FingerAssignment::new(Right, Pinky)
}
' ' => FingerAssignment::new(Right, Thumb),
_ => FingerAssignment::new(Right, Index),
}
}

51
src/keyboard/layout.rs Normal file
View File

@@ -0,0 +1,51 @@
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyboardLayout {
pub name: String,
pub rows: Vec<Vec<char>>,
}
impl KeyboardLayout {
pub fn qwerty() -> Self {
Self {
name: "QWERTY".to_string(),
rows: vec![
vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'],
],
}
}
#[allow(dead_code)]
pub fn dvorak() -> Self {
Self {
name: "Dvorak".to_string(),
rows: vec![
vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'],
vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'],
vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'],
],
}
}
#[allow(dead_code)]
pub fn colemak() -> Self {
Self {
name: "Colemak".to_string(),
rows: vec![
vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'],
vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'],
vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'],
],
}
}
}
impl Default for KeyboardLayout {
fn default() -> Self {
Self::qwerty()
}
}

2
src/keyboard/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod finger;
pub mod layout;

View File

@@ -1,3 +1,484 @@
fn main() {
println!("Hello, world!");
mod app;
mod config;
mod engine;
mod event;
mod generator;
mod keyboard;
mod session;
mod store;
mod ui;
use std::io;
use std::time::Duration;
use anyhow::Result;
use clap::Parser;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
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, Widget};
use ratatui::Terminal;
use app::{App, AppScreen, LessonMode};
use session::result::LessonResult;
use event::{AppEvent, EventHandler};
use ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::progress_bar::ProgressBar;
use ui::components::stats_dashboard::StatsDashboard;
use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea;
use ui::layout::AppLayout;
#[derive(Parser)]
#[command(name = "keydr", version, about = "Terminal typing tutor with adaptive learning")]
struct Cli {
#[arg(short, long, help = "Theme name")]
theme: Option<String>,
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
layout: Option<String>,
#[arg(short, long, help = "Number of words per lesson")]
words: Option<usize>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut app = App::new();
if let Some(words) = cli.words {
app.config.word_count = words;
}
if let Some(theme_name) = cli.theme {
if let Some(theme) = ui::theme::Theme::load(&theme_name) {
let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme));
app.theme = theme;
app.menu.theme = theme;
}
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let events = EventHandler::new(Duration::from_millis(100));
let result = run_app(&mut terminal, &mut app, &events);
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
if let Err(err) = result {
eprintln!("Error: {err:?}");
}
Ok(())
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App,
events: &EventHandler,
) -> Result<()> {
loop {
terminal.draw(|frame| render(frame, app))?;
match events.next()? {
AppEvent::Key(key) => handle_key(app, key),
AppEvent::Tick => {}
AppEvent::Resize(_, _) => {}
}
if app.should_quit {
return Ok(());
}
}
}
fn handle_key(app: &mut App, key: KeyEvent) {
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
app.should_quit = true;
return;
}
match app.screen {
AppScreen::Menu => handle_menu_key(app, key),
AppScreen::Lesson => handle_lesson_key(app, key),
AppScreen::LessonResult => handle_result_key(app, key),
AppScreen::StatsDashboard => handle_stats_key(app, key),
AppScreen::Settings => handle_settings_key(app, key),
}
}
fn handle_menu_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('1') => {
app.lesson_mode = LessonMode::Adaptive;
app.start_lesson();
}
KeyCode::Char('2') => {
app.lesson_mode = LessonMode::Code;
app.start_lesson();
}
KeyCode::Char('3') => {
app.lesson_mode = LessonMode::Passage;
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 {
0 => {
app.lesson_mode = LessonMode::Adaptive;
app.start_lesson();
}
1 => {
app.lesson_mode = LessonMode::Code;
app.start_lesson();
}
2 => {
app.lesson_mode = LessonMode::Passage;
app.start_lesson();
}
3 => app.go_to_stats(),
4 => app.go_to_settings(),
_ => {}
},
_ => {}
}
}
fn handle_lesson_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0);
if has_progress {
if let Some(ref lesson) = app.lesson {
let result = LessonResult::from_lesson(lesson, &app.lesson_events);
app.last_result = Some(result);
}
app.screen = AppScreen::LessonResult;
} else {
app.go_to_menu();
}
}
KeyCode::Backspace => app.backspace(),
KeyCode::Char(ch) => app.type_char(ch),
_ => {}
}
}
fn handle_result_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('r') => app.retry_lesson(),
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
KeyCode::Char('s') => app.go_to_stats(),
_ => {}
}
}
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 => {
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();
}
_ => {}
}
}
fn render(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
let bg = Block::default().style(Style::default().bg(colors.bg()));
frame.render_widget(bg, area);
match app.screen {
AppScreen::Menu => render_menu(frame, app),
AppScreen::Lesson => render_lesson(frame, app),
AppScreen::LessonResult => render_result(frame, app),
AppScreen::StatsDashboard => render_stats(frame, app),
AppScreen::Settings => render_settings(frame, app),
}
}
fn render_menu(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
let streak_text = if app.profile.streak_days > 0 {
format!(" | {} day streak", app.profile.streak_days)
} else {
String::new()
};
let header_info = format!(
" Level {} | Score {:.0} | {}/{} letters{}",
crate::engine::scoring::level_from_score(app.profile.total_score),
app.profile.total_score,
app.letter_unlock.unlocked_count(),
app.letter_unlock.total_letters(),
streak_text,
);
let header = Paragraph::new(Line::from(vec![
Span::styled(
" keydr ",
Style::default()
.fg(colors.header_fg())
.bg(colors.header_bg())
.add_modifier(Modifier::BOLD),
),
Span::styled(
&*header_info,
Style::default()
.fg(colors.text_pending())
.bg(colors.header_bg()),
),
]))
.style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, layout[0]);
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
frame.render_widget(&app.menu, menu_area);
let footer = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [s] Stats [q] Quit ",
Style::default().fg(colors.text_pending()),
)]));
frame.render_widget(footer, layout[2]);
}
fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
if let Some(ref lesson) = app.lesson {
let app_layout = AppLayout::new(area);
let mode_name = match app.lesson_mode {
LessonMode::Adaptive => "Adaptive",
LessonMode::Code => "Code",
LessonMode::Passage => "Passage",
};
let header_title = format!(" {mode_name} Practice ");
let focus_text = if let Some(focused) = app.letter_unlock.focused {
format!(" | Focus: '{focused}'")
} else {
String::new()
};
let header = Paragraph::new(Line::from(vec![
Span::styled(
&*header_title,
Style::default()
.fg(colors.header_fg())
.bg(colors.header_bg())
.add_modifier(Modifier::BOLD),
),
Span::styled(
&*focus_text,
Style::default()
.fg(colors.focused_key())
.bg(colors.header_bg()),
),
]))
.style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, app_layout.header);
let main_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(5),
Constraint::Length(3),
Constraint::Length(5),
])
.split(app_layout.main);
let typing = TypingArea::new(lesson, app.theme);
frame.render_widget(typing, main_layout[0]);
let progress = ProgressBar::new(
"Letter Progress",
app.letter_unlock.progress(),
app.theme,
);
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,
);
frame.render_widget(kbd, main_layout[2]);
let sidebar = StatsSidebar::new(lesson, app.theme);
frame.render_widget(sidebar, app_layout.sidebar);
let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] End lesson [Backspace] Delete ",
Style::default().fg(colors.text_pending()),
)));
frame.render_widget(footer, app_layout.footer);
}
}
fn render_result(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
if let Some(ref result) = app.last_result {
let centered = ui::layout::centered_rect(60, 70, area);
let dashboard = Dashboard::new(result, app.theme);
frame.render_widget(dashboard, centered);
}
}
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let dashboard = StatsDashboard::new(
&app.lesson_history,
&app.key_stats,
app.stats_tab,
app.config.target_wpm,
app.theme,
);
frame.render_widget(dashboard, area);
}
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()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(centered);
block.render(centered, frame.buffer_mut());
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 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 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());
}

58
src/session/input.rs Normal file
View File

@@ -0,0 +1,58 @@
use std::time::Instant;
use crate::session::lesson::LessonState;
#[derive(Clone, Debug)]
pub enum CharStatus {
Correct,
Incorrect(char),
}
#[derive(Clone, Debug)]
pub struct KeystrokeEvent {
pub expected: char,
#[allow(dead_code)]
pub actual: char,
pub timestamp: Instant,
pub correct: bool,
}
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> {
if lesson.is_complete() {
return None;
}
if lesson.started_at.is_none() {
lesson.started_at = Some(Instant::now());
}
let expected = lesson.target[lesson.cursor];
let correct = ch == expected;
let event = KeystrokeEvent {
expected,
actual: ch,
timestamp: Instant::now(),
correct,
};
if correct {
lesson.input.push(CharStatus::Correct);
} else {
lesson.input.push(CharStatus::Incorrect(ch));
}
lesson.cursor += 1;
if lesson.is_complete() {
lesson.finished_at = Some(Instant::now());
}
Some(event)
}
pub fn process_backspace(lesson: &mut LessonState) {
if lesson.cursor > 0 {
lesson.cursor -= 1;
lesson.input.pop();
}
}

108
src/session/lesson.rs Normal file
View File

@@ -0,0 +1,108 @@
use std::time::Instant;
use crate::session::input::CharStatus;
pub struct LessonState {
pub target: Vec<char>,
pub input: Vec<CharStatus>,
pub cursor: usize,
pub started_at: Option<Instant>,
pub finished_at: Option<Instant>,
}
impl LessonState {
pub fn new(text: &str) -> Self {
Self {
target: text.chars().collect(),
input: Vec::new(),
cursor: 0,
started_at: None,
finished_at: None,
}
}
pub fn is_complete(&self) -> bool {
self.cursor >= self.target.len()
}
pub fn elapsed_secs(&self) -> f64 {
match (self.started_at, self.finished_at) {
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
(Some(start), None) => start.elapsed().as_secs_f64(),
_ => 0.0,
}
}
pub fn correct_count(&self) -> usize {
self.input
.iter()
.filter(|s| matches!(s, CharStatus::Correct))
.count()
}
pub fn incorrect_count(&self) -> usize {
self.input
.iter()
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
.count()
}
pub fn wpm(&self) -> f64 {
let elapsed = self.elapsed_secs();
if elapsed < 0.1 {
return 0.0;
}
let chars = self.correct_count() as f64;
(chars / 5.0) / (elapsed / 60.0)
}
pub fn accuracy(&self) -> f64 {
let total = self.input.len();
if total == 0 {
return 100.0;
}
(self.correct_count() as f64 / total as f64) * 100.0
}
pub fn cpm(&self) -> f64 {
let elapsed = self.elapsed_secs();
if elapsed < 0.1 {
return 0.0;
}
self.correct_count() as f64 / (elapsed / 60.0)
}
pub fn progress(&self) -> f64 {
if self.target.is_empty() {
return 0.0;
}
self.cursor as f64 / self.target.len() as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_lesson() {
let lesson = LessonState::new("hello");
assert_eq!(lesson.target.len(), 5);
assert_eq!(lesson.cursor, 0);
assert!(!lesson.is_complete());
assert_eq!(lesson.progress(), 0.0);
}
#[test]
fn test_accuracy_starts_at_100() {
let lesson = LessonState::new("test");
assert_eq!(lesson.accuracy(), 100.0);
}
#[test]
fn test_empty_lesson_progress() {
let lesson = LessonState::new("");
assert!(lesson.is_complete());
assert_eq!(lesson.progress(), 0.0);
}
}

3
src/session/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod input;
pub mod lesson;
pub mod result;

53
src/session/result.rs Normal file
View File

@@ -0,0 +1,53 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::session::input::KeystrokeEvent;
use crate::session::lesson::LessonState;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonResult {
pub wpm: f64,
pub cpm: f64,
pub accuracy: f64,
pub correct: usize,
pub incorrect: usize,
pub total_chars: usize,
pub elapsed_secs: f64,
pub timestamp: DateTime<Utc>,
pub per_key_times: Vec<KeyTime>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyTime {
pub key: char,
pub time_ms: f64,
pub correct: bool,
}
impl LessonResult {
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
let per_key_times: Vec<KeyTime> = events
.windows(2)
.map(|pair| {
let dt = pair[1].timestamp.duration_since(pair[0].timestamp);
KeyTime {
key: pair[1].expected,
time_ms: dt.as_secs_f64() * 1000.0,
correct: pair[1].correct,
}
})
.collect();
Self {
wpm: lesson.wpm(),
cpm: lesson.cpm(),
accuracy: lesson.accuracy(),
correct: lesson.correct_count(),
incorrect: lesson.incorrect_count(),
total_chars: lesson.target.len(),
elapsed_secs: lesson.elapsed_secs(),
timestamp: Utc::now(),
per_key_times,
}
}
}

75
src/store/json_store.rs Normal file
View File

@@ -0,0 +1,75 @@
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
pub struct JsonStore {
base_dir: PathBuf,
}
impl JsonStore {
pub fn new() -> Result<Self> {
let base_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("keydr");
fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir })
}
fn file_path(&self, name: &str) -> PathBuf {
self.base_dir.join(name)
}
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
let path = self.file_path(name);
if path.exists() {
match fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => T::default(),
}
} else {
T::default()
}
}
fn save<T: Serialize>(&self, name: &str, data: &T) -> Result<()> {
let path = self.file_path(name);
let tmp_path = path.with_extension("tmp");
let json = serde_json::to_string_pretty(data)?;
let mut file = fs::File::create(&tmp_path)?;
file.write_all(json.as_bytes())?;
file.sync_all()?;
fs::rename(&tmp_path, &path)?;
Ok(())
}
pub fn load_profile(&self) -> ProfileData {
self.load("profile.json")
}
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {
self.save("profile.json", data)
}
pub fn load_key_stats(&self) -> KeyStatsData {
self.load("key_stats.json")
}
pub fn save_key_stats(&self, data: &KeyStatsData) -> Result<()> {
self.save("key_stats.json", data)
}
pub fn load_lesson_history(&self) -> LessonHistoryData {
self.load("lesson_history.json")
}
pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> {
self.save("lesson_history.json", data)
}
}

2
src/store/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod json_store;
pub mod schema;

61
src/store/schema.rs Normal file
View File

@@ -0,0 +1,61 @@
use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult;
const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32,
pub unlocked_letters: Vec<char>,
pub total_score: f64,
pub total_lessons: u32,
pub streak_days: u32,
pub best_streak: u32,
pub last_practice_date: Option<String>,
}
impl Default for ProfileData {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
unlocked_letters: Vec::new(),
total_score: 0.0,
total_lessons: 0,
streak_days: 0,
best_streak: 0,
last_practice_date: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStatsData {
pub schema_version: u32,
pub stats: KeyStatsStore,
}
impl Default for KeyStatsData {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
stats: KeyStatsStore::default(),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonHistoryData {
pub schema_version: u32,
pub lessons: Vec<LessonResult>,
}
impl Default for LessonHistoryData {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
lessons: Vec::new(),
}
}
}

View File

@@ -0,0 +1,67 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::symbols;
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
use crate::ui::theme::Theme;
pub struct WpmChart<'a> {
pub data: &'a [(f64, f64)],
pub theme: &'a Theme,
}
impl<'a> WpmChart<'a> {
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
Self { data, theme }
}
}
impl Widget for WpmChart<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
if self.data.is_empty() {
let block = Block::bordered()
.title(" WPM Over Time ")
.border_style(Style::default().fg(colors.border()));
block.render(area, buf);
return;
}
let max_x = self.data.last().map(|(x, _)| *x).unwrap_or(1.0);
let max_y = self
.data
.iter()
.map(|(_, y)| *y)
.fold(0.0f64, f64::max)
.max(10.0);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(colors.accent()))
.data(self.data);
let chart = Chart::new(vec![dataset])
.block(
Block::bordered()
.title(" WPM 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("WPM")
.style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_y * 1.1]),
);
chart.render(area, buf);
}
}

View File

@@ -0,0 +1,118 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::result::LessonResult;
use crate::ui::theme::Theme;
pub struct Dashboard<'a> {
pub result: &'a LessonResult,
pub theme: &'a Theme,
}
impl<'a> Dashboard<'a> {
pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self {
Self { result, theme }
}
}
impl Widget for Dashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Lesson Complete ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(2),
])
.split(inner);
let title = Paragraph::new(Line::from(Span::styled(
"Results",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
title.render(layout[0], buf);
let wpm_text = format!("{:.0} WPM", self.result.wpm);
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
let wpm_line = Line::from(vec![
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
Span::styled(
&*wpm_text,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(&*cpm_text, Style::default().fg(colors.text_pending())),
]);
Paragraph::new(wpm_line).render(layout[1], buf);
let acc_color = if self.result.accuracy >= 95.0 {
colors.success()
} else if self.result.accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
};
let acc_text = format!("{:.1}%", self.result.accuracy);
let acc_detail = format!(
" ({}/{} correct)",
self.result.correct, self.result.total_chars
);
let acc_line = Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*acc_text,
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
),
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
]);
Paragraph::new(acc_line).render(layout[2], buf);
let time_text = format!("{:.1}s", self.result.elapsed_secs);
let time_line = Line::from(vec![
Span::styled(" Time: ", Style::default().fg(colors.fg())),
Span::styled(&*time_text, Style::default().fg(colors.fg())),
]);
Paragraph::new(time_line).render(layout[3], buf);
let error_text = format!("{}", self.result.incorrect);
let chars_line = Line::from(vec![
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
Span::styled(
&*error_text,
Style::default().fg(if self.result.incorrect == 0 {
colors.success()
} else {
colors.error()
}),
),
]);
Paragraph::new(chars_line).render(layout[4], buf);
let help = Paragraph::new(Line::from(vec![
Span::styled(" [r] Retry ", Style::default().fg(colors.accent())),
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
Span::styled("[s] Stats", Style::default().fg(colors.accent())),
]));
help.render(layout[6], buf);
}
}

View File

@@ -0,0 +1,112 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
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,
}
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,
}
}
}
const 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'],
];
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;
let block = Block::bordered()
.title(" Keyboard ")
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 || inner.width < 30 {
return;
}
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;
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 + 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_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(finger_color(key))
} else {
Style::default()
.fg(colors.text_pending())
.bg(colors.bg())
};
let display = format!("[ {key} ]");
buf.set_string(x, y, &display, style);
}
}
}
}

150
src/ui/components/menu.rs Normal file
View File

@@ -0,0 +1,150 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::ui::theme::Theme;
pub struct MenuItem {
pub key: String,
pub label: String,
pub description: String,
}
pub struct Menu<'a> {
pub items: Vec<MenuItem>,
pub selected: usize,
pub theme: &'a Theme,
}
impl<'a> Menu<'a> {
pub fn new(theme: &'a Theme) -> Self {
Self {
items: vec![
MenuItem {
key: "1".to_string(),
label: "Adaptive Practice".to_string(),
description: "Phonetic words with adaptive letter unlocking".to_string(),
},
MenuItem {
key: "2".to_string(),
label: "Code Practice".to_string(),
description: "Practice typing code syntax".to_string(),
},
MenuItem {
key: "3".to_string(),
label: "Passage Mode".to_string(),
description: "Type passages from books".to_string(),
},
MenuItem {
key: "s".to_string(),
label: "Statistics".to_string(),
description: "View your typing statistics".to_string(),
},
MenuItem {
key: "c".to_string(),
label: "Settings".to_string(),
description: "Configure keydr".to_string(),
},
],
selected: 0,
theme,
}
}
pub fn next(&mut self) {
self.selected = (self.selected + 1) % self.items.len();
}
pub fn prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
} else {
self.selected = self.items.len() - 1;
}
}
}
impl Widget for &Menu<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
let title_lines = vec![
Line::from(""),
Line::from(Span::styled(
"keydr",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Terminal Typing Tutor",
Style::default().fg(colors.fg()),
)),
Line::from(""),
];
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
title.render(layout[0], buf);
let menu_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
self.items
.iter()
.map(|_| Constraint::Length(3))
.collect::<Vec<_>>(),
)
.split(layout[2]);
for (i, item) in self.items.iter().enumerate() {
let is_selected = i == self.selected;
let indicator = if is_selected { ">" } else { " " };
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
let desc_text = format!(" {}", item.description);
let lines = vec![
Line::from(Span::styled(
&*label_text,
Style::default()
.fg(if is_selected {
colors.accent()
} else {
colors.fg()
})
.add_modifier(if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
}),
)),
Line::from(Span::styled(
&*desc_text,
Style::default().fg(colors.text_pending()),
)),
];
let p = Paragraph::new(lines);
if i < menu_layout.len() {
p.render(menu_layout[i], buf);
}
}
}
}

8
src/ui/components/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod chart;
pub mod dashboard;
pub mod keyboard_diagram;
pub mod menu;
pub mod progress_bar;
pub mod stats_dashboard;
pub mod stats_sidebar;
pub mod typing_area;

View File

@@ -0,0 +1,53 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Widget};
use crate::ui::theme::Theme;
pub struct ProgressBar<'a> {
pub label: String,
pub ratio: f64,
pub theme: &'a Theme,
}
impl<'a> ProgressBar<'a> {
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
Self {
label: label.to_string(),
ratio: ratio.clamp(0.0, 1.0),
theme,
}
}
}
impl Widget for ProgressBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(format!(" {} ", self.label))
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let filled_width = (self.ratio * inner.width as f64) as u16;
let label = format!("{:.0}%", self.ratio * 100.0);
for x in inner.x..inner.x + inner.width {
let style = if x < inner.x + filled_width {
Style::default().fg(colors.bg()).bg(colors.bar_filled())
} else {
Style::default().fg(colors.fg()).bg(colors.bar_empty())
};
buf[(x, inner.y)].set_style(style);
}
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
}
}

View File

@@ -0,0 +1,740 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
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],
key_stats: &'a KeyStatsStore,
active_tab: usize,
target_wpm: u32,
theme: &'a Theme,
) -> Self {
Self {
history,
key_stats,
active_tab,
target_wpm,
theme,
}
}
}
impl Widget for StatsDashboard<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Statistics ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
if self.history.is_empty() {
let msg = Paragraph::new(Line::from(Span::styled(
"No lessons completed yet. Start typing!",
Style::default().fg(colors.text_pending()),
)));
msg.render(inner, buf);
return;
}
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
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
.history
.iter()
.map(|r| r.wpm)
.fold(0.0f64, f64::max);
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
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),
),
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
Span::styled(
&*best_wpm_str,
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
),
]),
Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*avg_acc_str,
Style::default().fg(if avg_accuracy >= 95.0 {
colors.success()
} 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(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

@@ -0,0 +1,87 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::lesson::LessonState;
use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> {
lesson: &'a LessonState,
theme: &'a Theme,
}
impl<'a> StatsSidebar<'a> {
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
Self { lesson, theme }
}
}
impl Widget for StatsSidebar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let wpm = self.lesson.wpm();
let accuracy = self.lesson.accuracy();
let progress = self.lesson.progress() * 100.0;
let correct = self.lesson.correct_count();
let incorrect = self.lesson.incorrect_count();
let elapsed = self.lesson.elapsed_secs();
let wpm_str = format!("{wpm:.0}");
let acc_str = format!("{accuracy:.1}%");
let prog_str = format!("{progress:.0}%");
let correct_str = format!("{correct}");
let incorrect_str = format!("{incorrect}");
let elapsed_str = format!("{elapsed:.1}s");
let lines = vec![
Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(&*wpm_str, Style::default().fg(colors.accent())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
Span::styled(
&*acc_str,
Style::default().fg(if accuracy >= 95.0 {
colors.success()
} else if accuracy >= 85.0 {
colors.warning()
} else {
colors.error()
}),
),
]),
Line::from(""),
Line::from(vec![
Span::styled("Progress: ", Style::default().fg(colors.fg())),
Span::styled(&*prog_str, Style::default().fg(colors.accent())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Correct: ", Style::default().fg(colors.fg())),
Span::styled(&*correct_str, Style::default().fg(colors.success())),
]),
Line::from(vec![
Span::styled("Errors: ", Style::default().fg(colors.fg())),
Span::styled(&*incorrect_str, Style::default().fg(colors.error())),
]),
Line::from(""),
Line::from(vec![
Span::styled("Time: ", Style::default().fg(colors.fg())),
Span::styled(&*elapsed_str, Style::default().fg(colors.fg())),
]),
];
let block = Block::bordered()
.title(" Stats ")
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let paragraph = Paragraph::new(lines).block(block);
paragraph.render(area, buf);
}
}

View File

@@ -0,0 +1,61 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::session::input::CharStatus;
use crate::session::lesson::LessonState;
use crate::ui::theme::Theme;
pub struct TypingArea<'a> {
lesson: &'a LessonState,
theme: &'a Theme,
}
impl<'a> TypingArea<'a> {
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
Self { lesson, theme }
}
}
impl Widget for TypingArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let mut spans: Vec<Span> = Vec::new();
for (i, &target_ch) in self.lesson.target.iter().enumerate() {
if i < self.lesson.cursor {
let style = match &self.lesson.input[i] {
CharStatus::Correct => Style::default().fg(colors.text_correct()),
CharStatus::Incorrect(_) => Style::default()
.fg(colors.text_incorrect())
.bg(colors.text_incorrect_bg())
.add_modifier(Modifier::UNDERLINED),
};
let display = match &self.lesson.input[i] {
CharStatus::Incorrect(actual) => *actual,
_ => target_ch,
};
spans.push(Span::styled(display.to_string(), style));
} else if i == self.lesson.cursor {
let style = Style::default()
.fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg());
spans.push(Span::styled(target_ch.to_string(), style));
} else {
let style = Style::default().fg(colors.text_pending());
spans.push(Span::styled(target_ch.to_string(), style));
}
}
let line = Line::from(spans);
let block = Block::bordered()
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false });
paragraph.render(area, buf);
}
}

53
src/ui/layout.rs Normal file
View File

@@ -0,0 +1,53 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
pub struct AppLayout {
pub header: Rect,
pub main: Rect,
pub sidebar: Rect,
pub footer: Rect,
}
impl AppLayout {
pub fn new(area: Rect) -> Self {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(3),
])
.split(area);
let horizontal = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(vertical[1]);
Self {
header: vertical[0],
main: horizontal[0],
sidebar: horizontal[1],
footer: vertical[2],
}
}
}
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}

3
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod components;
pub mod layout;
pub mod theme;

146
src/ui/theme.rs Normal file
View File

@@ -0,0 +1,146 @@
use std::fs;
use ratatui::style::Color;
use rust_embed::Embed;
use serde::{Deserialize, Serialize};
#[derive(Embed)]
#[folder = "assets/themes/"]
struct ThemeAssets;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
pub colors: ThemeColors,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ThemeColors {
pub bg: String,
pub fg: String,
pub text_correct: String,
pub text_incorrect: String,
pub text_incorrect_bg: String,
pub text_pending: String,
pub text_cursor_bg: String,
pub text_cursor_fg: String,
pub focused_key: String,
pub accent: String,
pub accent_dim: String,
pub border: String,
pub border_focused: String,
pub header_bg: String,
pub header_fg: String,
pub bar_filled: String,
pub bar_empty: String,
pub error: String,
pub warning: String,
pub success: String,
}
impl Theme {
pub fn load(name: &str) -> Option<Self> {
// Try user themes dir
if let Some(config_dir) = dirs::config_dir() {
let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml"));
if let Ok(content) = fs::read_to_string(&user_theme_path) {
if let Ok(theme) = toml::from_str::<Theme>(&content) {
return Some(theme);
}
}
}
// Try bundled themes
let filename = format!("{name}.toml");
if let Some(file) = ThemeAssets::get(&filename) {
if let Ok(content) = std::str::from_utf8(file.data.as_ref()) {
if let Ok(theme) = toml::from_str::<Theme>(content) {
return Some(theme);
}
}
}
None
}
pub fn available_themes() -> Vec<String> {
ThemeAssets::iter()
.filter_map(|f| {
f.strip_suffix(".toml").map(|n| n.to_string())
})
.collect()
}
}
impl Default for Theme {
fn default() -> Self {
Self::load("catppuccin-mocha").unwrap_or_else(|| Self {
name: "default".to_string(),
colors: ThemeColors::default(),
})
}
}
impl Default for ThemeColors {
fn default() -> Self {
Self {
bg: "#1e1e2e".to_string(),
fg: "#cdd6f4".to_string(),
text_correct: "#a6e3a1".to_string(),
text_incorrect: "#f38ba8".to_string(),
text_incorrect_bg: "#45273a".to_string(),
text_pending: "#a6adc8".to_string(),
text_cursor_bg: "#f5e0dc".to_string(),
text_cursor_fg: "#1e1e2e".to_string(),
focused_key: "#f9e2af".to_string(),
accent: "#89b4fa".to_string(),
accent_dim: "#45475a".to_string(),
border: "#45475a".to_string(),
border_focused: "#89b4fa".to_string(),
header_bg: "#313244".to_string(),
header_fg: "#cdd6f4".to_string(),
bar_filled: "#89b4fa".to_string(),
bar_empty: "#313244".to_string(),
error: "#f38ba8".to_string(),
warning: "#f9e2af".to_string(),
success: "#a6e3a1".to_string(),
}
}
}
impl ThemeColors {
pub fn parse_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
) {
return Color::Rgb(r, g, b);
}
}
Color::White
}
pub fn bg(&self) -> Color { Self::parse_color(&self.bg) }
pub fn fg(&self) -> Color { Self::parse_color(&self.fg) }
pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) }
pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) }
pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) }
pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) }
pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) }
pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) }
pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) }
pub fn accent(&self) -> Color { Self::parse_color(&self.accent) }
pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) }
pub fn border(&self) -> Color { Self::parse_color(&self.border) }
pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) }
pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) }
pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) }
pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) }
pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) }
pub fn error(&self) -> Color { Self::parse_color(&self.error) }
pub fn warning(&self) -> Color { Self::parse_color(&self.warning) }
pub fn success(&self) -> Color { Self::parse_color(&self.success) }
}