Skill tree progression system & whitespace support

This commit is contained in:
2026-02-15 07:30:34 +00:00
parent 13550505c1
commit 6d6815af02
22 changed files with 2883 additions and 238 deletions

View File

@@ -0,0 +1,507 @@
# Skill Tree Progression System & Whitespace Support
## Context
keydr currently tracks only a-z lowercase letters in its adaptive unlock system. Since keydr aims to be a coding-focused typing tutor, it must also train capitals, numbers, punctuation, whitespace (tabs/newlines), and code-specific symbols. The current flat a-z progression needs to be replaced with a branching skill tree that lets players choose their training path after mastering lowercase letters. Additionally, code drills currently strip newlines into spaces, making them unrealistic for real-world code practice.
## Skill Tree Structure
The tree is flat: a-z is the root, and all other branches are direct siblings at the same level. Once a-z is complete, all branches unlock simultaneously and the user can choose any order.
```
┌─────────────────┐
│ a-z Lowercase │ (ROOT - everyone starts here)
│ 26 keys, freq │
│ order unlock │
└────────┬────────┘
┌─────────┬──────────┼──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌──────────┐
│Capitals │ │Numbers │ │ Prose │ │White- │ │ Code │
│ A-Z │ │ 0-9 │ │ Punct. │ │ space │ │ Symbols │
│ 3 lvls │ │ 2 lvls │ │ 3 lvls │ │ 2 lvls │ │ 4 lvls │
└─────────┘ └────────┘ └────────┘ └────────┘ └──────────┘
```
### Prerequisites
- **a-z Lowercase** (root): Always available from start
- **All other branches**: Require a-z complete (all 26 lowercase letters confident). Once a-z is done, all 5 branches unlock simultaneously. User freely chooses which to pursue.
---
## Branch Status State Machine
Each branch has an explicit status:
```rust
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BranchStatus {
Locked, // Prerequisites not met
Available, // Prerequisites met, user hasn't started
InProgress, // User has begun drilling this branch
Complete, // All levels in branch are done
}
```
**Transitions:**
- `Locked → Available`: When a-z branch reaches `Complete`
- `Available → InProgress`: **Only** when user explicitly launches a branch drill from the skill tree (start-on-select model). The global adaptive drill does NOT auto-start branches.
- `InProgress → Complete`: When all keys in all levels of the branch reach confidence >= 1.0
**Multiple branches active**: Yes. The user can have multiple branches `InProgress` simultaneously. Each tracks its own current level independently.
**Global adaptive scope**: Only includes keys from `InProgress` and `Complete` branches. `Available` branches are not included — the user must visit the skill tree to start them.
---
## Detailed Level Breakdown
### Branch: a-z Lowercase (Root)
Uses existing frequency-order system. Starts with 6 keys, unlocks one at a time when all current keys reach confidence >= 1.0. Branch is "complete" when all 26 letters are confident.
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`
Total keys: **26**
### Branch: Capital Letters (3 levels)
- **Level 1 — Common Sentence Capitals** (8 keys): `T I A S W H B M`
- **Level 2 — Name Capitals** (10 keys): `J D R C E N P L F G`
- **Level 3 — Remaining Capitals** (8 keys): `O U K V Y X Q Z`
Total keys: **26**
Text generation rules:
- First word of each "sentence" (after `.` `?` `!` or at drill start) gets capitalized
- ~10-15% of words get capitalized as proper-noun-like words
- Focused capital letter is boosted (40% chance to appear in word starts)
### Branch: Numbers (2 levels)
- **Level 1 — Common Digits** (5 keys): `1 2 3 4 5`
- **Level 2 — All Digits** (5 keys): `0 6 7 8 9`
Total keys: **10**
Text generation rules:
- ~15% of words replaced with number expressions using only unlocked digits
- Patterns: counts ("3 items"), years ("2024"), IDs ("room 42"), measurements ("7 miles")
### Branch: Prose Punctuation (3 levels)
- **Level 1 — Essential** (3 keys): `. , '`
- **Level 2 — Common** (4 keys): `; : " -`
- **Level 3 — Expressive** (4 keys): `? ! ( )`
Total keys: **11**
Text generation rules follow natural prose patterns:
- `.` ends sentences (every 5-15 words), `,` separates clauses
- `'` in contractions (don't, it's, we'll)
- `"` wrapping quoted phrases, `;` between clauses, `:` before lists
- `-` in compound words (well-known), `?` for questions, `!` for exclamations
- `( )` for parenthetical asides
### Branch: Whitespace (2 levels)
- **Level 1 — Enter/Return** (1 key): `\n`
- **Level 2 — Tab/Indent** (1 key): `\t`
Total keys: **2**
Text generation rules:
- Line breaks at sentence boundaries (every ~60-80 chars)
- Tabs for indentation in code-like structures
- Once unlocked, **default adaptive drills automatically become multi-line**
### Branch: Code Symbols (4 levels)
- **Level 1 — Arithmetic & Assignment** (5 keys): `= + * /` and `-` (shared with Prose Punct L2)
- **Level 2 — Grouping** (6 keys): `{ } [ ] < >`
- **Level 3 — Logic & Reference** (5 keys): `& | ^ ~` and `!` (shared with Prose Punct L3)
- **Level 4 — Special** (7 keys): `` @ # $ % _ \ ` ``
Total keys: **23** (21 unique + 2 shared with Prose Punctuation)
Text generation rules:
- L1: Prose with simple expressions (`x = a + b`, `total = price * qty`)
- L2: Code-pattern templates (`if (x) { return y; }`, `arr[0]`)
- L3: Bitwise/logical patterns (`a & b`, `!flag`, `*ptr`)
- L4: Language-specific patterns (`@decorator`, `#include`, `snake_case`)
**Grand total**: 98 keys across branches, **96 unique keys** (after deducting 2 shared: `-` and `!`). `TOTAL_UNIQUE_KEYS` is derived at startup by collecting all keys from all branch definitions into a `HashSet` and taking `len()`. Stored as a field on `SkillTree` for use in scoring and UI.
---
## Shared Keys Between Branches
Two keys appear in multiple branches:
- `-` appears in Prose Punctuation L2 and Code Symbols L1
- `!` appears in Prose Punctuation L3 and Code Symbols L3
**Rule**: Confidence is tracked once per character in `KeyStatsStore` (keyed by `char`). If a user masters `-` in Prose Punctuation, it is automatically confident in Code Symbols too. When checking level completion, the branch reads the single confidence value for that char. This is idempotent — no special handling needed.
---
## Focused Key Policy
### Global Adaptive Drill (from menu)
1. Collect all keys from all `InProgress` branches (current level's keys only) plus all `Complete` branch keys
2. Find the key with the **lowest confidence < 1.0** across this entire set
3. If all keys are confident, no focused key (maintenance mode)
4. Boost the focused key in text generation (40% probability)
### Branch-Specific Drill (from skill tree)
1. Collect keys from the selected branch including **all prior completed levels** (as background reinforcement) plus the **current level's keys**, plus all a-z keys
2. Find the key with the **lowest confidence < 1.0** within the **current level keys only** (prior level keys are reinforcement, not focus targets)
3. If all current level keys are confident, advance the level and focus on the weakest new key
4. Boost the focused key in text generation (40% probability)
5. Prior-level keys always appear in generated text for reinforcement but are never the focused key
### Branches with Zero Progress
When a branch is `Available` but user hasn't started it yet:
- Launching a drill from that branch transitions it to `InProgress` at level 1
- The focused key is the weakest among level 1's keys (likely all at 0.0 confidence, so pick the first in definition order)
---
## Scoring
Current formula: `complexity = unlocked_count / 26`
**New formula**: `complexity = total_unlocked_keys / TOTAL_UNIQUE_KEYS`
Where `TOTAL_UNIQUE_KEYS = 96` is computed from branch definitions (deduplicated across shared keys). This scales naturally — the more branches the user has unlocked, the higher the complexity multiplier.
Level formula remains: `level = floor(sqrt(total_score / 100))`.
Menu header changes from `"X/26 letters"` to `"X/96 keys"`.
---
## Skill Tree UI
### New Screen: `AppScreen::SkillTree`
Accessible from menu via `[t] Skill Tree`. Renders **vertically** as a scrollable list.
```
╔══════════════════════════════════════════════════════════════════╗
║ SKILL TREE ║
╠══════════════════════════════════════════════════════════════════╣
║ ║
║ ★ Lowercase a-z COMPLETE 26/26 ║
║ ████████████████████████████████████████ Level 26/26 ║
║ ║
║ ── Branches (unlocked after a-z) ────────────────────────── ║
║ ║
║ ► Capitals A-Z Lvl 2/3 18/26 keys ║
║ ████████████████████░░░░░░░░░░░░ 69% ║
║ ║
║ Numbers 0-9 Lvl 0/2 0/10 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Prose Punctuation Lvl 1/3 3/11 keys ║
║ ██████████░░░░░░░░░░░░░░░░░░░░░ 27% ║
║ ║
║ Whitespace Lvl 0/2 0/2 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
║ Code Symbols Lvl 0/4 0/23 keys ║
║ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ║
║ ║
╠══════════════════════════════════════════════════════════════════╣
║ ► Capitals A-Z Level 2/3 ║
║ L1: T I A S W H B M (complete) ║
║ L2: J [D] R C E N P L F G (in progress, focused: D) ║
║ L3: O U K V Y X Q Z (locked) ║
║ Avg Confidence: ████████░░ 82% ║
║ ║
║ [Enter] Start Drill [↑↓/jk] Navigate [q] Back ║
╚══════════════════════════════════════════════════════════════════╝
```
**Layout:**
- **Top section**: Vertical list of all branches with status prefix, level, key count, progress bar
- **Bottom section**: Detail panel showing per-level key breakdown, confidence bars, focused key
- **Footer**: Controls
**Node states (prefix):**
- Locked: grayed out, no prefix, not selectable
- Available: normal color, no prefix
- In Progress `►`: accent color
- Complete `★`: gold/green
**Navigation:** `↑↓` / `j/k` move selection. `Enter` launches branch drill. `q` returns to menu.
**Keyboard diagram**: For non-printable keys (`Enter`, `Tab`), show them as labeled keys on the keyboard diagram in their standard positions. No special handling needed — they're physical keys with fixed positions.
---
## Code & Passage Drill Changes (Unranked Modes)
Code and Passage drills remain as separate menu options.
1. **Unranked tagging**: Add `ranked: bool` to `DrillResult` with `#[serde(default = "default_true")]` for backward compat
2. **Derive ranked from DrillContext**: At drill start, set `ranked = (drill_mode == Adaptive)`. Code/Passage → `ranked = false`.
3. **No progression**: `finish_drill()` gates skill tree updates on `result.ranked`
4. **History replay**: `rebuild_from_history()` uses `result.ranked` as the gate. No legacy fallback — since we reset on schema change (WIP policy), old history without `ranked` field won't exist.
5. **Visual indicators**:
- Drill header: "Code Drill (Unranked)" / "Passage Drill (Unranked)" in dimmed/muted color
- Result screen: "Unranked — does not count toward skill tree"
- Stats dashboard history: unranked rows shown with muted styling
---
## Whitespace Handling
### Tokenized Render Model (`typing_area.rs`)
Replace direct char→span rendering with a `RenderToken` approach to handle one-to-many char-to-cell mapping:
```rust
struct RenderToken {
target_idx: usize, // Index into DrillState.target
display: String, // What to show (e.g., "↵", "→···", "a")
style: Style, // Computed style (correct/incorrect/cursor/pending)
}
```
**Display mapper:**
- `\n` → visible `↵` marker token + hard line break (new `Line` in paragraph)
- `\t` → visible `→` marker + padding `·` tokens to next 4-char tab stop
- All other chars → single token with char as display
**Cursor/style mapping:** Maintain a `Vec<(usize, usize)>` mapping from `target_idx` to first display cell position. When highlighting cursor or errors, look up the target index to find which display tokens to style.
**Multi-line rendering:** Change from single `Line` to `Vec<Line>`. Split on newline tokens. Each line is a separate `Line` in the `Paragraph`.
### Input Pipeline (`main.rs` + `session/input.rs`)
Current flow: `main.rs` matches `KeyCode::Char(ch)``app.type_char(ch)`. Enter/Tab are currently consumed by other handlers (menu nav, etc.).
**Changes in `main.rs`:**
- When `screen == Drill` and drill is active:
- `KeyCode::Enter``app.type_char('\n')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::Tab``app.type_char('\t')` **unconditionally** (correctness decided by `process_char()`)
- `KeyCode::BackTab` (Shift+Tab) → ignore (no action)
- These must be checked **before** the existing Esc/Enter handlers for drill screen
- If Enter/Tab is typed when not expected, it registers as an error on the current char — same as typing any wrong key
**No changes to `session/input.rs`**: `process_char()` already compares `ch == expected` generically. It will work with `'\n'` and `'\t'` as-is.
### Code Drill Updates (`generator/code_syntax.rs`)
- Embedded snippets change from single-line `&str` to multi-line string literals with preserved indentation
- `extract_code_snippets()`: preserve original newlines and leading whitespace instead of `split_whitespace().join(" ")`
- `generate()`: join snippets with `\n\n` instead of `" "`
---
## Data Model Changes
### Persistence Policy (WIP stage)
**No backward compatibility migration.** On schema mismatch, reset persisted files to defaults. Bump schema version to 2. Add a note in changelog that local progress is intentionally reset for this version. This avoids over-engineering migration logic during early development.
### `ProfileData` (schema v2)
```rust
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32, // 2
pub skill_tree: SkillTreeProgress, // Replaces unlocked_letters
pub total_score: f64,
pub total_drills: u32,
pub streak_days: u32,
pub best_streak: u32,
pub last_practice_date: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillTreeProgress {
pub branches: HashMap<String, BranchProgress>, // String keys for stable JSON
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProgress {
pub status: BranchStatus,
pub current_level: usize, // 0-indexed into branch's levels array
// current_level = 0 means working on first level (plan's "Level 1")
// current_level = levels.len() only when status == Complete
}
```
**Indexing invariant**: `current_level` is always 0-indexed into `BranchDefinition.levels`. When the plan says "Level 1", "Level 2", etc. in human-readable text, that maps to `current_level = 0`, `current_level = 1`, etc. in code. A branch with `current_level = 0` and `status = InProgress` is actively working on its first level.
**HashMap uses `String` keys** (e.g., `"lowercase"`, `"capitals"`, `"numbers"`, etc.) for stable JSON serialization. `BranchId` enum has `to_key() -> &'static str` and `from_key()` methods.
### `DrillResult` Addition
```rust
#[serde(default = "default_true")]
pub ranked: bool,
```
### `KeyStatsStore`
No structural change. Already `HashMap<char, KeyStat>` — works for any char.
---
## Skill Tree Definition (Source of Truth)
Hard-coded static definition in `src/engine/skill_tree.rs`:
```rust
pub struct BranchDefinition {
pub id: BranchId,
pub name: &'static str,
pub levels: Vec<LevelDefinition>,
}
pub struct LevelDefinition {
pub name: &'static str,
pub keys: Vec<char>,
}
```
All branch/level/key definitions are `const`/`static` arrays. No data-driven manifest needed at this stage. The `SkillTree` struct holds:
- The static definition (reference)
- The persisted `SkillTreeProgress` (mutable state)
- Methods: `unlocked_keys(scope)`, `focused_key(scope, &KeyStatsStore)`, `update(&KeyStatsStore)`, `branch_status(id)`, `all_branches()`
---
## Implementation Phases
### Phase 1: Skill Tree Core & Data Model
**Goal**: Replace `LetterUnlock` with `SkillTree`, update persistence.
1. Create `src/engine/skill_tree.rs`:
- `BranchId` enum (`Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols`)
- `BranchStatus` enum (`Locked, Available, InProgress, Complete`)
- `BranchDefinition`, `LevelDefinition` structs
- Static branch definitions with all keys per level
- `SkillTree` struct with `update()`, `unlocked_keys()`, `focused_key()`, `branch_status()`
2. Update `src/store/schema.rs`: new `ProfileData` with `SkillTreeProgress`, schema v2, reset on mismatch
3. Add `ranked: bool` to `DrillResult` in `src/session/result.rs`
4. Update `src/app.rs`: replace `letter_unlock: LetterUnlock` with `skill_tree: SkillTree`, update `finish_drill()` to gate on `ranked`, update `rebuild_from_history()`, update scoring complexity formula
5. Delete/replace `src/engine/letter_unlock.rs`
**Key files**: `src/engine/skill_tree.rs` (new), `src/engine/letter_unlock.rs` (delete), `src/store/schema.rs`, `src/session/result.rs`, `src/app.rs`
**Tests**:
- Skill tree status transitions (Locked → Available → InProgress → Complete)
- Shared key confidence propagation
- Focused key selection (global vs branch scope)
- Level completion and advancement
- Schema reset on version mismatch
**Acceptance criteria**: `cargo build` passes, `cargo test` passes, existing adaptive drills work with skill tree (a-z only), scoring uses new formula.
### Phase 2: Whitespace Input & Rendering
**Goal**: Support Enter/Tab in typing drills with proper display.
1. Update `src/ui/components/typing_area.rs`: tokenized render model with `RenderToken`, multi-line support, visible `↵` and `→` markers
2. Update `src/main.rs`: route `KeyCode::Enter``'\n'` and `KeyCode::Tab``'\t'` when in drill mode, ignore `BackTab`
3. Update `src/generator/code_syntax.rs`: preserve newlines/indentation in snippets, change embedded snippets to multi-line, fix `extract_code_snippets()` to preserve whitespace
4. Optionally update `src/generator/passage.rs` with multi-line passage variants
**Key files**: `src/ui/components/typing_area.rs`, `src/main.rs`, `src/generator/code_syntax.rs`
**Tests**:
- RenderToken generation for strings with `\n` and `\t`
- Cursor position mapping with expanded tokens
- Enter/Tab input processing (reuse existing `process_char()` — just verify `'\n'` and `'\t'` work)
**Acceptance criteria**: Code drills display multi-line with visible whitespace markers, Enter/Tab advance the cursor correctly, backspace works across line boundaries.
### Phase 3: Text Generation for Capitals & Punctuation
**Goal**: Generate drill text that naturally incorporates capitals and punctuation.
1. Create `src/generator/capitalize.rs`: post-processing pass that capitalizes sentence starts and occasional words, using only unlocked capital letters
2. Create `src/generator/punctuate.rs`: post-processing pass that inserts periods, commas, apostrophes, etc. at natural positions, using only unlocked punctuation
3. Update `src/generator/phonetic.rs` or `src/app.rs` `generate_text()`: apply capitalize/punctuate passes when those branches are active
4. Update `src/engine/filter.rs` `CharFilter`: add awareness of which char types are allowed (lowercase, uppercase, punctuation, etc.)
**Key files**: `src/generator/capitalize.rs` (new), `src/generator/punctuate.rs` (new), `src/generator/phonetic.rs`, `src/app.rs`, `src/engine/filter.rs`
**Acceptance criteria**: Adaptive drills with Capitals branch active produce properly capitalized text. Drills with Prose Punctuation active have natural punctuation placement.
### Phase 4: Text Generation for Numbers & Code Symbols
**Goal**: Generate drill text with numbers and code symbol patterns.
1. Create `src/generator/numbers.rs`: injects number expressions into prose using only unlocked digits
2. Create `src/generator/code_patterns.rs`: code-pattern templates for Code Symbols branch drills (expressions, brackets, operators)
3. Update `src/app.rs` `generate_text()`: apply number/code passes based on active branches
4. For whitespace branch: when active, insert `\n` at sentence boundaries in generated text
**Key files**: `src/generator/numbers.rs` (new), `src/generator/code_patterns.rs` (new), `src/app.rs`
**Acceptance criteria**: Number expressions use only unlocked digits. Code symbol drills produce recognizable code-like patterns. Whitespace branch generates multi-line output.
### Phase 5: Skill Tree UI
**Goal**: Navigable skill tree screen with branch detail and drill launch.
1. Add `AppScreen::SkillTree` to `src/app.rs`
2. Create `src/ui/components/skill_tree.rs`: vertical branch list + detail panel widget
3. Update `src/main.rs`: handle key events for skill tree screen (navigation, drill launch)
4. Update `src/ui/components/menu.rs`: add `[t] Skill Tree` option
5. Update menu header: show `"X/96 keys"` instead of `"X/26 letters"`
6. Add `DrillMode::BranchDrill(BranchId)` or similar to track drill origin for branch-specific focus
**Key files**: `src/ui/components/skill_tree.rs` (new), `src/app.rs`, `src/main.rs`, `src/ui/components/menu.rs`
**Acceptance criteria**: Can navigate to skill tree from menu, see all branches with correct status, launch a branch-specific drill, return to menu.
### Phase 6: Unranked Mode Polish
**Goal**: Clearly distinguish ranked vs unranked drills in UI.
1. Update drill header in `src/main.rs`: show "(Unranked)" for Code/Passage modes
2. Update `src/ui/components/dashboard.rs` result screen: note "does not count toward skill tree"
3. Update `src/ui/components/stats_dashboard.rs`: muted styling for unranked history rows
4. Verify `rebuild_from_history()` correctly uses `ranked` field to gate skill tree updates
**Key files**: `src/main.rs`, `src/ui/components/dashboard.rs`, `src/ui/components/stats_dashboard.rs`, `src/app.rs`
**Acceptance criteria**: Code/Passage drills clearly marked unranked. Stats history shows visual distinction. Ranked drills advance skill tree, unranked don't.
---
## Verification
### Automated Tests
- **Skill tree transitions**: `Locked → Available → InProgress → Complete` for each branch
- **Shared keys**: Mastering `!` in Prose Punct → confident in Code Symbols too
- **Focused key**: Global scope selects weakest across all active branches; branch scope selects within branch
- **Level advancement**: Completing all keys in a level auto-advances to next
- **Ranked/unranked**: Only ranked drills update skill tree in `rebuild_from_history()`
- **Whitespace tokens**: RenderToken expansion for `\n` and `\t` produces correct display strings and index mapping
- **Input routing**: `'\n'` and `'\t'` correctly processed as typed characters
### Manual Testing
1. Launch app → a-z trunk works as before
2. Complete a-z (or edit profile to simulate) → all 5 branches show as Available
3. Navigate skill tree → select Capitals → launch drill → see capitalized text
4. Complete Capitals L1 → L2 keys appear in drills
5. Launch default adaptive with multiple branches active → text mixes all unlocked keys
6. Launch Code/Passage drill → header shows "(Unranked)", no skill tree progress
7. Start Whitespace branch → default adaptive becomes multi-line
8. Type Enter/Tab in code drills → cursor advances correctly, errors tracked
9. Quit and relaunch → progress preserved
10. Delete `~/.local/share/keydr/` → app resets cleanly to fresh state

View File

@@ -7,12 +7,16 @@ use rand::SeedableRng;
use crate::config::Config; use crate::config::Config;
use crate::engine::filter::CharFilter; use crate::engine::filter::CharFilter;
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::engine::letter_unlock::LetterUnlock;
use crate::engine::scoring; use crate::engine::scoring;
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree};
use crate::generator::capitalize;
use crate::generator::code_patterns;
use crate::generator::code_syntax::CodeSyntaxGenerator; use crate::generator::code_syntax::CodeSyntaxGenerator;
use crate::generator::dictionary::Dictionary; use crate::generator::dictionary::Dictionary;
use crate::generator::numbers;
use crate::generator::passage::PassageGenerator; use crate::generator::passage::PassageGenerator;
use crate::generator::phonetic::PhoneticGenerator; use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::punctuate;
use crate::generator::TextGenerator; use crate::generator::TextGenerator;
use crate::generator::transition_table::TransitionTable; use crate::generator::transition_table::TransitionTable;
@@ -31,6 +35,7 @@ pub enum AppScreen {
DrillResult, DrillResult,
StatsDashboard, StatsDashboard,
Settings, Settings,
SkillTree,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -48,11 +53,16 @@ impl DrillMode {
DrillMode::Passage => "passage", DrillMode::Passage => "passage",
} }
} }
pub fn is_ranked(self) -> bool {
matches!(self, DrillMode::Adaptive)
}
} }
pub struct App { pub struct App {
pub screen: AppScreen, pub screen: AppScreen,
pub drill_mode: DrillMode, pub drill_mode: DrillMode,
pub drill_scope: DrillScope,
pub drill: Option<DrillState>, pub drill: Option<DrillState>,
pub drill_events: Vec<KeystrokeEvent>, pub drill_events: Vec<KeystrokeEvent>,
pub last_result: Option<DrillResult>, pub last_result: Option<DrillResult>,
@@ -61,7 +71,7 @@ pub struct App {
pub theme: &'static Theme, pub theme: &'static Theme,
pub config: Config, pub config: Config,
pub key_stats: KeyStatsStore, pub key_stats: KeyStatsStore,
pub letter_unlock: LetterUnlock, pub skill_tree: SkillTree,
pub profile: ProfileData, pub profile: ProfileData,
pub store: Option<JsonStore>, pub store: Option<JsonStore>,
pub should_quit: bool, pub should_quit: bool,
@@ -71,6 +81,7 @@ pub struct App {
pub last_key_time: Option<Instant>, pub last_key_time: Option<Instant>,
pub history_selected: usize, pub history_selected: usize,
pub history_confirm_delete: bool, pub history_confirm_delete: bool,
pub skill_tree_selected: usize,
rng: SmallRng, rng: SmallRng,
transition_table: TransitionTable, transition_table: TransitionTable,
#[allow(dead_code)] #[allow(dead_code)]
@@ -86,22 +97,31 @@ impl App {
let store = JsonStore::new().ok(); let store = JsonStore::new().ok();
let (key_stats, letter_unlock, profile, drill_history) = if let Some(ref s) = store { let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
let ksd = s.load_key_stats(); // load_profile returns None if file exists but can't parse (schema mismatch)
let pd = s.load_profile(); let pd = s.load_profile();
let lhd = s.load_drill_history();
let lu = if pd.unlocked_letters.is_empty() { match pd {
LetterUnlock::new() Some(pd) if !pd.needs_reset() => {
} else { let ksd = s.load_key_stats();
LetterUnlock::from_included(pd.unlocked_letters.clone()) let lhd = s.load_drill_history();
}; let st = SkillTree::new(pd.skill_tree.clone());
(ksd.stats, st, pd, lhd.drills)
(ksd.stats, lu, pd, lhd.drills) }
_ => {
// Schema mismatch or parse failure: full reset of all stores
(
KeyStatsStore::default(),
SkillTree::default(),
ProfileData::default(),
Vec::new(),
)
}
}
} else { } else {
( (
KeyStatsStore::default(), KeyStatsStore::default(),
LetterUnlock::new(), SkillTree::default(),
ProfileData::default(), ProfileData::default(),
Vec::new(), Vec::new(),
) )
@@ -116,6 +136,7 @@ impl App {
let mut app = Self { let mut app = Self {
screen: AppScreen::Menu, screen: AppScreen::Menu,
drill_mode: DrillMode::Adaptive, drill_mode: DrillMode::Adaptive,
drill_scope: DrillScope::Global,
drill: None, drill: None,
drill_events: Vec::new(), drill_events: Vec::new(),
last_result: None, last_result: None,
@@ -124,7 +145,7 @@ impl App {
theme, theme,
config, config,
key_stats: key_stats_with_target, key_stats: key_stats_with_target,
letter_unlock, skill_tree,
profile, profile,
store, store,
should_quit: false, should_quit: false,
@@ -134,6 +155,7 @@ impl App {
last_key_time: None, last_key_time: None,
history_selected: 0, history_selected: 0,
history_confirm_delete: false, history_confirm_delete: false,
skill_tree_selected: 0,
rng: SmallRng::from_entropy(), rng: SmallRng::from_entropy(),
transition_table, transition_table,
dictionary, dictionary,
@@ -155,13 +177,84 @@ impl App {
match mode { match mode {
DrillMode::Adaptive => { DrillMode::Adaptive => {
let filter = CharFilter::new(self.letter_unlock.included.clone()); let scope = self.drill_scope;
let focused = self.letter_unlock.focused; let all_keys = self.skill_tree.unlocked_keys(scope);
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
// Generate base lowercase text using only lowercase keys from scope
let lowercase_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| ch.is_ascii_lowercase() || *ch == ' ')
.collect();
let filter = CharFilter::new(lowercase_keys);
// Only pass focused to phonetic generator if it's a lowercase letter
let lowercase_focused = focused.filter(|ch| ch.is_ascii_lowercase());
let table = self.transition_table.clone(); let table = self.transition_table.clone();
let dict = Dictionary::load(); let dict = Dictionary::load();
let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PhoneticGenerator::new(table, dict, rng); let mut generator = PhoneticGenerator::new(table, dict, rng);
generator.generate(&filter, focused, word_count) let mut text = generator.generate(&filter, lowercase_focused, word_count);
// Apply capitalization if uppercase keys are in scope
let cap_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
if !cap_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = capitalize::apply_capitalization(&text, &cap_keys, focused, &mut rng);
}
// Apply punctuation if punctuation keys are in scope
let punct_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| matches!(ch, '.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')'))
.collect();
if !punct_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = punctuate::apply_punctuation(&text, &punct_keys, focused, &mut rng);
}
// Apply numbers if digit keys are in scope
let digit_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| ch.is_ascii_digit())
.collect();
if !digit_keys.is_empty() {
let has_dot = all_keys.contains(&'.');
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused, &mut rng);
}
// Apply code symbols only if this drill is for the CodeSymbols branch,
// or if it's a global drill and CodeSymbols is active
let code_active = match scope {
DrillScope::Branch(id) => id == BranchId::CodeSymbols,
DrillScope::Global => matches!(
self.skill_tree.branch_status(BranchId::CodeSymbols),
BranchStatus::InProgress | BranchStatus::Complete
),
};
if code_active {
let symbol_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| matches!(ch,
'=' | '+' | '*' | '/' | '-' | '{' | '}' | '[' | ']' | '<' | '>' |
'&' | '|' | '^' | '~' | '@' | '#' | '$' | '%' | '_' | '\\' | '`'
))
.collect();
if !symbol_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = code_patterns::apply_code_symbols(&text, &symbol_keys, focused, &mut rng);
}
}
// Apply whitespace line breaks if newline is in scope
if all_keys.contains(&'\n') {
text = insert_line_breaks(&text);
}
text
} }
DrillMode::Code => { DrillMode::Code => {
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
@@ -204,22 +297,23 @@ impl App {
fn finish_drill(&mut self) { fn finish_drill(&mut self) {
if let Some(ref drill) = self.drill { if let Some(ref drill) = self.drill {
let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str()); let ranked = self.drill_mode.is_ranked();
let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str(), ranked);
if self.drill_mode == DrillMode::Adaptive { if ranked {
for kt in &result.per_key_times { for kt in &result.per_key_times {
if kt.correct { if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms); self.key_stats.update_key(kt.key, kt.time_ms);
} }
} }
self.letter_unlock.update(&self.key_stats); self.skill_tree.update(&self.key_stats);
} }
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); let complexity = self.skill_tree.complexity();
let score = scoring::compute_score(&result, complexity); let score = scoring::compute_score(&result, complexity);
self.profile.total_score += score; self.profile.total_score += score;
self.profile.total_drills += 1; self.profile.total_drills += 1;
self.profile.unlocked_letters = self.letter_unlock.included.clone(); self.profile.skill_tree = self.skill_tree.progress.clone();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
if self.profile.last_practice_date.as_deref() != Some(&today) { if self.profile.last_practice_date.as_deref() != Some(&today) {
@@ -262,11 +356,11 @@ impl App {
if let Some(ref store) = self.store { if let Some(ref store) = self.store {
let _ = store.save_profile(&self.profile); let _ = store.save_profile(&self.profile);
let _ = store.save_key_stats(&KeyStatsData { let _ = store.save_key_stats(&KeyStatsData {
schema_version: 1, schema_version: 2,
stats: self.key_stats.clone(), stats: self.key_stats.clone(),
}); });
let _ = store.save_drill_history(&DrillHistoryData { let _ = store.save_drill_history(&DrillHistoryData {
schema_version: 1, schema_version: 2,
drills: self.drill_history.clone(), drills: self.drill_history.clone(),
}); });
} }
@@ -312,27 +406,27 @@ impl App {
// Reset all derived state // Reset all derived state
self.key_stats = KeyStatsStore::default(); self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm(); self.key_stats.target_cpm = self.config.target_cpm();
self.letter_unlock = LetterUnlock::new(); self.skill_tree = SkillTree::default();
self.profile.total_score = 0.0; self.profile.total_score = 0.0;
self.profile.total_drills = 0; self.profile.total_drills = 0;
self.profile.streak_days = 0; self.profile.streak_days = 0;
self.profile.best_streak = 0; self.profile.best_streak = 0;
self.profile.last_practice_date = None; self.profile.last_practice_date = None;
// Replay each remaining session oldestnewest // Replay each remaining session oldest->newest
for result in &self.drill_history { for result in &self.drill_history {
// Only update adaptive progression for adaptive sessions // Only update skill tree for ranked sessions
if result.drill_mode == "adaptive" { if result.ranked {
for kt in &result.per_key_times { for kt in &result.per_key_times {
if kt.correct { if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms); self.key_stats.update_key(kt.key, kt.time_ms);
} }
} }
self.letter_unlock.update(&self.key_stats); self.skill_tree.update(&self.key_stats);
} }
// Compute score // Compute score
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); let complexity = self.skill_tree.complexity();
let score = scoring::compute_score(result, complexity); let score = scoring::compute_score(result, complexity);
self.profile.total_score += score; self.profile.total_score += score;
self.profile.total_drills += 1; self.profile.total_drills += 1;
@@ -359,7 +453,24 @@ impl App {
} }
} }
self.profile.unlocked_letters = self.letter_unlock.included.clone(); self.profile.skill_tree = self.skill_tree.progress.clone();
}
pub fn go_to_skill_tree(&mut self) {
self.skill_tree_selected = 0;
self.screen = AppScreen::SkillTree;
}
pub fn start_branch_drill(&mut self, branch_id: BranchId) {
// Start the branch if it's Available
self.skill_tree.start_branch(branch_id);
self.profile.skill_tree = self.skill_tree.progress.clone();
self.save_data();
// Use adaptive mode with branch-specific scope
self.drill_mode = DrillMode::Adaptive;
self.drill_scope = DrillScope::Branch(branch_id);
self.start_drill();
} }
pub fn go_to_settings(&mut self) { pub fn go_to_settings(&mut self) {
@@ -445,3 +556,33 @@ impl App {
} }
} }
} }
/// Insert newlines at sentence boundaries (~60-80 chars per line).
fn insert_line_breaks(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut col = 0;
for (i, ch) in text.chars().enumerate() {
result.push(ch);
col += 1;
// After sentence-ending punctuation + space, insert newline if past 60 chars
if col >= 60 && (ch == '.' || ch == '?' || ch == '!') {
// Check if next char is a space
let next = text.chars().nth(i + 1);
if next == Some(' ') {
result.push('\n');
col = 0;
// Skip the space (will be consumed by next iteration but we already broke the line)
}
} else if col >= 80 && ch == ' ' {
// Hard wrap at spaces if no sentence boundary found
// Replace the space we just pushed with a newline
result.pop();
result.push('\n');
col = 0;
}
}
result
}

View File

@@ -1,151 +0,0 @@
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);
}
}

View File

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

View File

@@ -7,8 +7,8 @@ pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 {
(speed * complexity) / (errors + 1.0) * (length / 50.0) (speed * complexity) / (errors + 1.0) * (length / 50.0)
} }
pub fn compute_complexity(unlocked_count: usize) -> f64 { pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 {
(unlocked_count as f64 / 26.0).max(0.1) (unlocked_count as f64 / total_keys as f64).max(0.1)
} }
pub fn level_from_score(total_score: f64) -> u32 { pub fn level_from_score(total_score: f64) -> u32 {
@@ -38,8 +38,8 @@ mod tests {
} }
#[test] #[test]
fn test_complexity_scales_with_letters() { fn test_complexity_scales_with_keys() {
assert!(compute_complexity(26) > compute_complexity(6)); assert!(compute_complexity(96, 96) > compute_complexity(6, 96));
assert!((compute_complexity(26) - 1.0).abs() < 0.001); assert!((compute_complexity(96, 96) - 1.0).abs() < 0.001);
} }
} }

