First improvement pass
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/target
|
||||
/clones/
|
||||
|
||||
1090
Cargo.lock
generated
1090
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,8 @@ rust-embed = "8.5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0"
|
||||
reqwest = { version = "0.12", features = ["blocking"], optional = true }
|
||||
|
||||
[features]
|
||||
default = ["network"]
|
||||
network = ["reqwest"]
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#4c4f69"
|
||||
text_correct = "#40a02b"
|
||||
text_incorrect = "#d20f39"
|
||||
text_incorrect_bg = "#f5c2cf"
|
||||
text_pending = "#9ca0b0"
|
||||
text_pending = "#7c7f93"
|
||||
text_cursor_bg = "#dc8a78"
|
||||
text_cursor_fg = "#eff1f5"
|
||||
focused_key = "#df8e1d"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#cdd6f4"
|
||||
text_correct = "#a6e3a1"
|
||||
text_incorrect = "#f38ba8"
|
||||
text_incorrect_bg = "#45273a"
|
||||
text_pending = "#585b70"
|
||||
text_pending = "#a6adc8"
|
||||
text_cursor_bg = "#f5e0dc"
|
||||
text_cursor_fg = "#1e1e2e"
|
||||
focused_key = "#f9e2af"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#f8f8f2"
|
||||
text_correct = "#50fa7b"
|
||||
text_incorrect = "#ff5555"
|
||||
text_incorrect_bg = "#44242a"
|
||||
text_pending = "#6272a4"
|
||||
text_pending = "#9aadce"
|
||||
text_cursor_bg = "#f1fa8c"
|
||||
text_cursor_fg = "#282a36"
|
||||
focused_key = "#f1fa8c"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#ebdbb2"
|
||||
text_correct = "#b8bb26"
|
||||
text_incorrect = "#fb4934"
|
||||
text_incorrect_bg = "#462726"
|
||||
text_pending = "#665c54"
|
||||
text_pending = "#a89984"
|
||||
text_cursor_bg = "#fabd2f"
|
||||
text_cursor_fg = "#282828"
|
||||
focused_key = "#fabd2f"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#eceff4"
|
||||
text_correct = "#a3be8c"
|
||||
text_incorrect = "#bf616a"
|
||||
text_incorrect_bg = "#3f2e31"
|
||||
text_pending = "#4c566a"
|
||||
text_pending = "#8fbcbb"
|
||||
text_cursor_bg = "#ebcb8b"
|
||||
text_cursor_fg = "#2e3440"
|
||||
focused_key = "#ebcb8b"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#abb2bf"
|
||||
text_correct = "#98c379"
|
||||
text_incorrect = "#e06c75"
|
||||
text_incorrect_bg = "#3e2a2d"
|
||||
text_pending = "#5c6370"
|
||||
text_pending = "#848b98"
|
||||
text_cursor_bg = "#e5c07b"
|
||||
text_cursor_fg = "#282c34"
|
||||
focused_key = "#e5c07b"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#839496"
|
||||
text_correct = "#859900"
|
||||
text_incorrect = "#dc322f"
|
||||
text_incorrect_bg = "#2a1a1a"
|
||||
text_pending = "#586e75"
|
||||
text_pending = "#839496"
|
||||
text_cursor_bg = "#b58900"
|
||||
text_cursor_fg = "#002b36"
|
||||
focused_key = "#b58900"
|
||||
|
||||
@@ -6,7 +6,7 @@ fg = "#c0caf5"
|
||||
text_correct = "#9ece6a"
|
||||
text_incorrect = "#f7768e"
|
||||
text_incorrect_bg = "#3b2232"
|
||||
text_pending = "#565f89"
|
||||
text_pending = "#9aa5ce"
|
||||
text_cursor_bg = "#e0af68"
|
||||
text_cursor_fg = "#1a1b26"
|
||||
focused_key = "#e0af68"
|
||||
|
||||
10002
assets/words-en.json
Normal file
10002
assets/words-en.json
Normal file
File diff suppressed because it is too large
Load Diff
290
docs/plans/2026-02-09-initial-plan.md
Normal file
290
docs/plans/2026-02-09-initial-plan.md
Normal 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"
|
||||
```
|
||||
188
docs/plans/2026-02-10-improvement.md
Normal file
188
docs/plans/2026-02-10-improvement.md
Normal 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)
|
||||
102
src/app.rs
102
src/app.rs
@@ -7,6 +7,7 @@ use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::engine::letter_unlock::LetterUnlock;
|
||||
use crate::engine::scoring;
|
||||
use crate::generator::code_syntax::CodeSyntaxGenerator;
|
||||
use crate::generator::dictionary::Dictionary;
|
||||
use crate::generator::passage::PassageGenerator;
|
||||
use crate::generator::phonetic::PhoneticGenerator;
|
||||
use crate::generator::TextGenerator;
|
||||
@@ -26,7 +27,6 @@ pub enum AppScreen {
|
||||
Lesson,
|
||||
LessonResult,
|
||||
StatsDashboard,
|
||||
#[allow(dead_code)]
|
||||
Settings,
|
||||
}
|
||||
|
||||
@@ -52,8 +52,12 @@ pub struct App {
|
||||
pub profile: ProfileData,
|
||||
pub store: Option<JsonStore>,
|
||||
pub should_quit: bool,
|
||||
pub settings_selected: usize,
|
||||
pub stats_tab: usize,
|
||||
rng: SmallRng,
|
||||
transition_table: TransitionTable,
|
||||
#[allow(dead_code)]
|
||||
dictionary: Dictionary,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -89,7 +93,8 @@ impl App {
|
||||
let mut key_stats_with_target = key_stats;
|
||||
key_stats_with_target.target_cpm = config.target_cpm();
|
||||
|
||||
let transition_table = TransitionTable::build_english();
|
||||
let dictionary = Dictionary::load();
|
||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||
|
||||
Self {
|
||||
screen: AppScreen::Menu,
|
||||
@@ -106,8 +111,11 @@ impl App {
|
||||
profile,
|
||||
store,
|
||||
should_quit: false,
|
||||
settings_selected: 0,
|
||||
stats_tab: 0,
|
||||
rng: SmallRng::from_entropy(),
|
||||
transition_table,
|
||||
dictionary,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +135,9 @@ impl App {
|
||||
let filter = CharFilter::new(self.letter_unlock.included.clone());
|
||||
let focused = self.letter_unlock.focused;
|
||||
let table = self.transition_table.clone();
|
||||
let dict = Dictionary::load();
|
||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||
let mut generator = PhoneticGenerator::new(table, rng);
|
||||
let mut generator = PhoneticGenerator::new(table, dict, rng);
|
||||
generator.generate(&filter, focused, word_count)
|
||||
}
|
||||
LessonMode::Code => {
|
||||
@@ -145,7 +154,8 @@ impl App {
|
||||
}
|
||||
LessonMode::Passage => {
|
||||
let filter = CharFilter::new(('a'..='z').collect());
|
||||
let mut generator = PassageGenerator::new();
|
||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||
let mut generator = PassageGenerator::new(rng);
|
||||
generator.generate(&filter, None, word_count)
|
||||
}
|
||||
}
|
||||
@@ -244,6 +254,90 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn go_to_stats(&mut self) {
|
||||
self.stats_tab = 0;
|
||||
self.screen = AppScreen::StatsDashboard;
|
||||
}
|
||||
|
||||
pub fn go_to_settings(&mut self) {
|
||||
self.settings_selected = 0;
|
||||
self.screen = AppScreen::Settings;
|
||||
}
|
||||
|
||||
pub fn settings_cycle_forward(&mut self) {
|
||||
match self.settings_selected {
|
||||
0 => {
|
||||
self.config.target_wpm = (self.config.target_wpm + 5).min(200);
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
}
|
||||
1 => {
|
||||
let themes = Theme::available_themes();
|
||||
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
|
||||
let next = (idx + 1) % themes.len();
|
||||
self.config.theme = themes[next].clone();
|
||||
} else if let Some(first) = themes.first() {
|
||||
self.config.theme = first.clone();
|
||||
}
|
||||
if let Some(new_theme) = Theme::load(&self.config.theme) {
|
||||
let theme: &'static Theme = Box::leak(Box::new(new_theme));
|
||||
self.theme = theme;
|
||||
self.menu.theme = theme;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
self.config.word_count = (self.config.word_count + 5).min(100);
|
||||
}
|
||||
3 => {
|
||||
let langs = ["rust", "python", "javascript", "go"];
|
||||
let current = self
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||
let next = (idx + 1) % langs.len();
|
||||
self.config.code_languages = vec![langs[next].to_string()];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings_cycle_backward(&mut self) {
|
||||
match self.settings_selected {
|
||||
0 => {
|
||||
self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
}
|
||||
1 => {
|
||||
let themes = Theme::available_themes();
|
||||
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
|
||||
let next = if idx == 0 { themes.len() - 1 } else { idx - 1 };
|
||||
self.config.theme = themes[next].clone();
|
||||
} else if let Some(first) = themes.first() {
|
||||
self.config.theme = first.clone();
|
||||
}
|
||||
if let Some(new_theme) = Theme::load(&self.config.theme) {
|
||||
let theme: &'static Theme = Box::leak(Box::new(new_theme));
|
||||
self.theme = theme;
|
||||
self.menu.theme = theme;
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
|
||||
}
|
||||
3 => {
|
||||
let langs = ["rust", "python", "javascript", "go"];
|
||||
let current = self
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
|
||||
self.config.code_languages = vec![langs[next].to_string()];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
src/generator/cache.rs
Normal file
49
src/generator/cache.rs
Normal 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
|
||||
}
|
||||
@@ -2,18 +2,81 @@ use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::engine::filter::CharFilter;
|
||||
use crate::generator::cache::{DiskCache, fetch_url};
|
||||
use crate::generator::TextGenerator;
|
||||
|
||||
pub struct CodeSyntaxGenerator {
|
||||
rng: SmallRng,
|
||||
language: String,
|
||||
fetched_snippets: Vec<String>,
|
||||
}
|
||||
|
||||
impl CodeSyntaxGenerator {
|
||||
pub fn new(rng: SmallRng, language: &str) -> Self {
|
||||
Self {
|
||||
let mut generator = Self {
|
||||
rng,
|
||||
language: language.to_string(),
|
||||
fetched_snippets: Vec::new(),
|
||||
};
|
||||
generator.load_cached_snippets();
|
||||
generator
|
||||
}
|
||||
|
||||
fn load_cached_snippets(&mut self) {
|
||||
if let Some(cache) = DiskCache::new("code_cache") {
|
||||
let key = format!("{}_snippets", self.language);
|
||||
if let Some(content) = cache.get(&key) {
|
||||
self.fetched_snippets = content
|
||||
.split("\n---SNIPPET---\n")
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_fetch_code(&mut self) {
|
||||
let urls = match self.language.as_str() {
|
||||
"rust" => vec![
|
||||
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/sync/mutex.rs",
|
||||
"https://raw.githubusercontent.com/serde-rs/serde/master/serde/src/ser/mod.rs",
|
||||
],
|
||||
"python" => vec![
|
||||
"https://raw.githubusercontent.com/python/cpython/main/Lib/json/encoder.py",
|
||||
"https://raw.githubusercontent.com/python/cpython/main/Lib/pathlib/__init__.py",
|
||||
],
|
||||
"javascript" | "js" => vec![
|
||||
"https://raw.githubusercontent.com/lodash/lodash/main/src/chunk.ts",
|
||||
"https://raw.githubusercontent.com/expressjs/express/master/lib/router/index.js",
|
||||
],
|
||||
"go" => vec![
|
||||
"https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go",
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
let cache = match DiskCache::new("code_cache") {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let key = format!("{}_snippets", self.language);
|
||||
if cache.get(&key).is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut all_snippets = Vec::new();
|
||||
for url in urls {
|
||||
if let Some(content) = fetch_url(url) {
|
||||
let snippets = extract_code_snippets(&content);
|
||||
all_snippets.extend(snippets);
|
||||
}
|
||||
}
|
||||
|
||||
if !all_snippets.is_empty() {
|
||||
let combined = all_snippets.join("\n---SNIPPET---\n");
|
||||
cache.put(&key, &combined);
|
||||
self.fetched_snippets = all_snippets;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +98,20 @@ impl CodeSyntaxGenerator {
|
||||
"trait Display { fn show(&self) -> String; }",
|
||||
"while let Some(item) = stack.pop() { process(item); }",
|
||||
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }",
|
||||
"let handle = std::thread::spawn(|| { println!(\"thread\"); });",
|
||||
"let mut map = HashMap::new(); map.insert(\"key\", 42);",
|
||||
"fn factorial(n: u64) -> u64 { if n <= 1 { 1 } else { n * factorial(n - 1) } }",
|
||||
"impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { None } }",
|
||||
"async fn fetch(url: &str) -> Result<String> { let body = reqwest::get(url).await?.text().await?; Ok(body) }",
|
||||
"let closure = |x: i32, y: i32| -> i32 { x + y };",
|
||||
"mod tests { use super::*; #[test] fn it_works() { assert_eq!(2 + 2, 4); } }",
|
||||
"pub struct Builder { name: Option<String> } impl Builder { pub fn name(mut self, n: &str) -> Self { self.name = Some(n.into()); self } }",
|
||||
"use std::sync::{Arc, Mutex}; let data = Arc::new(Mutex::new(vec![1, 2, 3]));",
|
||||
"if let Ok(value) = \"42\".parse::<i32>() { println!(\"parsed: {}\", value); }",
|
||||
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }",
|
||||
"type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;",
|
||||
"macro_rules! vec_of_strings { ($($x:expr),*) => { vec![$($x.to_string()),*] }; }",
|
||||
"let (tx, rx) = std::sync::mpsc::channel(); tx.send(42).unwrap();",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -52,6 +129,19 @@ impl CodeSyntaxGenerator {
|
||||
"from collections import defaultdict",
|
||||
"lambda x: x * 2 + 1",
|
||||
"dict_comp = {k: v for k, v in pairs.items()}",
|
||||
"async def fetch(url): async with aiohttp.ClientSession() as session: return await session.get(url)",
|
||||
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
|
||||
"@property def name(self): return self._name",
|
||||
"from dataclasses import dataclass; @dataclass class Config: name: str; value: int = 0",
|
||||
"yield from range(10)",
|
||||
"sorted(items, key=lambda x: x.name, reverse=True)",
|
||||
"from typing import Optional, List, Dict",
|
||||
"with contextlib.suppress(FileNotFoundError): os.remove(\"temp.txt\")",
|
||||
"class Meta(type): def __new__(cls, name, bases, attrs): return super().__new__(cls, name, bases, attrs)",
|
||||
"from functools import lru_cache; @lru_cache(maxsize=128) def expensive(n): return sum(range(n))",
|
||||
"from pathlib import Path; files = list(Path(\".\").glob(\"**/*.py\"))",
|
||||
"assert isinstance(result, dict), f\"Expected dict, got {type(result)}\"",
|
||||
"values = {*set_a, *set_b}; merged = {**dict_a, **dict_b}",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -69,6 +159,18 @@ impl CodeSyntaxGenerator {
|
||||
"try { parse(data); } catch (e) { console.error(e); }",
|
||||
"export default function handler(req, res) { res.send(\"ok\"); }",
|
||||
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);",
|
||||
"const promise = new Promise((resolve, reject) => { setTimeout(resolve, 1000); });",
|
||||
"const [first, ...rest] = array;",
|
||||
"class EventEmitter { constructor() { this.listeners = new Map(); } }",
|
||||
"const proxy = new Proxy(target, { get(obj, prop) { return obj[prop]; } });",
|
||||
"for await (const chunk of stream) { process(chunk); }",
|
||||
"const memoize = (fn) => { const cache = new Map(); return (...args) => cache.get(args) ?? fn(...args); };",
|
||||
"import { useState, useEffect } from 'react'; const [state, setState] = useState(null);",
|
||||
"const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);",
|
||||
"Object.entries(obj).forEach(([key, value]) => { console.log(key, value); });",
|
||||
"const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; };",
|
||||
"const observable = new Observable(subscriber => { subscriber.next(1); subscriber.complete(); });",
|
||||
"Symbol.iterator",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -84,6 +186,16 @@ impl CodeSyntaxGenerator {
|
||||
"switch val { case 1: return \"one\" default: return \"other\" }",
|
||||
"go func() { ch <- result }()",
|
||||
"defer file.Close()",
|
||||
"type Reader interface { Read(p []byte) (n int, err error) }",
|
||||
"ctx, cancel := context.WithTimeout(context.Background(), time.Second)",
|
||||
"var wg sync.WaitGroup; wg.Add(1); go func() { defer wg.Done() }()",
|
||||
"func (p *Point) Distance() float64 { return math.Sqrt(p.X*p.X + p.Y*p.Y) }",
|
||||
"select { case msg := <-ch: process(msg) case <-time.After(time.Second): timeout() }",
|
||||
"json.NewEncoder(w).Encode(response)",
|
||||
"http.HandleFunc(\"/api\", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\"ok\")) })",
|
||||
"func Map[T, U any](s []T, f func(T) U) []U { r := make([]U, len(s)); for i, v := range s { r[i] = f(v) }; return r }",
|
||||
"var once sync.Once; once.Do(func() { initialize() })",
|
||||
"buf := bytes.NewBuffer(nil); buf.WriteString(\"hello\")",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -105,18 +217,88 @@ impl TextGenerator for CodeSyntaxGenerator {
|
||||
_focused: Option<char>,
|
||||
word_count: usize,
|
||||
) -> String {
|
||||
let snippets = self.get_snippets();
|
||||
// Try to fetch from GitHub on first use
|
||||
if self.fetched_snippets.is_empty() {
|
||||
self.try_fetch_code();
|
||||
}
|
||||
|
||||
let embedded = self.get_snippets();
|
||||
let mut result = Vec::new();
|
||||
let target_words = word_count;
|
||||
let mut current_words = 0;
|
||||
|
||||
let total_available = embedded.len() + self.fetched_snippets.len();
|
||||
|
||||
while current_words < target_words {
|
||||
let idx = self.rng.gen_range(0..snippets.len());
|
||||
let snippet = snippets[idx];
|
||||
let idx = self.rng.gen_range(0..total_available.max(1));
|
||||
|
||||
let snippet = if idx < embedded.len() {
|
||||
embedded[idx]
|
||||
} else if !self.fetched_snippets.is_empty() {
|
||||
let f_idx = (idx - embedded.len()) % self.fetched_snippets.len();
|
||||
&self.fetched_snippets[f_idx]
|
||||
} else {
|
||||
embedded[idx % embedded.len()]
|
||||
};
|
||||
|
||||
current_words += snippet.split_whitespace().count();
|
||||
result.push(snippet);
|
||||
result.push(snippet.to_string());
|
||||
}
|
||||
|
||||
result.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract function-length snippets from raw source code
|
||||
fn extract_code_snippets(source: &str) -> Vec<String> {
|
||||
let mut snippets = Vec::new();
|
||||
let lines: Vec<&str> = source.lines().collect();
|
||||
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
// Look for function/method starts
|
||||
let line = lines[i].trim();
|
||||
let is_func_start = line.starts_with("fn ")
|
||||
|| line.starts_with("pub fn ")
|
||||
|| line.starts_with("def ")
|
||||
|| line.starts_with("func ")
|
||||
|| line.starts_with("function ")
|
||||
|| line.starts_with("async fn ")
|
||||
|| line.starts_with("pub async fn ");
|
||||
|
||||
if is_func_start {
|
||||
let mut snippet_lines = Vec::new();
|
||||
let mut depth = 0i32;
|
||||
let mut j = i;
|
||||
|
||||
while j < lines.len() && snippet_lines.len() < 30 {
|
||||
let l = lines[j];
|
||||
snippet_lines.push(l);
|
||||
|
||||
depth += l.chars().filter(|&c| c == '{' || c == '(').count() as i32;
|
||||
depth -= l.chars().filter(|&c| c == '}' || c == ')').count() as i32;
|
||||
|
||||
if depth <= 0 && j > i {
|
||||
break;
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
|
||||
if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 {
|
||||
let snippet = snippet_lines.join(" ");
|
||||
// Normalize whitespace
|
||||
let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if normalized.len() >= 20 && normalized.len() <= 500 {
|
||||
snippets.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
i = j + 1;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
snippets.truncate(50);
|
||||
snippets
|
||||
}
|
||||
|
||||
45
src/generator/dictionary.rs
Normal file
45
src/generator/dictionary.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod cache;
|
||||
pub mod code_syntax;
|
||||
pub mod dictionary;
|
||||
pub mod github_code;
|
||||
pub mod passage;
|
||||
pub mod phonetic;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::engine::filter::CharFilter;
|
||||
use crate::generator::cache::{DiskCache, fetch_url};
|
||||
use crate::generator::TextGenerator;
|
||||
|
||||
const PASSAGES: &[&str] = &[
|
||||
// Classic literature & speeches
|
||||
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
||||
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
|
||||
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
|
||||
@@ -17,21 +22,132 @@ const PASSAGES: &[&str] = &[
|
||||
"he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world",
|
||||
"the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below",
|
||||
"they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door",
|
||||
// Pride and Prejudice
|
||||
"it is a truth universally acknowledged that a single man in possession of a good fortune must be in want of a wife",
|
||||
"there is a stubbornness about me that never can bear to be frightened at the will of others my courage always rises at every attempt to intimidate me",
|
||||
"i could easily forgive his pride if he had not mortified mine but vanity not love has been my folly",
|
||||
// Alice in Wonderland
|
||||
"alice was beginning to get very tired of sitting by her sister on the bank and of having nothing to do",
|
||||
"who in the world am i that is the great puzzle she said as she looked around the strange room with wonder",
|
||||
"but i dont want to go among mad people alice remarked oh you cant help that said the cat were all mad here",
|
||||
// Great Gatsby
|
||||
"in my younger and more vulnerable years my father gave me some advice that i have been turning over in my mind ever since",
|
||||
"so we beat on boats against the current borne back ceaselessly into the past dreaming of that green light",
|
||||
// Sherlock Holmes
|
||||
"when you have eliminated the impossible whatever remains however improbable must be the truth my dear watson",
|
||||
"the world is full of obvious things which nobody by any chance ever observes but which are perfectly visible",
|
||||
// Moby Dick
|
||||
"call me ishmael some years ago having little or no money in my purse and nothing particular to interest me on shore",
|
||||
"it is not down on any map because true places never are and the voyage was long and the sea was deep",
|
||||
// 1984
|
||||
"it was a bright cold day in april and the clocks were striking thirteen winston smith his chin nuzzled into his breast",
|
||||
"who controls the past controls the future and who controls the present controls the past said the voice from the screen",
|
||||
// Walden
|
||||
"i went to the woods because i wished to live deliberately to front only the essential facts of life",
|
||||
"the mass of men lead lives of quiet desperation and go to the grave with the song still in them",
|
||||
// Science & philosophy
|
||||
"the only way to do great work is to love what you do and if you have not found it yet keep looking and do not settle",
|
||||
"imagination is more important than knowledge for while knowledge defines all we currently know imagination points to what we might discover",
|
||||
"the important thing is not to stop questioning for curiosity has its own reason for existing in this wonderful universe",
|
||||
"we are all in the gutter but some of us are looking at the stars and dreaming of worlds beyond our own",
|
||||
"the greatest glory in living lies not in never falling but in rising every time we fall and trying once more",
|
||||
// Nature & observation
|
||||
"the autumn wind scattered golden leaves across the garden as the last rays of sunlight painted the clouds in shades of orange and pink",
|
||||
"deep in the forest where the ancient trees stood tall and silent a small stream wound its way through moss covered stones",
|
||||
"the ocean stretched endlessly before them its surface catching the light of the setting sun in a thousand shimmering reflections",
|
||||
"morning mist hung low over the meadow as the first birds began their chorus and dew drops sparkled like diamonds on every blade of grass",
|
||||
"the mountain peak stood above the clouds its snow covered summit glowing pink and gold in the light of the early morning sun",
|
||||
// Everyday wisdom
|
||||
"the best time to plant a tree was twenty years ago and the second best time is now so do not wait any longer to begin",
|
||||
"a journey of a thousand miles begins with a single step and every great achievement started with the decision to try",
|
||||
"the more that you read the more things you will know and the more that you learn the more places you will go",
|
||||
"in three words i can sum up everything i have learned about life it goes on and so must we with hope",
|
||||
"happiness is not something ready made it comes from your own actions and your choices shape the life you live",
|
||||
"do not go where the path may lead but go instead where there is no path and leave a trail for others to follow",
|
||||
"success is not final failure is not fatal it is the courage to continue that counts in the end",
|
||||
"be yourself because everyone else is already taken and the world needs what only you can bring to it",
|
||||
"life is what happens when you are busy making other plans so enjoy the journey along the way",
|
||||
"the secret of getting ahead is getting started and the secret of getting started is breaking your tasks into small steps",
|
||||
];
|
||||
|
||||
/// Gutenberg book IDs for popular public domain works
|
||||
const GUTENBERG_IDS: &[(u32, &str)] = &[
|
||||
(1342, "pride_and_prejudice"),
|
||||
(11, "alice_in_wonderland"),
|
||||
(1661, "sherlock_holmes"),
|
||||
(84, "frankenstein"),
|
||||
(1952, "yellow_wallpaper"),
|
||||
(2701, "moby_dick"),
|
||||
(74, "tom_sawyer"),
|
||||
(345, "dracula"),
|
||||
(1232, "prince"),
|
||||
(76, "huckleberry_finn"),
|
||||
(5200, "metamorphosis"),
|
||||
(2542, "aesop_fables"),
|
||||
(174, "dorian_gray"),
|
||||
(98, "tale_two_cities"),
|
||||
(1080, "modest_proposal"),
|
||||
(219, "heart_of_darkness"),
|
||||
(4300, "ulysses"),
|
||||
(28054, "brothers_karamazov"),
|
||||
(2554, "crime_and_punishment"),
|
||||
(55, "oz"),
|
||||
];
|
||||
|
||||
pub struct PassageGenerator {
|
||||
current_idx: usize,
|
||||
fetched_passages: Vec<String>,
|
||||
rng: SmallRng,
|
||||
}
|
||||
|
||||
impl PassageGenerator {
|
||||
pub fn new() -> Self {
|
||||
Self { current_idx: 0 }
|
||||
pub fn new(rng: SmallRng) -> Self {
|
||||
let mut generator = Self {
|
||||
current_idx: 0,
|
||||
fetched_passages: Vec::new(),
|
||||
rng,
|
||||
};
|
||||
generator.load_cached_passages();
|
||||
generator
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PassageGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
fn try_fetch_gutenberg(&mut self) {
|
||||
let cache = match DiskCache::new("passages") {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Pick a random book that we haven't cached yet
|
||||
let uncached: Vec<(u32, &str)> = GUTENBERG_IDS
|
||||
.iter()
|
||||
.filter(|(_, name)| cache.get(name).is_none())
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
if uncached.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let idx = self.rng.gen_range(0..uncached.len());
|
||||
let (book_id, name) = uncached[idx];
|
||||
let url = format!("https://www.gutenberg.org/cache/epub/{book_id}/pg{book_id}.txt");
|
||||
|
||||
if let Some(content) = fetch_url(&url) {
|
||||
cache.put(name, &content);
|
||||
let paragraphs = extract_paragraphs(&content);
|
||||
self.fetched_passages.extend(paragraphs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +158,87 @@ impl TextGenerator for PassageGenerator {
|
||||
_focused: Option<char>,
|
||||
_word_count: usize,
|
||||
) -> String {
|
||||
let passage = PASSAGES[self.current_idx % PASSAGES.len()];
|
||||
// Try to fetch a new Gutenberg book in the background (first few calls)
|
||||
if self.fetched_passages.len() < 50 && self.current_idx < 3 {
|
||||
self.try_fetch_gutenberg();
|
||||
}
|
||||
|
||||
let total_passages = PASSAGES.len() + self.fetched_passages.len();
|
||||
|
||||
if total_passages == 0 {
|
||||
self.current_idx += 1;
|
||||
passage.to_string()
|
||||
return PASSAGES[0].to_string();
|
||||
}
|
||||
|
||||
// Mix embedded and fetched passages
|
||||
let idx = self.current_idx % total_passages;
|
||||
self.current_idx += 1;
|
||||
|
||||
if idx < PASSAGES.len() {
|
||||
PASSAGES[idx].to_string()
|
||||
} else {
|
||||
let fetched_idx = idx - PASSAGES.len();
|
||||
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract readable paragraphs from Gutenberg text, skipping header/footer
|
||||
fn extract_paragraphs(text: &str) -> Vec<String> {
|
||||
let mut paragraphs = Vec::new();
|
||||
|
||||
// Find the start of actual content (after Gutenberg header)
|
||||
let start_markers = ["*** START OF", "***START OF"];
|
||||
let end_markers = ["*** END OF", "***END OF"];
|
||||
|
||||
let content_start = start_markers
|
||||
.iter()
|
||||
.filter_map(|marker| text.find(marker))
|
||||
.min()
|
||||
.map(|pos| {
|
||||
// Find the end of the header line
|
||||
text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let content_end = end_markers
|
||||
.iter()
|
||||
.filter_map(|marker| text.find(marker))
|
||||
.min()
|
||||
.unwrap_or(text.len());
|
||||
|
||||
let content = &text[content_start..content_end];
|
||||
|
||||
// Split into paragraphs (double newline separated)
|
||||
for para in content.split("\r\n\r\n").chain(content.split("\n\n")) {
|
||||
let cleaned: String = para
|
||||
.lines()
|
||||
.map(|l| l.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
|
||||
.collect::<String>()
|
||||
.to_lowercase();
|
||||
|
||||
let word_count = cleaned.split_whitespace().count();
|
||||
if word_count >= 15 && word_count <= 60 {
|
||||
// Keep only the alpha/space portions for typing
|
||||
let typing_text: String = cleaned
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_lowercase() || *c == ' ')
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
if typing_text.split_whitespace().count() >= 10 {
|
||||
paragraphs.push(typing_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Take at most 100 paragraphs per book
|
||||
paragraphs.truncate(100);
|
||||
paragraphs
|
||||
}
|
||||
|
||||
@@ -2,17 +2,27 @@ use rand::rngs::SmallRng;
|
||||
use rand::Rng;
|
||||
|
||||
use crate::engine::filter::CharFilter;
|
||||
use crate::generator::dictionary::Dictionary;
|
||||
use crate::generator::transition_table::TransitionTable;
|
||||
use crate::generator::TextGenerator;
|
||||
|
||||
const MIN_WORD_LEN: usize = 3;
|
||||
const MAX_WORD_LEN: usize = 10;
|
||||
const MIN_REAL_WORDS: usize = 15;
|
||||
|
||||
pub struct PhoneticGenerator {
|
||||
table: TransitionTable,
|
||||
dictionary: Dictionary,
|
||||
rng: SmallRng,
|
||||
}
|
||||
|
||||
impl PhoneticGenerator {
|
||||
pub fn new(table: TransitionTable, rng: SmallRng) -> Self {
|
||||
Self { table, rng }
|
||||
pub fn new(table: TransitionTable, dictionary: Dictionary, rng: SmallRng) -> Self {
|
||||
Self {
|
||||
table,
|
||||
dictionary,
|
||||
rng,
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_weighted_from(
|
||||
@@ -46,29 +56,31 @@ impl PhoneticGenerator {
|
||||
Some(filtered.last().unwrap().0)
|
||||
}
|
||||
|
||||
fn generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||
let min_len = 3;
|
||||
let max_len = 10;
|
||||
let mut word = String::new();
|
||||
fn generate_phonetic_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||
for _attempt in 0..5 {
|
||||
let word = self.try_generate_word(filter, focused);
|
||||
if word.len() >= MIN_WORD_LEN {
|
||||
return word;
|
||||
}
|
||||
}
|
||||
// Fallback
|
||||
"the".to_string()
|
||||
}
|
||||
|
||||
fn try_generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||
let mut word = Vec::new();
|
||||
|
||||
// Start with space prefix
|
||||
let start_char = if let Some(focus) = focused {
|
||||
if self.rng.gen_bool(0.4) {
|
||||
let probs = self.table.get_next_probs(' ', focus).cloned();
|
||||
if let Some(probs) = probs {
|
||||
let filtered: Vec<(char, f64)> = probs
|
||||
.iter()
|
||||
.filter(|(ch, _)| filter.is_allowed(*ch))
|
||||
.copied()
|
||||
.collect();
|
||||
if !filtered.is_empty() {
|
||||
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
|
||||
word.push(focus);
|
||||
Self::pick_weighted_from(&mut self.rng, &filtered, filter)
|
||||
// Get next char from transition table
|
||||
let prefix = vec![' ', ' ', focus];
|
||||
if let Some(probs) = self.table.segment(&prefix) {
|
||||
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some(focus)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -76,60 +88,94 @@ impl PhoneticGenerator {
|
||||
None
|
||||
};
|
||||
|
||||
if word.is_empty() {
|
||||
// Pick a start from transition table
|
||||
let prefix = vec![' ', ' ', ' '];
|
||||
if let Some(probs) = self.table.segment(&prefix) {
|
||||
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, probs, filter) {
|
||||
word.push(ch);
|
||||
}
|
||||
}
|
||||
// Fallback: weighted random start
|
||||
if word.is_empty() {
|
||||
let starters: Vec<(char, f64)> = filter
|
||||
.allowed
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
(
|
||||
ch,
|
||||
if ch == 'e' || ch == 't' || ch == 'a' {
|
||||
3.0
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
)
|
||||
let w = match ch {
|
||||
'e' | 't' | 'a' => 3.0,
|
||||
'o' | 'i' | 'n' | 's' => 2.0,
|
||||
_ => 1.0,
|
||||
};
|
||||
(ch, w)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
|
||||
word.push(ch);
|
||||
} else {
|
||||
return "the".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ch) = start_char {
|
||||
word.push(ch);
|
||||
}
|
||||
|
||||
while word.len() < max_len {
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
let (prev, curr) = if len >= 2 {
|
||||
(chars[len - 2], chars[len - 1])
|
||||
while word.len() < MAX_WORD_LEN {
|
||||
// Build prefix from recent chars, padded with spaces
|
||||
let prefix_len = self.table.order - 1;
|
||||
let mut prefix = Vec::new();
|
||||
let start = if word.len() >= prefix_len {
|
||||
word.len() - prefix_len
|
||||
} else {
|
||||
(' ', chars[len - 1])
|
||||
0
|
||||
};
|
||||
|
||||
let space_prob = 1.3f64.powi(word.len() as i32 - min_len as i32);
|
||||
if word.len() >= min_len
|
||||
&& self
|
||||
.rng
|
||||
.gen_bool((space_prob / (space_prob + 5.0)).min(0.8))
|
||||
{
|
||||
break;
|
||||
for _ in 0..(prefix_len.saturating_sub(word.len())) {
|
||||
prefix.push(' ');
|
||||
}
|
||||
for i in start..word.len() {
|
||||
prefix.push(word[i]);
|
||||
}
|
||||
|
||||
let probs = self.table.get_next_probs(prev, curr).cloned();
|
||||
if let Some(probs) = probs {
|
||||
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &probs, filter) {
|
||||
// Check for word ending (space probability increases with length)
|
||||
if word.len() >= MIN_WORD_LEN {
|
||||
if let Some(probs) = self.table.segment(&prefix) {
|
||||
let space_weight: f64 = probs
|
||||
.iter()
|
||||
.filter(|(ch, _)| *ch == ' ')
|
||||
.map(|(_, w)| w)
|
||||
.sum();
|
||||
if space_weight > 0.0 {
|
||||
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
||||
let total: f64 = probs.iter().map(|(_, w)| w).sum();
|
||||
let space_prob = (space_weight * boost) / (total + space_weight * (boost - 1.0));
|
||||
if self.rng.gen_bool(space_prob.min(0.85)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Even without space in table, use length-based ending
|
||||
let end_prob = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
||||
if self.rng.gen_bool((end_prob / (end_prob + 5.0)).min(0.8)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get next character from transition table
|
||||
if let Some(probs) = self.table.segment(&prefix) {
|
||||
let non_space: Vec<(char, f64)> = probs
|
||||
.iter()
|
||||
.filter(|(ch, _)| *ch != ' ')
|
||||
.copied()
|
||||
.collect();
|
||||
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
|
||||
word.push(next);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Fallback to vowel
|
||||
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
|
||||
.iter()
|
||||
.filter(|&&v| filter.is_allowed(v))
|
||||
@@ -143,11 +189,7 @@ impl PhoneticGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
if word.is_empty() {
|
||||
"the".to_string()
|
||||
} else {
|
||||
word
|
||||
}
|
||||
word.iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,10 +200,42 @@ impl TextGenerator for PhoneticGenerator {
|
||||
focused: Option<char>,
|
||||
word_count: usize,
|
||||
) -> String {
|
||||
// keybr's approach: prefer real words when enough match the filter
|
||||
// Collect matching words into owned Vec to avoid borrow conflict
|
||||
let matching_words: Vec<String> = self
|
||||
.dictionary
|
||||
.find_matching(filter, focused)
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
|
||||
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
let mut last_word = String::new();
|
||||
|
||||
for _ in 0..word_count {
|
||||
words.push(self.generate_word(filter, focused));
|
||||
if use_real_words {
|
||||
// Pick a real word (avoid consecutive duplicates)
|
||||
let mut picked = None;
|
||||
for _ in 0..3 {
|
||||
let idx = self.rng.gen_range(0..matching_words.len());
|
||||
let word = matching_words[idx].clone();
|
||||
if word != last_word {
|
||||
picked = Some(word);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let word = match picked {
|
||||
Some(w) => w,
|
||||
None => self.generate_phonetic_word(filter, focused),
|
||||
};
|
||||
last_word.clone_from(&word);
|
||||
words.push(word);
|
||||
} else {
|
||||
// Fall back to phonetic pseudo-words
|
||||
let word = self.generate_phonetic_word(filter, focused);
|
||||
words.push(word);
|
||||
}
|
||||
}
|
||||
|
||||
words.join(" ")
|
||||
|
||||
@@ -4,29 +4,108 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct TransitionTable {
|
||||
pub transitions: HashMap<(char, char), Vec<(char, f64)>>,
|
||||
pub order: usize,
|
||||
transitions: HashMap<Vec<char>, Vec<(char, f64)>>,
|
||||
}
|
||||
|
||||
impl TransitionTable {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(order: usize) -> Self {
|
||||
Self {
|
||||
order,
|
||||
transitions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&mut self, prev: char, curr: char, next: char, weight: f64) {
|
||||
pub fn add(&mut self, prefix: &[char], next: char, weight: f64) {
|
||||
self.transitions
|
||||
.entry((prev, curr))
|
||||
.entry(prefix.to_vec())
|
||||
.or_default()
|
||||
.push((next, weight));
|
||||
}
|
||||
|
||||
pub fn get_next_probs(&self, prev: char, curr: char) -> Option<&Vec<(char, f64)>> {
|
||||
self.transitions.get(&(prev, curr))
|
||||
pub fn segment(&self, prefix: &[char]) -> Option<&Vec<(char, f64)>> {
|
||||
// Try exact prefix match first, then fall back to shorter prefixes
|
||||
let key_len = self.order - 1;
|
||||
let prefix = if prefix.len() >= key_len {
|
||||
&prefix[prefix.len() - key_len..]
|
||||
} else {
|
||||
prefix
|
||||
};
|
||||
|
||||
// Try progressively shorter prefixes for backoff
|
||||
for start in 0..prefix.len() {
|
||||
let key = prefix[start..].to_vec();
|
||||
if let Some(entries) = self.transitions.get(&key) {
|
||||
return Some(entries);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Build an order-4 transition table from a word frequency list.
|
||||
/// Words earlier in the list are higher frequency and get more weight.
|
||||
pub fn build_from_words(words: &[String]) -> Self {
|
||||
let mut table = Self::new(4);
|
||||
let prefix_len = 3; // order - 1
|
||||
|
||||
for (rank, word) in words.iter().enumerate() {
|
||||
if word.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
if !word.chars().all(|c| c.is_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Weight decreases with rank (frequency-based)
|
||||
let weight = 1.0 / (1.0 + (rank as f64 / 500.0));
|
||||
|
||||
// Add word start transitions (space prefix -> first chars)
|
||||
let chars: Vec<char> = word.chars().collect();
|
||||
|
||||
// Start of word: ' ' prefix
|
||||
for i in 0..chars.len() {
|
||||
let mut prefix = Vec::new();
|
||||
// Build prefix from space + preceding chars
|
||||
let start = if i >= prefix_len { i - prefix_len } else { 0 };
|
||||
if i < prefix_len {
|
||||
// Pad with spaces
|
||||
for _ in 0..(prefix_len - i) {
|
||||
prefix.push(' ');
|
||||
}
|
||||
}
|
||||
for j in start..i {
|
||||
prefix.push(chars[j]);
|
||||
}
|
||||
|
||||
let next = chars[i];
|
||||
table.add(&prefix, next, weight);
|
||||
}
|
||||
|
||||
// End of word: last chars -> space
|
||||
let end_start = if chars.len() >= prefix_len {
|
||||
chars.len() - prefix_len
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut end_prefix: Vec<char> = Vec::new();
|
||||
if chars.len() < prefix_len {
|
||||
for _ in 0..(prefix_len - chars.len()) {
|
||||
end_prefix.push(' ');
|
||||
}
|
||||
}
|
||||
for j in end_start..chars.len() {
|
||||
end_prefix.push(chars[j]);
|
||||
}
|
||||
table.add(&end_prefix, ' ', weight);
|
||||
}
|
||||
|
||||
table
|
||||
}
|
||||
|
||||
/// Legacy order-2 table for fallback
|
||||
#[allow(dead_code)]
|
||||
pub fn build_english() -> Self {
|
||||
let mut table = Self::new();
|
||||
let mut table = Self::new(4);
|
||||
|
||||
let common_patterns: &[(&str, f64)] = &[
|
||||
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
|
||||
@@ -40,25 +119,24 @@ impl TransitionTable {
|
||||
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
|
||||
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
|
||||
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
|
||||
("out", 2.0), ("ind", 2.0), ("ith", 2.0), ("ber", 2.0), ("der", 2.0),
|
||||
("out", 2.0), ("ind", 2.0), ("ber", 2.0), ("der", 2.0),
|
||||
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
|
||||
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
|
||||
("igh", 2.0), ("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
|
||||
("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
|
||||
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
|
||||
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
|
||||
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
|
||||
("anc", 1.5), ("enc", 1.5), ("inc", 1.5), ("onc", 1.5), ("unc", 1.5),
|
||||
("unt", 1.5), ("int", 1.5), ("ont", 1.5), ("ent", 1.5), ("ment", 1.5),
|
||||
("ness", 1.5), ("less", 1.5), ("able", 1.5), ("ible", 1.5), ("ting", 1.5),
|
||||
("ring", 1.5), ("sing", 1.5), ("king", 1.5), ("ning", 1.5), ("ling", 1.5),
|
||||
("wing", 1.5), ("ding", 1.5), ("ping", 1.5), ("ging", 1.5), ("ving", 1.5),
|
||||
("bing", 1.5), ("ming", 1.5), ("fing", 1.0), ("hing", 1.0), ("cing", 1.0),
|
||||
];
|
||||
|
||||
for &(pattern, weight) in common_patterns {
|
||||
let chars: Vec<char> = pattern.chars().collect();
|
||||
for window in chars.windows(3) {
|
||||
table.add(window[0], window[1], window[2], weight);
|
||||
let prefix = vec![window[0], window[1]];
|
||||
table.add(&prefix, window[2], weight);
|
||||
}
|
||||
// Also add shorter prefix entries for the start of patterns
|
||||
if chars.len() >= 2 {
|
||||
table.add(&[' ', chars[0]], chars[1], weight * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,20 +148,14 @@ impl TransitionTable {
|
||||
|
||||
for &c in &consonants {
|
||||
for &v in &vowels {
|
||||
table.add(' ', c, v, 1.0);
|
||||
table.add(v, c, 'e', 0.5);
|
||||
for &v2 in &vowels {
|
||||
table.add(c, v, v2.to_ascii_lowercase(), 0.3);
|
||||
}
|
||||
for &c2 in &consonants {
|
||||
table.add(v, c, c2, 0.2);
|
||||
}
|
||||
table.add(&[' ', c], v, 1.0);
|
||||
table.add(&[v, c], 'e', 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
for &v in &vowels {
|
||||
for &c in &consonants {
|
||||
table.add(' ', v, c, 0.5);
|
||||
table.add(&[' ', v], c, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +165,6 @@ impl TransitionTable {
|
||||
|
||||
impl Default for TransitionTable {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
Self::new(4)
|
||||
}
|
||||
}
|
||||
|
||||
153
src/main.rs
153
src/main.rs
@@ -22,7 +22,7 @@ use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
use ratatui::Terminal;
|
||||
|
||||
use app::{App, AppScreen, LessonMode};
|
||||
@@ -137,6 +137,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
app.start_lesson();
|
||||
}
|
||||
KeyCode::Char('s') => app.go_to_stats(),
|
||||
KeyCode::Char('c') => app.go_to_settings(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
||||
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
|
||||
KeyCode::Enter => match app.menu.selected {
|
||||
@@ -153,6 +154,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
app.start_lesson();
|
||||
}
|
||||
3 => app.go_to_stats(),
|
||||
4 => app.go_to_settings(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
@@ -191,13 +193,37 @@ fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||
fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
|
||||
KeyCode::Char('h') | KeyCode::Char('2') => app.stats_tab = 1,
|
||||
KeyCode::Char('k') | KeyCode::Char('3') => app.stats_tab = 2,
|
||||
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
|
||||
KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.go_to_menu(),
|
||||
KeyCode::Esc => {
|
||||
let _ = app.config.save();
|
||||
app.go_to_menu();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
if app.settings_selected > 0 {
|
||||
app.settings_selected -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if app.settings_selected < 3 {
|
||||
app.settings_selected += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
|
||||
app.settings_cycle_forward();
|
||||
}
|
||||
KeyCode::Left | KeyCode::Char('h') => {
|
||||
app.settings_cycle_backward();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -313,7 +339,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
||||
.constraints([
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(5),
|
||||
])
|
||||
.split(app_layout.main);
|
||||
|
||||
@@ -327,8 +353,13 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
||||
);
|
||||
frame.render_widget(progress, main_layout[1]);
|
||||
|
||||
let next_char = lesson
|
||||
.target
|
||||
.get(lesson.cursor)
|
||||
.copied();
|
||||
let kbd = KeyboardDiagram::new(
|
||||
app.letter_unlock.focused,
|
||||
next_char,
|
||||
&app.letter_unlock.included,
|
||||
app.theme,
|
||||
);
|
||||
@@ -357,7 +388,13 @@ fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
||||
|
||||
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let dashboard = StatsDashboard::new(&app.lesson_history, app.theme);
|
||||
let dashboard = StatsDashboard::new(
|
||||
&app.lesson_history,
|
||||
&app.key_stats,
|
||||
app.stats_tab,
|
||||
app.config.target_wpm,
|
||||
app.theme,
|
||||
);
|
||||
frame.render_widget(dashboard, area);
|
||||
}
|
||||
|
||||
@@ -365,45 +402,83 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
let centered = ui::layout::centered_rect(60, 80, area);
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Settings ")
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(centered);
|
||||
block.render(centered, frame.buffer_mut());
|
||||
|
||||
let target_wpm = format!(" Target WPM: {}", app.config.target_wpm);
|
||||
let theme_name = format!(" Theme: {}", app.config.theme);
|
||||
let layout_name = format!(" Layout: {}", app.config.keyboard_layout);
|
||||
let languages = format!(" Languages: {}", app.config.code_languages.join(", "));
|
||||
let available_themes = ui::theme::Theme::available_themes();
|
||||
let languages_all = ["rust", "python", "javascript", "go"];
|
||||
let current_lang = app
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Settings coming soon...",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
&*target_wpm,
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
&*theme_name,
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
&*layout_name,
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
&*languages,
|
||||
Style::default().fg(colors.fg()),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" [ESC] Back",
|
||||
Style::default().fg(colors.accent()),
|
||||
)),
|
||||
let fields: Vec<(String, String)> = vec![
|
||||
("Target WPM".to_string(), format!("{}", app.config.target_wpm)),
|
||||
("Theme".to_string(), app.config.theme.clone()),
|
||||
("Word Count".to_string(), format!("{}", app.config.word_count)),
|
||||
("Code Language".to_string(), current_lang.to_string()),
|
||||
];
|
||||
|
||||
let paragraph = Paragraph::new(lines).block(block);
|
||||
frame.render_widget(paragraph, area);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(fields.len() as u16 * 3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
let header = Paragraph::new(Line::from(Span::styled(
|
||||
" Use arrows to navigate, Enter/Right to change, ESC to save & exit",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
header.render(layout[0], frame.buffer_mut());
|
||||
|
||||
let field_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(fields.iter().map(|_| Constraint::Length(3)).collect::<Vec<_>>())
|
||||
.split(layout[1]);
|
||||
|
||||
for (i, (label, value)) in fields.iter().enumerate() {
|
||||
let is_selected = i == app.settings_selected;
|
||||
let indicator = if is_selected { " > " } else { " " };
|
||||
|
||||
let label_text = format!("{indicator}{label}:");
|
||||
let value_text = format!(" < {value} >");
|
||||
|
||||
let label_style = Style::default().fg(if is_selected {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.fg()
|
||||
}).add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() });
|
||||
|
||||
let value_style = Style::default().fg(if is_selected {
|
||||
colors.focused_key()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
});
|
||||
|
||||
let lines = vec![
|
||||
Line::from(Span::styled(label_text, label_style)),
|
||||
Line::from(Span::styled(value_text, value_style)),
|
||||
];
|
||||
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
||||
}
|
||||
|
||||
let _ = (available_themes, languages_all);
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" [ESC] Save & back [Enter/arrows] Change value",
|
||||
Style::default().fg(colors.accent()),
|
||||
)));
|
||||
footer.render(layout[3], frame.buffer_mut());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{self, Finger, Hand};
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
pub focused_key: Option<char>,
|
||||
pub next_key: Option<char>,
|
||||
pub unlocked_keys: &'a [char],
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
@@ -14,11 +16,13 @@ pub struct KeyboardDiagram<'a> {
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
pub fn new(
|
||||
focused_key: Option<char>,
|
||||
next_key: Option<char>,
|
||||
unlocked_keys: &'a [char],
|
||||
theme: &'a Theme,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
next_key,
|
||||
unlocked_keys,
|
||||
theme,
|
||||
}
|
||||
@@ -31,6 +35,21 @@ const ROWS: &[&[char]] = &[
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
|
||||
fn finger_color(ch: char) -> Color {
|
||||
let assignment = finger::qwerty_finger(ch);
|
||||
match (assignment.hand, assignment.finger) {
|
||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
|
||||
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
|
||||
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
_ => Color::Rgb(120, 120, 120),
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for KeyboardDiagram<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
@@ -42,12 +61,12 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 || inner.width < 20 {
|
||||
if inner.height < 3 || inner.width < 30 {
|
||||
return;
|
||||
}
|
||||
|
||||
let key_width: u16 = 4;
|
||||
let offsets: &[u16] = &[1, 2, 4];
|
||||
let key_width: u16 = 5;
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
|
||||
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
@@ -59,19 +78,26 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + 3 > inner.x + inner.width {
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||
let is_focused = self.focused_key == Some(key);
|
||||
let is_next = self.next_key == Some(key);
|
||||
|
||||
let style = if is_focused {
|
||||
let style = if is_next {
|
||||
Style::default()
|
||||
.fg(colors.bg())
|
||||
.bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default()
|
||||
.fg(colors.bg())
|
||||
.bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default().fg(colors.fg()).bg(colors.accent_dim())
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(finger_color(key))
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(colors.text_pending())
|
||||
|
||||
@@ -4,18 +4,34 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::session::result::LessonResult;
|
||||
use crate::ui::components::chart::WpmChart;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct StatsDashboard<'a> {
|
||||
pub history: &'a [LessonResult],
|
||||
pub key_stats: &'a KeyStatsStore,
|
||||
pub active_tab: usize,
|
||||
pub target_wpm: u32,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl<'a> StatsDashboard<'a> {
|
||||
pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self {
|
||||
Self { history, theme }
|
||||
pub fn new(
|
||||
history: &'a [LessonResult],
|
||||
key_stats: &'a KeyStatsStore,
|
||||
active_tab: usize,
|
||||
target_wpm: u32,
|
||||
theme: &'a Theme,
|
||||
) -> Self {
|
||||
Self {
|
||||
history,
|
||||
key_stats,
|
||||
active_tab,
|
||||
target_wpm,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +58,64 @@ impl Widget for StatsDashboard<'_> {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(8),
|
||||
Constraint::Length(2),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Tab header
|
||||
let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"];
|
||||
let tab_spans: Vec<Span> = tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, &label)| {
|
||||
let style = if i == self.active_tab {
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending())
|
||||
};
|
||||
vec![
|
||||
Span::styled(format!(" {label} "), style),
|
||||
Span::raw(" "),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
||||
|
||||
// Tab content
|
||||
match self.active_tab {
|
||||
0 => self.render_dashboard_tab(layout[1], buf),
|
||||
1 => self.render_history_tab(layout[1], buf),
|
||||
2 => self.render_keystrokes_tab(layout[1], buf),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Footer
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
|
||||
Style::default().fg(colors.accent()),
|
||||
)));
|
||||
footer.render(layout[2], buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl StatsDashboard<'_> {
|
||||
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(8),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Summary stats
|
||||
let avg_wpm =
|
||||
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||
let best_wpm = self
|
||||
@@ -57,63 +125,616 @@ impl Widget for StatsDashboard<'_> {
|
||||
.fold(0.0f64, f64::max);
|
||||
let avg_accuracy =
|
||||
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||||
let total_lessons = self.history.len();
|
||||
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||
|
||||
let total_str = format!("{total_lessons}");
|
||||
let total_str = format!("{}", self.history.len());
|
||||
let avg_wpm_str = format!("{avg_wpm:.0}");
|
||||
let best_wpm_str = format!("{best_wpm:.0}");
|
||||
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||||
let time_str = format_duration(total_time);
|
||||
|
||||
let summary = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*total_str,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*best_wpm_str,
|
||||
Style::default()
|
||||
.fg(colors.success())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled(" Avg Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
&*avg_acc_str,
|
||||
Style::default().fg(if avg_accuracy >= 95.0 {
|
||||
colors.success()
|
||||
} else {
|
||||
} else if avg_accuracy >= 85.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
}),
|
||||
),
|
||||
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
|
||||
]),
|
||||
];
|
||||
Paragraph::new(summary).render(layout[0], buf);
|
||||
|
||||
// Progress bars
|
||||
self.render_progress_bars(layout[1], buf);
|
||||
|
||||
// Charts
|
||||
let chart_layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[2]);
|
||||
|
||||
// WPM chart
|
||||
let wpm_data: Vec<(f64, f64)> = self
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(50)
|
||||
.enumerate()
|
||||
.map(|(i, r)| (i as f64, r.wpm))
|
||||
.collect();
|
||||
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
|
||||
|
||||
// Accuracy chart
|
||||
let acc_data: Vec<(f64, f64)> = self
|
||||
.history
|
||||
.iter()
|
||||
.rev()
|
||||
.take(50)
|
||||
.enumerate()
|
||||
.map(|(i, r)| (i as f64, r.accuracy))
|
||||
.collect();
|
||||
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
let avg_wpm =
|
||||
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||
let avg_accuracy =
|
||||
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||||
|
||||
// WPM progress
|
||||
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
|
||||
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
|
||||
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
|
||||
|
||||
// Accuracy progress
|
||||
let acc_pct = avg_accuracy.min(100.0);
|
||||
let acc_label = format!(" Acc: {acc_pct:.1}%");
|
||||
let acc_color = if acc_pct >= 95.0 {
|
||||
colors.success()
|
||||
} else if acc_pct >= 85.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
|
||||
|
||||
// Level progress
|
||||
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
|
||||
let level = ((total_score / 100.0).sqrt() as u32).max(1);
|
||||
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
||||
let current_level_score = (level as f64).powi(2) * 100.0;
|
||||
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
|
||||
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
|
||||
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
|
||||
}
|
||||
|
||||
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Recent tests table
|
||||
let header = Line::from(vec![
|
||||
Span::styled(
|
||||
" # WPM Raw Acc% Time Date",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
|
||||
let mut lines = vec![header, Line::from(Span::styled(
|
||||
" ─────────────────────────────────────────────",
|
||||
Style::default().fg(colors.border()),
|
||||
))];
|
||||
|
||||
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
|
||||
let total = self.history.len();
|
||||
|
||||
for (i, result) in recent.iter().enumerate() {
|
||||
let idx = total - i;
|
||||
let raw_wpm = result.cpm / 5.0;
|
||||
let time_str = format!("{:.1}s", result.elapsed_secs);
|
||||
let date_str = result.timestamp.format("%m/%d %H:%M").to_string();
|
||||
|
||||
let idx_str = format!("{idx:>3}");
|
||||
let wpm_str = format!("{:>6.0}", result.wpm);
|
||||
let raw_str = format!("{:>6.0}", raw_wpm);
|
||||
let acc_str = format!("{:>6.1}%", result.accuracy);
|
||||
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
|
||||
|
||||
let acc_color = if result.accuracy >= 95.0 {
|
||||
colors.success()
|
||||
} else if result.accuracy >= 85.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(layout[0], buf);
|
||||
|
||||
// Per-key speed
|
||||
self.render_per_key_speed(layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Per-Key Average Speed (ms) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.width < 52 || inner.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
let letters: Vec<char> = ('a'..='z').collect();
|
||||
let max_time = letters
|
||||
.iter()
|
||||
.filter_map(|&ch| self.key_stats.stats.get(&ch))
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.fold(0.0f64, f64::max)
|
||||
.max(1.0);
|
||||
|
||||
// Render bar chart: letter label on row 0, bar on row 1
|
||||
let bar_width = (inner.width as usize).min(52) / 26;
|
||||
let bar_width = bar_width.max(1) as u16;
|
||||
|
||||
for (i, &ch) in letters.iter().enumerate() {
|
||||
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
|
||||
if x >= inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let time = self
|
||||
.key_stats
|
||||
.stats
|
||||
.get(&ch)
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let ratio = time / max_time;
|
||||
let color = if ratio < 0.3 {
|
||||
colors.success()
|
||||
} else if ratio < 0.6 {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
|
||||
// Letter label
|
||||
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
|
||||
|
||||
// Simple bar indicator
|
||||
if inner.height >= 2 {
|
||||
let bar_char = if time > 0.0 {
|
||||
match (ratio * 8.0) as u8 {
|
||||
0 => '▁',
|
||||
1 => '▂',
|
||||
2 => '▃',
|
||||
3 => '▄',
|
||||
4 => '▅',
|
||||
5 => '▆',
|
||||
6 => '▇',
|
||||
_ => '█',
|
||||
}
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
buf.set_string(
|
||||
x,
|
||||
inner.y + 1,
|
||||
&bar_char.to_string(),
|
||||
Style::default().fg(color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = bar_width;
|
||||
}
|
||||
|
||||
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(7),
|
||||
Constraint::Min(5),
|
||||
Constraint::Length(6),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Keyboard accuracy heatmap
|
||||
self.render_keyboard_heatmap(layout[0], buf);
|
||||
|
||||
// Slowest/Fastest keys
|
||||
let key_layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(layout[1]);
|
||||
|
||||
self.render_slowest_keys(key_layout[0], buf);
|
||||
self.render_fastest_keys(key_layout[1], buf);
|
||||
self.render_char_stats(key_layout[2], buf);
|
||||
|
||||
// Word/Character stats summary
|
||||
self.render_overall_stats(layout[2], buf);
|
||||
}
|
||||
|
||||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Keyboard Accuracy ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 || inner.width < 40 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows: &[&[char]] = &[
|
||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
let key_width: u16 = 4;
|
||||
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + 3 > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let color = if accuracy >= 100.0 {
|
||||
colors.text_pending()
|
||||
} else if accuracy >= 90.0 {
|
||||
colors.warning()
|
||||
} else if accuracy > 0.0 {
|
||||
colors.error()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
};
|
||||
|
||||
let display = format!("[{key}]");
|
||||
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_key_accuracy(&self, key: char) -> f64 {
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
|
||||
for result in self.history {
|
||||
for kt in &result.per_key_times {
|
||||
if kt.key == key {
|
||||
total += 1;
|
||||
if kt.correct {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
correct as f64 / total as f64 * 100.0
|
||||
}
|
||||
|
||||
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Slowest ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut key_times: Vec<(char, f64)> = self
|
||||
.key_stats
|
||||
.stats
|
||||
.iter()
|
||||
.filter(|(_, s)| s.sample_count > 0)
|
||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
y,
|
||||
&text,
|
||||
Style::default().fg(colors.error()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Fastest ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut key_times: Vec<(char, f64)> = self
|
||||
.key_stats
|
||||
.stats
|
||||
.iter()
|
||||
.filter(|(_, s)| s.sample_count > 0)
|
||||
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
y,
|
||||
&text,
|
||||
Style::default().fg(colors.success()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Key Stats ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut total_correct = 0usize;
|
||||
let mut total_incorrect = 0usize;
|
||||
|
||||
for result in self.history {
|
||||
total_correct += result.correct;
|
||||
total_incorrect += result.incorrect;
|
||||
}
|
||||
|
||||
let total = total_correct + total_incorrect;
|
||||
let overall_acc = if total > 0 {
|
||||
total_correct as f64 / total as f64 * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let lines = [
|
||||
format!(" Total: {total}"),
|
||||
format!(" Correct: {total_correct}"),
|
||||
format!(" Wrong: {total_incorrect}"),
|
||||
format!(" Acc: {overall_acc:.1}%"),
|
||||
];
|
||||
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Overall ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
|
||||
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
|
||||
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
|
||||
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_chars}"),
|
||||
Style::default().fg(colors.accent()),
|
||||
),
|
||||
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_correct}"),
|
||||
Style::default().fg(colors.success()),
|
||||
),
|
||||
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_incorrect}"),
|
||||
Style::default().fg(if total_incorrect > 0 {
|
||||
colors.error()
|
||||
} else {
|
||||
colors.success()
|
||||
}),
|
||||
),
|
||||
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format_duration(total_time),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
Paragraph::new(summary).render(layout[0], buf);
|
||||
|
||||
let chart_data: Vec<(f64, f64)> = self
|
||||
.history
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| (i as f64, r.wpm))
|
||||
.collect();
|
||||
WpmChart::new(&chart_data, self.theme).render(layout[1], buf);
|
||||
|
||||
let help = Paragraph::new(Line::from(Span::styled(
|
||||
" [ESC] Back to menu",
|
||||
Style::default().fg(colors.accent()),
|
||||
)));
|
||||
help.render(layout[2], buf);
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
label: &str,
|
||||
ratio: f64,
|
||||
fill_color: ratatui::style::Color,
|
||||
empty_color: ratatui::style::Color,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
) {
|
||||
if area.height < 2 || area.width < 10 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Label on first line
|
||||
buf.set_string(
|
||||
area.x,
|
||||
area.y,
|
||||
label,
|
||||
Style::default().fg(fill_color),
|
||||
);
|
||||
|
||||
// Bar on second line
|
||||
let bar_width = (area.width as usize).saturating_sub(4);
|
||||
let filled = (ratio * bar_width as f64) as usize;
|
||||
|
||||
let bar_y = area.y + 1;
|
||||
buf.set_string(area.x, bar_y, " ", Style::default());
|
||||
|
||||
for i in 0..bar_width {
|
||||
let x = area.x + 2 + i as u16;
|
||||
if x >= area.x + area.width {
|
||||
break;
|
||||
}
|
||||
let (ch, color) = if i < filled {
|
||||
('█', fill_color)
|
||||
} else {
|
||||
('░', empty_color)
|
||||
};
|
||||
buf.set_string(x, bar_y, &ch.to_string(), Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_accuracy_chart(
|
||||
data: &[(f64, f64)],
|
||||
theme: &Theme,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
) {
|
||||
use ratatui::symbols;
|
||||
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
|
||||
|
||||
let colors = &theme.colors;
|
||||
|
||||
if data.is_empty() {
|
||||
let block = Block::bordered()
|
||||
.title(" Accuracy Over Time ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
block.render(area, buf);
|
||||
return;
|
||||
}
|
||||
|
||||
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
|
||||
|
||||
let dataset = Dataset::default()
|
||||
.marker(symbols::Marker::Braille)
|
||||
.graph_type(GraphType::Line)
|
||||
.style(Style::default().fg(colors.success()))
|
||||
.data(data);
|
||||
|
||||
let chart = Chart::new(vec![dataset])
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Accuracy Over Time ")
|
||||
.border_style(Style::default().fg(colors.border())),
|
||||
)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
.title("Lesson")
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.bounds([0.0, max_x]),
|
||||
)
|
||||
.y_axis(
|
||||
Axis::default()
|
||||
.title("%")
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.bounds([80.0, 100.0]),
|
||||
);
|
||||
|
||||
chart.render(area, buf);
|
||||
}
|
||||
|
||||
fn format_duration(secs: f64) -> String {
|
||||
let total = secs as u64;
|
||||
let hours = total / 3600;
|
||||
let mins = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if hours > 0 {
|
||||
format!("{hours}h {mins}m {s}s")
|
||||
} else if mins > 0 {
|
||||
format!("{mins}m {s}s")
|
||||
} else {
|
||||
format!("{s}s")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ impl Default for ThemeColors {
|
||||
text_correct: "#a6e3a1".to_string(),
|
||||
text_incorrect: "#f38ba8".to_string(),
|
||||
text_incorrect_bg: "#45273a".to_string(),
|
||||
text_pending: "#585b70".to_string(),
|
||||
text_pending: "#a6adc8".to_string(),
|
||||
text_cursor_bg: "#f5e0dc".to_string(),
|
||||
text_cursor_fg: "#1e1e2e".to_string(),
|
||||
focused_key: "#f9e2af".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user