Skill tree progression system & whitespace support
This commit is contained in:
507
docs/plans/2026-02-15-skill-tree-progression-system.md
Normal file
507
docs/plans/2026-02-15-skill-tree-progression-system.md
Normal 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
|
||||||
203
src/app.rs
203
src/app.rs
@@ -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();
|
||||||
|
|
||||||
|
match pd {
|
||||||
|
Some(pd) if !pd.needs_reset() => {
|
||||||
|
let ksd = s.load_key_stats();
|
||||||
let lhd = s.load_drill_history();
|
let lhd = s.load_drill_history();
|
||||||
|
let st = SkillTree::new(pd.skill_tree.clone());
|
||||||
let lu = if pd.unlocked_letters.is_empty() {
|
(ksd.stats, st, pd, lhd.drills)
|
||||||
LetterUnlock::new()
|
}
|
||||||
} else {
|
_ => {
|
||||||
LetterUnlock::from_included(pd.unlocked_letters.clone())
|
// Schema mismatch or parse failure: full reset of all stores
|
||||||
};
|
(
|
||||||
|
KeyStatsStore::default(),
|
||||||
(ksd.stats, lu, pd, lhd.drills)
|
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 oldest→newest
|
// 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
854
src/engine/skill_tree.rs
Normal 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(¤t_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
123
src/generator/capitalize.rs
Normal 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})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/generator/code_patterns.rs
Normal file
220
src/generator/code_patterns.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
132
src/generator/numbers.rs
Normal 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
213
src/generator/punctuate.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/main.rs
101
src/main.rs
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<()> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -42,12 +42,19 @@ 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),
|
||||||
)))
|
)];
|
||||||
|
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);
|
.alignment(Alignment::Center);
|
||||||
title.render(layout[0], buf);
|
title.render(layout[0], buf);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
354
src/ui/components/skill_tree.rs
Normal file
354
src/ui/components/skill_tree.rs
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user