854
src/engine/skill_tree.rs Normal file
View File

@@ -0,0 +1,854 @@
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
// --- Branch ID ---
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BranchId {
Lowercase,
Capitals,
Numbers,
ProsePunctuation,
Whitespace,
CodeSymbols,
}
impl BranchId {
pub fn to_key(self) -> &'static str {
match self {
BranchId::Lowercase => "lowercase",
BranchId::Capitals => "capitals",
BranchId::Numbers => "numbers",
BranchId::ProsePunctuation => "prose_punctuation",
BranchId::Whitespace => "whitespace",
BranchId::CodeSymbols => "code_symbols",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"lowercase" => Some(BranchId::Lowercase),
"capitals" => Some(BranchId::Capitals),
"numbers" => Some(BranchId::Numbers),
"prose_punctuation" => Some(BranchId::ProsePunctuation),
"whitespace" => Some(BranchId::Whitespace),
"code_symbols" => Some(BranchId::CodeSymbols),
_ => None,
}
}
pub fn all() -> &'static [BranchId] {
&[
BranchId::Lowercase,
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
]
}
}
// --- Branch Status ---
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BranchStatus {
Locked,
Available,
InProgress,
Complete,
}
// --- Static Definitions ---
pub struct LevelDefinition {
pub name: &'static str,
pub keys: &'static [char],
}
pub struct BranchDefinition {
pub id: BranchId,
pub name: &'static str,
pub levels: &'static [LevelDefinition],
}
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
name: "Frequency Order",
keys: &[
'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 CAPITALS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Common Sentence Capitals",
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
},
LevelDefinition {
name: "Name Capitals",
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
},
LevelDefinition {
name: "Remaining Capitals",
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
},
];
const NUMBERS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Common Digits",
keys: &['1', '2', '3', '4', '5'],
},
LevelDefinition {
name: "All Digits",
keys: &['0', '6', '7', '8', '9'],
},
];
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Essential",
keys: &['.', ',', '\''],
},
LevelDefinition {
name: "Common",
keys: &[';', ':', '"', '-'],
},
LevelDefinition {
name: "Expressive",
keys: &['?', '!', '(', ')'],
},
];
const WHITESPACE_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Enter/Return",
keys: &['\n'],
},
LevelDefinition {
name: "Tab/Indent",
keys: &['\t'],
},
];
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Arithmetic & Assignment",
keys: &['=', '+', '*', '/', '-'],
},
LevelDefinition {
name: "Grouping",
keys: &['{', '}', '[', ']', '<', '>'],
},
LevelDefinition {
name: "Logic & Reference",
keys: &['&', '|', '^', '~', '!'],
},
LevelDefinition {
name: "Special",
keys: &['@', '#', '$', '%', '_', '\\', '`'],
},
];
pub const ALL_BRANCHES: &[BranchDefinition] = &[
BranchDefinition {
id: BranchId::Lowercase,
name: "Lowercase a-z",
levels: LOWERCASE_LEVELS,
},
BranchDefinition {
id: BranchId::Capitals,
name: "Capitals A-Z",
levels: CAPITALS_LEVELS,
},
BranchDefinition {
id: BranchId::Numbers,
name: "Numbers 0-9",
levels: NUMBERS_LEVELS,
},
BranchDefinition {
id: BranchId::ProsePunctuation,
name: "Prose Punctuation",
levels: PROSE_PUNCTUATION_LEVELS,
},
BranchDefinition {
id: BranchId::Whitespace,
name: "Whitespace",
levels: WHITESPACE_LEVELS,
},
BranchDefinition {
id: BranchId::CodeSymbols,
name: "Code Symbols",
levels: CODE_SYMBOLS_LEVELS,
},
];
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
ALL_BRANCHES
.iter()
.find(|b| b.id == id)
.expect("branch definition not found")
}
// --- Persisted Progress ---
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProgress {
pub status: BranchStatus,
pub current_level: usize,
}
impl Default for BranchProgress {
fn default() -> Self {
Self {
status: BranchStatus::Locked,
current_level: 0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillTreeProgress {
pub branches: HashMap<String, BranchProgress>,
}
impl Default for SkillTreeProgress {
fn default() -> Self {
let mut branches = HashMap::new();
// Lowercase starts as InProgress; everything else Locked
branches.insert(
BranchId::Lowercase.to_key().to_string(),
BranchProgress {
status: BranchStatus::InProgress,
current_level: 0,
},
);
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
branches.insert(id.to_key().to_string(), BranchProgress::default());
}
Self { branches }
}
}
// --- Skill Tree Engine ---
/// The scope for key collection and focus selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DrillScope {
/// Global adaptive: all InProgress + Complete branches
Global,
/// Branch-specific drill: specific branch + a-z background
Branch(BranchId),
}
pub struct SkillTree {
pub progress: SkillTreeProgress,
pub total_unique_keys: usize,
}
/// Number of lowercase letters to start with before unlocking one-at-a-time
const LOWERCASE_MIN_KEYS: usize = 6;
impl SkillTree {
pub fn new(progress: SkillTreeProgress) -> Self {
let total_unique_keys = Self::compute_total_unique_keys();
Self {
progress,
total_unique_keys,
}
}
fn compute_total_unique_keys() -> usize {
let mut all_keys: HashSet<char> = HashSet::new();
for branch in ALL_BRANCHES {
for level in branch.levels {
for &key in level.keys {
all_keys.insert(key);
}
}
}
all_keys.len()
}
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
self.progress
.branches
.get(id.to_key())
.map(|bp| &bp.status)
.unwrap_or(&BranchStatus::Locked)
}
pub fn branch_progress(&self, id: BranchId) -> &BranchProgress {
static DEFAULT: BranchProgress = BranchProgress {
status: BranchStatus::Locked,
current_level: 0,
};
self.progress
.branches
.get(id.to_key())
.unwrap_or(&DEFAULT)
}
pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress {
self.progress
.branches
.entry(id.to_key().to_string())
.or_default()
}
/// Start a branch (transition Available -> InProgress).
pub fn start_branch(&mut self, id: BranchId) {
let bp = self.branch_progress_mut(id);
if bp.status == BranchStatus::Available {
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
}
}
/// Collect all unlocked keys for the given scope.
pub fn unlocked_keys(&self, scope: DrillScope) -> Vec<char> {
match scope {
DrillScope::Global => self.global_unlocked_keys(),
DrillScope::Branch(id) => self.branch_unlocked_keys(id),
}
}
fn global_unlocked_keys(&self) -> Vec<char> {
let mut keys = Vec::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
// For lowercase, use the progressive unlock system
if branch_def.id == BranchId::Lowercase {
keys.extend(self.lowercase_unlocked_keys());
} else {
// Include current level's keys + all prior levels
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
keys.extend_from_slice(level.keys);
}
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
keys
}
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
let mut keys = Vec::new();
// Always include a-z background keys
if id != BranchId::Lowercase {
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
match lowercase_bp.status {
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
BranchStatus::Complete => {
for level in lowercase_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
// Include keys from the target branch
let branch_def = get_branch_definition(id);
let bp = self.branch_progress(id);
if id == BranchId::Lowercase {
keys.extend(self.lowercase_unlocked_keys());
} else {
match bp.status {
BranchStatus::InProgress => {
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
keys.extend_from_slice(level.keys);
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
keys
}
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
fn lowercase_unlocked_keys(&self) -> Vec<char> {
let def = get_branch_definition(BranchId::Lowercase);
let bp = self.branch_progress(BranchId::Lowercase);
let all_keys = def.levels[0].keys;
match bp.status {
BranchStatus::Complete => all_keys.to_vec(),
BranchStatus::InProgress => {
// current_level represents number of keys unlocked beyond LOWERCASE_MIN_KEYS
let count = (LOWERCASE_MIN_KEYS + bp.current_level).min(all_keys.len());
all_keys[..count].to_vec()
}
_ => Vec::new(),
}
}
/// Number of unlocked lowercase letters (for display).
pub fn lowercase_unlocked_count(&self) -> usize {
self.lowercase_unlocked_keys().len()
}
/// Find the focused (weakest) key for the given scope.
pub fn focused_key(&self, scope: DrillScope, stats: &KeyStatsStore) -> Option<char> {
match scope {
DrillScope::Global => self.global_focused_key(stats),
DrillScope::Branch(id) => self.branch_focused_key(id, stats),
}
}
fn global_focused_key(&self, stats: &KeyStatsStore) -> Option<char> {
// Collect keys from all InProgress branches (current level only) + complete branches
let mut focus_candidates = Vec::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
if branch_def.id == BranchId::Lowercase {
focus_candidates.extend(self.lowercase_unlocked_keys());
} else if bp.current_level < branch_def.levels.len() {
// Only current level keys are focus candidates
focus_candidates
.extend_from_slice(branch_def.levels[bp.current_level].keys);
// Plus prior level keys for reinforcement
for i in 0..bp.current_level {
focus_candidates.extend_from_slice(branch_def.levels[i].keys);
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
focus_candidates.extend_from_slice(level.keys);
}
}
_ => {}
}
}
Self::weakest_key(&focus_candidates, stats)
}
fn branch_focused_key(&self, id: BranchId, stats: &KeyStatsStore) -> Option<char> {
let branch_def = get_branch_definition(id);
let bp = self.branch_progress(id);
if id == BranchId::Lowercase {
return Self::weakest_key(&self.lowercase_unlocked_keys(), stats);
}
match bp.status {
BranchStatus::InProgress if bp.current_level < branch_def.levels.len() => {
// Focus only within current level's keys
let current_keys = branch_def.levels[bp.current_level].keys;
Self::weakest_key(&current_keys.to_vec(), stats)
}
_ => None,
}
}
fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option<char> {
keys.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()
}
/// Update skill tree progress based on current key stats.
/// Call after updating KeyStatsStore.
pub fn update(&mut self, stats: &KeyStatsStore) {
// Update lowercase branch (progressive unlock)
self.update_lowercase(stats);
// Check if lowercase is complete -> unlock other branches
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
let bp = self.branch_progress_mut(id);
if bp.status == BranchStatus::Locked {
bp.status = BranchStatus::Available;
}
}
}
// Update InProgress branches (non-lowercase)
for branch_def in ALL_BRANCHES {
if branch_def.id == BranchId::Lowercase {
continue;
}
let bp = self.branch_progress(branch_def.id).clone();
if bp.status != BranchStatus::InProgress {
continue;
}
self.update_branch_level(branch_def, stats);
}
}
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
let bp = self.branch_progress(BranchId::Lowercase).clone();
if bp.status != BranchStatus::InProgress {
return;
}
let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys;
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
if current_count >= all_keys.len() {
// All 26 keys unlocked, check if all confident
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.status = BranchStatus::Complete;
bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS;
}
return;
}
// Check if all current keys are confident -> unlock next
let current_keys = &all_keys[..current_count];
let all_confident = current_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.current_level += 1;
}
}
fn update_branch_level(&mut self, branch_def: &BranchDefinition, stats: &KeyStatsStore) {
let bp = self.branch_progress(branch_def.id).clone();
if bp.current_level >= branch_def.levels.len() {
// Already past last level, mark complete
let bp_mut = self.branch_progress_mut(branch_def.id);
bp_mut.status = BranchStatus::Complete;
return;
}
// Check if all keys in current level are confident
let current_level_keys = branch_def.levels[bp.current_level].keys;
let all_confident = current_level_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(branch_def.id);
bp_mut.current_level += 1;
if bp_mut.current_level >= branch_def.levels.len() {
bp_mut.status = BranchStatus::Complete;
}
}
}
/// Total number of unlocked unique keys across all branches.
pub fn total_unlocked_count(&self) -> usize {
let mut keys: HashSet<char> = HashSet::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
if branch_def.id == BranchId::Lowercase {
for key in self.lowercase_unlocked_keys() {
keys.insert(key);
}
} else {
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
for &key in level.keys {
keys.insert(key);
}
}
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
for &key in level.keys {
keys.insert(key);
}
}
}
_ => {}
}
}
keys.len()
}
/// Complexity for scoring: total_unlocked / total_unique
pub fn complexity(&self) -> f64 {
(self.total_unlocked_count() as f64 / self.total_unique_keys as f64).max(0.1)
}
/// Get all branch definitions with their current progress (for UI).
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
ALL_BRANCHES
.iter()
.map(|def| (def, self.branch_progress(def.id)))
.collect()
}
/// Total keys defined in a branch (across all levels).
pub fn branch_total_keys(id: BranchId) -> usize {
let def = get_branch_definition(id);
def.levels.iter().map(|l| l.keys.len()).sum()
}
/// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
let def = get_branch_definition(id);
def.levels
.iter()
.flat_map(|l| l.keys.iter())
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
}
}
impl Default for SkillTree {
fn default() -> Self {
Self::new(SkillTreeProgress::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
for &ch in keys {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
}
#[test]
fn test_initial_state() {
let tree = SkillTree::default();
assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::InProgress);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Locked);
assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Locked);
}
#[test]
fn test_total_unique_keys() {
let tree = SkillTree::default();
assert_eq!(tree.total_unique_keys, 96);
}
#[test]
fn test_initial_lowercase_unlocked() {
let tree = SkillTree::default();
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS);
assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']);
}
#[test]
fn test_lowercase_progressive_unlock() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
tree.update(&stats);
// Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), 7);
assert!(keys.contains(&'s'));
}
#[test]
fn test_lowercase_completion_unlocks_branches() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all 26 lowercase keys confident
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
tree.update(&stats);
}
assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::Complete);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::ProsePunctuation), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::Whitespace), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::CodeSymbols), BranchStatus::Available);
}
#[test]
fn test_start_branch() {
let mut tree = SkillTree::default();
// Force capitals to Available
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::Available;
tree.start_branch(BranchId::Capitals);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 0);
}
#[test]
fn test_branch_level_advancement() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Set capitals to InProgress at level 0
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make level 1 capitals confident: T I A S W H B M
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
tree.update(&stats);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress);
}
#[test]
fn test_branch_completion() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make all capital letter levels confident
let all_caps: Vec<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
// Update multiple times for level advancement
for _ in 0..5 {
tree.update(&stats);
}
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Complete);
}
#[test]
fn test_shared_key_confidence() {
let _tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// '-' is shared between ProsePunctuation L2 and CodeSymbols L1
// Master it once
make_stats_confident(&mut stats, &['-']);
// Both branches should see it as confident
assert!(stats.get_confidence('-') >= 1.0);
}
#[test]
fn test_focused_key_global() {
let tree = SkillTree::default();
let stats = KeyStatsStore::default();
// All keys at 0 confidence, focused should be first in order
let focused = tree.focused_key(DrillScope::Global, &stats);
assert!(focused.is_some());
// Should be one of the initial 6 lowercase keys
assert!(
['e', 't', 'a', 'o', 'i', 'n'].contains(&focused.unwrap()),
"focused: {:?}",
focused
);
}
#[test]
fn test_focused_key_branch() {
let mut tree = SkillTree::default();
let stats = KeyStatsStore::default();
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
let focused = tree.focused_key(DrillScope::Branch(BranchId::Capitals), &stats);
assert!(focused.is_some());
// Should be one of level 1 capitals
assert!(
['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'].contains(&focused.unwrap()),
"focused: {:?}",
focused
);
}
#[test]
fn test_complexity_scales() {
let tree = SkillTree::default();
let initial_complexity = tree.complexity();
assert!(initial_complexity > 0.0);
assert!(initial_complexity < 1.0);
// Full unlock should give complexity ~1.0
let mut full_tree = SkillTree::default();
for id in BranchId::all() {
let bp = full_tree.branch_progress_mut(*id);
bp.status = BranchStatus::Complete;
}
let full_complexity = full_tree.complexity();
assert!((full_complexity - 1.0).abs() < 0.01);
}
#[test]
fn test_branch_keys_for_drill() {
let mut tree = SkillTree::default();
// Set lowercase complete, capitals in progress at level 1
tree.branch_progress_mut(BranchId::Lowercase).status = BranchStatus::Complete;
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 1;
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
// Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10)
assert!(keys.contains(&'e')); // lowercase background
assert!(keys.contains(&'T')); // Capitals L1
assert!(keys.contains(&'J')); // Capitals L2 (current level)
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
}
}

