Compare commits
2 Commits
739d79d6a2
...
c78a8a90a3
| Author | SHA1 | Date | |
|---|---|---|---|
| c78a8a90a3 | |||
| f65e3d8413 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
|
/clones/
|
||||||
|
|||||||
3191
Cargo.lock
generated
Normal file
3191
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@@ -2,5 +2,23 @@
|
|||||||
name = "keydr"
|
name = "keydr"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
description = "Terminal typing tutor with adaptive learning"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ratatui = { version = "0.30", features = ["crossterm_0_28"] }
|
||||||
|
crossterm = "0.28"
|
||||||
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
toml = "0.8"
|
||||||
|
rand = { version = "0.8", features = ["small_rng"] }
|
||||||
|
dirs = "6.0"
|
||||||
|
rust-embed = "8.5"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "2.0"
|
||||||
|
reqwest = { version = "0.12", features = ["blocking"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["network"]
|
||||||
|
network = ["reqwest"]
|
||||||
|
|||||||
23
assets/themes/catppuccin-latte.toml
Normal file
23
assets/themes/catppuccin-latte.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "catppuccin-latte"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#eff1f5"
|
||||||
|
fg = "#4c4f69"
|
||||||
|
text_correct = "#40a02b"
|
||||||
|
text_incorrect = "#d20f39"
|
||||||
|
text_incorrect_bg = "#f5c2cf"
|
||||||
|
text_pending = "#7c7f93"
|
||||||
|
text_cursor_bg = "#dc8a78"
|
||||||
|
text_cursor_fg = "#eff1f5"
|
||||||
|
focused_key = "#df8e1d"
|
||||||
|
accent = "#1e66f5"
|
||||||
|
accent_dim = "#ccd0da"
|
||||||
|
border = "#ccd0da"
|
||||||
|
border_focused = "#1e66f5"
|
||||||
|
header_bg = "#e6e9ef"
|
||||||
|
header_fg = "#4c4f69"
|
||||||
|
bar_filled = "#1e66f5"
|
||||||
|
bar_empty = "#e6e9ef"
|
||||||
|
error = "#d20f39"
|
||||||
|
warning = "#df8e1d"
|
||||||
|
success = "#40a02b"
|
||||||
23
assets/themes/catppuccin-mocha.toml
Normal file
23
assets/themes/catppuccin-mocha.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "catppuccin-mocha"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#1e1e2e"
|
||||||
|
fg = "#cdd6f4"
|
||||||
|
text_correct = "#a6e3a1"
|
||||||
|
text_incorrect = "#f38ba8"
|
||||||
|
text_incorrect_bg = "#45273a"
|
||||||
|
text_pending = "#a6adc8"
|
||||||
|
text_cursor_bg = "#f5e0dc"
|
||||||
|
text_cursor_fg = "#1e1e2e"
|
||||||
|
focused_key = "#f9e2af"
|
||||||
|
accent = "#89b4fa"
|
||||||
|
accent_dim = "#45475a"
|
||||||
|
border = "#45475a"
|
||||||
|
border_focused = "#89b4fa"
|
||||||
|
header_bg = "#313244"
|
||||||
|
header_fg = "#cdd6f4"
|
||||||
|
bar_filled = "#89b4fa"
|
||||||
|
bar_empty = "#313244"
|
||||||
|
error = "#f38ba8"
|
||||||
|
warning = "#f9e2af"
|
||||||
|
success = "#a6e3a1"
|
||||||
23
assets/themes/dracula.toml
Normal file
23
assets/themes/dracula.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "dracula"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282a36"
|
||||||
|
fg = "#f8f8f2"
|
||||||
|
text_correct = "#50fa7b"
|
||||||
|
text_incorrect = "#ff5555"
|
||||||
|
text_incorrect_bg = "#44242a"
|
||||||
|
text_pending = "#9aadce"
|
||||||
|
text_cursor_bg = "#f1fa8c"
|
||||||
|
text_cursor_fg = "#282a36"
|
||||||
|
focused_key = "#f1fa8c"
|
||||||
|
accent = "#bd93f9"
|
||||||
|
accent_dim = "#44475a"
|
||||||
|
border = "#44475a"
|
||||||
|
border_focused = "#bd93f9"
|
||||||
|
header_bg = "#44475a"
|
||||||
|
header_fg = "#f8f8f2"
|
||||||
|
bar_filled = "#bd93f9"
|
||||||
|
bar_empty = "#44475a"
|
||||||
|
error = "#ff5555"
|
||||||
|
warning = "#f1fa8c"
|
||||||
|
success = "#50fa7b"
|
||||||
23
assets/themes/gruvbox-dark.toml
Normal file
23
assets/themes/gruvbox-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "gruvbox-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282828"
|
||||||
|
fg = "#ebdbb2"
|
||||||
|
text_correct = "#b8bb26"
|
||||||
|
text_incorrect = "#fb4934"
|
||||||
|
text_incorrect_bg = "#462726"
|
||||||
|
text_pending = "#a89984"
|
||||||
|
text_cursor_bg = "#fabd2f"
|
||||||
|
text_cursor_fg = "#282828"
|
||||||
|
focused_key = "#fabd2f"
|
||||||
|
accent = "#83a598"
|
||||||
|
accent_dim = "#3c3836"
|
||||||
|
border = "#504945"
|
||||||
|
border_focused = "#83a598"
|
||||||
|
header_bg = "#3c3836"
|
||||||
|
header_fg = "#ebdbb2"
|
||||||
|
bar_filled = "#83a598"
|
||||||
|
bar_empty = "#3c3836"
|
||||||
|
error = "#fb4934"
|
||||||
|
warning = "#fabd2f"
|
||||||
|
success = "#b8bb26"
|
||||||
23
assets/themes/nord.toml
Normal file
23
assets/themes/nord.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "nord"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#2e3440"
|
||||||
|
fg = "#eceff4"
|
||||||
|
text_correct = "#a3be8c"
|
||||||
|
text_incorrect = "#bf616a"
|
||||||
|
text_incorrect_bg = "#3f2e31"
|
||||||
|
text_pending = "#8fbcbb"
|
||||||
|
text_cursor_bg = "#ebcb8b"
|
||||||
|
text_cursor_fg = "#2e3440"
|
||||||
|
focused_key = "#ebcb8b"
|
||||||
|
accent = "#88c0d0"
|
||||||
|
accent_dim = "#3b4252"
|
||||||
|
border = "#4c566a"
|
||||||
|
border_focused = "#88c0d0"
|
||||||
|
header_bg = "#3b4252"
|
||||||
|
header_fg = "#eceff4"
|
||||||
|
bar_filled = "#88c0d0"
|
||||||
|
bar_empty = "#3b4252"
|
||||||
|
error = "#bf616a"
|
||||||
|
warning = "#ebcb8b"
|
||||||
|
success = "#a3be8c"
|
||||||
23
assets/themes/one-dark.toml
Normal file
23
assets/themes/one-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "one-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#282c34"
|
||||||
|
fg = "#abb2bf"
|
||||||
|
text_correct = "#98c379"
|
||||||
|
text_incorrect = "#e06c75"
|
||||||
|
text_incorrect_bg = "#3e2a2d"
|
||||||
|
text_pending = "#848b98"
|
||||||
|
text_cursor_bg = "#e5c07b"
|
||||||
|
text_cursor_fg = "#282c34"
|
||||||
|
focused_key = "#e5c07b"
|
||||||
|
accent = "#61afef"
|
||||||
|
accent_dim = "#3e4451"
|
||||||
|
border = "#3e4451"
|
||||||
|
border_focused = "#61afef"
|
||||||
|
header_bg = "#21252b"
|
||||||
|
header_fg = "#abb2bf"
|
||||||
|
bar_filled = "#61afef"
|
||||||
|
bar_empty = "#21252b"
|
||||||
|
error = "#e06c75"
|
||||||
|
warning = "#e5c07b"
|
||||||
|
success = "#98c379"
|
||||||
23
assets/themes/solarized-dark.toml
Normal file
23
assets/themes/solarized-dark.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "solarized-dark"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#002b36"
|
||||||
|
fg = "#839496"
|
||||||
|
text_correct = "#859900"
|
||||||
|
text_incorrect = "#dc322f"
|
||||||
|
text_incorrect_bg = "#2a1a1a"
|
||||||
|
text_pending = "#839496"
|
||||||
|
text_cursor_bg = "#b58900"
|
||||||
|
text_cursor_fg = "#002b36"
|
||||||
|
focused_key = "#b58900"
|
||||||
|
accent = "#268bd2"
|
||||||
|
accent_dim = "#073642"
|
||||||
|
border = "#586e75"
|
||||||
|
border_focused = "#268bd2"
|
||||||
|
header_bg = "#073642"
|
||||||
|
header_fg = "#93a1a1"
|
||||||
|
bar_filled = "#268bd2"
|
||||||
|
bar_empty = "#073642"
|
||||||
|
error = "#dc322f"
|
||||||
|
warning = "#b58900"
|
||||||
|
success = "#859900"
|
||||||
23
assets/themes/tokyo-night.toml
Normal file
23
assets/themes/tokyo-night.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name = "tokyo-night"
|
||||||
|
|
||||||
|
[colors]
|
||||||
|
bg = "#1a1b26"
|
||||||
|
fg = "#c0caf5"
|
||||||
|
text_correct = "#9ece6a"
|
||||||
|
text_incorrect = "#f7768e"
|
||||||
|
text_incorrect_bg = "#3b2232"
|
||||||
|
text_pending = "#9aa5ce"
|
||||||
|
text_cursor_bg = "#e0af68"
|
||||||
|
text_cursor_fg = "#1a1b26"
|
||||||
|
focused_key = "#e0af68"
|
||||||
|
accent = "#7aa2f7"
|
||||||
|
accent_dim = "#292e42"
|
||||||
|
border = "#3b4261"
|
||||||
|
border_focused = "#7aa2f7"
|
||||||
|
header_bg = "#24283b"
|
||||||
|
header_fg = "#c0caf5"
|
||||||
|
bar_filled = "#7aa2f7"
|
||||||
|
bar_empty = "#24283b"
|
||||||
|
error = "#f7768e"
|
||||||
|
warning = "#e0af68"
|
||||||
|
success = "#9ece6a"
|
||||||
10002
assets/words-en.json
Normal file
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)
|
||||||
343
src/app.rs
Normal file
343
src/app.rs
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::engine::letter_unlock::LetterUnlock;
|
||||||
|
use crate::engine::scoring;
|
||||||
|
use crate::generator::code_syntax::CodeSyntaxGenerator;
|
||||||
|
use crate::generator::dictionary::Dictionary;
|
||||||
|
use crate::generator::passage::PassageGenerator;
|
||||||
|
use crate::generator::phonetic::PhoneticGenerator;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
use crate::generator::transition_table::TransitionTable;
|
||||||
|
|
||||||
|
use crate::session::input::{self, KeystrokeEvent};
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::store::json_store::JsonStore;
|
||||||
|
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
|
||||||
|
use crate::ui::components::menu::Menu;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum AppScreen {
|
||||||
|
Menu,
|
||||||
|
Lesson,
|
||||||
|
LessonResult,
|
||||||
|
StatsDashboard,
|
||||||
|
Settings,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum LessonMode {
|
||||||
|
Adaptive,
|
||||||
|
Code,
|
||||||
|
Passage,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub screen: AppScreen,
|
||||||
|
pub lesson_mode: LessonMode,
|
||||||
|
pub lesson: Option<LessonState>,
|
||||||
|
pub lesson_events: Vec<KeystrokeEvent>,
|
||||||
|
pub last_result: Option<LessonResult>,
|
||||||
|
pub lesson_history: Vec<LessonResult>,
|
||||||
|
pub menu: Menu<'static>,
|
||||||
|
pub theme: &'static Theme,
|
||||||
|
pub config: Config,
|
||||||
|
pub key_stats: KeyStatsStore,
|
||||||
|
pub letter_unlock: LetterUnlock,
|
||||||
|
pub profile: ProfileData,
|
||||||
|
pub store: Option<JsonStore>,
|
||||||
|
pub should_quit: bool,
|
||||||
|
pub settings_selected: usize,
|
||||||
|
pub stats_tab: usize,
|
||||||
|
rng: SmallRng,
|
||||||
|
transition_table: TransitionTable,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
dictionary: Dictionary,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let config = Config::load().unwrap_or_default();
|
||||||
|
let loaded_theme = Theme::load(&config.theme).unwrap_or_default();
|
||||||
|
let theme: &'static Theme = Box::leak(Box::new(loaded_theme));
|
||||||
|
let menu = Menu::new(theme);
|
||||||
|
|
||||||
|
let store = JsonStore::new().ok();
|
||||||
|
|
||||||
|
let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store {
|
||||||
|
let ksd = s.load_key_stats();
|
||||||
|
let pd = s.load_profile();
|
||||||
|
let lhd = s.load_lesson_history();
|
||||||
|
|
||||||
|
let lu = if pd.unlocked_letters.is_empty() {
|
||||||
|
LetterUnlock::new()
|
||||||
|
} else {
|
||||||
|
LetterUnlock::from_included(pd.unlocked_letters.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
(ksd.stats, lu, pd, lhd.lessons)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
KeyStatsStore::default(),
|
||||||
|
LetterUnlock::new(),
|
||||||
|
ProfileData::default(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut key_stats_with_target = key_stats;
|
||||||
|
key_stats_with_target.target_cpm = config.target_cpm();
|
||||||
|
|
||||||
|
let dictionary = Dictionary::load();
|
||||||
|
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
screen: AppScreen::Menu,
|
||||||
|
lesson_mode: LessonMode::Adaptive,
|
||||||
|
lesson: None,
|
||||||
|
lesson_events: Vec::new(),
|
||||||
|
last_result: None,
|
||||||
|
lesson_history,
|
||||||
|
menu,
|
||||||
|
theme,
|
||||||
|
config,
|
||||||
|
key_stats: key_stats_with_target,
|
||||||
|
letter_unlock,
|
||||||
|
profile,
|
||||||
|
store,
|
||||||
|
should_quit: false,
|
||||||
|
settings_selected: 0,
|
||||||
|
stats_tab: 0,
|
||||||
|
rng: SmallRng::from_entropy(),
|
||||||
|
transition_table,
|
||||||
|
dictionary,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_lesson(&mut self) {
|
||||||
|
let text = self.generate_text();
|
||||||
|
self.lesson = Some(LessonState::new(&text));
|
||||||
|
self.lesson_events.clear();
|
||||||
|
self.screen = AppScreen::Lesson;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_text(&mut self) -> String {
|
||||||
|
let word_count = self.config.word_count;
|
||||||
|
let mode = self.lesson_mode;
|
||||||
|
|
||||||
|
match mode {
|
||||||
|
LessonMode::Adaptive => {
|
||||||
|
let filter = CharFilter::new(self.letter_unlock.included.clone());
|
||||||
|
let focused = self.letter_unlock.focused;
|
||||||
|
let table = self.transition_table.clone();
|
||||||
|
let dict = Dictionary::load();
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = PhoneticGenerator::new(table, dict, rng);
|
||||||
|
generator.generate(&filter, focused, word_count)
|
||||||
|
}
|
||||||
|
LessonMode::Code => {
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
let lang = self
|
||||||
|
.config
|
||||||
|
.code_languages
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "rust".to_string());
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = CodeSyntaxGenerator::new(rng, &lang);
|
||||||
|
generator.generate(&filter, None, word_count)
|
||||||
|
}
|
||||||
|
LessonMode::Passage => {
|
||||||
|
let filter = CharFilter::new(('a'..='z').collect());
|
||||||
|
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||||
|
let mut generator = PassageGenerator::new(rng);
|
||||||
|
generator.generate(&filter, None, word_count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn type_char(&mut self, ch: char) {
|
||||||
|
if let Some(ref mut lesson) = self.lesson {
|
||||||
|
if let Some(event) = input::process_char(lesson, ch) {
|
||||||
|
self.lesson_events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if lesson.is_complete() {
|
||||||
|
self.finish_lesson();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backspace(&mut self) {
|
||||||
|
if let Some(ref mut lesson) = self.lesson {
|
||||||
|
input::process_backspace(lesson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_lesson(&mut self) {
|
||||||
|
if let Some(ref lesson) = self.lesson {
|
||||||
|
let result = LessonResult::from_lesson(lesson, &self.lesson_events);
|
||||||
|
|
||||||
|
if self.lesson_mode == LessonMode::Adaptive {
|
||||||
|
for kt in &result.per_key_times {
|
||||||
|
if kt.correct {
|
||||||
|
self.key_stats.update_key(kt.key, kt.time_ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.letter_unlock.update(&self.key_stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
|
||||||
|
let score = scoring::compute_score(&result, complexity);
|
||||||
|
self.profile.total_score += score;
|
||||||
|
self.profile.total_lessons += 1;
|
||||||
|
self.profile.unlocked_letters = self.letter_unlock.included.clone();
|
||||||
|
|
||||||
|
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
if self.profile.last_practice_date.as_deref() != Some(&today) {
|
||||||
|
if let Some(ref last) = self.profile.last_practice_date {
|
||||||
|
let yesterday = (chrono::Utc::now() - chrono::Duration::days(1))
|
||||||
|
.format("%Y-%m-%d")
|
||||||
|
.to_string();
|
||||||
|
if last == &yesterday {
|
||||||
|
self.profile.streak_days += 1;
|
||||||
|
} else {
|
||||||
|
self.profile.streak_days = 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.profile.streak_days = 1;
|
||||||
|
}
|
||||||
|
self.profile.best_streak =
|
||||||
|
self.profile.best_streak.max(self.profile.streak_days);
|
||||||
|
self.profile.last_practice_date = Some(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.lesson_history.push(result.clone());
|
||||||
|
if self.lesson_history.len() > 500 {
|
||||||
|
self.lesson_history.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_result = Some(result);
|
||||||
|
self.screen = AppScreen::LessonResult;
|
||||||
|
|
||||||
|
self.save_data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_data(&self) {
|
||||||
|
if let Some(ref store) = self.store {
|
||||||
|
let _ = store.save_profile(&self.profile);
|
||||||
|
let _ = store.save_key_stats(&KeyStatsData {
|
||||||
|
schema_version: 1,
|
||||||
|
stats: self.key_stats.clone(),
|
||||||
|
});
|
||||||
|
let _ = store.save_lesson_history(&LessonHistoryData {
|
||||||
|
schema_version: 1,
|
||||||
|
lessons: self.lesson_history.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn retry_lesson(&mut self) {
|
||||||
|
self.start_lesson();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_to_menu(&mut self) {
|
||||||
|
self.screen = AppScreen::Menu;
|
||||||
|
self.lesson = None;
|
||||||
|
self.lesson_events.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_to_stats(&mut self) {
|
||||||
|
self.stats_tab = 0;
|
||||||
|
self.screen = AppScreen::StatsDashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go_to_settings(&mut self) {
|
||||||
|
self.settings_selected = 0;
|
||||||
|
self.screen = AppScreen::Settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_cycle_forward(&mut self) {
|
||||||
|
match self.settings_selected {
|
||||||
|
0 => {
|
||||||
|
self.config.target_wpm = (self.config.target_wpm + 5).min(200);
|
||||||
|
self.key_stats.target_cpm = self.config.target_cpm();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let themes = Theme::available_themes();
|
||||||
|
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
|
||||||
|
let next = (idx + 1) % themes.len();
|
||||||
|
self.config.theme = themes[next].clone();
|
||||||
|
} else if let Some(first) = themes.first() {
|
||||||
|
self.config.theme = first.clone();
|
||||||
|
}
|
||||||
|
if let Some(new_theme) = Theme::load(&self.config.theme) {
|
||||||
|
let theme: &'static Theme = Box::leak(Box::new(new_theme));
|
||||||
|
self.theme = theme;
|
||||||
|
self.menu.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.config.word_count = (self.config.word_count + 5).min(100);
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
let langs = ["rust", "python", "javascript", "go"];
|
||||||
|
let current = self
|
||||||
|
.config
|
||||||
|
.code_languages
|
||||||
|
.first()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("rust");
|
||||||
|
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||||
|
let next = (idx + 1) % langs.len();
|
||||||
|
self.config.code_languages = vec![langs[next].to_string()];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_cycle_backward(&mut self) {
|
||||||
|
match self.settings_selected {
|
||||||
|
0 => {
|
||||||
|
self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
|
||||||
|
self.key_stats.target_cpm = self.config.target_cpm();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let themes = Theme::available_themes();
|
||||||
|
if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) {
|
||||||
|
let next = if idx == 0 { themes.len() - 1 } else { idx - 1 };
|
||||||
|
self.config.theme = themes[next].clone();
|
||||||
|
} else if let Some(first) = themes.first() {
|
||||||
|
self.config.theme = first.clone();
|
||||||
|
}
|
||||||
|
if let Some(new_theme) = Theme::load(&self.config.theme) {
|
||||||
|
let theme: &'static Theme = Box::leak(Box::new(new_theme));
|
||||||
|
self.theme = theme;
|
||||||
|
self.menu.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
let langs = ["rust", "python", "javascript", "go"];
|
||||||
|
let current = self
|
||||||
|
.config
|
||||||
|
.code_languages
|
||||||
|
.first()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("rust");
|
||||||
|
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||||
|
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
|
||||||
|
self.config.code_languages = vec![langs[next].to_string()];
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/config.rs
Normal file
82
src/config.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_target_wpm")]
|
||||||
|
pub target_wpm: u32,
|
||||||
|
#[serde(default = "default_theme")]
|
||||||
|
pub theme: String,
|
||||||
|
#[serde(default = "default_keyboard_layout")]
|
||||||
|
pub keyboard_layout: String,
|
||||||
|
#[serde(default = "default_code_languages")]
|
||||||
|
pub code_languages: Vec<String>,
|
||||||
|
#[serde(default = "default_word_count")]
|
||||||
|
pub word_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_target_wpm() -> u32 {
|
||||||
|
35
|
||||||
|
}
|
||||||
|
fn default_theme() -> String {
|
||||||
|
"catppuccin-mocha".to_string()
|
||||||
|
}
|
||||||
|
fn default_keyboard_layout() -> String {
|
||||||
|
"qwerty".to_string()
|
||||||
|
}
|
||||||
|
fn default_code_languages() -> Vec<String> {
|
||||||
|
vec!["rust".to_string()]
|
||||||
|
}
|
||||||
|
fn default_word_count() -> usize {
|
||||||
|
20
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
target_wpm: default_target_wpm(),
|
||||||
|
theme: default_theme(),
|
||||||
|
keyboard_layout: default_keyboard_layout(),
|
||||||
|
code_languages: default_code_languages(),
|
||||||
|
word_count: default_word_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self> {
|
||||||
|
let path = Self::config_path();
|
||||||
|
if path.exists() {
|
||||||
|
let content = fs::read_to_string(&path)?;
|
||||||
|
let config: Config = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
} else {
|
||||||
|
Ok(Config::default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn save(&self) -> Result<()> {
|
||||||
|
let path = Self::config_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(self)?;
|
||||||
|
fs::write(&path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
dirs::config_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr")
|
||||||
|
.join("config.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_cpm(&self) -> f64 {
|
||||||
|
self.target_wpm as f64 * 5.0
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/engine/filter.rs
Normal file
20
src/engine/filter.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
pub struct CharFilter {
|
||||||
|
pub allowed: Vec<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharFilter {
|
||||||
|
pub fn new(allowed: Vec<char>) -> Self {
|
||||||
|
Self { allowed }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_allowed(&self, ch: char) -> bool {
|
||||||
|
self.allowed.contains(&ch) || ch == ' '
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn filter_text(&self, text: &str) -> String {
|
||||||
|
text.chars()
|
||||||
|
.filter(|&ch| self.is_allowed(ch))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/engine/key_stats.rs
Normal file
120
src/engine/key_stats.rs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const EMA_ALPHA: f64 = 0.1;
|
||||||
|
const DEFAULT_TARGET_CPM: f64 = 175.0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStat {
|
||||||
|
pub filtered_time_ms: f64,
|
||||||
|
pub best_time_ms: f64,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub sample_count: usize,
|
||||||
|
pub recent_times: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStat {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
filtered_time_ms: 1000.0,
|
||||||
|
best_time_ms: f64::MAX,
|
||||||
|
confidence: 0.0,
|
||||||
|
sample_count: 0,
|
||||||
|
recent_times: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStatsStore {
|
||||||
|
pub stats: HashMap<char, KeyStat>,
|
||||||
|
pub target_cpm: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStatsStore {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
stats: HashMap::new(),
|
||||||
|
target_cpm: DEFAULT_TARGET_CPM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStatsStore {
|
||||||
|
pub fn update_key(&mut self, key: char, time_ms: f64) {
|
||||||
|
let stat = self.stats.entry(key).or_default();
|
||||||
|
stat.sample_count += 1;
|
||||||
|
|
||||||
|
if stat.sample_count == 1 {
|
||||||
|
stat.filtered_time_ms = time_ms;
|
||||||
|
} else {
|
||||||
|
stat.filtered_time_ms =
|
||||||
|
EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
|
||||||
|
|
||||||
|
let target_time_ms = 60000.0 / self.target_cpm;
|
||||||
|
stat.confidence = target_time_ms / stat.filtered_time_ms;
|
||||||
|
|
||||||
|
stat.recent_times.push(time_ms);
|
||||||
|
if stat.recent_times.len() > 30 {
|
||||||
|
stat.recent_times.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_confidence(&self, key: char) -> f64 {
|
||||||
|
self.stats
|
||||||
|
.get(&key)
|
||||||
|
.map(|s| s.confidence)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn get_stat(&self, key: char) -> Option<&KeyStat> {
|
||||||
|
self.stats.get(&key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_confidence_is_zero() {
|
||||||
|
let store = KeyStatsStore::default();
|
||||||
|
assert_eq!(store.get_confidence('a'), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_update_key_creates_stat() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
store.update_key('e', 300.0);
|
||||||
|
assert!(store.get_confidence('e') > 0.0);
|
||||||
|
assert_eq!(store.stats.get(&'e').unwrap().sample_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_ema_converges() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
// Type key fast many times - confidence should increase
|
||||||
|
for _ in 0..50 {
|
||||||
|
store.update_key('t', 200.0);
|
||||||
|
}
|
||||||
|
let conf = store.get_confidence('t');
|
||||||
|
// At 175 CPM target, target_time = 60000/175 = 342.8ms
|
||||||
|
// With 200ms typing time, confidence = 342.8/200 = 1.71
|
||||||
|
assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_slow_typing_low_confidence() {
|
||||||
|
let mut store = KeyStatsStore::default();
|
||||||
|
for _ in 0..50 {
|
||||||
|
store.update_key('a', 1000.0);
|
||||||
|
}
|
||||||
|
let conf = store.get_confidence('a');
|
||||||
|
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
|
||||||
|
assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}");
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/engine/learning_rate.rs
Normal file
59
src/engine/learning_rate.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn polynomial_regression(times: &[f64]) -> Option<f64> {
|
||||||
|
if times.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let n = times.len();
|
||||||
|
let xs: Vec<f64> = (0..n).map(|i| i as f64).collect();
|
||||||
|
|
||||||
|
let x_mean: f64 = xs.iter().sum::<f64>() / n as f64;
|
||||||
|
let y_mean: f64 = times.iter().sum::<f64>() / n as f64;
|
||||||
|
|
||||||
|
let mut ss_xy = 0.0;
|
||||||
|
let mut ss_xx = 0.0;
|
||||||
|
let mut ss_yy = 0.0;
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let dx = xs[i] - x_mean;
|
||||||
|
let dy = times[i] - y_mean;
|
||||||
|
ss_xy += dx * dy;
|
||||||
|
ss_xx += dx * dx;
|
||||||
|
ss_yy += dy * dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ss_xx < 1e-10 || ss_yy < 1e-10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slope = ss_xy / ss_xx;
|
||||||
|
let r_squared = (ss_xy * ss_xy) / (ss_xx * ss_yy);
|
||||||
|
|
||||||
|
if r_squared < 0.5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let predicted_next = y_mean + slope * (n as f64 - x_mean);
|
||||||
|
Some(predicted_next.max(0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn learning_rate_description(times: &[f64]) -> &'static str {
|
||||||
|
match polynomial_regression(times) {
|
||||||
|
Some(predicted) => {
|
||||||
|
if times.is_empty() {
|
||||||
|
return "No data";
|
||||||
|
}
|
||||||
|
let current = times.last().unwrap();
|
||||||
|
let improvement = (current - predicted) / current * 100.0;
|
||||||
|
if improvement > 5.0 {
|
||||||
|
"Improving"
|
||||||
|
} else if improvement < -5.0 {
|
||||||
|
"Slowing down"
|
||||||
|
} else {
|
||||||
|
"Steady"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => "Not enough data",
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/engine/letter_unlock.rs
Normal file
151
src/engine/letter_unlock.rs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
|
||||||
|
pub const FREQUENCY_ORDER: &[char] = &[
|
||||||
|
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
|
||||||
|
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_LETTERS: usize = 6;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LetterUnlock {
|
||||||
|
pub included: Vec<char>,
|
||||||
|
pub focused: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LetterUnlock {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
|
||||||
|
Self {
|
||||||
|
included,
|
||||||
|
focused: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_included(included: Vec<char>) -> Self {
|
||||||
|
let mut lu = Self {
|
||||||
|
included,
|
||||||
|
focused: None,
|
||||||
|
};
|
||||||
|
lu.focused = None;
|
||||||
|
lu
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update(&mut self, stats: &KeyStatsStore) {
|
||||||
|
let all_confident = self
|
||||||
|
.included
|
||||||
|
.iter()
|
||||||
|
.all(|&ch| stats.get_confidence(ch) >= 1.0);
|
||||||
|
|
||||||
|
if all_confident {
|
||||||
|
for &letter in FREQUENCY_ORDER {
|
||||||
|
if !self.included.contains(&letter) {
|
||||||
|
self.included.push(letter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while self.included.len() < MIN_LETTERS {
|
||||||
|
for &letter in FREQUENCY_ORDER {
|
||||||
|
if !self.included.contains(&letter) {
|
||||||
|
self.included.push(letter);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.focused = self
|
||||||
|
.included
|
||||||
|
.iter()
|
||||||
|
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
|
||||||
|
.min_by(|&&a, &&b| {
|
||||||
|
stats
|
||||||
|
.get_confidence(a)
|
||||||
|
.partial_cmp(&stats.get_confidence(b))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
.copied();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn is_unlocked(&self, ch: char) -> bool {
|
||||||
|
self.included.contains(&ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unlocked_count(&self) -> usize {
|
||||||
|
self.included.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_letters(&self) -> usize {
|
||||||
|
FREQUENCY_ORDER.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> f64 {
|
||||||
|
self.unlocked_count() as f64 / self.total_letters() as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LetterUnlock {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_initial_unlock_has_min_letters() {
|
||||||
|
let lu = LetterUnlock::new();
|
||||||
|
assert_eq!(lu.unlocked_count(), 6);
|
||||||
|
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_unlock_without_confidence() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let stats = KeyStatsStore::default();
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.unlocked_count(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unlock_when_all_confident() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
// Make all included keys confident by typing fast
|
||||||
|
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
|
||||||
|
for _ in 0..50 {
|
||||||
|
stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.unlocked_count(), 7);
|
||||||
|
assert!(lu.included.contains(&'s'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_focused_key_is_weakest() {
|
||||||
|
let mut lu = LetterUnlock::new();
|
||||||
|
let mut stats = KeyStatsStore::default();
|
||||||
|
// Make most keys confident except 'o'
|
||||||
|
for &ch in &['e', 't', 'a', 'i', 'n'] {
|
||||||
|
for _ in 0..50 {
|
||||||
|
stats.update_key(ch, 200.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stats.update_key('o', 1000.0); // slow on 'o'
|
||||||
|
lu.update(&stats);
|
||||||
|
assert_eq!(lu.focused, Some('o'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_progress_ratio() {
|
||||||
|
let lu = LetterUnlock::new();
|
||||||
|
let expected = 6.0 / 26.0;
|
||||||
|
assert!((lu.progress() - expected).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/engine/mod.rs
Normal file
5
src/engine/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod filter;
|
||||||
|
pub mod key_stats;
|
||||||
|
pub mod learning_rate;
|
||||||
|
pub mod letter_unlock;
|
||||||
|
pub mod scoring;
|
||||||
45
src/engine/scoring.rs
Normal file
45
src/engine/scoring.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use crate::session::result::LessonResult;
|
||||||
|
|
||||||
|
pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 {
|
||||||
|
let speed = result.cpm;
|
||||||
|
let errors = result.incorrect as f64;
|
||||||
|
let length = result.total_chars as f64;
|
||||||
|
(speed * complexity) / (errors + 1.0) * (length / 50.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_complexity(unlocked_count: usize) -> f64 {
|
||||||
|
(unlocked_count as f64 / 26.0).max(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn level_from_score(total_score: f64) -> u32 {
|
||||||
|
let level = (total_score / 100.0).sqrt() as u32;
|
||||||
|
level.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn score_to_next_level(total_score: f64) -> f64 {
|
||||||
|
let current_level = level_from_score(total_score);
|
||||||
|
let next_level_score = ((current_level + 1) as f64).powi(2) * 100.0;
|
||||||
|
next_level_score - total_score
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_level_starts_at_one() {
|
||||||
|
assert_eq!(level_from_score(0.0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_level_increases_with_score() {
|
||||||
|
assert!(level_from_score(1000.0) > level_from_score(100.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_complexity_scales_with_letters() {
|
||||||
|
assert!(compute_complexity(26) > compute_complexity(6));
|
||||||
|
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/event.rs
Normal file
49
src/event.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyEvent};
|
||||||
|
|
||||||
|
pub enum AppEvent {
|
||||||
|
Key(KeyEvent),
|
||||||
|
Tick,
|
||||||
|
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventHandler {
|
||||||
|
rx: mpsc::Receiver<AppEvent>,
|
||||||
|
_tx: mpsc::Sender<AppEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventHandler {
|
||||||
|
pub fn new(tick_rate: Duration) -> Self {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let _tx = tx.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
if event::poll(tick_rate).unwrap_or(false) {
|
||||||
|
match event::read() {
|
||||||
|
Ok(Event::Key(key)) => {
|
||||||
|
if tx.send(AppEvent::Key(key)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Resize(w, h)) => {
|
||||||
|
if tx.send(AppEvent::Resize(w, h)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else if tx.send(AppEvent::Tick).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { rx, _tx }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&self) -> anyhow::Result<AppEvent> {
|
||||||
|
Ok(self.rx.recv()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/generator/cache.rs
Normal file
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
|
||||||
|
}
|
||||||
304
src/generator/code_syntax.rs
Normal file
304
src/generator/code_syntax.rs
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::cache::{DiskCache, fetch_url};
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
pub struct CodeSyntaxGenerator {
|
||||||
|
rng: SmallRng,
|
||||||
|
language: String,
|
||||||
|
fetched_snippets: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CodeSyntaxGenerator {
|
||||||
|
pub fn new(rng: SmallRng, language: &str) -> Self {
|
||||||
|
let mut generator = Self {
|
||||||
|
rng,
|
||||||
|
language: language.to_string(),
|
||||||
|
fetched_snippets: Vec::new(),
|
||||||
|
};
|
||||||
|
generator.load_cached_snippets();
|
||||||
|
generator
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cached_snippets(&mut self) {
|
||||||
|
if let Some(cache) = DiskCache::new("code_cache") {
|
||||||
|
let key = format!("{}_snippets", self.language);
|
||||||
|
if let Some(content) = cache.get(&key) {
|
||||||
|
self.fetched_snippets = content
|
||||||
|
.split("\n---SNIPPET---\n")
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_fetch_code(&mut self) {
|
||||||
|
let urls = match self.language.as_str() {
|
||||||
|
"rust" => vec![
|
||||||
|
"https://raw.githubusercontent.com/tokio-rs/tokio/master/tokio/src/sync/mutex.rs",
|
||||||
|
"https://raw.githubusercontent.com/serde-rs/serde/master/serde/src/ser/mod.rs",
|
||||||
|
],
|
||||||
|
"python" => vec![
|
||||||
|
"https://raw.githubusercontent.com/python/cpython/main/Lib/json/encoder.py",
|
||||||
|
"https://raw.githubusercontent.com/python/cpython/main/Lib/pathlib/__init__.py",
|
||||||
|
],
|
||||||
|
"javascript" | "js" => vec![
|
||||||
|
"https://raw.githubusercontent.com/lodash/lodash/main/src/chunk.ts",
|
||||||
|
"https://raw.githubusercontent.com/expressjs/express/master/lib/router/index.js",
|
||||||
|
],
|
||||||
|
"go" => vec![
|
||||||
|
"https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go",
|
||||||
|
],
|
||||||
|
_ => vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let cache = match DiskCache::new("code_cache") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = format!("{}_snippets", self.language);
|
||||||
|
if cache.get(&key).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_snippets = Vec::new();
|
||||||
|
for url in urls {
|
||||||
|
if let Some(content) = fetch_url(url) {
|
||||||
|
let snippets = extract_code_snippets(&content);
|
||||||
|
all_snippets.extend(snippets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !all_snippets.is_empty() {
|
||||||
|
let combined = all_snippets.join("\n---SNIPPET---\n");
|
||||||
|
cache.put(&key, &combined);
|
||||||
|
self.fetched_snippets = all_snippets;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rust_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"fn main() { println!(\"hello\"); }",
|
||||||
|
"let mut x = 0; x += 1;",
|
||||||
|
"for i in 0..10 { println!(\"{}\", i); }",
|
||||||
|
"if x > 0 { return true; }",
|
||||||
|
"match val { Some(x) => x, None => 0 }",
|
||||||
|
"struct Point { x: f64, y: f64 }",
|
||||||
|
"impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }",
|
||||||
|
"let v: Vec<i32> = vec![1, 2, 3];",
|
||||||
|
"fn add(a: i32, b: i32) -> i32 { a + b }",
|
||||||
|
"use std::collections::HashMap;",
|
||||||
|
"pub fn process(input: &str) -> Result<String, Error> { Ok(input.to_string()) }",
|
||||||
|
"let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::<Vec<_>>();",
|
||||||
|
"enum Color { Red, Green, Blue }",
|
||||||
|
"trait Display { fn show(&self) -> String; }",
|
||||||
|
"while let Some(item) = stack.pop() { process(item); }",
|
||||||
|
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }",
|
||||||
|
"let handle = std::thread::spawn(|| { println!(\"thread\"); });",
|
||||||
|
"let mut map = HashMap::new(); map.insert(\"key\", 42);",
|
||||||
|
"fn factorial(n: u64) -> u64 { if n <= 1 { 1 } else { n * factorial(n - 1) } }",
|
||||||
|
"impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { None } }",
|
||||||
|
"async fn fetch(url: &str) -> Result<String> { let body = reqwest::get(url).await?.text().await?; Ok(body) }",
|
||||||
|
"let closure = |x: i32, y: i32| -> i32 { x + y };",
|
||||||
|
"mod tests { use super::*; #[test] fn it_works() { assert_eq!(2 + 2, 4); } }",
|
||||||
|
"pub struct Builder { name: Option<String> } impl Builder { pub fn name(mut self, n: &str) -> Self { self.name = Some(n.into()); self } }",
|
||||||
|
"use std::sync::{Arc, Mutex}; let data = Arc::new(Mutex::new(vec![1, 2, 3]));",
|
||||||
|
"if let Ok(value) = \"42\".parse::<i32>() { println!(\"parsed: {}\", value); }",
|
||||||
|
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }",
|
||||||
|
"type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;",
|
||||||
|
"macro_rules! vec_of_strings { ($($x:expr),*) => { vec![$($x.to_string()),*] }; }",
|
||||||
|
"let (tx, rx) = std::sync::mpsc::channel(); tx.send(42).unwrap();",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn python_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"def main(): print(\"hello\")",
|
||||||
|
"for i in range(10): print(i)",
|
||||||
|
"if x > 0: return True",
|
||||||
|
"class Point: def __init__(self, x, y): self.x = x",
|
||||||
|
"import os; path = os.path.join(\"a\", \"b\")",
|
||||||
|
"result = [x * 2 for x in items if x > 0]",
|
||||||
|
"with open(\"file.txt\") as f: data = f.read()",
|
||||||
|
"def add(a: int, b: int) -> int: return a + b",
|
||||||
|
"try: result = process(data) except ValueError as e: print(e)",
|
||||||
|
"from collections import defaultdict",
|
||||||
|
"lambda x: x * 2 + 1",
|
||||||
|
"dict_comp = {k: v for k, v in pairs.items()}",
|
||||||
|
"async def fetch(url): async with aiohttp.ClientSession() as session: return await session.get(url)",
|
||||||
|
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
|
||||||
|
"@property def name(self): return self._name",
|
||||||
|
"from dataclasses import dataclass; @dataclass class Config: name: str; value: int = 0",
|
||||||
|
"yield from range(10)",
|
||||||
|
"sorted(items, key=lambda x: x.name, reverse=True)",
|
||||||
|
"from typing import Optional, List, Dict",
|
||||||
|
"with contextlib.suppress(FileNotFoundError): os.remove(\"temp.txt\")",
|
||||||
|
"class Meta(type): def __new__(cls, name, bases, attrs): return super().__new__(cls, name, bases, attrs)",
|
||||||
|
"from functools import lru_cache; @lru_cache(maxsize=128) def expensive(n): return sum(range(n))",
|
||||||
|
"from pathlib import Path; files = list(Path(\".\").glob(\"**/*.py\"))",
|
||||||
|
"assert isinstance(result, dict), f\"Expected dict, got {type(result)}\"",
|
||||||
|
"values = {*set_a, *set_b}; merged = {**dict_a, **dict_b}",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn javascript_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"const x = 42; console.log(x);",
|
||||||
|
"function add(a, b) { return a + b; }",
|
||||||
|
"const arr = [1, 2, 3].map(x => x * 2);",
|
||||||
|
"if (x > 0) { return true; }",
|
||||||
|
"for (let i = 0; i < 10; i++) { console.log(i); }",
|
||||||
|
"class Point { constructor(x, y) { this.x = x; this.y = y; } }",
|
||||||
|
"const { name, age } = person;",
|
||||||
|
"async function fetch(url) { const res = await get(url); return res.json(); }",
|
||||||
|
"const obj = { ...defaults, ...overrides };",
|
||||||
|
"try { parse(data); } catch (e) { console.error(e); }",
|
||||||
|
"export default function handler(req, res) { res.send(\"ok\"); }",
|
||||||
|
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);",
|
||||||
|
"const promise = new Promise((resolve, reject) => { setTimeout(resolve, 1000); });",
|
||||||
|
"const [first, ...rest] = array;",
|
||||||
|
"class EventEmitter { constructor() { this.listeners = new Map(); } }",
|
||||||
|
"const proxy = new Proxy(target, { get(obj, prop) { return obj[prop]; } });",
|
||||||
|
"for await (const chunk of stream) { process(chunk); }",
|
||||||
|
"const memoize = (fn) => { const cache = new Map(); return (...args) => cache.get(args) ?? fn(...args); };",
|
||||||
|
"import { useState, useEffect } from 'react'; const [state, setState] = useState(null);",
|
||||||
|
"const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);",
|
||||||
|
"Object.entries(obj).forEach(([key, value]) => { console.log(key, value); });",
|
||||||
|
"const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; };",
|
||||||
|
"const observable = new Observable(subscriber => { subscriber.next(1); subscriber.complete(); });",
|
||||||
|
"Symbol.iterator",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn go_snippets() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
"func main() { fmt.Println(\"hello\") }",
|
||||||
|
"for i := 0; i < 10; i++ { fmt.Println(i) }",
|
||||||
|
"if err != nil { return err }",
|
||||||
|
"type Point struct { X float64; Y float64 }",
|
||||||
|
"func add(a, b int) int { return a + b }",
|
||||||
|
"import \"fmt\"",
|
||||||
|
"result := make([]int, 0, 10)",
|
||||||
|
"switch val { case 1: return \"one\" default: return \"other\" }",
|
||||||
|
"go func() { ch <- result }()",
|
||||||
|
"defer file.Close()",
|
||||||
|
"type Reader interface { Read(p []byte) (n int, err error) }",
|
||||||
|
"ctx, cancel := context.WithTimeout(context.Background(), time.Second)",
|
||||||
|
"var wg sync.WaitGroup; wg.Add(1); go func() { defer wg.Done() }()",
|
||||||
|
"func (p *Point) Distance() float64 { return math.Sqrt(p.X*p.X + p.Y*p.Y) }",
|
||||||
|
"select { case msg := <-ch: process(msg) case <-time.After(time.Second): timeout() }",
|
||||||
|
"json.NewEncoder(w).Encode(response)",
|
||||||
|
"http.HandleFunc(\"/api\", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\"ok\")) })",
|
||||||
|
"func Map[T, U any](s []T, f func(T) U) []U { r := make([]U, len(s)); for i, v := range s { r[i] = f(v) }; return r }",
|
||||||
|
"var once sync.Once; once.Do(func() { initialize() })",
|
||||||
|
"buf := bytes.NewBuffer(nil); buf.WriteString(\"hello\")",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_snippets(&self) -> Vec<&'static str> {
|
||||||
|
match self.language.as_str() {
|
||||||
|
"rust" => Self::rust_snippets(),
|
||||||
|
"python" => Self::python_snippets(),
|
||||||
|
"javascript" | "js" => Self::javascript_snippets(),
|
||||||
|
"go" => Self::go_snippets(),
|
||||||
|
_ => Self::rust_snippets(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for CodeSyntaxGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
// Try to fetch from GitHub on first use
|
||||||
|
if self.fetched_snippets.is_empty() {
|
||||||
|
self.try_fetch_code();
|
||||||
|
}
|
||||||
|
|
||||||
|
let embedded = self.get_snippets();
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let target_words = word_count;
|
||||||
|
let mut current_words = 0;
|
||||||
|
|
||||||
|
let total_available = embedded.len() + self.fetched_snippets.len();
|
||||||
|
|
||||||
|
while current_words < target_words {
|
||||||
|
let idx = self.rng.gen_range(0..total_available.max(1));
|
||||||
|
|
||||||
|
let snippet = if idx < embedded.len() {
|
||||||
|
embedded[idx]
|
||||||
|
} else if !self.fetched_snippets.is_empty() {
|
||||||
|
let f_idx = (idx - embedded.len()) % self.fetched_snippets.len();
|
||||||
|
&self.fetched_snippets[f_idx]
|
||||||
|
} else {
|
||||||
|
embedded[idx % embedded.len()]
|
||||||
|
};
|
||||||
|
|
||||||
|
current_words += snippet.split_whitespace().count();
|
||||||
|
result.push(snippet.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
result.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract function-length snippets from raw source code
|
||||||
|
fn extract_code_snippets(source: &str) -> Vec<String> {
|
||||||
|
let mut snippets = Vec::new();
|
||||||
|
let lines: Vec<&str> = source.lines().collect();
|
||||||
|
|
||||||
|
let mut i = 0;
|
||||||
|
while i < lines.len() {
|
||||||
|
// Look for function/method starts
|
||||||
|
let line = lines[i].trim();
|
||||||
|
let is_func_start = line.starts_with("fn ")
|
||||||
|
|| line.starts_with("pub fn ")
|
||||||
|
|| line.starts_with("def ")
|
||||||
|
|| line.starts_with("func ")
|
||||||
|
|| line.starts_with("function ")
|
||||||
|
|| line.starts_with("async fn ")
|
||||||
|
|| line.starts_with("pub async fn ");
|
||||||
|
|
||||||
|
if is_func_start {
|
||||||
|
let mut snippet_lines = Vec::new();
|
||||||
|
let mut depth = 0i32;
|
||||||
|
let mut j = i;
|
||||||
|
|
||||||
|
while j < lines.len() && snippet_lines.len() < 30 {
|
||||||
|
let l = lines[j];
|
||||||
|
snippet_lines.push(l);
|
||||||
|
|
||||||
|
depth += l.chars().filter(|&c| c == '{' || c == '(').count() as i32;
|
||||||
|
depth -= l.chars().filter(|&c| c == '}' || c == ')').count() as i32;
|
||||||
|
|
||||||
|
if depth <= 0 && j > i {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 {
|
||||||
|
let snippet = snippet_lines.join(" ");
|
||||||
|
// Normalize whitespace
|
||||||
|
let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
|
if normalized.len() >= 20 && normalized.len() <= 500 {
|
||||||
|
snippets.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i = j + 1;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets.truncate(50);
|
||||||
|
snippets
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/generator/github_code.rs
Normal file
41
src/generator/github_code.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct GitHubCodeGenerator {
|
||||||
|
cached_snippets: Vec<String>,
|
||||||
|
current_idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GitHubCodeGenerator {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
cached_snippets: Vec::new(),
|
||||||
|
current_idx: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GitHubCodeGenerator {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for GitHubCodeGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
_word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
if self.cached_snippets.is_empty() {
|
||||||
|
return "// GitHub code fetching not yet configured. Use settings to add a repository."
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
let snippet = self.cached_snippets[self.current_idx % self.cached_snippets.len()].clone();
|
||||||
|
self.current_idx += 1;
|
||||||
|
snippet
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/generator/mod.rs
Normal file
14
src/generator/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
pub mod cache;
|
||||||
|
pub mod code_syntax;
|
||||||
|
pub mod dictionary;
|
||||||
|
pub mod github_code;
|
||||||
|
pub mod passage;
|
||||||
|
pub mod phonetic;
|
||||||
|
pub mod transition_table;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
|
||||||
|
pub trait TextGenerator {
|
||||||
|
fn generate(&mut self, filter: &CharFilter, focused: Option<char>, word_count: usize)
|
||||||
|
-> String;
|
||||||
|
}
|
||||||
244
src/generator/passage.rs
Normal file
244
src/generator/passage.rs
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::cache::{DiskCache, fetch_url};
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
const PASSAGES: &[&str] = &[
|
||||||
|
// Classic literature & speeches
|
||||||
|
"the quick brown fox jumps over the lazy dog and then runs across the field while the sun sets behind the distant hills",
|
||||||
|
"it was the best of times it was the worst of times it was the age of wisdom it was the age of foolishness",
|
||||||
|
"in the beginning there was nothing but darkness and then the light appeared slowly spreading across the vast empty space",
|
||||||
|
"she walked along the narrow path through the forest listening to the birds singing in the trees above her head",
|
||||||
|
"the old man sat on the bench watching the children play in the park while the autumn leaves fell softly around him",
|
||||||
|
"there is nothing either good or bad but thinking makes it so for the mind is its own place and in itself can make a heaven of hell",
|
||||||
|
"to be or not to be that is the question whether it is nobler in the mind to suffer the slings and arrows of outrageous fortune",
|
||||||
|
"all that glitters is not gold and not all those who wander are lost for the old that is strong does not wither",
|
||||||
|
"the river flowed quietly through the green valley and the mountains rose high on either side covered with trees and snow",
|
||||||
|
"a long time ago in a land far away there lived a wise king who ruled his people with kindness and justice",
|
||||||
|
"the rain fell steadily on the roof making a soft drumming sound that filled the room and made everything feel calm",
|
||||||
|
"she opened the door and stepped outside into the cool morning air breathing deeply as the first light of dawn appeared",
|
||||||
|
"he picked up the book and began to read turning the pages slowly as the story drew him deeper and deeper into its world",
|
||||||
|
"the stars shone brightly in the clear night sky and the moon cast a silver light over the sleeping town below",
|
||||||
|
"they gathered around the fire telling stories and laughing while the wind howled outside and the snow piled up against the door",
|
||||||
|
// Pride and Prejudice
|
||||||
|
"it is a truth universally acknowledged that a single man in possession of a good fortune must be in want of a wife",
|
||||||
|
"there is a stubbornness about me that never can bear to be frightened at the will of others my courage always rises at every attempt to intimidate me",
|
||||||
|
"i could easily forgive his pride if he had not mortified mine but vanity not love has been my folly",
|
||||||
|
// Alice in Wonderland
|
||||||
|
"alice was beginning to get very tired of sitting by her sister on the bank and of having nothing to do",
|
||||||
|
"who in the world am i that is the great puzzle she said as she looked around the strange room with wonder",
|
||||||
|
"but i dont want to go among mad people alice remarked oh you cant help that said the cat were all mad here",
|
||||||
|
// Great Gatsby
|
||||||
|
"in my younger and more vulnerable years my father gave me some advice that i have been turning over in my mind ever since",
|
||||||
|
"so we beat on boats against the current borne back ceaselessly into the past dreaming of that green light",
|
||||||
|
// Sherlock Holmes
|
||||||
|
"when you have eliminated the impossible whatever remains however improbable must be the truth my dear watson",
|
||||||
|
"the world is full of obvious things which nobody by any chance ever observes but which are perfectly visible",
|
||||||
|
// Moby Dick
|
||||||
|
"call me ishmael some years ago having little or no money in my purse and nothing particular to interest me on shore",
|
||||||
|
"it is not down on any map because true places never are and the voyage was long and the sea was deep",
|
||||||
|
// 1984
|
||||||
|
"it was a bright cold day in april and the clocks were striking thirteen winston smith his chin nuzzled into his breast",
|
||||||
|
"who controls the past controls the future and who controls the present controls the past said the voice from the screen",
|
||||||
|
// Walden
|
||||||
|
"i went to the woods because i wished to live deliberately to front only the essential facts of life",
|
||||||
|
"the mass of men lead lives of quiet desperation and go to the grave with the song still in them",
|
||||||
|
// Science & philosophy
|
||||||
|
"the only way to do great work is to love what you do and if you have not found it yet keep looking and do not settle",
|
||||||
|
"imagination is more important than knowledge for while knowledge defines all we currently know imagination points to what we might discover",
|
||||||
|
"the important thing is not to stop questioning for curiosity has its own reason for existing in this wonderful universe",
|
||||||
|
"we are all in the gutter but some of us are looking at the stars and dreaming of worlds beyond our own",
|
||||||
|
"the greatest glory in living lies not in never falling but in rising every time we fall and trying once more",
|
||||||
|
// Nature & observation
|
||||||
|
"the autumn wind scattered golden leaves across the garden as the last rays of sunlight painted the clouds in shades of orange and pink",
|
||||||
|
"deep in the forest where the ancient trees stood tall and silent a small stream wound its way through moss covered stones",
|
||||||
|
"the ocean stretched endlessly before them its surface catching the light of the setting sun in a thousand shimmering reflections",
|
||||||
|
"morning mist hung low over the meadow as the first birds began their chorus and dew drops sparkled like diamonds on every blade of grass",
|
||||||
|
"the mountain peak stood above the clouds its snow covered summit glowing pink and gold in the light of the early morning sun",
|
||||||
|
// Everyday wisdom
|
||||||
|
"the best time to plant a tree was twenty years ago and the second best time is now so do not wait any longer to begin",
|
||||||
|
"a journey of a thousand miles begins with a single step and every great achievement started with the decision to try",
|
||||||
|
"the more that you read the more things you will know and the more that you learn the more places you will go",
|
||||||
|
"in three words i can sum up everything i have learned about life it goes on and so must we with hope",
|
||||||
|
"happiness is not something ready made it comes from your own actions and your choices shape the life you live",
|
||||||
|
"do not go where the path may lead but go instead where there is no path and leave a trail for others to follow",
|
||||||
|
"success is not final failure is not fatal it is the courage to continue that counts in the end",
|
||||||
|
"be yourself because everyone else is already taken and the world needs what only you can bring to it",
|
||||||
|
"life is what happens when you are busy making other plans so enjoy the journey along the way",
|
||||||
|
"the secret of getting ahead is getting started and the secret of getting started is breaking your tasks into small steps",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gutenberg book IDs for popular public domain works
|
||||||
|
const GUTENBERG_IDS: &[(u32, &str)] = &[
|
||||||
|
(1342, "pride_and_prejudice"),
|
||||||
|
(11, "alice_in_wonderland"),
|
||||||
|
(1661, "sherlock_holmes"),
|
||||||
|
(84, "frankenstein"),
|
||||||
|
(1952, "yellow_wallpaper"),
|
||||||
|
(2701, "moby_dick"),
|
||||||
|
(74, "tom_sawyer"),
|
||||||
|
(345, "dracula"),
|
||||||
|
(1232, "prince"),
|
||||||
|
(76, "huckleberry_finn"),
|
||||||
|
(5200, "metamorphosis"),
|
||||||
|
(2542, "aesop_fables"),
|
||||||
|
(174, "dorian_gray"),
|
||||||
|
(98, "tale_two_cities"),
|
||||||
|
(1080, "modest_proposal"),
|
||||||
|
(219, "heart_of_darkness"),
|
||||||
|
(4300, "ulysses"),
|
||||||
|
(28054, "brothers_karamazov"),
|
||||||
|
(2554, "crime_and_punishment"),
|
||||||
|
(55, "oz"),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct PassageGenerator {
|
||||||
|
current_idx: usize,
|
||||||
|
fetched_passages: Vec<String>,
|
||||||
|
rng: SmallRng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PassageGenerator {
|
||||||
|
pub fn new(rng: SmallRng) -> Self {
|
||||||
|
let mut generator = Self {
|
||||||
|
current_idx: 0,
|
||||||
|
fetched_passages: Vec::new(),
|
||||||
|
rng,
|
||||||
|
};
|
||||||
|
generator.load_cached_passages();
|
||||||
|
generator
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_cached_passages(&mut self) {
|
||||||
|
if let Some(cache) = DiskCache::new("passages") {
|
||||||
|
for &(_, name) in GUTENBERG_IDS {
|
||||||
|
if let Some(content) = cache.get(name) {
|
||||||
|
let paragraphs = extract_paragraphs(&content);
|
||||||
|
self.fetched_passages.extend(paragraphs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_fetch_gutenberg(&mut self) {
|
||||||
|
let cache = match DiskCache::new("passages") {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pick a random book that we haven't cached yet
|
||||||
|
let uncached: Vec<(u32, &str)> = GUTENBERG_IDS
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, name)| cache.get(name).is_none())
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if uncached.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = self.rng.gen_range(0..uncached.len());
|
||||||
|
let (book_id, name) = uncached[idx];
|
||||||
|
let url = format!("https://www.gutenberg.org/cache/epub/{book_id}/pg{book_id}.txt");
|
||||||
|
|
||||||
|
if let Some(content) = fetch_url(&url) {
|
||||||
|
cache.put(name, &content);
|
||||||
|
let paragraphs = extract_paragraphs(&content);
|
||||||
|
self.fetched_passages.extend(paragraphs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for PassageGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
_filter: &CharFilter,
|
||||||
|
_focused: Option<char>,
|
||||||
|
_word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
// Try to fetch a new Gutenberg book in the background (first few calls)
|
||||||
|
if self.fetched_passages.len() < 50 && self.current_idx < 3 {
|
||||||
|
self.try_fetch_gutenberg();
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_passages = PASSAGES.len() + self.fetched_passages.len();
|
||||||
|
|
||||||
|
if total_passages == 0 {
|
||||||
|
self.current_idx += 1;
|
||||||
|
return PASSAGES[0].to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix embedded and fetched passages
|
||||||
|
let idx = self.current_idx % total_passages;
|
||||||
|
self.current_idx += 1;
|
||||||
|
|
||||||
|
if idx < PASSAGES.len() {
|
||||||
|
PASSAGES[idx].to_string()
|
||||||
|
} else {
|
||||||
|
let fetched_idx = idx - PASSAGES.len();
|
||||||
|
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract readable paragraphs from Gutenberg text, skipping header/footer
|
||||||
|
fn extract_paragraphs(text: &str) -> Vec<String> {
|
||||||
|
let mut paragraphs = Vec::new();
|
||||||
|
|
||||||
|
// Find the start of actual content (after Gutenberg header)
|
||||||
|
let start_markers = ["*** START OF", "***START OF"];
|
||||||
|
let end_markers = ["*** END OF", "***END OF"];
|
||||||
|
|
||||||
|
let content_start = start_markers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|marker| text.find(marker))
|
||||||
|
.min()
|
||||||
|
.map(|pos| {
|
||||||
|
// Find the end of the header line
|
||||||
|
text[pos..].find('\n').map(|nl| pos + nl + 1).unwrap_or(pos)
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let content_end = end_markers
|
||||||
|
.iter()
|
||||||
|
.filter_map(|marker| text.find(marker))
|
||||||
|
.min()
|
||||||
|
.unwrap_or(text.len());
|
||||||
|
|
||||||
|
let content = &text[content_start..content_end];
|
||||||
|
|
||||||
|
// Split into paragraphs (double newline separated)
|
||||||
|
for para in content.split("\r\n\r\n").chain(content.split("\n\n")) {
|
||||||
|
let cleaned: String = para
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
let word_count = cleaned.split_whitespace().count();
|
||||||
|
if word_count >= 15 && word_count <= 60 {
|
||||||
|
// Keep only the alpha/space portions for typing
|
||||||
|
let typing_text: String = cleaned
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_lowercase() || *c == ' ')
|
||||||
|
.collect::<String>()
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
if typing_text.split_whitespace().count() >= 10 {
|
||||||
|
paragraphs.push(typing_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take at most 100 paragraphs per book
|
||||||
|
paragraphs.truncate(100);
|
||||||
|
paragraphs
|
||||||
|
}
|
||||||
243
src/generator/phonetic.rs
Normal file
243
src/generator/phonetic.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
use rand::rngs::SmallRng;
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::engine::filter::CharFilter;
|
||||||
|
use crate::generator::dictionary::Dictionary;
|
||||||
|
use crate::generator::transition_table::TransitionTable;
|
||||||
|
use crate::generator::TextGenerator;
|
||||||
|
|
||||||
|
const MIN_WORD_LEN: usize = 3;
|
||||||
|
const MAX_WORD_LEN: usize = 10;
|
||||||
|
const MIN_REAL_WORDS: usize = 15;
|
||||||
|
|
||||||
|
pub struct PhoneticGenerator {
|
||||||
|
table: TransitionTable,
|
||||||
|
dictionary: Dictionary,
|
||||||
|
rng: SmallRng,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhoneticGenerator {
|
||||||
|
pub fn new(table: TransitionTable, dictionary: Dictionary, rng: SmallRng) -> Self {
|
||||||
|
Self {
|
||||||
|
table,
|
||||||
|
dictionary,
|
||||||
|
rng,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_weighted_from(
|
||||||
|
rng: &mut SmallRng,
|
||||||
|
options: &[(char, f64)],
|
||||||
|
filter: &CharFilter,
|
||||||
|
) -> Option<char> {
|
||||||
|
let filtered: Vec<(char, f64)> = options
|
||||||
|
.iter()
|
||||||
|
.filter(|(ch, _)| filter.is_allowed(*ch))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total: f64 = filtered.iter().map(|(_, w)| w).sum();
|
||||||
|
if total <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut roll = rng.gen_range(0.0..total);
|
||||||
|
for (ch, weight) in &filtered {
|
||||||
|
roll -= weight;
|
||||||
|
if roll <= 0.0 {
|
||||||
|
return Some(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(filtered.last().unwrap().0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_phonetic_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||||
|
for _attempt in 0..5 {
|
||||||
|
let word = self.try_generate_word(filter, focused);
|
||||||
|
if word.len() >= MIN_WORD_LEN {
|
||||||
|
return word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback
|
||||||
|
"the".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_generate_word(&mut self, filter: &CharFilter, focused: Option<char>) -> String {
|
||||||
|
let mut word = Vec::new();
|
||||||
|
|
||||||
|
// Start with space prefix
|
||||||
|
let start_char = if let Some(focus) = focused {
|
||||||
|
if self.rng.gen_bool(0.4) && filter.is_allowed(focus) {
|
||||||
|
word.push(focus);
|
||||||
|
// Get next char from transition table
|
||||||
|
let prefix = vec![' ', ' ', focus];
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
Self::pick_weighted_from(&mut self.rng, probs, filter)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if word.is_empty() {
|
||||||
|
// Pick a start from transition table
|
||||||
|
let prefix = vec![' ', ' ', ' '];
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, probs, filter) {
|
||||||
|
word.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: weighted random start
|
||||||
|
if word.is_empty() {
|
||||||
|
let starters: Vec<(char, f64)> = filter
|
||||||
|
.allowed
|
||||||
|
.iter()
|
||||||
|
.map(|&ch| {
|
||||||
|
let w = match ch {
|
||||||
|
'e' | 't' | 'a' => 3.0,
|
||||||
|
'o' | 'i' | 'n' | 's' => 2.0,
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
|
(ch, w)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(ch) = Self::pick_weighted_from(&mut self.rng, &starters, filter) {
|
||||||
|
word.push(ch);
|
||||||
|
} else {
|
||||||
|
return "the".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ch) = start_char {
|
||||||
|
word.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
while word.len() < MAX_WORD_LEN {
|
||||||
|
// Build prefix from recent chars, padded with spaces
|
||||||
|
let prefix_len = self.table.order - 1;
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
let start = if word.len() >= prefix_len {
|
||||||
|
word.len() - prefix_len
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
for _ in 0..(prefix_len.saturating_sub(word.len())) {
|
||||||
|
prefix.push(' ');
|
||||||
|
}
|
||||||
|
for i in start..word.len() {
|
||||||
|
prefix.push(word[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for word ending (space probability increases with length)
|
||||||
|
if word.len() >= MIN_WORD_LEN {
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
let space_weight: f64 = probs
|
||||||
|
.iter()
|
||||||
|
.filter(|(ch, _)| *ch == ' ')
|
||||||
|
.map(|(_, w)| w)
|
||||||
|
.sum();
|
||||||
|
if space_weight > 0.0 {
|
||||||
|
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
||||||
|
let total: f64 = probs.iter().map(|(_, w)| w).sum();
|
||||||
|
let space_prob = (space_weight * boost) / (total + space_weight * (boost - 1.0));
|
||||||
|
if self.rng.gen_bool(space_prob.min(0.85)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Even without space in table, use length-based ending
|
||||||
|
let end_prob = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
|
||||||
|
if self.rng.gen_bool((end_prob / (end_prob + 5.0)).min(0.8)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next character from transition table
|
||||||
|
if let Some(probs) = self.table.segment(&prefix) {
|
||||||
|
let non_space: Vec<(char, f64)> = probs
|
||||||
|
.iter()
|
||||||
|
.filter(|(ch, _)| *ch != ' ')
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
|
||||||
|
word.push(next);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to vowel
|
||||||
|
let vowels: Vec<(char, f64)> = ['a', 'e', 'i', 'o', 'u']
|
||||||
|
.iter()
|
||||||
|
.filter(|&&v| filter.is_allowed(v))
|
||||||
|
.map(|&v| (v, 1.0))
|
||||||
|
.collect();
|
||||||
|
if let Some(v) = Self::pick_weighted_from(&mut self.rng, &vowels, filter) {
|
||||||
|
word.push(v);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
word.iter().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextGenerator for PhoneticGenerator {
|
||||||
|
fn generate(
|
||||||
|
&mut self,
|
||||||
|
filter: &CharFilter,
|
||||||
|
focused: Option<char>,
|
||||||
|
word_count: usize,
|
||||||
|
) -> String {
|
||||||
|
// keybr's approach: prefer real words when enough match the filter
|
||||||
|
// Collect matching words into owned Vec to avoid borrow conflict
|
||||||
|
let matching_words: Vec<String> = self
|
||||||
|
.dictionary
|
||||||
|
.find_matching(filter, focused)
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
let use_real_words = matching_words.len() >= MIN_REAL_WORDS;
|
||||||
|
|
||||||
|
let mut words: Vec<String> = Vec::new();
|
||||||
|
let mut last_word = String::new();
|
||||||
|
|
||||||
|
for _ in 0..word_count {
|
||||||
|
if use_real_words {
|
||||||
|
// Pick a real word (avoid consecutive duplicates)
|
||||||
|
let mut picked = None;
|
||||||
|
for _ in 0..3 {
|
||||||
|
let idx = self.rng.gen_range(0..matching_words.len());
|
||||||
|
let word = matching_words[idx].clone();
|
||||||
|
if word != last_word {
|
||||||
|
picked = Some(word);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let word = match picked {
|
||||||
|
Some(w) => w,
|
||||||
|
None => self.generate_phonetic_word(filter, focused),
|
||||||
|
};
|
||||||
|
last_word.clone_from(&word);
|
||||||
|
words.push(word);
|
||||||
|
} else {
|
||||||
|
// Fall back to phonetic pseudo-words
|
||||||
|
let word = self.generate_phonetic_word(filter, focused);
|
||||||
|
words.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
words.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
170
src/generator/transition_table.rs
Normal file
170
src/generator/transition_table.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TransitionTable {
|
||||||
|
pub order: usize,
|
||||||
|
transitions: HashMap<Vec<char>, Vec<(char, f64)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TransitionTable {
|
||||||
|
pub fn new(order: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
order,
|
||||||
|
transitions: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add(&mut self, prefix: &[char], next: char, weight: f64) {
|
||||||
|
self.transitions
|
||||||
|
.entry(prefix.to_vec())
|
||||||
|
.or_default()
|
||||||
|
.push((next, weight));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn segment(&self, prefix: &[char]) -> Option<&Vec<(char, f64)>> {
|
||||||
|
// Try exact prefix match first, then fall back to shorter prefixes
|
||||||
|
let key_len = self.order - 1;
|
||||||
|
let prefix = if prefix.len() >= key_len {
|
||||||
|
&prefix[prefix.len() - key_len..]
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try progressively shorter prefixes for backoff
|
||||||
|
for start in 0..prefix.len() {
|
||||||
|
let key = prefix[start..].to_vec();
|
||||||
|
if let Some(entries) = self.transitions.get(&key) {
|
||||||
|
return Some(entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an order-4 transition table from a word frequency list.
|
||||||
|
/// Words earlier in the list are higher frequency and get more weight.
|
||||||
|
pub fn build_from_words(words: &[String]) -> Self {
|
||||||
|
let mut table = Self::new(4);
|
||||||
|
let prefix_len = 3; // order - 1
|
||||||
|
|
||||||
|
for (rank, word) in words.iter().enumerate() {
|
||||||
|
if word.len() < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !word.chars().all(|c| c.is_ascii_lowercase()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight decreases with rank (frequency-based)
|
||||||
|
let weight = 1.0 / (1.0 + (rank as f64 / 500.0));
|
||||||
|
|
||||||
|
// Add word start transitions (space prefix -> first chars)
|
||||||
|
let chars: Vec<char> = word.chars().collect();
|
||||||
|
|
||||||
|
// Start of word: ' ' prefix
|
||||||
|
for i in 0..chars.len() {
|
||||||
|
let mut prefix = Vec::new();
|
||||||
|
// Build prefix from space + preceding chars
|
||||||
|
let start = if i >= prefix_len { i - prefix_len } else { 0 };
|
||||||
|
if i < prefix_len {
|
||||||
|
// Pad with spaces
|
||||||
|
for _ in 0..(prefix_len - i) {
|
||||||
|
prefix.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j in start..i {
|
||||||
|
prefix.push(chars[j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = chars[i];
|
||||||
|
table.add(&prefix, next, weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of word: last chars -> space
|
||||||
|
let end_start = if chars.len() >= prefix_len {
|
||||||
|
chars.len() - prefix_len
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let mut end_prefix: Vec<char> = Vec::new();
|
||||||
|
if chars.len() < prefix_len {
|
||||||
|
for _ in 0..(prefix_len - chars.len()) {
|
||||||
|
end_prefix.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j in end_start..chars.len() {
|
||||||
|
end_prefix.push(chars[j]);
|
||||||
|
}
|
||||||
|
table.add(&end_prefix, ' ', weight);
|
||||||
|
}
|
||||||
|
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy order-2 table for fallback
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn build_english() -> Self {
|
||||||
|
let mut table = Self::new(4);
|
||||||
|
|
||||||
|
let common_patterns: &[(&str, f64)] = &[
|
||||||
|
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
|
||||||
|
("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0),
|
||||||
|
("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0),
|
||||||
|
("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0),
|
||||||
|
("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0),
|
||||||
|
("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5),
|
||||||
|
("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5),
|
||||||
|
("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0),
|
||||||
|
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
|
||||||
|
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
|
||||||
|
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
|
||||||
|
("out", 2.0), ("ind", 2.0), ("ber", 2.0), ("der", 2.0),
|
||||||
|
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
|
||||||
|
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
|
||||||
|
("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
|
||||||
|
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
|
||||||
|
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
|
||||||
|
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
for &(pattern, weight) in common_patterns {
|
||||||
|
let chars: Vec<char> = pattern.chars().collect();
|
||||||
|
for window in chars.windows(3) {
|
||||||
|
let prefix = vec![window[0], window[1]];
|
||||||
|
table.add(&prefix, window[2], weight);
|
||||||
|
}
|
||||||
|
// Also add shorter prefix entries for the start of patterns
|
||||||
|
if chars.len() >= 2 {
|
||||||
|
table.add(&[' ', chars[0]], chars[1], weight * 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||||
|
let consonants = [
|
||||||
|
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v',
|
||||||
|
'w', 'x', 'y', 'z',
|
||||||
|
];
|
||||||
|
|
||||||
|
for &c in &consonants {
|
||||||
|
for &v in &vowels {
|
||||||
|
table.add(&[' ', c], v, 1.0);
|
||||||
|
table.add(&[v, c], 'e', 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for &v in &vowels {
|
||||||
|
for &c in &consonants {
|
||||||
|
table.add(&[' ', v], c, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TransitionTable {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/keyboard/finger.rs
Normal file
50
src/keyboard/finger.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Hand {
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Finger {
|
||||||
|
Pinky,
|
||||||
|
Ring,
|
||||||
|
Middle,
|
||||||
|
Index,
|
||||||
|
Thumb,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct FingerAssignment {
|
||||||
|
pub hand: Hand,
|
||||||
|
pub finger: Finger,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FingerAssignment {
|
||||||
|
pub fn new(hand: Hand, finger: Finger) -> Self {
|
||||||
|
Self { hand, finger }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn qwerty_finger(ch: char) -> FingerAssignment {
|
||||||
|
use Finger::*;
|
||||||
|
use Hand::*;
|
||||||
|
|
||||||
|
match ch {
|
||||||
|
'q' | 'a' | 'z' | '1' => FingerAssignment::new(Left, Pinky),
|
||||||
|
'w' | 's' | 'x' | '2' => FingerAssignment::new(Left, Ring),
|
||||||
|
'e' | 'd' | 'c' | '3' => FingerAssignment::new(Left, Middle),
|
||||||
|
'r' | 'f' | 'v' | 't' | 'g' | 'b' | '4' | '5' => FingerAssignment::new(Left, Index),
|
||||||
|
'y' | 'h' | 'n' | 'u' | 'j' | 'm' | '6' | '7' => FingerAssignment::new(Right, Index),
|
||||||
|
'i' | 'k' | ',' | '8' => FingerAssignment::new(Right, Middle),
|
||||||
|
'o' | 'l' | '.' | '9' => FingerAssignment::new(Right, Ring),
|
||||||
|
'p' | ';' | '/' | '0' | '-' | '=' | '[' | ']' | '\'' | '\\' => {
|
||||||
|
FingerAssignment::new(Right, Pinky)
|
||||||
|
}
|
||||||
|
' ' => FingerAssignment::new(Right, Thumb),
|
||||||
|
_ => FingerAssignment::new(Right, Index),
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/keyboard/layout.rs
Normal file
51
src/keyboard/layout.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyboardLayout {
|
||||||
|
pub name: String,
|
||||||
|
pub rows: Vec<Vec<char>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyboardLayout {
|
||||||
|
pub fn qwerty() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "QWERTY".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||||
|
vec!['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||||
|
vec!['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn dvorak() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Dvorak".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['\'', ',', '.', 'p', 'y', 'f', 'g', 'c', 'r', 'l'],
|
||||||
|
vec!['a', 'o', 'e', 'u', 'i', 'd', 'h', 't', 'n', 's'],
|
||||||
|
vec![';', 'q', 'j', 'k', 'x', 'b', 'm', 'w', 'v', 'z'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn colemak() -> Self {
|
||||||
|
Self {
|
||||||
|
name: "Colemak".to_string(),
|
||||||
|
rows: vec![
|
||||||
|
vec!['q', 'w', 'f', 'p', 'g', 'j', 'l', 'u', 'y'],
|
||||||
|
vec!['a', 'r', 's', 't', 'd', 'h', 'n', 'e', 'i', 'o'],
|
||||||
|
vec!['z', 'x', 'c', 'v', 'b', 'k', 'm'],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyboardLayout {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::qwerty()
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/keyboard/mod.rs
Normal file
2
src/keyboard/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod finger;
|
||||||
|
pub mod layout;
|
||||||
485
src/main.rs
485
src/main.rs
@@ -1,3 +1,484 @@
|
|||||||
fn main() {
|
mod app;
|
||||||
println!("Hello, world!");
|
mod config;
|
||||||
|
mod engine;
|
||||||
|
mod event;
|
||||||
|
mod generator;
|
||||||
|
mod keyboard;
|
||||||
|
mod session;
|
||||||
|
mod store;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
|
||||||
|
use app::{App, AppScreen, LessonMode};
|
||||||
|
use session::result::LessonResult;
|
||||||
|
use event::{AppEvent, EventHandler};
|
||||||
|
use ui::components::dashboard::Dashboard;
|
||||||
|
use ui::components::keyboard_diagram::KeyboardDiagram;
|
||||||
|
use ui::components::progress_bar::ProgressBar;
|
||||||
|
use ui::components::stats_dashboard::StatsDashboard;
|
||||||
|
use ui::components::stats_sidebar::StatsSidebar;
|
||||||
|
use ui::components::typing_area::TypingArea;
|
||||||
|
use ui::layout::AppLayout;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "keydr", version, about = "Terminal typing tutor with adaptive learning")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short, long, help = "Theme name")]
|
||||||
|
theme: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
|
||||||
|
layout: Option<String>,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Number of words per lesson")]
|
||||||
|
words: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
|
||||||
|
if let Some(words) = cli.words {
|
||||||
|
app.config.word_count = words;
|
||||||
|
}
|
||||||
|
if let Some(theme_name) = cli.theme {
|
||||||
|
if let Some(theme) = ui::theme::Theme::load(&theme_name) {
|
||||||
|
let theme: &'static ui::theme::Theme = Box::leak(Box::new(theme));
|
||||||
|
app.theme = theme;
|
||||||
|
app.menu.theme = theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let events = EventHandler::new(Duration::from_millis(100));
|
||||||
|
|
||||||
|
let result = run_app(&mut terminal, &mut app, &events);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = result {
|
||||||
|
eprintln!("Error: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_app(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &mut App,
|
||||||
|
events: &EventHandler,
|
||||||
|
) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| render(frame, app))?;
|
||||||
|
|
||||||
|
match events.next()? {
|
||||||
|
AppEvent::Key(key) => handle_key(app, key),
|
||||||
|
AppEvent::Tick => {}
|
||||||
|
AppEvent::Resize(_, _) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.should_quit {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(app: &mut App, key: KeyEvent) {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||||
|
app.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Menu => handle_menu_key(app, key),
|
||||||
|
AppScreen::Lesson => handle_lesson_key(app, key),
|
||||||
|
AppScreen::LessonResult => handle_result_key(app, key),
|
||||||
|
AppScreen::StatsDashboard => handle_stats_key(app, key),
|
||||||
|
AppScreen::Settings => handle_settings_key(app, key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
|
||||||
|
KeyCode::Char('1') => {
|
||||||
|
app.lesson_mode = LessonMode::Adaptive;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('2') => {
|
||||||
|
app.lesson_mode = LessonMode::Code;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
app.lesson_mode = LessonMode::Passage;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
KeyCode::Char('s') => app.go_to_stats(),
|
||||||
|
KeyCode::Char('c') => app.go_to_settings(),
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => app.menu.next(),
|
||||||
|
KeyCode::Enter => match app.menu.selected {
|
||||||
|
0 => {
|
||||||
|
app.lesson_mode = LessonMode::Adaptive;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
app.lesson_mode = LessonMode::Code;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
app.lesson_mode = LessonMode::Passage;
|
||||||
|
app.start_lesson();
|
||||||
|
}
|
||||||
|
3 => app.go_to_stats(),
|
||||||
|
4 => app.go_to_settings(),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_lesson_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0);
|
||||||
|
if has_progress {
|
||||||
|
if let Some(ref lesson) = app.lesson {
|
||||||
|
let result = LessonResult::from_lesson(lesson, &app.lesson_events);
|
||||||
|
app.last_result = Some(result);
|
||||||
|
}
|
||||||
|
app.screen = AppScreen::LessonResult;
|
||||||
|
} else {
|
||||||
|
app.go_to_menu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => app.backspace(),
|
||||||
|
KeyCode::Char(ch) => app.type_char(ch),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('r') => app.retry_lesson(),
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(),
|
||||||
|
KeyCode::Char('s') => app.go_to_stats(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||||
|
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0,
|
||||||
|
KeyCode::Char('h') | KeyCode::Char('2') => app.stats_tab = 1,
|
||||||
|
KeyCode::Char('k') | KeyCode::Char('3') => app.stats_tab = 2,
|
||||||
|
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
|
||||||
|
KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 },
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
let _ = app.config.save();
|
||||||
|
app.go_to_menu();
|
||||||
|
}
|
||||||
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
|
if app.settings_selected > 0 {
|
||||||
|
app.settings_selected -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
|
if app.settings_selected < 3 {
|
||||||
|
app.settings_selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => {
|
||||||
|
app.settings_cycle_forward();
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('h') => {
|
||||||
|
app.settings_cycle_backward();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let bg = Block::default().style(Style::default().bg(colors.bg()));
|
||||||
|
frame.render_widget(bg, area);
|
||||||
|
|
||||||
|
match app.screen {
|
||||||
|
AppScreen::Menu => render_menu(frame, app),
|
||||||
|
AppScreen::Lesson => render_lesson(frame, app),
|
||||||
|
AppScreen::LessonResult => render_result(frame, app),
|
||||||
|
AppScreen::StatsDashboard => render_stats(frame, app),
|
||||||
|
AppScreen::Settings => render_settings(frame, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let streak_text = if app.profile.streak_days > 0 {
|
||||||
|
format!(" | {} day streak", app.profile.streak_days)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let header_info = format!(
|
||||||
|
" Level {} | Score {:.0} | {}/{} letters{}",
|
||||||
|
crate::engine::scoring::level_from_score(app.profile.total_score),
|
||||||
|
app.profile.total_score,
|
||||||
|
app.letter_unlock.unlocked_count(),
|
||||||
|
app.letter_unlock.total_letters(),
|
||||||
|
streak_text,
|
||||||
|
);
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" keydr ",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.header_fg())
|
||||||
|
.bg(colors.header_bg())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
&*header_info,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.text_pending())
|
||||||
|
.bg(colors.header_bg()),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
|
frame.render_widget(header, layout[0]);
|
||||||
|
|
||||||
|
let menu_area = ui::layout::centered_rect(50, 80, layout[1]);
|
||||||
|
frame.render_widget(&app.menu, menu_area);
|
||||||
|
|
||||||
|
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||||
|
" [1-3] Start [s] Stats [q] Quit ",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)]));
|
||||||
|
frame.render_widget(footer, layout[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
if let Some(ref lesson) = app.lesson {
|
||||||
|
let app_layout = AppLayout::new(area);
|
||||||
|
|
||||||
|
let mode_name = match app.lesson_mode {
|
||||||
|
LessonMode::Adaptive => "Adaptive",
|
||||||
|
LessonMode::Code => "Code",
|
||||||
|
LessonMode::Passage => "Passage",
|
||||||
|
};
|
||||||
|
let header_title = format!(" {mode_name} Practice ");
|
||||||
|
let focus_text = if let Some(focused) = app.letter_unlock.focused {
|
||||||
|
format!(" | Focus: '{focused}'")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let header = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
&*header_title,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.header_fg())
|
||||||
|
.bg(colors.header_bg())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
&*focus_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.bg(colors.header_bg()),
|
||||||
|
),
|
||||||
|
]))
|
||||||
|
.style(Style::default().bg(colors.header_bg()));
|
||||||
|
frame.render_widget(header, app_layout.header);
|
||||||
|
|
||||||
|
let main_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(5),
|
||||||
|
])
|
||||||
|
.split(app_layout.main);
|
||||||
|
|
||||||
|
let typing = TypingArea::new(lesson, app.theme);
|
||||||
|
frame.render_widget(typing, main_layout[0]);
|
||||||
|
|
||||||
|
let progress = ProgressBar::new(
|
||||||
|
"Letter Progress",
|
||||||
|
app.letter_unlock.progress(),
|
||||||
|
app.theme,
|
||||||
|
);
|
||||||
|
frame.render_widget(progress, main_layout[1]);
|
||||||
|
|
||||||
|
let next_char = lesson
|
||||||
|
.target
|
||||||
|
.get(lesson.cursor)
|
||||||
|
.copied();
|
||||||
|
let kbd = KeyboardDiagram::new(
|
||||||
|
app.letter_unlock.focused,
|
||||||
|
next_char,
|
||||||
|
&app.letter_unlock.included,
|
||||||
|
app.theme,
|
||||||
|
);
|
||||||
|
frame.render_widget(kbd, main_layout[2]);
|
||||||
|
|
||||||
|
let sidebar = StatsSidebar::new(lesson, app.theme);
|
||||||
|
frame.render_widget(sidebar, app_layout.sidebar);
|
||||||
|
|
||||||
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" [ESC] End lesson [Backspace] Delete ",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
frame.render_widget(footer, app_layout.footer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_result(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
|
||||||
|
if let Some(ref result) = app.last_result {
|
||||||
|
let centered = ui::layout::centered_rect(60, 70, area);
|
||||||
|
let dashboard = Dashboard::new(result, app.theme);
|
||||||
|
frame.render_widget(dashboard, centered);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let dashboard = StatsDashboard::new(
|
||||||
|
&app.lesson_history,
|
||||||
|
&app.key_stats,
|
||||||
|
app.stats_tab,
|
||||||
|
app.config.target_wpm,
|
||||||
|
app.theme,
|
||||||
|
);
|
||||||
|
frame.render_widget(dashboard, area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
let colors = &app.theme.colors;
|
||||||
|
|
||||||
|
let centered = ui::layout::centered_rect(60, 80, area);
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Settings ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(centered);
|
||||||
|
block.render(centered, frame.buffer_mut());
|
||||||
|
|
||||||
|
let available_themes = ui::theme::Theme::available_themes();
|
||||||
|
let languages_all = ["rust", "python", "javascript", "go"];
|
||||||
|
let current_lang = app
|
||||||
|
.config
|
||||||
|
.code_languages
|
||||||
|
.first()
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("rust");
|
||||||
|
|
||||||
|
let fields: Vec<(String, String)> = vec![
|
||||||
|
("Target WPM".to_string(), format!("{}", app.config.target_wpm)),
|
||||||
|
("Theme".to_string(), app.config.theme.clone()),
|
||||||
|
("Word Count".to_string(), format!("{}", app.config.word_count)),
|
||||||
|
("Code Language".to_string(), current_lang.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(fields.len() as u16 * 3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let header = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" Use arrows to navigate, Enter/Right to change, ESC to save & exit",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
header.render(layout[0], frame.buffer_mut());
|
||||||
|
|
||||||
|
let field_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(fields.iter().map(|_| Constraint::Length(3)).collect::<Vec<_>>())
|
||||||
|
.split(layout[1]);
|
||||||
|
|
||||||
|
for (i, (label, value)) in fields.iter().enumerate() {
|
||||||
|
let is_selected = i == app.settings_selected;
|
||||||
|
let indicator = if is_selected { " > " } else { " " };
|
||||||
|
|
||||||
|
let label_text = format!("{indicator}{label}:");
|
||||||
|
let value_text = format!(" < {value} >");
|
||||||
|
|
||||||
|
let label_style = Style::default().fg(if is_selected {
|
||||||
|
colors.accent()
|
||||||
|
} else {
|
||||||
|
colors.fg()
|
||||||
|
}).add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() });
|
||||||
|
|
||||||
|
let value_style = Style::default().fg(if is_selected {
|
||||||
|
colors.focused_key()
|
||||||
|
} else {
|
||||||
|
colors.text_pending()
|
||||||
|
});
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(label_text, label_style)),
|
||||||
|
Line::from(Span::styled(value_text, value_style)),
|
||||||
|
];
|
||||||
|
Paragraph::new(lines).render(field_layout[i], frame.buffer_mut());
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = (available_themes, languages_all);
|
||||||
|
|
||||||
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" [ESC] Save & back [Enter/arrows] Change value",
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
)));
|
||||||
|
footer.render(layout[3], frame.buffer_mut());
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/session/input.rs
Normal file
58
src/session/input.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub enum CharStatus {
|
||||||
|
Correct,
|
||||||
|
Incorrect(char),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct KeystrokeEvent {
|
||||||
|
pub expected: char,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub actual: char,
|
||||||
|
pub timestamp: Instant,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> {
|
||||||
|
if lesson.is_complete() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if lesson.started_at.is_none() {
|
||||||
|
lesson.started_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = lesson.target[lesson.cursor];
|
||||||
|
let correct = ch == expected;
|
||||||
|
|
||||||
|
let event = KeystrokeEvent {
|
||||||
|
expected,
|
||||||
|
actual: ch,
|
||||||
|
timestamp: Instant::now(),
|
||||||
|
correct,
|
||||||
|
};
|
||||||
|
|
||||||
|
if correct {
|
||||||
|
lesson.input.push(CharStatus::Correct);
|
||||||
|
} else {
|
||||||
|
lesson.input.push(CharStatus::Incorrect(ch));
|
||||||
|
}
|
||||||
|
lesson.cursor += 1;
|
||||||
|
|
||||||
|
if lesson.is_complete() {
|
||||||
|
lesson.finished_at = Some(Instant::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_backspace(lesson: &mut LessonState) {
|
||||||
|
if lesson.cursor > 0 {
|
||||||
|
lesson.cursor -= 1;
|
||||||
|
lesson.input.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/session/lesson.rs
Normal file
108
src/session/lesson.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::session::input::CharStatus;
|
||||||
|
|
||||||
|
pub struct LessonState {
|
||||||
|
pub target: Vec<char>,
|
||||||
|
pub input: Vec<CharStatus>,
|
||||||
|
pub cursor: usize,
|
||||||
|
pub started_at: Option<Instant>,
|
||||||
|
pub finished_at: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LessonState {
|
||||||
|
pub fn new(text: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
target: text.chars().collect(),
|
||||||
|
input: Vec::new(),
|
||||||
|
cursor: 0,
|
||||||
|
started_at: None,
|
||||||
|
finished_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.cursor >= self.target.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed_secs(&self) -> f64 {
|
||||||
|
match (self.started_at, self.finished_at) {
|
||||||
|
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
|
||||||
|
(Some(start), None) => start.elapsed().as_secs_f64(),
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn correct_count(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s, CharStatus::Correct))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn incorrect_count(&self) -> usize {
|
||||||
|
self.input
|
||||||
|
.iter()
|
||||||
|
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wpm(&self) -> f64 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let chars = self.correct_count() as f64;
|
||||||
|
(chars / 5.0) / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn accuracy(&self) -> f64 {
|
||||||
|
let total = self.input.len();
|
||||||
|
if total == 0 {
|
||||||
|
return 100.0;
|
||||||
|
}
|
||||||
|
(self.correct_count() as f64 / total as f64) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cpm(&self) -> f64 {
|
||||||
|
let elapsed = self.elapsed_secs();
|
||||||
|
if elapsed < 0.1 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.correct_count() as f64 / (elapsed / 60.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> f64 {
|
||||||
|
if self.target.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
self.cursor as f64 / self.target.len() as f64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new_lesson() {
|
||||||
|
let lesson = LessonState::new("hello");
|
||||||
|
assert_eq!(lesson.target.len(), 5);
|
||||||
|
assert_eq!(lesson.cursor, 0);
|
||||||
|
assert!(!lesson.is_complete());
|
||||||
|
assert_eq!(lesson.progress(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accuracy_starts_at_100() {
|
||||||
|
let lesson = LessonState::new("test");
|
||||||
|
assert_eq!(lesson.accuracy(), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_empty_lesson_progress() {
|
||||||
|
let lesson = LessonState::new("");
|
||||||
|
assert!(lesson.is_complete());
|
||||||
|
assert_eq!(lesson.progress(), 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/session/mod.rs
Normal file
3
src/session/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod input;
|
||||||
|
pub mod lesson;
|
||||||
|
pub mod result;
|
||||||
53
src/session/result.rs
Normal file
53
src/session/result.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::session::input::KeystrokeEvent;
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LessonResult {
|
||||||
|
pub wpm: f64,
|
||||||
|
pub cpm: f64,
|
||||||
|
pub accuracy: f64,
|
||||||
|
pub correct: usize,
|
||||||
|
pub incorrect: usize,
|
||||||
|
pub total_chars: usize,
|
||||||
|
pub elapsed_secs: f64,
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub per_key_times: Vec<KeyTime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyTime {
|
||||||
|
pub key: char,
|
||||||
|
pub time_ms: f64,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LessonResult {
|
||||||
|
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
|
||||||
|
let per_key_times: Vec<KeyTime> = events
|
||||||
|
.windows(2)
|
||||||
|
.map(|pair| {
|
||||||
|
let dt = pair[1].timestamp.duration_since(pair[0].timestamp);
|
||||||
|
KeyTime {
|
||||||
|
key: pair[1].expected,
|
||||||
|
time_ms: dt.as_secs_f64() * 1000.0,
|
||||||
|
correct: pair[1].correct,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
wpm: lesson.wpm(),
|
||||||
|
cpm: lesson.cpm(),
|
||||||
|
accuracy: lesson.accuracy(),
|
||||||
|
correct: lesson.correct_count(),
|
||||||
|
incorrect: lesson.incorrect_count(),
|
||||||
|
total_chars: lesson.target.len(),
|
||||||
|
elapsed_secs: lesson.elapsed_secs(),
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
per_key_times,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/store/json_store.rs
Normal file
75
src/store/json_store.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{de::DeserializeOwned, Serialize};
|
||||||
|
|
||||||
|
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData};
|
||||||
|
|
||||||
|
pub struct JsonStore {
|
||||||
|
base_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonStore {
|
||||||
|
pub fn new() -> Result<Self> {
|
||||||
|
let base_dir = dirs::data_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join("keydr");
|
||||||
|
fs::create_dir_all(&base_dir)?;
|
||||||
|
Ok(Self { base_dir })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_path(&self, name: &str) -> PathBuf {
|
||||||
|
self.base_dir.join(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load<T: DeserializeOwned + Default>(&self, name: &str) -> T {
|
||||||
|
let path = self.file_path(name);
|
||||||
|
if path.exists() {
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => T::default(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save<T: Serialize>(&self, name: &str, data: &T) -> Result<()> {
|
||||||
|
let path = self.file_path(name);
|
||||||
|
let tmp_path = path.with_extension("tmp");
|
||||||
|
|
||||||
|
let json = serde_json::to_string_pretty(data)?;
|
||||||
|
let mut file = fs::File::create(&tmp_path)?;
|
||||||
|
file.write_all(json.as_bytes())?;
|
||||||
|
file.sync_all()?;
|
||||||
|
|
||||||
|
fs::rename(&tmp_path, &path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_profile(&self) -> ProfileData {
|
||||||
|
self.load("profile.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {
|
||||||
|
self.save("profile.json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_key_stats(&self) -> KeyStatsData {
|
||||||
|
self.load("key_stats.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_key_stats(&self, data: &KeyStatsData) -> Result<()> {
|
||||||
|
self.save("key_stats.json", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_lesson_history(&self) -> LessonHistoryData {
|
||||||
|
self.load("lesson_history.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> {
|
||||||
|
self.save("lesson_history.json", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/store/mod.rs
Normal file
2
src/store/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod json_store;
|
||||||
|
pub mod schema;
|
||||||
61
src/store/schema.rs
Normal file
61
src/store/schema.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
|
||||||
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub unlocked_letters: Vec<char>,
|
||||||
|
pub total_score: f64,
|
||||||
|
pub total_lessons: u32,
|
||||||
|
pub streak_days: u32,
|
||||||
|
pub best_streak: u32,
|
||||||
|
pub last_practice_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProfileData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
unlocked_letters: Vec::new(),
|
||||||
|
total_score: 0.0,
|
||||||
|
total_lessons: 0,
|
||||||
|
streak_days: 0,
|
||||||
|
best_streak: 0,
|
||||||
|
last_practice_date: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct KeyStatsData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub stats: KeyStatsStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for KeyStatsData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
stats: KeyStatsStore::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LessonHistoryData {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub lessons: Vec<LessonResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LessonHistoryData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
lessons: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/ui/components/chart.rs
Normal file
67
src/ui/components/chart.rs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::symbols;
|
||||||
|
use ratatui::widgets::{Axis, Block, Chart, Dataset, GraphType, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct WpmChart<'a> {
|
||||||
|
pub data: &'a [(f64, f64)],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> WpmChart<'a> {
|
||||||
|
pub fn new(data: &'a [(f64, f64)], theme: &'a Theme) -> Self {
|
||||||
|
Self { data, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for WpmChart<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
if self.data.is_empty() {
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" WPM Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
block.render(area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_x = self.data.last().map(|(x, _)| *x).unwrap_or(1.0);
|
||||||
|
let max_y = self
|
||||||
|
.data
|
||||||
|
.iter()
|
||||||
|
.map(|(_, y)| *y)
|
||||||
|
.fold(0.0f64, f64::max)
|
||||||
|
.max(10.0);
|
||||||
|
|
||||||
|
let dataset = Dataset::default()
|
||||||
|
.marker(symbols::Marker::Braille)
|
||||||
|
.graph_type(GraphType::Line)
|
||||||
|
.style(Style::default().fg(colors.accent()))
|
||||||
|
.data(self.data);
|
||||||
|
|
||||||
|
let chart = Chart::new(vec![dataset])
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(" WPM Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border())),
|
||||||
|
)
|
||||||
|
.x_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("Lesson")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([0.0, max_x]),
|
||||||
|
)
|
||||||
|
.y_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("WPM")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([0.0, max_y * 1.1]),
|
||||||
|
);
|
||||||
|
|
||||||
|
chart.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/ui/components/dashboard.rs
Normal file
118
src/ui/components/dashboard.rs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct Dashboard<'a> {
|
||||||
|
pub result: &'a LessonResult,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Dashboard<'a> {
|
||||||
|
pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self {
|
||||||
|
Self { result, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Dashboard<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Lesson Complete ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(0),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let title = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"Results",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
|
let wpm_text = format!("{:.0} WPM", self.result.wpm);
|
||||||
|
let cpm_text = format!(" ({:.0} CPM)", self.result.cpm);
|
||||||
|
let wpm_line = Line::from(vec![
|
||||||
|
Span::styled(" Speed: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*wpm_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(&*cpm_text, Style::default().fg(colors.text_pending())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(wpm_line).render(layout[1], buf);
|
||||||
|
|
||||||
|
let acc_color = if self.result.accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if self.result.accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
};
|
||||||
|
let acc_text = format!("{:.1}%", self.result.accuracy);
|
||||||
|
let acc_detail = format!(
|
||||||
|
" ({}/{} correct)",
|
||||||
|
self.result.correct, self.result.total_chars
|
||||||
|
);
|
||||||
|
let acc_line = Line::from(vec![
|
||||||
|
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*acc_text,
|
||||||
|
Style::default().fg(acc_color).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(&*acc_detail, Style::default().fg(colors.text_pending())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(acc_line).render(layout[2], buf);
|
||||||
|
|
||||||
|
let time_text = format!("{:.1}s", self.result.elapsed_secs);
|
||||||
|
let time_line = Line::from(vec![
|
||||||
|
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*time_text, Style::default().fg(colors.fg())),
|
||||||
|
]);
|
||||||
|
Paragraph::new(time_line).render(layout[3], buf);
|
||||||
|
|
||||||
|
let error_text = format!("{}", self.result.incorrect);
|
||||||
|
let chars_line = Line::from(vec![
|
||||||
|
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*error_text,
|
||||||
|
Style::default().fg(if self.result.incorrect == 0 {
|
||||||
|
colors.success()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
Paragraph::new(chars_line).render(layout[4], buf);
|
||||||
|
|
||||||
|
let help = Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(" [r] Retry ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[q] Menu ", Style::default().fg(colors.accent())),
|
||||||
|
Span::styled("[s] Stats", Style::default().fg(colors.accent())),
|
||||||
|
]));
|
||||||
|
help.render(layout[6], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/ui/components/keyboard_diagram.rs
Normal file
112
src/ui/components/keyboard_diagram.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Color, Style};
|
||||||
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
|
use crate::keyboard::finger::{self, Finger, Hand};
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct KeyboardDiagram<'a> {
|
||||||
|
pub focused_key: Option<char>,
|
||||||
|
pub next_key: Option<char>,
|
||||||
|
pub unlocked_keys: &'a [char],
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> KeyboardDiagram<'a> {
|
||||||
|
pub fn new(
|
||||||
|
focused_key: Option<char>,
|
||||||
|
next_key: Option<char>,
|
||||||
|
unlocked_keys: &'a [char],
|
||||||
|
theme: &'a Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
focused_key,
|
||||||
|
next_key,
|
||||||
|
unlocked_keys,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: &[&[char]] = &[
|
||||||
|
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||||
|
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||||
|
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
];
|
||||||
|
|
||||||
|
fn finger_color(ch: char) -> Color {
|
||||||
|
let assignment = finger::qwerty_finger(ch);
|
||||||
|
match (assignment.hand, assignment.finger) {
|
||||||
|
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||||
|
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||||
|
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||||
|
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
|
||||||
|
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
|
||||||
|
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||||
|
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||||
|
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||||
|
_ => Color::Rgb(120, 120, 120),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for KeyboardDiagram<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Keyboard ")
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 3 || inner.width < 30 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key_width: u16 = 5;
|
||||||
|
let offsets: &[u16] = &[1, 3, 5];
|
||||||
|
|
||||||
|
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||||
|
let y = inner.y + row_idx as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
for (col_idx, &key) in row.iter().enumerate() {
|
||||||
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
|
if x + key_width > inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||||
|
let is_focused = self.focused_key == Some(key);
|
||||||
|
let is_next = self.next_key == Some(key);
|
||||||
|
|
||||||
|
let style = if is_next {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.bg())
|
||||||
|
.bg(colors.accent())
|
||||||
|
} else if is_focused {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.bg())
|
||||||
|
.bg(colors.focused_key())
|
||||||
|
} else if is_unlocked {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.fg())
|
||||||
|
.bg(finger_color(key))
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.text_pending())
|
||||||
|
.bg(colors.bg())
|
||||||
|
};
|
||||||
|
|
||||||
|
let display = format!("[ {key} ]");
|
||||||
|
buf.set_string(x, y, &display, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/ui/components/menu.rs
Normal file
150
src/ui/components/menu.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct MenuItem {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Menu<'a> {
|
||||||
|
pub items: Vec<MenuItem>,
|
||||||
|
pub selected: usize,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Menu<'a> {
|
||||||
|
pub fn new(theme: &'a Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
items: vec![
|
||||||
|
MenuItem {
|
||||||
|
key: "1".to_string(),
|
||||||
|
label: "Adaptive Practice".to_string(),
|
||||||
|
description: "Phonetic words with adaptive letter unlocking".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "2".to_string(),
|
||||||
|
label: "Code Practice".to_string(),
|
||||||
|
description: "Practice typing code syntax".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "3".to_string(),
|
||||||
|
label: "Passage Mode".to_string(),
|
||||||
|
description: "Type passages from books".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "s".to_string(),
|
||||||
|
label: "Statistics".to_string(),
|
||||||
|
description: "View your typing statistics".to_string(),
|
||||||
|
},
|
||||||
|
MenuItem {
|
||||||
|
key: "c".to_string(),
|
||||||
|
label: "Settings".to_string(),
|
||||||
|
description: "Configure keydr".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selected: 0,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(&mut self) {
|
||||||
|
self.selected = (self.selected + 1) % self.items.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(&mut self) {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
} else {
|
||||||
|
self.selected = self.items.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for &Menu<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(0),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
let title_lines = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"keydr",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Terminal Typing Tutor",
|
||||||
|
Style::default().fg(colors.fg()),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
];
|
||||||
|
|
||||||
|
let title = Paragraph::new(title_lines).alignment(Alignment::Center);
|
||||||
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
|
let menu_layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints(
|
||||||
|
self.items
|
||||||
|
.iter()
|
||||||
|
.map(|_| Constraint::Length(3))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.split(layout[2]);
|
||||||
|
|
||||||
|
for (i, item) in self.items.iter().enumerate() {
|
||||||
|
let is_selected = i == self.selected;
|
||||||
|
let indicator = if is_selected { ">" } else { " " };
|
||||||
|
|
||||||
|
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
|
||||||
|
let desc_text = format!(" {}", item.description);
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*label_text,
|
||||||
|
Style::default()
|
||||||
|
.fg(if is_selected {
|
||||||
|
colors.accent()
|
||||||
|
} else {
|
||||||
|
colors.fg()
|
||||||
|
})
|
||||||
|
.add_modifier(if is_selected {
|
||||||
|
Modifier::BOLD
|
||||||
|
} else {
|
||||||
|
Modifier::empty()
|
||||||
|
}),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
&*desc_text,
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
|
||||||
|
let p = Paragraph::new(lines);
|
||||||
|
if i < menu_layout.len() {
|
||||||
|
p.render(menu_layout[i], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/ui/components/mod.rs
Normal file
8
src/ui/components/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
pub mod chart;
|
||||||
|
pub mod dashboard;
|
||||||
|
pub mod keyboard_diagram;
|
||||||
|
pub mod menu;
|
||||||
|
pub mod progress_bar;
|
||||||
|
pub mod stats_dashboard;
|
||||||
|
pub mod stats_sidebar;
|
||||||
|
pub mod typing_area;
|
||||||
53
src/ui/components/progress_bar.rs
Normal file
53
src/ui/components/progress_bar.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::widgets::{Block, Widget};
|
||||||
|
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct ProgressBar<'a> {
|
||||||
|
pub label: String,
|
||||||
|
pub ratio: f64,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> ProgressBar<'a> {
|
||||||
|
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
|
||||||
|
Self {
|
||||||
|
label: label.to_string(),
|
||||||
|
ratio: ratio.clamp(0.0, 1.0),
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for ProgressBar<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(format!(" {} ", self.label))
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.width == 0 || inner.height == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filled_width = (self.ratio * inner.width as f64) as u16;
|
||||||
|
let label = format!("{:.0}%", self.ratio * 100.0);
|
||||||
|
|
||||||
|
for x in inner.x..inner.x + inner.width {
|
||||||
|
let style = if x < inner.x + filled_width {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.bar_filled())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.fg()).bg(colors.bar_empty())
|
||||||
|
};
|
||||||
|
buf[(x, inner.y)].set_style(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
|
||||||
|
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
|
||||||
|
}
|
||||||
|
}
|
||||||
740
src/ui/components/stats_dashboard.rs
Normal file
740
src/ui/components/stats_dashboard.rs
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::engine::key_stats::KeyStatsStore;
|
||||||
|
use crate::session::result::LessonResult;
|
||||||
|
use crate::ui::components::chart::WpmChart;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct StatsDashboard<'a> {
|
||||||
|
pub history: &'a [LessonResult],
|
||||||
|
pub key_stats: &'a KeyStatsStore,
|
||||||
|
pub active_tab: usize,
|
||||||
|
pub target_wpm: u32,
|
||||||
|
pub theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatsDashboard<'a> {
|
||||||
|
pub fn new(
|
||||||
|
history: &'a [LessonResult],
|
||||||
|
key_stats: &'a KeyStatsStore,
|
||||||
|
active_tab: usize,
|
||||||
|
target_wpm: u32,
|
||||||
|
theme: &'a Theme,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
history,
|
||||||
|
key_stats,
|
||||||
|
active_tab,
|
||||||
|
target_wpm,
|
||||||
|
theme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for StatsDashboard<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Statistics ")
|
||||||
|
.border_style(Style::default().fg(colors.accent()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if self.history.is_empty() {
|
||||||
|
let msg = Paragraph::new(Line::from(Span::styled(
|
||||||
|
"No lessons completed yet. Start typing!",
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
)));
|
||||||
|
msg.render(inner, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.split(inner);
|
||||||
|
|
||||||
|
// Tab header
|
||||||
|
let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"];
|
||||||
|
let tab_spans: Vec<Span> = tabs
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, &label)| {
|
||||||
|
let style = if i == self.active_tab {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.text_pending())
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
Span::styled(format!(" {label} "), style),
|
||||||
|
Span::raw(" "),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
||||||
|
|
||||||
|
// Tab content
|
||||||
|
match self.active_tab {
|
||||||
|
0 => self.render_dashboard_tab(layout[1], buf),
|
||||||
|
1 => self.render_history_tab(layout[1], buf),
|
||||||
|
2 => self.render_keystrokes_tab(layout[1], buf),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
let footer = Paragraph::new(Line::from(Span::styled(
|
||||||
|
" [ESC] Back [Tab] Next tab [D/H/K] Switch tab",
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
)));
|
||||||
|
footer.render(layout[2], buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StatsDashboard<'_> {
|
||||||
|
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(4),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(8),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Summary stats
|
||||||
|
let avg_wpm =
|
||||||
|
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||||
|
let best_wpm = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.wpm)
|
||||||
|
.fold(0.0f64, f64::max);
|
||||||
|
let avg_accuracy =
|
||||||
|
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||||||
|
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||||
|
|
||||||
|
let total_str = format!("{}", self.history.len());
|
||||||
|
let avg_wpm_str = format!("{avg_wpm:.0}");
|
||||||
|
let best_wpm_str = format!("{best_wpm:.0}");
|
||||||
|
let avg_acc_str = format!("{avg_accuracy:.1}%");
|
||||||
|
let time_str = format_duration(total_time);
|
||||||
|
|
||||||
|
let summary = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Lessons: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*total_str,
|
||||||
|
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(" Avg WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*avg_wpm_str, Style::default().fg(colors.accent())),
|
||||||
|
Span::styled(" Best WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*best_wpm_str,
|
||||||
|
Style::default().fg(colors.success()).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*avg_acc_str,
|
||||||
|
Style::default().fg(if avg_accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if avg_accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*time_str, Style::default().fg(colors.text_pending())),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
Paragraph::new(summary).render(layout[0], buf);
|
||||||
|
|
||||||
|
// Progress bars
|
||||||
|
self.render_progress_bars(layout[1], buf);
|
||||||
|
|
||||||
|
// Charts
|
||||||
|
let chart_layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||||
|
.split(layout[2]);
|
||||||
|
|
||||||
|
// WPM chart
|
||||||
|
let wpm_data: Vec<(f64, f64)> = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(50)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| (i as f64, r.wpm))
|
||||||
|
.collect();
|
||||||
|
WpmChart::new(&wpm_data, self.theme).render(chart_layout[0], buf);
|
||||||
|
|
||||||
|
// Accuracy chart
|
||||||
|
let acc_data: Vec<(f64, f64)> = self
|
||||||
|
.history
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(50)
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, r)| (i as f64, r.accuracy))
|
||||||
|
.collect();
|
||||||
|
render_accuracy_chart(&acc_data, self.theme, chart_layout[1], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_progress_bars(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let avg_wpm =
|
||||||
|
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
|
||||||
|
let avg_accuracy =
|
||||||
|
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
|
||||||
|
|
||||||
|
// WPM progress
|
||||||
|
let wpm_pct = (avg_wpm / self.target_wpm as f64 * 100.0).min(100.0);
|
||||||
|
let wpm_label = format!(" WPM: {avg_wpm:.0}/{} ({wpm_pct:.0}%)", self.target_wpm);
|
||||||
|
render_text_bar(&wpm_label, wpm_pct / 100.0, colors.accent(), colors.bar_empty(), layout[0], buf);
|
||||||
|
|
||||||
|
// Accuracy progress
|
||||||
|
let acc_pct = avg_accuracy.min(100.0);
|
||||||
|
let acc_label = format!(" Acc: {acc_pct:.1}%");
|
||||||
|
let acc_color = if acc_pct >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if acc_pct >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
};
|
||||||
|
render_text_bar(&acc_label, acc_pct / 100.0, acc_color, colors.bar_empty(), layout[1], buf);
|
||||||
|
|
||||||
|
// Level progress
|
||||||
|
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
|
||||||
|
let level = ((total_score / 100.0).sqrt() as u32).max(1);
|
||||||
|
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
|
||||||
|
let current_level_score = (level as f64).powi(2) * 100.0;
|
||||||
|
let level_pct = ((total_score - current_level_score) / (next_level_score - current_level_score)).clamp(0.0, 1.0);
|
||||||
|
let level_label = format!(" Lvl {level} ({:.0}%)", level_pct * 100.0);
|
||||||
|
render_text_bar(&level_label, level_pct, colors.focused_key(), colors.bar_empty(), layout[2], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(8),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Recent tests table
|
||||||
|
let header = Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" # WPM Raw Acc% Time Date",
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.accent())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mut lines = vec![header, Line::from(Span::styled(
|
||||||
|
" ─────────────────────────────────────────────",
|
||||||
|
Style::default().fg(colors.border()),
|
||||||
|
))];
|
||||||
|
|
||||||
|
let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect();
|
||||||
|
let total = self.history.len();
|
||||||
|
|
||||||
|
for (i, result) in recent.iter().enumerate() {
|
||||||
|
let idx = total - i;
|
||||||
|
let raw_wpm = result.cpm / 5.0;
|
||||||
|
let time_str = format!("{:.1}s", result.elapsed_secs);
|
||||||
|
let date_str = result.timestamp.format("%m/%d %H:%M").to_string();
|
||||||
|
|
||||||
|
let idx_str = format!("{idx:>3}");
|
||||||
|
let wpm_str = format!("{:>6.0}", result.wpm);
|
||||||
|
let raw_str = format!("{:>6.0}", raw_wpm);
|
||||||
|
let acc_str = format!("{:>6.1}%", result.accuracy);
|
||||||
|
let row = format!(" {idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}");
|
||||||
|
|
||||||
|
let acc_color = if result.accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if result.accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(row, Style::default().fg(acc_color))));
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(lines).render(layout[0], buf);
|
||||||
|
|
||||||
|
// Per-key speed
|
||||||
|
self.render_per_key_speed(layout[1], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Per-Key Average Speed (ms) ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.width < 52 || inner.height < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let letters: Vec<char> = ('a'..='z').collect();
|
||||||
|
let max_time = letters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|&ch| self.key_stats.stats.get(&ch))
|
||||||
|
.map(|s| s.filtered_time_ms)
|
||||||
|
.fold(0.0f64, f64::max)
|
||||||
|
.max(1.0);
|
||||||
|
|
||||||
|
// Render bar chart: letter label on row 0, bar on row 1
|
||||||
|
let bar_width = (inner.width as usize).min(52) / 26;
|
||||||
|
let bar_width = bar_width.max(1) as u16;
|
||||||
|
|
||||||
|
for (i, &ch) in letters.iter().enumerate() {
|
||||||
|
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1));
|
||||||
|
if x >= inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = self
|
||||||
|
.key_stats
|
||||||
|
.stats
|
||||||
|
.get(&ch)
|
||||||
|
.map(|s| s.filtered_time_ms)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
let ratio = time / max_time;
|
||||||
|
let color = if ratio < 0.3 {
|
||||||
|
colors.success()
|
||||||
|
} else if ratio < 0.6 {
|
||||||
|
colors.accent()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Letter label
|
||||||
|
buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color));
|
||||||
|
|
||||||
|
// Simple bar indicator
|
||||||
|
if inner.height >= 2 {
|
||||||
|
let bar_char = if time > 0.0 {
|
||||||
|
match (ratio * 8.0) as u8 {
|
||||||
|
0 => '▁',
|
||||||
|
1 => '▂',
|
||||||
|
2 => '▃',
|
||||||
|
3 => '▄',
|
||||||
|
4 => '▅',
|
||||||
|
5 => '▆',
|
||||||
|
6 => '▇',
|
||||||
|
_ => '█',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
};
|
||||||
|
buf.set_string(
|
||||||
|
x,
|
||||||
|
inner.y + 1,
|
||||||
|
&bar_char.to_string(),
|
||||||
|
Style::default().fg(color),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = bar_width;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let layout = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(7),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(6),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Keyboard accuracy heatmap
|
||||||
|
self.render_keyboard_heatmap(layout[0], buf);
|
||||||
|
|
||||||
|
// Slowest/Fastest keys
|
||||||
|
let key_layout = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
Constraint::Percentage(34),
|
||||||
|
Constraint::Percentage(33),
|
||||||
|
])
|
||||||
|
.split(layout[1]);
|
||||||
|
|
||||||
|
self.render_slowest_keys(key_layout[0], buf);
|
||||||
|
self.render_fastest_keys(key_layout[1], buf);
|
||||||
|
self.render_char_stats(key_layout[2], buf);
|
||||||
|
|
||||||
|
// Word/Character stats summary
|
||||||
|
self.render_overall_stats(layout[2], buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Keyboard Accuracy ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
if inner.height < 3 || inner.width < 40 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: &[&[char]] = &[
|
||||||
|
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||||
|
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||||
|
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||||
|
];
|
||||||
|
let offsets: &[u16] = &[1, 3, 5];
|
||||||
|
let key_width: u16 = 4;
|
||||||
|
|
||||||
|
for (row_idx, row) in rows.iter().enumerate() {
|
||||||
|
let y = inner.y + row_idx as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
for (col_idx, &key) in row.iter().enumerate() {
|
||||||
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
|
if x + 3 > inner.x + inner.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let accuracy = self.get_key_accuracy(key);
|
||||||
|
let color = if accuracy >= 100.0 {
|
||||||
|
colors.text_pending()
|
||||||
|
} else if accuracy >= 90.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else if accuracy > 0.0 {
|
||||||
|
colors.error()
|
||||||
|
} else {
|
||||||
|
colors.text_pending()
|
||||||
|
};
|
||||||
|
|
||||||
|
let display = format!("[{key}]");
|
||||||
|
buf.set_string(x, y, &display, Style::default().fg(color).bg(colors.bg()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_key_accuracy(&self, key: char) -> f64 {
|
||||||
|
let mut correct = 0usize;
|
||||||
|
let mut total = 0usize;
|
||||||
|
|
||||||
|
for result in self.history {
|
||||||
|
for kt in &result.per_key_times {
|
||||||
|
if kt.key == key {
|
||||||
|
total += 1;
|
||||||
|
if kt.correct {
|
||||||
|
correct += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
correct as f64 / total as f64 * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Slowest ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let mut key_times: Vec<(char, f64)> = self
|
||||||
|
.key_stats
|
||||||
|
.stats
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.sample_count > 0)
|
||||||
|
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||||
|
.collect();
|
||||||
|
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||||
|
|
||||||
|
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||||
|
let y = inner.y + i as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let text = format!(" '{ch}' {time:.0}ms");
|
||||||
|
buf.set_string(
|
||||||
|
inner.x,
|
||||||
|
y,
|
||||||
|
&text,
|
||||||
|
Style::default().fg(colors.error()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_fastest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Fastest ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let mut key_times: Vec<(char, f64)> = self
|
||||||
|
.key_stats
|
||||||
|
.stats
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, s)| s.sample_count > 0)
|
||||||
|
.map(|(&ch, s)| (ch, s.filtered_time_ms))
|
||||||
|
.collect();
|
||||||
|
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||||
|
|
||||||
|
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||||
|
let y = inner.y + i as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let text = format!(" '{ch}' {time:.0}ms");
|
||||||
|
buf.set_string(
|
||||||
|
inner.x,
|
||||||
|
y,
|
||||||
|
&text,
|
||||||
|
Style::default().fg(colors.success()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_char_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Key Stats ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let mut total_correct = 0usize;
|
||||||
|
let mut total_incorrect = 0usize;
|
||||||
|
|
||||||
|
for result in self.history {
|
||||||
|
total_correct += result.correct;
|
||||||
|
total_incorrect += result.incorrect;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = total_correct + total_incorrect;
|
||||||
|
let overall_acc = if total > 0 {
|
||||||
|
total_correct as f64 / total as f64 * 100.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let lines = [
|
||||||
|
format!(" Total: {total}"),
|
||||||
|
format!(" Correct: {total_correct}"),
|
||||||
|
format!(" Wrong: {total_incorrect}"),
|
||||||
|
format!(" Acc: {overall_acc:.1}%"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (i, line) in lines.iter().enumerate() {
|
||||||
|
let y = inner.y + i as u16;
|
||||||
|
if y >= inner.y + inner.height {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.set_string(inner.x, y, line, Style::default().fg(colors.fg()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Overall ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
|
||||||
|
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
|
||||||
|
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
|
||||||
|
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
|
||||||
|
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Characters typed: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("{total_chars}"),
|
||||||
|
Style::default().fg(colors.accent()),
|
||||||
|
),
|
||||||
|
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("{total_correct}"),
|
||||||
|
Style::default().fg(colors.success()),
|
||||||
|
),
|
||||||
|
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format!("{total_incorrect}"),
|
||||||
|
Style::default().fg(if total_incorrect > 0 {
|
||||||
|
colors.error()
|
||||||
|
} else {
|
||||||
|
colors.success()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Span::styled(" Total time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
format_duration(total_time),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
Paragraph::new(lines).render(inner, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_text_bar(
|
||||||
|
label: &str,
|
||||||
|
ratio: f64,
|
||||||
|
fill_color: ratatui::style::Color,
|
||||||
|
empty_color: ratatui::style::Color,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
) {
|
||||||
|
if area.height < 2 || area.width < 10 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label on first line
|
||||||
|
buf.set_string(
|
||||||
|
area.x,
|
||||||
|
area.y,
|
||||||
|
label,
|
||||||
|
Style::default().fg(fill_color),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bar on second line
|
||||||
|
let bar_width = (area.width as usize).saturating_sub(4);
|
||||||
|
let filled = (ratio * bar_width as f64) as usize;
|
||||||
|
|
||||||
|
let bar_y = area.y + 1;
|
||||||
|
buf.set_string(area.x, bar_y, " ", Style::default());
|
||||||
|
|
||||||
|
for i in 0..bar_width {
|
||||||
|
let x = area.x + 2 + i as u16;
|
||||||
|
if x >= area.x + area.width {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (ch, color) = if i < filled {
|
||||||
|
('█', fill_color)
|
||||||
|
} else {
|
||||||
|
('░', empty_color)
|
||||||
|
};
|
||||||
|
buf.set_string(x, bar_y, &ch.to_string(), Style::default().fg(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_accuracy_chart(
|
||||||
|
data: &[(f64, f64)],
|
||||||
|
theme: &Theme,
|
||||||
|
area: Rect,
|
||||||
|
buf: &mut Buffer,
|
||||||
|
) {
|
||||||
|
use ratatui::symbols;
|
||||||
|
use ratatui::widgets::{Axis, Chart, Dataset, GraphType};
|
||||||
|
|
||||||
|
let colors = &theme.colors;
|
||||||
|
|
||||||
|
if data.is_empty() {
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Accuracy Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border()));
|
||||||
|
block.render(area, buf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let max_x = data.last().map(|(x, _)| *x).unwrap_or(1.0);
|
||||||
|
|
||||||
|
let dataset = Dataset::default()
|
||||||
|
.marker(symbols::Marker::Braille)
|
||||||
|
.graph_type(GraphType::Line)
|
||||||
|
.style(Style::default().fg(colors.success()))
|
||||||
|
.data(data);
|
||||||
|
|
||||||
|
let chart = Chart::new(vec![dataset])
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(" Accuracy Over Time ")
|
||||||
|
.border_style(Style::default().fg(colors.border())),
|
||||||
|
)
|
||||||
|
.x_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("Lesson")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([0.0, max_x]),
|
||||||
|
)
|
||||||
|
.y_axis(
|
||||||
|
Axis::default()
|
||||||
|
.title("%")
|
||||||
|
.style(Style::default().fg(colors.text_pending()))
|
||||||
|
.bounds([80.0, 100.0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
chart.render(area, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_duration(secs: f64) -> String {
|
||||||
|
let total = secs as u64;
|
||||||
|
let hours = total / 3600;
|
||||||
|
let mins = (total % 3600) / 60;
|
||||||
|
let s = total % 60;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}h {mins}m {s}s")
|
||||||
|
} else if mins > 0 {
|
||||||
|
format!("{mins}m {s}s")
|
||||||
|
} else {
|
||||||
|
format!("{s}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/ui/components/stats_sidebar.rs
Normal file
87
src/ui/components/stats_sidebar.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||||
|
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct StatsSidebar<'a> {
|
||||||
|
lesson: &'a LessonState,
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StatsSidebar<'a> {
|
||||||
|
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
||||||
|
Self { lesson, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for StatsSidebar<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
|
||||||
|
let wpm = self.lesson.wpm();
|
||||||
|
let accuracy = self.lesson.accuracy();
|
||||||
|
let progress = self.lesson.progress() * 100.0;
|
||||||
|
let correct = self.lesson.correct_count();
|
||||||
|
let incorrect = self.lesson.incorrect_count();
|
||||||
|
let elapsed = self.lesson.elapsed_secs();
|
||||||
|
|
||||||
|
let wpm_str = format!("{wpm:.0}");
|
||||||
|
let acc_str = format!("{accuracy:.1}%");
|
||||||
|
let prog_str = format!("{progress:.0}%");
|
||||||
|
let correct_str = format!("{correct}");
|
||||||
|
let incorrect_str = format!("{incorrect}");
|
||||||
|
let elapsed_str = format!("{elapsed:.1}s");
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("WPM: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*wpm_str, Style::default().fg(colors.accent())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Accuracy: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(
|
||||||
|
&*acc_str,
|
||||||
|
Style::default().fg(if accuracy >= 95.0 {
|
||||||
|
colors.success()
|
||||||
|
} else if accuracy >= 85.0 {
|
||||||
|
colors.warning()
|
||||||
|
} else {
|
||||||
|
colors.error()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Progress: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*prog_str, Style::default().fg(colors.accent())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Correct: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*correct_str, Style::default().fg(colors.success())),
|
||||||
|
]),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Errors: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*incorrect_str, Style::default().fg(colors.error())),
|
||||||
|
]),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Time: ", Style::default().fg(colors.fg())),
|
||||||
|
Span::styled(&*elapsed_str, Style::default().fg(colors.fg())),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
let block = Block::bordered()
|
||||||
|
.title(" Stats ")
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(lines).block(block);
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ui/components/typing_area.rs
Normal file
61
src/ui/components/typing_area.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use ratatui::buffer::Buffer;
|
||||||
|
use ratatui::layout::Rect;
|
||||||
|
use ratatui::style::{Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||||
|
|
||||||
|
use crate::session::input::CharStatus;
|
||||||
|
use crate::session::lesson::LessonState;
|
||||||
|
use crate::ui::theme::Theme;
|
||||||
|
|
||||||
|
pub struct TypingArea<'a> {
|
||||||
|
lesson: &'a LessonState,
|
||||||
|
theme: &'a Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> TypingArea<'a> {
|
||||||
|
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self {
|
||||||
|
Self { lesson, theme }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for TypingArea<'_> {
|
||||||
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||||
|
let colors = &self.theme.colors;
|
||||||
|
let mut spans: Vec<Span> = Vec::new();
|
||||||
|
|
||||||
|
for (i, &target_ch) in self.lesson.target.iter().enumerate() {
|
||||||
|
if i < self.lesson.cursor {
|
||||||
|
let style = match &self.lesson.input[i] {
|
||||||
|
CharStatus::Correct => Style::default().fg(colors.text_correct()),
|
||||||
|
CharStatus::Incorrect(_) => Style::default()
|
||||||
|
.fg(colors.text_incorrect())
|
||||||
|
.bg(colors.text_incorrect_bg())
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
};
|
||||||
|
let display = match &self.lesson.input[i] {
|
||||||
|
CharStatus::Incorrect(actual) => *actual,
|
||||||
|
_ => target_ch,
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(display.to_string(), style));
|
||||||
|
} else if i == self.lesson.cursor {
|
||||||
|
let style = Style::default()
|
||||||
|
.fg(colors.text_cursor_fg())
|
||||||
|
.bg(colors.text_cursor_bg());
|
||||||
|
spans.push(Span::styled(target_ch.to_string(), style));
|
||||||
|
} else {
|
||||||
|
let style = Style::default().fg(colors.text_pending());
|
||||||
|
spans.push(Span::styled(target_ch.to_string(), style));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let line = Line::from(spans);
|
||||||
|
let block = Block::bordered()
|
||||||
|
.border_style(Style::default().fg(colors.border()))
|
||||||
|
.style(Style::default().bg(colors.bg()));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false });
|
||||||
|
|
||||||
|
paragraph.render(area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/ui/layout.rs
Normal file
53
src/ui/layout.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
|
|
||||||
|
pub struct AppLayout {
|
||||||
|
pub header: Rect,
|
||||||
|
pub main: Rect,
|
||||||
|
pub sidebar: Rect,
|
||||||
|
pub footer: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppLayout {
|
||||||
|
pub fn new(area: Rect) -> Self {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let horizontal = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||||
|
.split(vertical[1]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
header: vertical[0],
|
||||||
|
main: horizontal[0],
|
||||||
|
sidebar: horizontal[1],
|
||||||
|
footer: vertical[2],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||||
|
let vertical = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
Constraint::Percentage(percent_y),
|
||||||
|
Constraint::Percentage((100 - percent_y) / 2),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
Constraint::Percentage(percent_x),
|
||||||
|
Constraint::Percentage((100 - percent_x) / 2),
|
||||||
|
])
|
||||||
|
.split(vertical[1])[1]
|
||||||
|
}
|
||||||
3
src/ui/mod.rs
Normal file
3
src/ui/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod components;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod theme;
|
||||||
146
src/ui/theme.rs
Normal file
146
src/ui/theme.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use ratatui::style::Color;
|
||||||
|
use rust_embed::Embed;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Embed)]
|
||||||
|
#[folder = "assets/themes/"]
|
||||||
|
struct ThemeAssets;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub name: String,
|
||||||
|
pub colors: ThemeColors,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ThemeColors {
|
||||||
|
pub bg: String,
|
||||||
|
pub fg: String,
|
||||||
|
pub text_correct: String,
|
||||||
|
pub text_incorrect: String,
|
||||||
|
pub text_incorrect_bg: String,
|
||||||
|
pub text_pending: String,
|
||||||
|
pub text_cursor_bg: String,
|
||||||
|
pub text_cursor_fg: String,
|
||||||
|
pub focused_key: String,
|
||||||
|
pub accent: String,
|
||||||
|
pub accent_dim: String,
|
||||||
|
pub border: String,
|
||||||
|
pub border_focused: String,
|
||||||
|
pub header_bg: String,
|
||||||
|
pub header_fg: String,
|
||||||
|
pub bar_filled: String,
|
||||||
|
pub bar_empty: String,
|
||||||
|
pub error: String,
|
||||||
|
pub warning: String,
|
||||||
|
pub success: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
pub fn load(name: &str) -> Option<Self> {
|
||||||
|
// Try user themes dir
|
||||||
|
if let Some(config_dir) = dirs::config_dir() {
|
||||||
|
let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml"));
|
||||||
|
if let Ok(content) = fs::read_to_string(&user_theme_path) {
|
||||||
|
if let Ok(theme) = toml::from_str::<Theme>(&content) {
|
||||||
|
return Some(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try bundled themes
|
||||||
|
let filename = format!("{name}.toml");
|
||||||
|
if let Some(file) = ThemeAssets::get(&filename) {
|
||||||
|
if let Ok(content) = std::str::from_utf8(file.data.as_ref()) {
|
||||||
|
if let Ok(theme) = toml::from_str::<Theme>(content) {
|
||||||
|
return Some(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn available_themes() -> Vec<String> {
|
||||||
|
ThemeAssets::iter()
|
||||||
|
.filter_map(|f| {
|
||||||
|
f.strip_suffix(".toml").map(|n| n.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Theme {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::load("catppuccin-mocha").unwrap_or_else(|| Self {
|
||||||
|
name: "default".to_string(),
|
||||||
|
colors: ThemeColors::default(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ThemeColors {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bg: "#1e1e2e".to_string(),
|
||||||
|
fg: "#cdd6f4".to_string(),
|
||||||
|
text_correct: "#a6e3a1".to_string(),
|
||||||
|
text_incorrect: "#f38ba8".to_string(),
|
||||||
|
text_incorrect_bg: "#45273a".to_string(),
|
||||||
|
text_pending: "#a6adc8".to_string(),
|
||||||
|
text_cursor_bg: "#f5e0dc".to_string(),
|
||||||
|
text_cursor_fg: "#1e1e2e".to_string(),
|
||||||
|
focused_key: "#f9e2af".to_string(),
|
||||||
|
accent: "#89b4fa".to_string(),
|
||||||
|
accent_dim: "#45475a".to_string(),
|
||||||
|
border: "#45475a".to_string(),
|
||||||
|
border_focused: "#89b4fa".to_string(),
|
||||||
|
header_bg: "#313244".to_string(),
|
||||||
|
header_fg: "#cdd6f4".to_string(),
|
||||||
|
bar_filled: "#89b4fa".to_string(),
|
||||||
|
bar_empty: "#313244".to_string(),
|
||||||
|
error: "#f38ba8".to_string(),
|
||||||
|
warning: "#f9e2af".to_string(),
|
||||||
|
success: "#a6e3a1".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeColors {
|
||||||
|
pub fn parse_color(hex: &str) -> Color {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
if hex.len() == 6 {
|
||||||
|
if let (Ok(r), Ok(g), Ok(b)) = (
|
||||||
|
u8::from_str_radix(&hex[0..2], 16),
|
||||||
|
u8::from_str_radix(&hex[2..4], 16),
|
||||||
|
u8::from_str_radix(&hex[4..6], 16),
|
||||||
|
) {
|
||||||
|
return Color::Rgb(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Color::White
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bg(&self) -> Color { Self::parse_color(&self.bg) }
|
||||||
|
pub fn fg(&self) -> Color { Self::parse_color(&self.fg) }
|
||||||
|
pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) }
|
||||||
|
pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) }
|
||||||
|
pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) }
|
||||||
|
pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) }
|
||||||
|
pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) }
|
||||||
|
pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) }
|
||||||
|
pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) }
|
||||||
|
pub fn accent(&self) -> Color { Self::parse_color(&self.accent) }
|
||||||
|
pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) }
|
||||||
|
pub fn border(&self) -> Color { Self::parse_color(&self.border) }
|
||||||
|
pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) }
|
||||||
|
pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) }
|
||||||
|
pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) }
|
||||||
|
pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) }
|
||||||
|
pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) }
|
||||||
|
pub fn error(&self) -> Color { Self::parse_color(&self.error) }
|
||||||
|
pub fn warning(&self) -> Color { Self::parse_color(&self.warning) }
|
||||||
|
pub fn success(&self) -> Color { Self::parse_color(&self.success) }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user