123
src/generator/capitalize.rs Normal file
View File

@@ -0,0 +1,123 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that capitalizes words in generated text.
/// Only capitalizes using letters from `unlocked_capitals`.
pub fn apply_capitalization(
text: &str,
unlocked_capitals: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_capitals.is_empty() {
return text.to_string();
}
// If focused key is an uppercase letter, boost its probability
let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
let mut result = String::with_capacity(text.len());
let mut at_sentence_start = true;
for (i, ch) in text.chars().enumerate() {
if at_sentence_start && ch.is_ascii_lowercase() {
let upper = ch.to_ascii_uppercase();
if unlocked_capitals.contains(&upper) {
result.push(upper);
at_sentence_start = false;
continue;
}
}
// After period/question/exclamation + space, next word starts a sentence
if ch == ' ' && i > 0 {
let prev = text.as_bytes().get(i - 1).map(|&b| b as char);
if matches!(prev, Some('.' | '?' | '!')) {
at_sentence_start = true;
}
}
// Capitalize word starts: boosted for focused key, ~12% for others
if ch.is_ascii_lowercase() && !at_sentence_start {
let is_word_start = i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' ');
if is_word_start {
let upper = ch.to_ascii_uppercase();
if unlocked_capitals.contains(&upper) {
let prob = if focused_upper == Some(upper) { 0.40 } else { 0.12 };
if rng.gen_bool(prob) {
result.push(upper);
continue;
}
}
}
}
if ch != '.' && ch != '?' && ch != '!' {
at_sentence_start = false;
}
result.push(ch);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_caps_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_capitalizes_first_word() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("hello world", &['H', 'W'], None, &mut rng);
assert!(result.starts_with('H'));
}
#[test]
fn test_only_capitalizes_unlocked() {
let mut rng = SmallRng::seed_from_u64(42);
// Only 'W' is unlocked, not 'H'
let result = apply_capitalization("hello world", &['W'], None, &mut rng);
assert!(result.starts_with('h')); // 'H' not unlocked
}
#[test]
fn test_after_period() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("one. two", &['O', 'T'], None, &mut rng);
assert!(result.starts_with('O'));
assert!(result.contains("Two") || result.contains("two"));
// At minimum, first word should be capitalized
}
#[test]
fn test_focused_capital_boosted() {
// With focused 'W', W capitalization should happen more often
let caps = &['H', 'W'];
let mut focused_count = 0;
let mut unfocused_count = 0;
// Run many trials to check statistical boosting
for seed in 0..200 {
let mut rng = SmallRng::seed_from_u64(seed);
let text = "hello world wide web wonder what where who will work";
let result = apply_capitalization(text, caps, Some('W'), &mut rng);
// Count W capitalizations (skip first word which is always capitalized if 'H' is available)
focused_count += result.matches('W').count();
let mut rng2 = SmallRng::seed_from_u64(seed);
let result2 = apply_capitalization(text, caps, None, &mut rng2);
unfocused_count += result2.matches('W').count();
}
assert!(
focused_count > unfocused_count,
"Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})"
);
}
}

View File

@@ -0,0 +1,220 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts code-like expressions into text.
/// Only uses symbols from `unlocked_symbols`.
pub fn apply_code_symbols(
text: &str,
unlocked_symbols: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_symbols.is_empty() {
return text.to_string();
}
// If focused key is a code symbol, boost insertion probability
let focused_symbol = focused.filter(|ch| unlocked_symbols.contains(ch));
let base_prob = if focused_symbol.is_some() { 0.35 } else { 0.20 };
let words: Vec<&str> = text.split(' ').collect();
let mut result = Vec::new();
for word in &words {
if rng.gen_bool(base_prob) {
let expr = generate_code_expr(word, unlocked_symbols, focused_symbol, rng);
result.push(expr);
} else {
result.push(word.to_string());
}
}
result.join(" ")
}
fn generate_code_expr(
word: &str,
symbols: &[char],
focused_symbol: Option<char>,
rng: &mut SmallRng,
) -> String {
// Categorize available symbols
let has = |ch: char| symbols.contains(&ch);
// Try various patterns based on available symbols
let mut patterns: Vec<Box<dyn Fn(&mut SmallRng) -> String>> = Vec::new();
// Track which patterns use the focused symbol for priority selection
let mut focused_patterns: Vec<usize> = Vec::new();
// Arithmetic & Assignment patterns
if has('=') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} = val")));
if focused_symbol == Some('=') { focused_patterns.push(idx); }
}
if has('+') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} + num")));
if focused_symbol == Some('+') { focused_patterns.push(idx); }
}
if has('*') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} * cnt")));
if focused_symbol == Some('*') { focused_patterns.push(idx); }
}
if has('/') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} / max")));
if focused_symbol == Some('/') { focused_patterns.push(idx); }
}
if has('-') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} - one")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("-{w}")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
}
if has('=') && has('+') {
let w = word.to_string();
patterns.push(Box::new(move |_| format!("{w} += one")));
}
if has('=') && has('-') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} -= one")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
}
if has('=') && has('=') {
let w = word.to_string();
patterns.push(Box::new(move |_| format!("{w} == nil")));
}
// Grouping patterns
if has('{') && has('}') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{{ {w} }}")));
if matches!(focused_symbol, Some('{') | Some('}')) { focused_patterns.push(idx); }
}
if has('[') && has(']') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w}[idx]")));
if matches!(focused_symbol, Some('[') | Some(']')) { focused_patterns.push(idx); }
}
if has('<') && has('>') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("Vec<{w}>")));
if matches!(focused_symbol, Some('<') | Some('>')) { focused_patterns.push(idx); }
}
// Logic patterns
if has('&') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("&{w}")));
if focused_symbol == Some('&') { focused_patterns.push(idx); }
}
if has('|') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} | nil")));
if focused_symbol == Some('|') { focused_patterns.push(idx); }
}
if has('!') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("!{w}")));
if focused_symbol == Some('!') { focused_patterns.push(idx); }
}
// Special patterns
if has('@') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("@{w}")));
if focused_symbol == Some('@') { focused_patterns.push(idx); }
}
if has('#') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("#{w}")));
if focused_symbol == Some('#') { focused_patterns.push(idx); }
}
if has('_') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w}_val")));
if focused_symbol == Some('_') { focused_patterns.push(idx); }
}
if has('$') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("${w}")));
if focused_symbol == Some('$') { focused_patterns.push(idx); }
}
if has('\\') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("\\{w}")));
if focused_symbol == Some('\\') { focused_patterns.push(idx); }
}
if patterns.is_empty() {
return word.to_string();
}
// 50% chance to prefer a pattern that uses the focused symbol
let idx = if !focused_patterns.is_empty() && rng.gen_bool(0.50) {
focused_patterns[rng.gen_range(0..focused_patterns.len())]
} else {
rng.gen_range(0..patterns.len())
};
patterns[idx](rng)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_symbols_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_code_symbols("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_uses_only_unlocked_symbols() {
let mut rng = SmallRng::seed_from_u64(42);
let symbols = ['=', '+'];
let text = "a b c d e f g h i j";
let result = apply_code_symbols(text, &symbols, None, &mut rng);
for ch in result.chars() {
if !ch.is_alphanumeric() && ch != ' ' {
assert!(
symbols.contains(&ch),
"Unexpected symbol '{ch}' in: {result}"
);
}
}
}
#[test]
fn test_dash_patterns_generated() {
let mut rng = SmallRng::seed_from_u64(42);
let symbols = ['-', '='];
let text = "a b c d e f g h i j k l m n o p q r s t";
let result = apply_code_symbols(text, &symbols, None, &mut rng);
assert!(result.contains('-'), "Expected dash in: {result}");
}
}

View File

@@ -245,11 +245,11 @@ impl TextGenerator for CodeSyntaxGenerator {
result.push(snippet.to_string()); result.push(snippet.to_string());
} }
result.join(" ") result.join("\n\n")
} }
} }
/// Extract function-length snippets from raw source code /// Extract function-length snippets from raw source code, preserving whitespace.
fn extract_code_snippets(source: &str) -> Vec<String> { fn extract_code_snippets(source: &str) -> Vec<String> {
let mut snippets = Vec::new(); let mut snippets = Vec::new();
let lines: Vec<&str> = source.lines().collect(); let lines: Vec<&str> = source.lines().collect();
@@ -285,11 +285,11 @@ fn extract_code_snippets(source: &str) -> Vec<String> {
} }
if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 { if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 {
let snippet = snippet_lines.join(" "); // Preserve original newlines and indentation
// Normalize whitespace let snippet = snippet_lines.join("\n");
let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" "); let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count();
if normalized.len() >= 20 && normalized.len() <= 500 { if char_count >= 20 && snippet.len() <= 800 {
snippets.push(normalized); snippets.push(snippet);
} }
} }

View File

@@ -1,9 +1,13 @@
pub mod cache; pub mod cache;
pub mod capitalize;
pub mod code_patterns;
pub mod code_syntax; pub mod code_syntax;
pub mod dictionary; pub mod dictionary;
pub mod github_code; pub mod github_code;
pub mod numbers;
pub mod passage; pub mod passage;
pub mod phonetic; pub mod phonetic;
pub mod punctuate;
pub mod transition_table; pub mod transition_table;
use crate::engine::filter::CharFilter; use crate::engine::filter::CharFilter;

132
src/generator/numbers.rs Normal file
View File

@@ -0,0 +1,132 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts number expressions into text.
/// Only uses digits from `unlocked_digits`.
pub fn apply_numbers(
text: &str,
unlocked_digits: &[char],
has_dot: bool,
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_digits.is_empty() {
return text.to_string();
}
// If focused key is a digit, boost number insertion probability
let focused_digit = focused.filter(|ch| ch.is_ascii_digit());
let base_prob = if focused_digit.is_some() { 0.30 } else { 0.15 };
let words: Vec<&str> = text.split(' ').collect();
let mut result = Vec::new();
for word in &words {
if rng.gen_bool(base_prob) {
let expr = generate_number_expr(unlocked_digits, has_dot, focused_digit, rng);
result.push(expr);
} else {
result.push(word.to_string());
}
}
result.join(" ")
}
fn generate_number_expr(
digits: &[char],
has_dot: bool,
focused_digit: Option<char>,
rng: &mut SmallRng,
) -> String {
// Determine how many patterns are available (version pattern needs dot)
let max_pattern = if has_dot { 5 } else { 4 };
let pattern = rng.gen_range(0..max_pattern);
let num = match pattern {
0 => {
// Simple count: "3" or "42"
random_number(digits, 1, 3, focused_digit, rng)
}
1 => {
// Measurement: "7 miles" or "42 items"
let num = random_number(digits, 1, 2, focused_digit, rng);
let units = ["items", "miles", "days", "lines", "times", "parts"];
let unit = units[rng.gen_range(0..units.len())];
return format!("{num} {unit}");
}
2 => {
// Year-like: "2024"
random_number(digits, 4, 4, focused_digit, rng)
}
3 => {
// ID: "room 42" or "page 7"
let prefixes = ["room", "page", "step", "item", "line", "port"];
let prefix = prefixes[rng.gen_range(0..prefixes.len())];
let num = random_number(digits, 1, 3, focused_digit, rng);
return format!("{prefix} {num}");
}
_ => {
// Version-like: "3.14" or "2.0" (only when dot is available)
let major = random_number(digits, 1, 1, focused_digit, rng);
let minor = random_number(digits, 1, 2, focused_digit, rng);
return format!("{major}.{minor}");
}
};
num
}
fn random_number(
digits: &[char],
min_len: usize,
max_len: usize,
focused_digit: Option<char>,
rng: &mut SmallRng,
) -> String {
let len = rng.gen_range(min_len..=max_len);
(0..len)
.map(|_| {
// 40% chance to use the focused digit if it's a digit
if let Some(fd) = focused_digit {
if rng.gen_bool(0.40) {
return fd;
}
}
digits[rng.gen_range(0..digits.len())]
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_numbers_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_numbers("hello world", &[], false, None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_numbers_use_only_unlocked_digits() {
let mut rng = SmallRng::seed_from_u64(42);
let digits = ['1', '2', '3'];
let text = "a b c d e f g h i j k l m n o p q r s t";
let result = apply_numbers(text, &digits, false, None, &mut rng);
for ch in result.chars() {
if ch.is_ascii_digit() {
assert!(digits.contains(&ch), "Unexpected digit {ch} in: {result}");
}
}
}
#[test]
fn test_no_dot_without_punctuation() {
let mut rng = SmallRng::seed_from_u64(42);
let digits = ['1', '2', '3', '4', '5'];
let text = "a b c d e f g h i j k l m n o p q r s t";
let result = apply_numbers(text, &digits, false, None, &mut rng);
assert!(!result.contains('.'), "Should not contain dot when has_dot=false: {result}");
}
}

213
src/generator/punctuate.rs Normal file
View File

@@ -0,0 +1,213 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts punctuation into generated text.
/// Only uses punctuation chars from `unlocked_punct`.
pub fn apply_punctuation(
text: &str,
unlocked_punct: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_punct.is_empty() {
return text.to_string();
}
// If focused key is a punctuation char in our set, boost its insertion probability
let focused_punct = focused.filter(|ch| unlocked_punct.contains(ch));
let words: Vec<&str> = text.split(' ').collect();
if words.is_empty() {
return text.to_string();
}
let has_period = unlocked_punct.contains(&'.');
let has_comma = unlocked_punct.contains(&',');
let has_apostrophe = unlocked_punct.contains(&'\'');
let has_semicolon = unlocked_punct.contains(&';');
let has_colon = unlocked_punct.contains(&':');
let has_quote = unlocked_punct.contains(&'"');
let has_dash = unlocked_punct.contains(&'-');
let has_question = unlocked_punct.contains(&'?');
let has_exclaim = unlocked_punct.contains(&'!');
let has_open_paren = unlocked_punct.contains(&'(');
let has_close_paren = unlocked_punct.contains(&')');
let mut result = Vec::new();
let mut words_since_period = 0;
let mut words_since_comma = 0;
for (i, word) in words.iter().enumerate() {
let mut w = word.to_string();
// Contractions (~8% of words, boosted if apostrophe is focused)
let apostrophe_prob = if focused_punct == Some('\'') { 0.30 } else { 0.08 };
if has_apostrophe && w.len() >= 3 && rng.gen_bool(apostrophe_prob) {
w = make_contraction(&w, rng);
}
// Compound words with dash (~5% of words, boosted if dash is focused)
let dash_prob = if focused_punct == Some('-') { 0.25 } else { 0.05 };
if has_dash && i + 1 < words.len() && rng.gen_bool(dash_prob) {
w.push('-');
}
// Sentence ending punctuation
words_since_period += 1;
let end_sentence = words_since_period >= 8 && rng.gen_bool(0.15)
|| words_since_period >= 12;
if end_sentence && i < words.len() - 1 {
let q_prob = if focused_punct == Some('?') { 0.40 } else { 0.15 };
let excl_prob = if focused_punct == Some('!') { 0.40 } else { 0.10 };
if has_question && rng.gen_bool(q_prob) {
w.push('?');
} else if has_exclaim && rng.gen_bool(excl_prob) {
w.push('!');
} else if has_period {
w.push('.');
}
words_since_period = 0;
words_since_comma = 0;
} else {
// Comma after clause (~every 4-6 words)
words_since_comma += 1;
let comma_prob = if focused_punct == Some(',') { 0.40 } else { 0.20 };
if has_comma && words_since_comma >= 4 && rng.gen_bool(comma_prob) && i < words.len() - 1 {
w.push(',');
words_since_comma = 0;
}
// Semicolon between clauses (rare, boosted if focused)
let semi_prob = if focused_punct == Some(';') { 0.25 } else { 0.05 };
if has_semicolon && words_since_comma >= 5 && rng.gen_bool(semi_prob) && i < words.len() - 1 {
w.push(';');
words_since_comma = 0;
}
// Colon before list-like content (rare, boosted if focused)
let colon_prob = if focused_punct == Some(':') { 0.20 } else { 0.03 };
if has_colon && rng.gen_bool(colon_prob) && i < words.len() - 1 {
w.push(':');
}
}
// Quoted phrases (~5% chance to start a quote, boosted if focused)
let quote_prob = if focused_punct == Some('"') { 0.20 } else { 0.04 };
if has_quote && rng.gen_bool(quote_prob) && i + 2 < words.len() {
w = format!("\"{w}");
}
// Parenthetical asides (rare, boosted if focused)
let paren_prob = if matches!(focused_punct, Some('(' | ')')) { 0.15 } else { 0.03 };
if has_open_paren && has_close_paren && rng.gen_bool(paren_prob) && i + 2 < words.len() {
w = format!("({w}");
}
result.push(w);
}
// End with period if we have it
if has_period {
if let Some(last) = result.last_mut() {
let last_char = last.chars().last();
if !matches!(last_char, Some('.' | '?' | '!' | '"' | ')')) {
last.push('.');
}
}
}
// Close any open quotes/parens
let mut open_quotes = 0i32;
let mut open_parens = 0i32;
for w in &result {
for ch in w.chars() {
if ch == '"' { open_quotes += 1; }
if ch == '(' { open_parens += 1; }
if ch == ')' { open_parens -= 1; }
}
}
if let Some(last) = result.last_mut() {
if open_quotes % 2 != 0 && has_quote {
// Remove trailing period to put quote after
let had_period = last.ends_with('.');
if had_period {
last.pop();
}
last.push('"');
if had_period {
last.push('.');
}
}
if open_parens > 0 && has_close_paren {
let had_period = last.ends_with('.');
if had_period {
last.pop();
}
last.push(')');
if had_period {
last.push('.');
}
}
}
result.join(" ")
}
fn make_contraction(word: &str, rng: &mut SmallRng) -> String {
// Simple contractions based on common patterns
let contractions: &[(&str, &str)] = &[
("not", "n't"),
("will", "'ll"),
("would", "'d"),
("have", "'ve"),
("are", "'re"),
("is", "'s"),
];
for &(base, suffix) in contractions {
if word == base {
// For "not" -> "don't", "can't", etc. - just return the contraction form
return format!("{word}{suffix}");
}
}
// Generic: ~chance to add 's
if rng.gen_bool(0.5) {
format!("{word}'s")
} else {
word.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_punct_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_punctuation("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_adds_period_at_end() {
let mut rng = SmallRng::seed_from_u64(42);
let text = "one two three four five six seven eight nine ten";
let result = apply_punctuation(text, &['.'], None, &mut rng);
assert!(result.ends_with('.'));
}
#[test]
fn test_period_appears_mid_text() {
let mut rng = SmallRng::seed_from_u64(42);
let words: Vec<&str> = (0..20).map(|_| "word").collect();
let text = words.join(" ");
let result = apply_punctuation(&text, &['.', ','], None, &mut rng);
// Should have at least one period somewhere in the middle
let period_count = result.chars().filter(|&c| c == '.').count();
assert!(period_count >= 1, "Expected periods in: {result}");
}
}

View File

@@ -29,7 +29,9 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Terminal; use ratatui::Terminal;
use app::{App, AppScreen, DrillMode}; use app::{App, AppScreen, DrillMode};
use engine::skill_tree::DrillScope;
use session::result::DrillResult; use session::result::DrillResult;
use ui::components::skill_tree::{SkillTreeWidget, selectable_branches};
use event::{AppEvent, EventHandler}; use event::{AppEvent, EventHandler};
use ui::components::dashboard::Dashboard; use ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::keyboard_diagram::KeyboardDiagram;
@@ -160,6 +162,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
AppScreen::DrillResult => handle_result_key(app, key), AppScreen::DrillResult => handle_result_key(app, key),
AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key),
AppScreen::Settings => handle_settings_key(app, key), AppScreen::Settings => handle_settings_key(app, key),
AppScreen::SkillTree => handle_skill_tree_key(app, key),
} }
} }
@@ -168,16 +171,20 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('1') => { KeyCode::Char('1') => {
app.drill_mode = DrillMode::Adaptive; app.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
KeyCode::Char('2') => { KeyCode::Char('2') => {
app.drill_mode = DrillMode::Code; app.drill_mode = DrillMode::Code;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
KeyCode::Char('3') => { KeyCode::Char('3') => {
app.drill_mode = DrillMode::Passage; app.drill_mode = DrillMode::Passage;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
KeyCode::Char('t') => app.go_to_skill_tree(),
KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(), KeyCode::Char('c') => app.go_to_settings(),
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(), KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
@@ -185,18 +192,22 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
KeyCode::Enter => match app.menu.selected { KeyCode::Enter => match app.menu.selected {
0 => { 0 => {
app.drill_mode = DrillMode::Adaptive; app.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
1 => { 1 => {
app.drill_mode = DrillMode::Code; app.drill_mode = DrillMode::Code;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
2 => { 2 => {
app.drill_mode = DrillMode::Passage; app.drill_mode = DrillMode::Passage;
app.drill_scope = DrillScope::Global;
app.start_drill(); app.start_drill();
} }
3 => app.go_to_stats(), 3 => app.go_to_skill_tree(),
4 => app.go_to_settings(), 4 => app.go_to_stats(),
5 => app.go_to_settings(),
_ => {} _ => {}
}, },
_ => {} _ => {}
@@ -204,13 +215,29 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
} }
fn handle_drill_key(app: &mut App, key: KeyEvent) { fn handle_drill_key(app: &mut App, key: KeyEvent) {
// Route Enter/Tab as typed characters during active drills
if app.drill.is_some() {
match key.code {
KeyCode::Enter => {
app.type_char('\n');
return;
}
KeyCode::Tab => {
app.type_char('\t');
return;
}
KeyCode::BackTab => return, // Ignore Shift+Tab
_ => {}
}
}
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0); let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
if has_progress && app.drill_mode != DrillMode::Adaptive { if has_progress && app.drill_mode != DrillMode::Adaptive {
// Non-adaptive: show result screen for partial drill // Non-adaptive: show result screen for partial drill
if let Some(ref drill) = app.drill { if let Some(ref drill) = app.drill {
let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str()); let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str(), app.drill_mode.is_ranked());
app.last_result = Some(result); app.last_result = Some(result);
} }
app.screen = AppScreen::DrillResult; app.screen = AppScreen::DrillResult;
@@ -317,6 +344,33 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
} }
} }
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
let branches = selectable_branches();
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Up | KeyCode::Char('k') => {
app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if app.skill_tree_selected + 1 < branches.len() {
app.skill_tree_selected += 1;
}
}
KeyCode::Enter => {
if app.skill_tree_selected < branches.len() {
let branch_id = branches[app.skill_tree_selected];
let status = app.skill_tree.branch_status(branch_id).clone();
if status == engine::skill_tree::BranchStatus::Available
|| status == engine::skill_tree::BranchStatus::InProgress
{
app.start_branch_drill(branch_id);
}
}
}
_ => {}
}
}
fn render(frame: &mut ratatui::Frame, app: &App) { fn render(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let colors = &app.theme.colors; let colors = &app.theme.colors;
@@ -330,6 +384,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
AppScreen::DrillResult => render_result(frame, app), AppScreen::DrillResult => render_result(frame, app),
AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::StatsDashboard => render_stats(frame, app),
AppScreen::Settings => render_settings(frame, app), AppScreen::Settings => render_settings(frame, app),
AppScreen::SkillTree => render_skill_tree(frame, app),
} }
} }
@@ -352,11 +407,11 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
String::new() String::new()
}; };
let header_info = format!( let header_info = format!(
" Level {} | Score {:.0} | {}/{} letters{}", " Level {} | Score {:.0} | {}/{} keys{}",
crate::engine::scoring::level_from_score(app.profile.total_score), crate::engine::scoring::level_from_score(app.profile.total_score),
app.profile.total_score, app.profile.total_score,
app.letter_unlock.unlocked_count(), app.skill_tree.total_unlocked_count(),
app.letter_unlock.total_letters(), app.skill_tree.total_unique_keys,
streak_text, streak_text,
); );
let header = Paragraph::new(Line::from(vec![ let header = Paragraph::new(Line::from(vec![
@@ -381,7 +436,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(&app.menu, menu_area); frame.render_widget(&app.menu, menu_area);
let footer = Paragraph::new(Line::from(vec![Span::styled( let footer = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [s] Stats [q] Quit ", " [1-3] Start [t] Skill Tree [s] Stats [q] Quit ",
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
)])); )]));
frame.render_widget(footer, layout[2]); frame.render_widget(footer, layout[2]);
@@ -397,8 +452,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let mode_name = match app.drill_mode { let mode_name = match app.drill_mode {
DrillMode::Adaptive => "Adaptive", DrillMode::Adaptive => "Adaptive",
DrillMode::Code => "Code", DrillMode::Code => "Code (Unranked)",
DrillMode::Passage => "Passage", DrillMode::Passage => "Passage (Unranked)",
}; };
// For medium/narrow: show compact stats in header // For medium/narrow: show compact stats in header
@@ -420,7 +475,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(header, app_layout.header); frame.render_widget(header, app_layout.header);
} else { } else {
let header_title = format!(" {mode_name} Drill "); let header_title = format!(" {mode_name} Drill ");
let focus_text = if let Some(focused) = app.letter_unlock.focused { let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let focus_text = if let Some(focused) = focused {
format!(" | Focus: '{focused}'") format!(" | Focus: '{focused}'")
} else { } else {
String::new() String::new()
@@ -466,9 +522,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let mut idx = 1; let mut idx = 1;
if show_progress { if show_progress {
let unlocked = app.skill_tree.total_unlocked_count() as f64;
let total = app.skill_tree.total_unique_keys as f64;
let progress_val = (unlocked / total).min(1.0);
let progress = ProgressBar::new( let progress = ProgressBar::new(
"Letter Progress", "Key Progress",
app.letter_unlock.progress(), progress_val,
app.theme, app.theme,
); );
frame.render_widget(progress, main_layout[idx]); frame.render_widget(progress, main_layout[idx]);
@@ -477,10 +536,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
if show_kbd { if show_kbd {
let next_char = drill.target.get(drill.cursor).copied(); let next_char = drill.target.get(drill.cursor).copied();
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let kbd = KeyboardDiagram::new( let kbd = KeyboardDiagram::new(
app.letter_unlock.focused, focused,
next_char, next_char,
&app.letter_unlock.included, &unlocked_keys,
&app.depressed_keys, &app.depressed_keys,
app.theme, app.theme,
) )
@@ -609,3 +670,15 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
))); )));
footer.render(layout[3], frame.buffer_mut()); footer.render(layout[3], frame.buffer_mut());
} }
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let centered = ui::layout::centered_rect(70, 90, area);
let widget = SkillTreeWidget::new(
&app.skill_tree,
&app.key_stats,
app.skill_tree_selected,
app.theme,
);
frame.render_widget(widget, centered);
}

View File

@@ -17,12 +17,18 @@ pub struct DrillResult {
pub per_key_times: Vec<KeyTime>, pub per_key_times: Vec<KeyTime>,
#[serde(default = "default_drill_mode", alias = "lesson_mode")] #[serde(default = "default_drill_mode", alias = "lesson_mode")]
pub drill_mode: String, pub drill_mode: String,
#[serde(default = "default_true")]
pub ranked: bool,
} }
fn default_drill_mode() -> String { fn default_drill_mode() -> String {
"adaptive".to_string() "adaptive".to_string()
} }
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyTime { pub struct KeyTime {
pub key: char, pub key: char,
@@ -31,7 +37,7 @@ pub struct KeyTime {
} }
impl DrillResult { impl DrillResult {
pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str) -> Self { pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str, ranked: bool) -> Self {
let per_key_times: Vec<KeyTime> = events let per_key_times: Vec<KeyTime> = events
.windows(2) .windows(2)
.map(|pair| { .map(|pair| {
@@ -63,6 +69,7 @@ impl DrillResult {
timestamp: Utc::now(), timestamp: Utc::now(),
per_key_times, per_key_times,
drill_mode: drill_mode.to_string(), drill_mode: drill_mode.to_string(),
ranked,
} }
} }
} }

View File

@@ -49,8 +49,17 @@ impl JsonStore {
Ok(()) Ok(())
} }
pub fn load_profile(&self) -> ProfileData { /// Load and deserialize profile. Returns None if file exists but
self.load("profile.json") /// cannot be parsed (schema mismatch / corruption).
pub fn load_profile(&self) -> Option<ProfileData> {
let path = self.file_path("profile.json");
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
// No file yet — return fresh default (not a schema mismatch)
Some(ProfileData::default())
}
} }
pub fn save_profile(&self, data: &ProfileData) -> Result<()> { pub fn save_profile(&self, data: &ProfileData) -> Result<()> {

View File

@@ -1,14 +1,15 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::SkillTreeProgress;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData { pub struct ProfileData {
pub schema_version: u32, pub schema_version: u32,
pub unlocked_letters: Vec<char>, pub skill_tree: SkillTreeProgress,
pub total_score: f64, pub total_score: f64,
#[serde(alias = "total_lessons")] #[serde(alias = "total_lessons")]
pub total_drills: u32, pub total_drills: u32,
@@ -21,7 +22,7 @@ impl Default for ProfileData {
fn default() -> Self { fn default() -> Self {
Self { Self {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
unlocked_letters: Vec::new(), skill_tree: SkillTreeProgress::default(),
total_score: 0.0, total_score: 0.0,
total_drills: 0, total_drills: 0,
streak_days: 0, streak_days: 0,
@@ -31,6 +32,13 @@ impl Default for ProfileData {
} }
} }
impl ProfileData {
/// Check if loaded data has a stale schema version and needs reset.
pub fn needs_reset(&self) -> bool {
self.schema_version != SCHEMA_VERSION
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStatsData { pub struct KeyStatsData {
pub schema_version: u32, pub schema_version: u32,

View File

@@ -42,13 +42,20 @@ impl Widget for Dashboard<'_> {
]) ])
.split(inner); .split(inner);
let title = Paragraph::new(Line::from(Span::styled( let mut title_spans = vec![Span::styled(
"Results", "Results",
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
))) )];
.alignment(Alignment::Center); if !self.result.ranked {
title_spans.push(Span::styled(
" (Unranked \u{2014} does not count toward skill tree)",
Style::default().fg(colors.text_pending()),
));
}
let title = Paragraph::new(Line::from(title_spans))
.alignment(Alignment::Center);
title.render(layout[0], buf); title.render(layout[0], buf);
let wpm_text = format!("{:.0} WPM", self.result.wpm); let wpm_text = format!("{:.0} WPM", self.result.wpm);

View File

@@ -37,6 +37,11 @@ impl<'a> Menu<'a> {
label: "Passage Drill".to_string(), label: "Passage Drill".to_string(),
description: "Type passages from books".to_string(), description: "Type passages from books".to_string(),
}, },
MenuItem {
key: "t".to_string(),
label: "Skill Tree".to_string(),
description: "View progression branches and launch drills".to_string(),
},
MenuItem { MenuItem {
key: "s".to_string(), key: "s".to_string(),
label: "Statistics".to_string(), label: "Statistics".to_string(),

View File

@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod keyboard_diagram; pub mod keyboard_diagram;
pub mod menu; pub mod menu;
pub mod progress_bar; pub mod progress_bar;
pub mod skill_tree;
pub mod stats_dashboard; pub mod stats_dashboard;
pub mod stats_sidebar; pub mod stats_sidebar;
pub mod typing_area; pub mod typing_area;

View File

@@ -0,0 +1,354 @@
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::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine,
get_branch_definition,
};
use crate::ui::theme::Theme;
pub struct SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
theme: &'a Theme,
}
impl<'a> SkillTreeWidget<'a> {
pub fn new(
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
theme: &'a Theme,
) -> Self {
Self {
skill_tree,
key_stats,
selected,
theme,
}
}
}
/// Get the list of selectable branch IDs (all non-Lowercase branches).
pub fn selectable_branches() -> Vec<BranchId> {
vec![
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
]
}
impl Widget for SkillTreeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Skill Tree ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
let branches = selectable_branches();
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
// --- Branch list ---
self.render_branch_list(layout[0], buf, &branches);
// --- Separator ---
let sep = Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(layout[1].width as usize),
Style::default().fg(colors.border()),
)));
sep.render(layout[1], buf);
// --- Detail panel for selected branch ---
self.render_detail_panel(layout[2], buf, &branches);
// --- Footer ---
let footer_text = if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
" Complete a-z to unlock branches "
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress {
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back "
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
}
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
};
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
Style::default().fg(colors.text_pending()),
)));
footer.render(layout[3], buf);
}
}
impl SkillTreeWidget<'_> {
fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new();
// Root: Lowercase a-z
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_total = lowercase_def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let lowercase_confident = self.skill_tree.branch_confident_keys(BranchId::Lowercase, self.key_stats);
let (prefix, style) = match lowercase_bp.status {
BranchStatus::Complete => (
"\u{2605} ",
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD),
),
BranchStatus::InProgress => (
"\u{25b6} ",
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
),
_ => (
" ",
Style::default().fg(colors.text_pending()),
),
};
let status_text = match lowercase_bp.status {
BranchStatus::Complete => "COMPLETE".to_string(),
BranchStatus::InProgress => {
let unlocked = self.skill_tree.lowercase_unlocked_count();
format!("{unlocked}/{lowercase_total}")
}
_ => "LOCKED".to_string(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {prefix}{name}", name = lowercase_def.name),
style,
),
Span::styled(
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
Style::default().fg(colors.text_pending()),
),
]));
// Progress bar for lowercase
let pct = if lowercase_total > 0 {
lowercase_confident as f64 / lowercase_total as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
// Separator
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
)));
// Branches
for (i, &branch_id) in branches.iter().enumerate() {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let confident_keys = self.skill_tree.branch_confident_keys(branch_id, self.key_stats);
let is_selected = i == self.selected;
let (prefix, style) = match bp.status {
BranchStatus::Complete => (
"\u{2605} ",
if is_selected {
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD)
},
),
BranchStatus::InProgress => (
"\u{25b6} ",
if is_selected {
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)
},
),
BranchStatus::Available => (
" ",
if is_selected {
Style::default().fg(colors.fg()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.fg())
},
),
BranchStatus::Locked => (
" ",
Style::default().fg(colors.text_pending()),
),
};
let status_text = match bp.status {
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"),
BranchStatus::InProgress => format!("Lvl {}/{} {confident_keys}/{total_keys} keys", bp.current_level + 1, def.levels.len()),
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
};
let sel_indicator = if is_selected { "> " } else { " " };
lines.push(Line::from(vec![
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
Span::styled(format!(" {status_text}"), Style::default().fg(colors.text_pending())),
]));
let pct = if total_keys > 0 {
confident_keys as f64 / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
}
let paragraph = Paragraph::new(lines);
paragraph.render(area, buf);
}
fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
let colors = &self.theme.colors;
if self.selected >= branches.len() {
return;
}
let branch_id = branches[self.selected];
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let mut lines: Vec<Line> = Vec::new();
// Branch title with level info
let level_text = match bp.status {
BranchStatus::InProgress => format!("Level {}/{}", bp.current_level + 1, def.levels.len()),
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()),
_ => format!("Level 0/{}", def.levels.len()),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {}", def.name),
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {level_text}"),
Style::default().fg(colors.text_pending()),
),
]));
// Per-level key breakdown
let focused = self.skill_tree.focused_key(DrillScope::Branch(branch_id), self.key_stats);
for (level_idx, level) in def.levels.iter().enumerate() {
let level_status = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
"complete"
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
"in progress"
} else {
"locked"
};
let mut key_spans: Vec<Span> = Vec::new();
key_spans.push(Span::styled(
format!(" L{}: ", level_idx + 1),
Style::default().fg(colors.fg()),
));
for &key in level.keys {
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
let is_focused = focused == Some(key);
let display = if key == '\n' {
"\\n".to_string()
} else if key == '\t' {
"\\t".to_string()
} else {
key.to_string()
};
let style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
.add_modifier(Modifier::BOLD)
} else if is_confident {
Style::default().fg(colors.text_correct())
} else if level_status == "locked" {
Style::default().fg(colors.text_pending())
} else {
Style::default().fg(colors.fg())
};
key_spans.push(Span::styled(display, style));
key_spans.push(Span::raw(" "));
}
key_spans.push(Span::styled(
format!(" ({level_status})"),
Style::default().fg(colors.text_pending()),
));
lines.push(Line::from(key_spans));
}
// Average confidence
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let avg_conf = if total_keys > 0 {
let sum: f64 = def.levels.iter()
.flat_map(|l| l.keys.iter())
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
.sum();
sum / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" Avg Confidence: {} {:.0}%", progress_bar_str(avg_conf, 20), avg_conf * 100.0),
Style::default().fg(colors.text_pending()),
)));
let paragraph = Paragraph::new(lines);
paragraph.render(area, buf);
}
}
fn progress_bar_str(pct: f64, width: usize) -> String {
let filled = (pct * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
format!(
"{}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(empty),
)
}

View File

@@ -488,7 +488,7 @@ impl StatsDashboard<'_> {
table_block.render(layout[0], buf); table_block.render(layout[0], buf);
let header = Line::from(vec![Span::styled( let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date", " # WPM Raw Acc% Time Date Mode",
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
@@ -523,8 +523,14 @@ impl StatsDashboard<'_> {
" " " "
}; };
let mode_str = if result.ranked {
""
} else {
" (unranked)"
};
let row = format!( let row = format!(
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str}" " {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}",
mode = result.drill_mode,
); );
let acc_color = if result.accuracy >= 95.0 { let acc_color = if result.accuracy >= 95.0 {
@@ -538,6 +544,9 @@ impl StatsDashboard<'_> {
let is_selected = i == self.history_selected; let is_selected = i == self.history_selected;
let style = if is_selected { let style = if is_selected {
Style::default().fg(acc_color).bg(colors.accent_dim()) Style::default().fg(acc_color).bg(colors.accent_dim())
} else if !result.ranked {
// Muted styling for unranked drills
Style::default().fg(colors.text_pending())
} else { } else {
Style::default().fg(acc_color) Style::default().fg(acc_color)
}; };

View File

@@ -19,43 +19,172 @@ impl<'a> TypingArea<'a> {
} }
} }
/// A render token maps a single target character to its display representation.
struct RenderToken {
target_idx: usize,
display: String,
is_line_break: bool,
}
/// Expand target chars into render tokens, handling whitespace display.
fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
let mut tokens = Vec::new();
let mut col = 0usize;
for (i, &ch) in target.iter().enumerate() {
match ch {
'\n' => {
tokens.push(RenderToken {
target_idx: i,
display: "\u{21b5}".to_string(), // ↵
is_line_break: true,
});
col = 0;
}
'\t' => {
let tab_width = 4 - (col % 4);
let mut display = String::from("\u{2192}"); // →
for _ in 1..tab_width {
display.push('\u{00b7}'); // ·
}
tokens.push(RenderToken {
target_idx: i,
display,
is_line_break: false,
});
col += tab_width;
}
_ => {
tokens.push(RenderToken {
target_idx: i,
display: ch.to_string(),
is_line_break: false,
});
col += 1;
}
}
}
tokens
}
impl Widget for TypingArea<'_> { impl Widget for TypingArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let mut spans: Vec<Span> = Vec::new(); let tokens = build_render_tokens(&self.drill.target);
for (i, &target_ch) in self.drill.target.iter().enumerate() { // Group tokens into lines, splitting on line_break tokens
if i < self.drill.cursor { let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
let style = match &self.drill.input[i] {
for token in &tokens {
let idx = token.target_idx;
let style = if idx < self.drill.cursor {
match &self.drill.input[idx] {
CharStatus::Correct => Style::default().fg(colors.text_correct()), CharStatus::Correct => Style::default().fg(colors.text_correct()),
CharStatus::Incorrect(_) => Style::default() CharStatus::Incorrect(_) => Style::default()
.fg(colors.text_incorrect()) .fg(colors.text_incorrect())
.bg(colors.text_incorrect_bg()) .bg(colors.text_incorrect_bg())
.add_modifier(Modifier::UNDERLINED), .add_modifier(Modifier::UNDERLINED),
}; }
let display = match &self.drill.input[i] { } else if idx == self.drill.cursor {
CharStatus::Incorrect(actual) => *actual, Style::default()
_ => target_ch,
};
spans.push(Span::styled(display.to_string(), style));
} else if i == self.drill.cursor {
let style = Style::default()
.fg(colors.text_cursor_fg()) .fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg()); .bg(colors.text_cursor_bg())
spans.push(Span::styled(target_ch.to_string(), style));
} else { } else {
let style = Style::default().fg(colors.text_pending()); Style::default().fg(colors.text_pending())
spans.push(Span::styled(target_ch.to_string(), style)); };
// For incorrect chars, show the actual typed char for regular chars,
// but always show the token display for whitespace markers
let display = if idx < self.drill.cursor {
if let CharStatus::Incorrect(actual) = &self.drill.input[idx] {
let target_ch = self.drill.target[idx];
if target_ch == '\n' || target_ch == '\t' {
// Show the whitespace marker even when incorrect
token.display.clone()
} else {
actual.to_string()
}
} else {
token.display.clone()
}
} else {
token.display.clone()
};
lines.last_mut().unwrap().push(Span::styled(display, style));
if token.is_line_break {
lines.push(Vec::new());
} }
} }
let line = Line::from(spans); let ratatui_lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
let block = Block::bordered() let block = Block::bordered()
.border_style(Style::default().fg(colors.border())) .border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false }); let paragraph = Paragraph::new(ratatui_lines)
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(area, buf); paragraph.render(area, buf);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_tokens_basic() {
let target: Vec<char> = "abc".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert_eq!(tokens[0].display, "a");
assert_eq!(tokens[1].display, "b");
assert_eq!(tokens[2].display, "c");
assert!(!tokens[0].is_line_break);
}
#[test]
fn test_render_tokens_newline() {
let target: Vec<char> = "a\nb".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert_eq!(tokens[1].display, "\u{21b5}"); // ↵
assert!(tokens[1].is_line_break);
assert_eq!(tokens[1].target_idx, 1);
}
#[test]
fn test_render_tokens_tab() {
let target: Vec<char> = "\tx".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 2);
// Tab at col 0: width = 4 - (0 % 4) = 4 => "→···"
assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
assert!(!tokens[0].is_line_break);
assert_eq!(tokens[0].target_idx, 0);
}
#[test]
fn test_render_tokens_tab_alignment() {
// "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "→·"
let target: Vec<char> = "ab\t".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens[2].display, "\u{2192}\u{00b7}");
}
#[test]
fn test_render_tokens_newline_resets_column() {
// "\n\tx" -> after newline, col resets to 0, tab_width = 4
let target: Vec<char> = "\n\tx".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert!(tokens[0].is_line_break);
assert_eq!(tokens[1].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
}
}