Key milestone overlays + keyboard diagram improvements
Also splits out a separate store for ranked stats from overall key stats.
This commit is contained in:
@@ -0,0 +1,530 @@
|
||||
# Plan: Key Milestone Overlays + Keyboard Diagram Improvements
|
||||
|
||||
## Context
|
||||
|
||||
The app progressively unlocks keys as users master them via the skill tree system. Currently, when a key is unlocked or mastered, there's no celebratory feedback. This plan adds encouraging milestone overlays with keyboard visualization and finger guidance. It also improves the keyboard diagram to render modifier keys (shift, tab, enter, space, backspace) as interactive keys rather than static labels, and adds a new Keyboard Explorer screen.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
This plan is structured into 5 independent phases that can be implemented and validated separately to reduce regression risk.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Key Display Adapter (prerequisite for all phases)
|
||||
|
||||
**File: `src/keyboard/display.rs` (new)**
|
||||
|
||||
Add a thin adapter module that centralizes all sentinel-char ↔ display-name conversions. This isolates encoding concerns so that UI, stats, and rendering code never directly match on sentinel chars.
|
||||
|
||||
```rust
|
||||
/// Human-readable display name for a key character (including sentinels).
|
||||
pub fn key_display_name(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Backspace",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Enter",
|
||||
' ' => "Space",
|
||||
_ => "", // caller uses ch.to_string() for printable chars
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for compact UI contexts (heatmaps, compact keyboard).
|
||||
pub fn key_short_label(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Bksp",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Ent",
|
||||
' ' => "Spc",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// All sentinel chars used for non-printable keys.
|
||||
pub const MODIFIER_SENTINELS: &[char] = &['\x08', '\t', '\n'];
|
||||
```
|
||||
|
||||
Register in `src/keyboard/mod.rs` with `pub mod display;`.
|
||||
|
||||
All subsequent phases use these functions instead of inline sentinel matching. This makes future migration to a typed `KeyId` a single-module change.
|
||||
|
||||
**Sentinel boundary policy:** Sentinel chars (`'\x08'`, `'\t'`, `'\n'`) are allowed only at two boundaries:
|
||||
1. **Input boundary** — `handle_key` in `src/main.rs` converts `KeyCode::Backspace/Tab/Enter` to sentinels for `depressed_keys` and drill input.
|
||||
2. **Storage boundary** — `KeyStatsStore` and `drill_history` store sentinels as `char` keys.
|
||||
|
||||
All UI rendering, stats display, and business logic must consume the adapter functions (`key_display_name`, `key_short_label`, `MODIFIER_SENTINELS`) rather than matching sentinels directly. Add a code comment at the top of `display.rs` documenting this policy.
|
||||
|
||||
**Enforcement:** Add a `#[test]` in `display.rs` that runs `grep -rn '\\\\x08\|\\\\t.*=>\|\\\\n.*=>' src/` (or equivalent) and asserts that direct sentinel matches only appear in allowed files (`display.rs`, `main.rs` input handling, `key_stats.rs`). This is a lightweight lint that catches accidental sentinel leakage in UI/business logic during `cargo test` without requiring CI changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Keyboard Diagram — Add Missing Keys & Shift Support
|
||||
|
||||
### 1a. Track modifier keys as depressed keys
|
||||
|
||||
**File: `src/main.rs` — `handle_key` function (~line 155)**
|
||||
|
||||
Currently only `KeyCode::Char(ch)` inserts into `depressed_keys`. Add tracking for:
|
||||
- `KeyCode::Backspace` → insert `'\x08'` into `depressed_keys`
|
||||
- `KeyCode::Tab` → insert `'\t'`
|
||||
- `KeyCode::Enter` → insert `'\n'`
|
||||
- Shift state is already tracked via `app.shift_held`
|
||||
|
||||
On `Release` events, remove these sentinels similarly to how `Char` releases work. The tick-based fallback clear (line 134-143) already handles `depressed_keys.clear()` which covers these sentinels too.
|
||||
|
||||
### 1b. Render modifier keys in the keyboard diagram
|
||||
|
||||
**File: `src/ui/components/keyboard_diagram.rs`**
|
||||
|
||||
Currently, modifiers are rendered as plain text labels (lines 253-286). Change them to rendered key boxes that participate in the highlight/depress system:
|
||||
|
||||
- **Row 0 (number row):** Add `[Bksp]` key after `=`/`+`. Highlight when `'\x08'` is in `depressed_keys`. Finger color: Right Pinky.
|
||||
- **Row 1 (top row):** Add `[Tab]` key before `q`. Highlight when `'\t'` is in `depressed_keys`. Finger color: Left Pinky.
|
||||
- **Row 2 (home row):** Add `[Enter]` key after `'`/`"`. Highlight when `'\n'` is in `depressed_keys`. Finger color: Right Pinky.
|
||||
- **Row 3 (bottom row):** Add `[Shft]` at start and end. Highlight when `shift_held` is true. Left Shift = Left Pinky finger color, Right Shift = Right Pinky finger color.
|
||||
- **Row 4 (new row):** Add `[ Space ]` centered. Highlight when `' '` is in `depressed_keys`. Finger color: Thumb.
|
||||
|
||||
Full layout visualization (full mode):
|
||||
```
|
||||
[ ` ][ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ][ 7 ][ 8 ][ 9 ][ 0 ][ - ][ = ][Bksp]
|
||||
[Tab][ q ][ w ][ e ][ r ][ t ][ y ][ u ][ i ][ o ][ p ][ [ ][ ] ][ \ ]
|
||||
[ ][ a ][ s ][ d ][ f ][ g ][ h ][ j ][ k ][ l ][ ; ][ ' ][Enter]
|
||||
[Shft][ z ][ x ][ c ][ v ][ b ][ n ][ m ][ , ][ . ][ / ][Shft]
|
||||
[ Space ]
|
||||
```
|
||||
|
||||
Note: Row 2 position before `a` renders as `[ ]` (caps lock, unused).
|
||||
|
||||
When `shift_held` is true:
|
||||
- Shift keys light up with their finger color (brightened)
|
||||
- All character keys show shifted variants (already implemented via `shift_held` field)
|
||||
|
||||
Compact mode: add `[S]` on each side of bottom row, and abbreviated `[T]`, `[E]`, `[B]` for Tab/Enter/Backspace where space permits.
|
||||
|
||||
Adaptive breakpoints for overlay/small terminal: if inner height < 6, skip space row; if < 5, use compact mode.
|
||||
|
||||
### 1c. Height adjustments
|
||||
|
||||
**File: `src/main.rs` — `render_drill` (~line 1011-1019)**
|
||||
|
||||
The `kbd_height` calculation needs to increase by 1-2 rows for the space row and modifier keys in full mode. Update:
|
||||
- Full mode: `kbd_height = 8` (5 rows + 2 border + 1 spacing)
|
||||
- Compact mode: `kbd_height = 6` (4 rows + 2 border)
|
||||
|
||||
### Phase 1 Verification
|
||||
- `cargo build && cargo test`
|
||||
- Press backspace during drill → verify `[Bksp]` lights up
|
||||
- Press tab → verify `[Tab]` lights up
|
||||
- Press enter → verify `[Enter]` lights up
|
||||
- Press shift → verify both `[Shft]` keys light up and all keys show shifted variants
|
||||
- Type space → verify space bar lights up
|
||||
- Verify compact mode works on narrow terminals
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Key Milestone Detection
|
||||
|
||||
### 2a. Return change events from `SkillTree::update`
|
||||
|
||||
**File: `src/engine/skill_tree.rs`**
|
||||
|
||||
Add a return type:
|
||||
|
||||
```rust
|
||||
pub struct SkillTreeUpdate {
|
||||
pub newly_unlocked: Vec<char>,
|
||||
pub newly_mastered: Vec<char>,
|
||||
}
|
||||
```
|
||||
|
||||
Modify `update()` to:
|
||||
1. Snapshot current unlocked keys (via `unlocked_keys(DrillScope::Global)`) as a `HashSet<char>` before changes
|
||||
2. Snapshot per-key confidence before changes (for keys currently unlocked)
|
||||
3. Perform existing update logic
|
||||
4. Snapshot unlocked keys after
|
||||
5. `newly_unlocked` = keys in after but not in before
|
||||
6. `newly_mastered` = keys where confidence was < 1.0 before but >= 1.0 after (only check keys in the before set)
|
||||
|
||||
### 2b. Finger info text generation
|
||||
|
||||
**File: `src/keyboard/finger.rs`**
|
||||
|
||||
Add `description()` method to `FingerAssignment`:
|
||||
|
||||
```rust
|
||||
pub fn description(&self) -> &'static str {
|
||||
match (self.hand, self.finger) {
|
||||
(Hand::Left, Finger::Pinky) => "left pinky",
|
||||
(Hand::Left, Finger::Ring) => "left ring finger",
|
||||
(Hand::Left, Finger::Middle) => "left middle finger",
|
||||
(Hand::Left, Finger::Index) => "left index finger",
|
||||
(Hand::Left, Finger::Thumb) => "left thumb",
|
||||
(Hand::Right, Finger::Pinky) => "right pinky",
|
||||
(Hand::Right, Finger::Ring) => "right ring finger",
|
||||
(Hand::Right, Finger::Middle)=> "right middle finger",
|
||||
(Hand::Right, Finger::Index) => "right index finger",
|
||||
(Hand::Right, Finger::Thumb) => "right thumb",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Finger info is looked up via `KeyboardModel::finger_for_char(ch)` which uses position-based mapping that works across all layouts (QWERTY, Dvorak, Colemak).
|
||||
|
||||
### 2c. Find key's skill tree location
|
||||
|
||||
**File: `src/engine/skill_tree.rs`**
|
||||
|
||||
Add helper:
|
||||
|
||||
```rust
|
||||
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
|
||||
// Returns (branch_def, level_name, 1-based position_in_level)
|
||||
for branch in ALL_BRANCHES {
|
||||
for level in branch.levels {
|
||||
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
|
||||
return Some((branch, level.name, pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2 Verification
|
||||
- `cargo test` — existing tests pass
|
||||
- Add unit test: `update()` returns correct `newly_unlocked` when keys are unlocked
|
||||
- Add unit test: `update()` returns correct `newly_mastered` when confidence crosses 1.0
|
||||
- Add unit test: `find_key_branch('e')` returns `(Lowercase, "Frequency Order", 1)`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Milestone Overlay UI
|
||||
|
||||
### 3a. Milestone data structures
|
||||
|
||||
**File: `src/app.rs`**
|
||||
|
||||
Add to `App`:
|
||||
```rust
|
||||
pub milestone_queue: VecDeque<KeyMilestonePopup>,
|
||||
```
|
||||
|
||||
Types (can live in `app.rs` or a new `src/milestone.rs`):
|
||||
```rust
|
||||
pub struct KeyMilestonePopup {
|
||||
pub kind: MilestoneKind,
|
||||
pub keys: Vec<char>,
|
||||
pub finger_info: Vec<(char, String)>, // (key, "left ring finger")
|
||||
}
|
||||
|
||||
pub enum MilestoneKind {
|
||||
Unlock,
|
||||
Mastery,
|
||||
}
|
||||
```
|
||||
|
||||
### 3b. Capture milestone events in `finish_drill`
|
||||
|
||||
**File: `src/app.rs` — `finish_drill` (~line 485)**
|
||||
|
||||
After `self.skill_tree.update(&self.key_stats)` (line 502), capture the `SkillTreeUpdate`. If `newly_unlocked` is non-empty, push an Unlock milestone to the queue with finger info for each key. If `newly_mastered` is non-empty, push a Mastery milestone to the queue. Both can be queued — they'll show one at a time.
|
||||
|
||||
Build finger info using `self.keyboard_model.finger_for_char(ch).description()`.
|
||||
|
||||
**Multi-key milestones:** Each `KeyMilestonePopup` can contain multiple keys (e.g., if 3 keys unlock in one drill completion). The overlay shows all keys together: "You unlocked: 'e', 'r', 'i'" with finger info for each. This is preferred over one overlay per key to avoid a long queue of nearly identical overlays. If both unlocks and masteries occur, they are separate milestones in the queue (one Unlock overlay, one Mastery overlay).
|
||||
|
||||
For shifted characters, also include shift key guidance:
|
||||
- Left-hand characters → "Hold Right Shift (right pinky)"
|
||||
- Right-hand characters → "Hold Left Shift (left pinky)"
|
||||
|
||||
### 3c. Milestone overlay rendering
|
||||
|
||||
**File: `src/main.rs` — `render_drill`**
|
||||
|
||||
After rendering the drill screen, check `app.milestone_queue.front()`. If present, render a centered overlay using `Clear` + bordered block. Layout adapts to terminal size:
|
||||
|
||||
- Large terminal (height >= 25): Full keyboard diagram + text
|
||||
- Medium (height >= 15): Compact keyboard + text
|
||||
- Small (height < 15): Text only, no keyboard diagram
|
||||
|
||||
Overlay content:
|
||||
- Title: "Key Unlocked!" or "Key Mastered!"
|
||||
- Key display: "You unlocked: 's'" / "You mastered: 's'"
|
||||
- Finger info (unlock only): "Use your left ring finger"
|
||||
- Encouraging message (randomly selected from pool)
|
||||
- Keyboard diagram with `focused_key` set to the **first key** in the milestone's key list. For multi-key milestones, only the first key is highlighted on the diagram; all keys are listed textually above.
|
||||
- For shifted characters: `shift_held = true` on diagram
|
||||
- Footer: "Press any key to continue (Backspace dismisses only)"
|
||||
|
||||
Encouraging message pools:
|
||||
|
||||
**Unlock:**
|
||||
- "Nice work! Keep building your typing skills."
|
||||
- "Another key added to your arsenal!"
|
||||
- "Your keyboard is growing! Keep it up."
|
||||
- "One step closer to full keyboard mastery!"
|
||||
|
||||
**Mastery:**
|
||||
- "This key is now at full confidence!"
|
||||
- "You've got this key down pat!"
|
||||
- "Muscle memory locked in!"
|
||||
- "One more key conquered!"
|
||||
|
||||
### 3d. Milestone dismissal — per-key-class behavior
|
||||
|
||||
**File: `src/main.rs` — `handle_drill_key`**
|
||||
|
||||
At the top of `handle_drill_key`, check `app.milestone_queue.front()`. If present, pop the front milestone from the queue, then handle the dismissing key based on its class:
|
||||
|
||||
| Key class | Dismiss? | Replay into drill? | Notes |
|
||||
|---|---|---|---|
|
||||
| `KeyCode::Char(ch)` | Yes | Yes — fall through to normal input | Most common case; no keystrokes lost |
|
||||
| `KeyCode::Tab` | Yes | Yes — fall through to tab handling | Tab is valid drill input |
|
||||
| `KeyCode::Enter` | Yes | Yes — fall through to enter handling | Enter is valid drill input |
|
||||
| `KeyCode::Backspace` | Yes | No — dismiss only | Replaying backspace would delete progress the user didn't intend to undo |
|
||||
| `KeyCode::Esc` | Yes | Yes — Esc falls through to drill exit | Clears entire milestone queue and exits drill immediately |
|
||||
| Other (arrows, etc.) | Yes | No — dismiss only | Non-drill keys just dismiss |
|
||||
|
||||
Implementation: after popping the milestone, check the key code. For `Char`, `Tab`, `Enter`, and `Esc`, let the key continue through the existing `handle_drill_key` logic. For `Backspace` and all other keys, return early after dismissal.
|
||||
|
||||
### Phase 3 Verification
|
||||
- Start fresh, type until 7th key unlocks → milestone overlay appears
|
||||
- Press a letter key → overlay disappears AND that letter is typed into the drill
|
||||
- Press Tab during overlay → overlay disappears AND tab is processed as drill input
|
||||
- Press Enter during overlay → overlay disappears AND enter is processed as drill input
|
||||
- Press Backspace during overlay → overlay disappears, no drill input change
|
||||
- Press Esc during overlay → overlay disappears AND drill exits
|
||||
- Master a key → mastery overlay appears
|
||||
- Multiple milestones in one drill → overlays show sequentially
|
||||
- Verify correct finger info text
|
||||
- Shifted character unlock → shift keys highlighted on diagram
|
||||
- Small terminal → verify overlay degrades gracefully
|
||||
- Small terminal + multi-key milestone → verify text-only layout shows all keys and finger info without overflow
|
||||
- Encouraging messages: assert from message pool membership (not exact string) in any UI tests to avoid flaky assertions from randomness
|
||||
- Multi-key milestone → verify first key is highlighted on keyboard diagram, all keys listed textually
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Stats Dashboard — Add Modifier Key Stats
|
||||
|
||||
### 4a. Add modifier key stats to keyboard heatmaps
|
||||
|
||||
**File: `src/ui/components/stats_dashboard.rs`**
|
||||
|
||||
In `render_keyboard_heatmap` (line 654) and `render_keyboard_timing` (line 768), after rendering the 4 character rows, render modifier key stats:
|
||||
|
||||
- **Backspace** (`'\x08'`): After number row, render `Bksp` + stat value
|
||||
- **Tab** (`'\t'`): Before top row, render `Tab` + stat value
|
||||
- **Enter** (`'\n'`): After home row, render `Ent` + stat value
|
||||
- **Space** (`' '`): Below bottom row, render `Spc` + stat value
|
||||
|
||||
Use the same `get_key_accuracy` / `get_key_time_ms` methods (they work with any `char`).
|
||||
|
||||
### 4b. Include modifier keys in key ranking lists
|
||||
|
||||
In `render_worst_accuracy_keys` (line 957) and `render_best_accuracy_keys` (line 1030), add `' '`, `'\t'`, `'\n'` to the `all_keys` set so these keys appear in accuracy rankings. The `render_slowest_keys`/`render_fastest_keys` already pull from `key_stats.stats` which includes these keys automatically.
|
||||
|
||||
### Phase 4 Verification
|
||||
- Open Stats → Accuracy tab → keyboard heatmap shows Tab, Enter, Space with stats
|
||||
- Open Stats → Timing tab → same
|
||||
- Tab/Space appear in worst/best accuracy lists when they have data
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Keyboard Explorer Screen
|
||||
|
||||
### 5a. Add `AppScreen::Keyboard` and menu item
|
||||
|
||||
**File: `src/app.rs`**
|
||||
|
||||
Add `Keyboard` to `AppScreen` enum. Add field:
|
||||
```rust
|
||||
pub keyboard_explorer_selected: Option<char>,
|
||||
```
|
||||
|
||||
**File: `src/ui/components/menu.rs`**
|
||||
|
||||
Add menu item with key `"b"` (not `"k"` which conflicts with j/k vim navigation):
|
||||
```rust
|
||||
MenuItem {
|
||||
key: "b".to_string(),
|
||||
label: "Keyboard".to_string(),
|
||||
description: "Explore keyboard layout and key statistics".to_string(),
|
||||
}
|
||||
```
|
||||
|
||||
Insert between "Skill Tree" and "Statistics". Final menu order:
|
||||
- 0: `[1]` Adaptive Drill
|
||||
- 1: `[2]` Code Drill
|
||||
- 2: `[3]` Passage Drill
|
||||
- 3: `[t]` Skill Tree
|
||||
- 4: `[b]` Keyboard
|
||||
- 5: `[s]` Statistics
|
||||
- 6: `[c]` Settings
|
||||
|
||||
### 5b. Menu routing
|
||||
|
||||
**File: `src/main.rs` — `handle_menu_key`**
|
||||
|
||||
Add `KeyCode::Char('b')` → `app.screen = AppScreen::Keyboard; app.keyboard_explorer_selected = None`. Update Enter handler indices: 4 → Keyboard, 5 → Stats, 6 → Settings.
|
||||
|
||||
Update footer hint: `" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit "`.
|
||||
|
||||
### 5c. Keyboard Explorer rendering
|
||||
|
||||
**File: `src/main.rs`**
|
||||
|
||||
Add `render_keyboard_explorer` function. Layout:
|
||||
|
||||
1. **Header** (3 lines): " Keyboard Explorer " + "Press any key to see details"
|
||||
2. **Keyboard diagram** (8 lines): Full `KeyboardDiagram` with:
|
||||
- `focused_key`: `app.keyboard_explorer_selected`
|
||||
- `next_key`: None
|
||||
- `unlocked_keys`: `app.skill_tree.unlocked_keys(DrillScope::Global)`
|
||||
- `depressed_keys`: `&app.depressed_keys`
|
||||
- `shift_held`: `app.shift_held`
|
||||
3. **Key detail panel** (remaining space): Bordered block showing stats for selected key
|
||||
4. **Footer** (1 line): "[ESC] Back"
|
||||
|
||||
Key detail panel content (when a key is selected):
|
||||
```
|
||||
┌─ Key Details: 's' ──────────────────────────────┐
|
||||
│ Finger: Left ring finger │
|
||||
│ Unlocked: Yes │
|
||||
│ Mastery: 87% confidence │
|
||||
│ Branch: Lowercase a-z │
|
||||
│ Level: Frequency Order (key #7) │
|
||||
│ Avg Time: 245ms (best: 198ms) │
|
||||
│ Accuracy: 96.2% (385/400 correct) │
|
||||
│ Samples: 400 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Data sources:
|
||||
- Finger: `keyboard_model.finger_for_char(ch).description()`
|
||||
- Unlocked: check if `ch` is in `skill_tree.unlocked_keys(DrillScope::Global)`
|
||||
- Mastery: `key_stats.get_confidence(ch)` formatted as percentage
|
||||
- Branch/Level: `find_key_branch(ch)` from Phase 2
|
||||
- Avg Time / Best: `key_stats.get_stat(ch)` → `filtered_time_ms`, `best_time_ms`
|
||||
- Accuracy: precomputed (see 5e)
|
||||
- Samples: `key_stats.get_stat(ch)` → `sample_count`
|
||||
|
||||
### 5d. Key handling
|
||||
|
||||
**File: `src/main.rs`**
|
||||
|
||||
Add `handle_keyboard_explorer_key`:
|
||||
- `Esc` → go to menu
|
||||
- `KeyCode::Char('q')` when no key selected → go to menu; when key selected → select 'q' (so user can explore 'q')
|
||||
- `KeyCode::Char(ch)` → set `keyboard_explorer_selected = Some(ch)` (see normalization below)
|
||||
- `KeyCode::Tab` → set selected to `'\t'`
|
||||
- `KeyCode::Enter` → set selected to `'\n'`
|
||||
- `KeyCode::Backspace` → set selected to `'\x08'`
|
||||
|
||||
**Shifted character normalization strategy:** Store the literal `ch` value from the `KeyCode::Char(ch)` event as-is. Do NOT transform using `shift_held` state. crossterm delivers the already-shifted character in the event (e.g., Shift+a → `KeyCode::Char('A')`, Shift+1 → `KeyCode::Char('!')`), so the event `ch` is the correct key identity. The `shift_held` flag is used only for keyboard diagram rendering (to show shifted labels on all keys), not for determining which key was selected. Show shift guidance in the detail panel for any shifted character (uppercase or symbol) using `keyboard_model.finger_for_char(ch)` to determine hand and thus which shift key to recommend.
|
||||
|
||||
For Keyboard Explorer, also show shift key guidance for shifted keys in the detail panel:
|
||||
- Left-hand characters → "Hold Right Shift (right pinky)"
|
||||
- Right-hand characters → "Hold Left Shift (left pinky)"
|
||||
|
||||
### 5e. Precomputed accuracy for explorer
|
||||
|
||||
**File: `src/app.rs`**
|
||||
|
||||
Add a cached accuracy field to `App`:
|
||||
```rust
|
||||
pub explorer_accuracy_cache: Option<(char, usize, usize)>, // (cached_key, correct, total)
|
||||
```
|
||||
|
||||
Add a method `App::key_accuracy(ch: char) -> (usize, usize)` that checks the cache first. If `cached_key == ch`, return cached values. Otherwise, perform a single linear scan of `drill_history`, cache the result, and return it. The cache is invalidated automatically when `keyboard_explorer_selected` changes (set cache to `None` in the key handler). This avoids redundant O(n) scans on every render frame during key hold or rapid redraw.
|
||||
|
||||
### Phase 5 Verification
|
||||
- `cargo build && cargo test`
|
||||
- Open Keyboard from menu via `b` key → verify diagram shown
|
||||
- Press any letter → detail panel shows finger, branch, level, stats
|
||||
- Press shift → shift keys light up, all keys show shifted variants
|
||||
- Press shifted key (e.g. Shift+a → 'A') → detail panel shows shifted character info with shift key guidance
|
||||
- Tab/Enter/Backspace/Space → light up and show details
|
||||
- Key with no stats → "No data yet"
|
||||
- Esc → return to menu
|
||||
- Verify `j`/`k` still work for menu navigation (no hotkey conflict)
|
||||
|
||||
---
|
||||
|
||||
## Finger Assignment Reference Data (informational)
|
||||
|
||||
The existing `KeyboardModel::finger_for_position` method (in `src/keyboard/model.rs`) handles finger assignments by physical position for all layouts. The table below is for reference only — the implementation in `finger_for_position` is the source of truth. Add unit tests against that method to validate correctness rather than maintaining this table. **Shifted characters use the same finger as their base key.**
|
||||
|
||||
### QWERTY — All 96 Keys by Finger
|
||||
|
||||
**Left Pinky (11 keys):**
|
||||
- Base: `` ` `` `1` `q` `a` `z`
|
||||
- Shifted: `~` `!` `Q` `A` `Z`
|
||||
- Modifier: Tab (`\t`)
|
||||
|
||||
**Left Ring (8 keys):**
|
||||
- Base: `2` `w` `s` `x`
|
||||
- Shifted: `@` `W` `S` `X`
|
||||
|
||||
**Left Middle (8 keys):**
|
||||
- Base: `3` `e` `d` `c`
|
||||
- Shifted: `#` `E` `D` `C`
|
||||
|
||||
**Left Index (16 keys):**
|
||||
- Base: `4` `5` `r` `t` `f` `g` `v` `b`
|
||||
- Shifted: `$` `%` `R` `T` `F` `G` `V` `B`
|
||||
|
||||
**Right Index (16 keys):**
|
||||
- Base: `6` `7` `y` `u` `h` `j` `n` `m`
|
||||
- Shifted: `^` `&` `Y` `U` `H` `J` `N` `M`
|
||||
|
||||
**Right Middle (8 keys):**
|
||||
- Base: `8` `i` `k` `,`
|
||||
- Shifted: `*` `I` `K` `<`
|
||||
|
||||
**Right Ring (8 keys):**
|
||||
- Base: `9` `o` `l` `.`
|
||||
- Shifted: `(` `O` `L` `>`
|
||||
|
||||
**Right Pinky (21 keys):**
|
||||
- Base: `0` `-` `=` `p` `[` `]` `\` `;` `'` `/`
|
||||
- Shifted: `)` `_` `+` `P` `{` `}` `|` `:` `"` `?`
|
||||
- Modifiers: Backspace (`\x08`), Enter (`\n`)
|
||||
|
||||
**Thumb (1 key):**
|
||||
- Space (` `)
|
||||
|
||||
### Dvorak & Colemak
|
||||
|
||||
Finger assignments are **position-based** — the same physical key positions use the same fingers. `KeyboardModel::finger_for_char(ch)` looks up a character's physical position via `find_key_position` then calls `finger_for_position`, so it returns the correct finger for any layout automatically.
|
||||
|
||||
### Shift Key Guidance for Shifted Characters
|
||||
|
||||
- **Left-hand characters**: Hold **Right Shift** (right pinky)
|
||||
- **Right-hand characters**: Hold **Left Shift** (left pinky)
|
||||
|
||||
---
|
||||
|
||||
## Critical Files to Modify
|
||||
|
||||
1. **`src/keyboard/display.rs`** (new) — Centralized key display adapter for sentinel ↔ display name conversions (Phase 0)
|
||||
2. **`src/keyboard/finger.rs`** — Add `description()` method (Phase 2)
|
||||
3. **`src/engine/skill_tree.rs`** — Add `SkillTreeUpdate` return type, `find_key_branch()` helper (Phase 2)
|
||||
4. **`src/app.rs`** — Add `milestone_queue`, `keyboard_explorer_selected`, `AppScreen::Keyboard`, milestone structs (Phases 3, 5)
|
||||
5. **`src/ui/components/keyboard_diagram.rs`** — Render Tab, Enter, Shift, Space, Backspace as interactive keys (Phase 1)
|
||||
6. **`src/main.rs`** — Modifier depressed state tracking, milestone overlay, keyboard explorer screen, menu routing (Phases 1, 3, 5)
|
||||
7. **`src/ui/components/stats_dashboard.rs`** — Add modifier keys to keyboard heatmaps and ranking lists (Phase 4)
|
||||
8. **`src/ui/components/menu.rs`** — Add "Keyboard" menu item with key `b` (Phase 5)
|
||||
|
||||
## Terminology
|
||||
|
||||
Throughout the implementation, use consistent terminology:
|
||||
- "Milestone" for the unlock/mastery event system (not "popup" or "notification")
|
||||
- "Milestone overlay" for the UI element shown during a milestone (not "pop-up", "modal", or "dialog")
|
||||
- "Enter" (not "Return") for the Enter key
|
||||
- "Keyboard Explorer" for the new menu screen
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Non-US layouts beyond QWERTY/Dvorak/Colemak are out of scope for this plan
|
||||
- The `KeyDisplay` adapter (Phase 0) is intentionally thin — a full typed `KeyId` enum migration is deferred to a future plan
|
||||
- Left/right shift distinction is not tracked separately (both display as "Shift")
|
||||
176
src/app.rs
176
src/app.rs
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
@@ -54,6 +54,7 @@ pub enum AppScreen {
|
||||
PassageDownloadProgress,
|
||||
CodeIntro,
|
||||
CodeDownloadProgress,
|
||||
Keyboard,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -75,6 +76,32 @@ pub enum CodeDownloadCompleteAction {
|
||||
ReturnToSettings,
|
||||
}
|
||||
|
||||
pub enum MilestoneKind {
|
||||
Unlock,
|
||||
Mastery,
|
||||
}
|
||||
|
||||
pub struct KeyMilestonePopup {
|
||||
pub kind: MilestoneKind,
|
||||
pub keys: Vec<char>,
|
||||
pub finger_info: Vec<(char, String)>,
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
const UNLOCK_MESSAGES: &[&str] = &[
|
||||
"Nice work! Keep building your typing skills.",
|
||||
"Another key added to your arsenal!",
|
||||
"Your keyboard is growing! Keep it up.",
|
||||
"One step closer to full keyboard mastery!",
|
||||
];
|
||||
|
||||
const MASTERY_MESSAGES: &[&str] = &[
|
||||
"This key is now at full confidence!",
|
||||
"You've got this key down pat!",
|
||||
"Muscle memory locked in!",
|
||||
"One more key conquered!",
|
||||
];
|
||||
|
||||
struct DownloadJob {
|
||||
downloaded_bytes: Arc<AtomicU64>,
|
||||
total_bytes: Arc<AtomicU64>,
|
||||
@@ -109,6 +136,7 @@ pub struct App {
|
||||
pub theme: &'static Theme,
|
||||
pub config: Config,
|
||||
pub key_stats: KeyStatsStore,
|
||||
pub ranked_key_stats: KeyStatsStore,
|
||||
pub skill_tree: SkillTree,
|
||||
pub profile: ProfileData,
|
||||
pub store: Option<JsonStore>,
|
||||
@@ -154,7 +182,12 @@ pub struct App {
|
||||
pub code_download_attempted: bool,
|
||||
pub code_download_action: CodeDownloadCompleteAction,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
pub keyboard_model: KeyboardModel,
|
||||
pub milestone_queue: VecDeque<KeyMilestonePopup>,
|
||||
pub keyboard_explorer_selected: Option<char>,
|
||||
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
|
||||
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
|
||||
rng: SmallRng,
|
||||
transition_table: TransitionTable,
|
||||
#[allow(dead_code)]
|
||||
@@ -176,20 +209,22 @@ impl App {
|
||||
|
||||
let store = JsonStore::new().ok();
|
||||
|
||||
let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
|
||||
let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
|
||||
// load_profile returns None if file exists but can't parse (schema mismatch)
|
||||
let pd = s.load_profile();
|
||||
|
||||
match pd {
|
||||
Some(pd) if !pd.needs_reset() => {
|
||||
let ksd = s.load_key_stats();
|
||||
let rksd = s.load_ranked_key_stats();
|
||||
let lhd = s.load_drill_history();
|
||||
let st = SkillTree::new(pd.skill_tree.clone());
|
||||
(ksd.stats, st, pd, lhd.drills)
|
||||
(ksd.stats, rksd.stats, st, pd, lhd.drills)
|
||||
}
|
||||
_ => {
|
||||
// Schema mismatch or parse failure: full reset of all stores
|
||||
(
|
||||
KeyStatsStore::default(),
|
||||
KeyStatsStore::default(),
|
||||
SkillTree::default(),
|
||||
ProfileData::default(),
|
||||
@@ -199,6 +234,7 @@ impl App {
|
||||
}
|
||||
} else {
|
||||
(
|
||||
KeyStatsStore::default(),
|
||||
KeyStatsStore::default(),
|
||||
SkillTree::default(),
|
||||
ProfileData::default(),
|
||||
@@ -208,6 +244,8 @@ impl App {
|
||||
|
||||
let mut key_stats_with_target = key_stats;
|
||||
key_stats_with_target.target_cpm = config.target_cpm();
|
||||
let mut ranked_key_stats_with_target = ranked_key_stats;
|
||||
ranked_key_stats_with_target.target_cpm = config.target_cpm();
|
||||
|
||||
let dictionary = Dictionary::load();
|
||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||
@@ -231,6 +269,7 @@ impl App {
|
||||
theme,
|
||||
config,
|
||||
key_stats: key_stats_with_target,
|
||||
ranked_key_stats: ranked_key_stats_with_target,
|
||||
skill_tree,
|
||||
profile,
|
||||
store,
|
||||
@@ -276,7 +315,12 @@ impl App {
|
||||
code_download_attempted: false,
|
||||
code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
|
||||
shift_held: false,
|
||||
caps_lock: false,
|
||||
keyboard_model,
|
||||
milestone_queue: VecDeque::new(),
|
||||
keyboard_explorer_selected: None,
|
||||
explorer_accuracy_cache_overall: None,
|
||||
explorer_accuracy_cache_ranked: None,
|
||||
rng: SmallRng::from_entropy(),
|
||||
transition_table,
|
||||
dictionary,
|
||||
@@ -303,7 +347,7 @@ impl App {
|
||||
DrillMode::Adaptive => {
|
||||
let scope = self.drill_scope;
|
||||
let all_keys = self.skill_tree.unlocked_keys(scope);
|
||||
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
|
||||
let focused = self.skill_tree.focused_key(scope, &self.ranked_key_stats);
|
||||
|
||||
// Generate base lowercase text using only lowercase keys from scope
|
||||
let lowercase_keys: Vec<char> = all_keys
|
||||
@@ -493,13 +537,65 @@ impl App {
|
||||
false,
|
||||
);
|
||||
|
||||
if ranked {
|
||||
// Update timing stats for all drill modes
|
||||
let before_stats = if ranked {
|
||||
Some(self.ranked_key_stats.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for kt in &result.per_key_times {
|
||||
if kt.correct {
|
||||
self.key_stats.update_key(kt.key, kt.time_ms);
|
||||
}
|
||||
}
|
||||
self.skill_tree.update(&self.key_stats);
|
||||
|
||||
if ranked {
|
||||
for kt in &result.per_key_times {
|
||||
if kt.correct {
|
||||
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
|
||||
}
|
||||
}
|
||||
let update = self
|
||||
.skill_tree
|
||||
.update(&self.ranked_key_stats, before_stats.as_ref());
|
||||
|
||||
// Queue milestone overlays for newly unlocked keys
|
||||
if !update.newly_unlocked.is_empty() {
|
||||
let finger_info: Vec<(char, String)> = update
|
||||
.newly_unlocked
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let desc = self.keyboard_model.finger_for_char(ch).description();
|
||||
(ch, desc.to_string())
|
||||
})
|
||||
.collect();
|
||||
let msg = UNLOCK_MESSAGES[self.rng.gen_range(0..UNLOCK_MESSAGES.len())];
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::Unlock,
|
||||
keys: update.newly_unlocked,
|
||||
finger_info,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
|
||||
// Queue milestone overlays for newly mastered keys
|
||||
if !update.newly_mastered.is_empty() {
|
||||
let finger_info: Vec<(char, String)> = update
|
||||
.newly_mastered
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let desc = self.keyboard_model.finger_for_char(ch).description();
|
||||
(ch, desc.to_string())
|
||||
})
|
||||
.collect();
|
||||
let msg = MASTERY_MESSAGES[self.rng.gen_range(0..MASTERY_MESSAGES.len())];
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::Mastery,
|
||||
keys: update.newly_mastered,
|
||||
finger_info,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let complexity = self.skill_tree.complexity();
|
||||
@@ -554,6 +650,13 @@ impl App {
|
||||
true,
|
||||
);
|
||||
|
||||
// Update timing stats for all completed keystrokes
|
||||
for kt in &result.per_key_times {
|
||||
if kt.correct {
|
||||
self.key_stats.update_key(kt.key, kt.time_ms);
|
||||
}
|
||||
}
|
||||
|
||||
self.drill_history.push(result.clone());
|
||||
if self.drill_history.len() > 500 {
|
||||
self.drill_history.remove(0);
|
||||
@@ -572,6 +675,10 @@ impl App {
|
||||
schema_version: 2,
|
||||
stats: self.key_stats.clone(),
|
||||
});
|
||||
let _ = store.save_ranked_key_stats(&KeyStatsData {
|
||||
schema_version: 2,
|
||||
stats: self.ranked_key_stats.clone(),
|
||||
});
|
||||
let _ = store.save_drill_history(&DrillHistoryData {
|
||||
schema_version: 2,
|
||||
drills: self.drill_history.clone(),
|
||||
@@ -628,6 +735,8 @@ impl App {
|
||||
// Reset all derived state
|
||||
self.key_stats = KeyStatsStore::default();
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
self.ranked_key_stats = KeyStatsStore::default();
|
||||
self.ranked_key_stats.target_cpm = self.config.target_cpm();
|
||||
self.skill_tree = SkillTree::default();
|
||||
self.profile.total_score = 0.0;
|
||||
self.profile.total_drills = 0;
|
||||
@@ -637,14 +746,20 @@ impl App {
|
||||
|
||||
// Replay each remaining session oldest->newest
|
||||
for result in &self.drill_history {
|
||||
// Only update skill tree for ranked sessions
|
||||
if result.ranked {
|
||||
// Update timing stats for all sessions
|
||||
for kt in &result.per_key_times {
|
||||
if kt.correct {
|
||||
self.key_stats.update_key(kt.key, kt.time_ms);
|
||||
}
|
||||
}
|
||||
self.skill_tree.update(&self.key_stats);
|
||||
// Only update skill tree for ranked sessions
|
||||
if result.ranked {
|
||||
for kt in &result.per_key_times {
|
||||
if kt.correct {
|
||||
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
|
||||
}
|
||||
}
|
||||
self.skill_tree.update(&self.ranked_key_stats, None);
|
||||
}
|
||||
|
||||
// Partial sessions are visible in history but do not affect profile/streak activity.
|
||||
@@ -700,6 +815,47 @@ impl App {
|
||||
self.start_drill();
|
||||
}
|
||||
|
||||
pub fn go_to_keyboard(&mut self) {
|
||||
self.keyboard_explorer_selected = None;
|
||||
self.explorer_accuracy_cache_overall = None;
|
||||
self.explorer_accuracy_cache_ranked = None;
|
||||
self.screen = AppScreen::Keyboard;
|
||||
}
|
||||
|
||||
pub fn key_accuracy(&mut self, ch: char, ranked_only: bool) -> (usize, usize) {
|
||||
let cache = if ranked_only {
|
||||
self.explorer_accuracy_cache_ranked
|
||||
} else {
|
||||
self.explorer_accuracy_cache_overall
|
||||
};
|
||||
if let Some((cached_key, correct, total)) = cache {
|
||||
if cached_key == ch {
|
||||
return (correct, total);
|
||||
}
|
||||
}
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
for result in &self.drill_history {
|
||||
if ranked_only && !result.ranked {
|
||||
continue;
|
||||
}
|
||||
for kt in &result.per_key_times {
|
||||
if kt.key == ch {
|
||||
total += 1;
|
||||
if kt.correct {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ranked_only {
|
||||
self.explorer_accuracy_cache_ranked = Some((ch, correct, total));
|
||||
} else {
|
||||
self.explorer_accuracy_cache_overall = Some((ch, correct, total));
|
||||
}
|
||||
(correct, total)
|
||||
}
|
||||
|
||||
pub fn go_to_code_language_select(&mut self) {
|
||||
let options = code_language_options();
|
||||
self.code_language_selected = options
|
||||
@@ -1153,6 +1309,7 @@ impl App {
|
||||
0 => {
|
||||
self.config.target_wpm = (self.config.target_wpm + 5).min(200);
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
self.ranked_key_stats.target_cpm = self.config.target_cpm();
|
||||
}
|
||||
1 => {
|
||||
let themes = Theme::available_themes();
|
||||
@@ -1219,6 +1376,7 @@ impl App {
|
||||
0 => {
|
||||
self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
|
||||
self.key_stats.target_cpm = self.config.target_cpm();
|
||||
self.ranked_key_stats.target_cpm = self.config.target_cpm();
|
||||
}
|
||||
1 => {
|
||||
let themes = Theme::available_themes();
|
||||
|
||||
@@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
|
||||
/// Events returned by `SkillTree::update` describing what changed.
|
||||
pub struct SkillTreeUpdate {
|
||||
pub newly_unlocked: Vec<char>,
|
||||
pub newly_mastered: Vec<char>,
|
||||
}
|
||||
|
||||
// --- Branch ID ---
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -188,6 +194,19 @@ pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Find which branch and level a key belongs to.
|
||||
/// Returns (branch_def, level_name, 1-based position in level).
|
||||
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
|
||||
for branch in ALL_BRANCHES {
|
||||
for level in branch.levels {
|
||||
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
|
||||
return Some((branch, level.name, pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
|
||||
ALL_BRANCHES
|
||||
.iter()
|
||||
@@ -487,7 +506,19 @@ impl SkillTree {
|
||||
|
||||
/// Update skill tree progress based on current key stats.
|
||||
/// Call after updating KeyStatsStore.
|
||||
pub fn update(&mut self, stats: &KeyStatsStore) {
|
||||
///
|
||||
/// `before_stats` is an optional snapshot of key stats *before* this drill's data was added.
|
||||
/// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0).
|
||||
/// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
stats: &KeyStatsStore,
|
||||
before_stats: Option<&KeyStatsStore>,
|
||||
) -> SkillTreeUpdate {
|
||||
// Snapshot unlocked keys before tree structure changes
|
||||
let before_unlocked: HashSet<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
|
||||
// Update lowercase branch (progressive unlock)
|
||||
self.update_lowercase(stats);
|
||||
|
||||
@@ -518,6 +549,34 @@ impl SkillTree {
|
||||
}
|
||||
self.update_branch_level(branch_def, stats);
|
||||
}
|
||||
|
||||
// Snapshot after
|
||||
let after_unlocked: HashSet<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
|
||||
let newly_unlocked: Vec<char> = after_unlocked
|
||||
.difference(&before_unlocked)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats,
|
||||
// but now have confidence >= 1.0 in current stats
|
||||
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
|
||||
before_unlocked
|
||||
.iter()
|
||||
.filter(|&&ch| {
|
||||
before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0
|
||||
})
|
||||
.copied()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
SkillTreeUpdate {
|
||||
newly_unlocked,
|
||||
newly_mastered,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
|
||||
@@ -731,7 +790,7 @@ mod tests {
|
||||
|
||||
// Make initial 6 keys confident
|
||||
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
|
||||
// Should unlock 7th key ('s')
|
||||
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||
@@ -750,7 +809,7 @@ mod tests {
|
||||
|
||||
// Need to repeatedly update as each unlock requires all current keys confident
|
||||
for _ in 0..30 {
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
@@ -805,7 +864,7 @@ mod tests {
|
||||
|
||||
// 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);
|
||||
tree.update(&stats, None);
|
||||
|
||||
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
|
||||
assert_eq!(
|
||||
@@ -829,7 +888,7 @@ mod tests {
|
||||
|
||||
// Update multiple times for level advancement
|
||||
for _ in 0..5 {
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
@@ -968,4 +1027,69 @@ mod tests {
|
||||
assert!(0 < branches.len());
|
||||
assert!(branches.len() - 1 < branches.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_returns_newly_unlocked() {
|
||||
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']);
|
||||
let result = tree.update(&stats, None);
|
||||
|
||||
// Should unlock 7th key ('s')
|
||||
assert!(
|
||||
result.newly_unlocked.contains(&'s'),
|
||||
"newly_unlocked: {:?}",
|
||||
result.newly_unlocked
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_returns_newly_mastered() {
|
||||
let mut tree = SkillTree::default();
|
||||
let mut stats = KeyStatsStore::default();
|
||||
|
||||
// Snapshot before any key stats are added
|
||||
let before_stats = stats.clone();
|
||||
|
||||
// Make first 5 keys confident
|
||||
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']);
|
||||
let result = tree.update(&stats, Some(&before_stats));
|
||||
|
||||
// The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered
|
||||
for &ch in &['e', 't', 'a', 'o', 'i'] {
|
||||
assert!(
|
||||
result.newly_mastered.contains(&ch),
|
||||
"expected {} in newly_mastered: {:?}",
|
||||
ch,
|
||||
result.newly_mastered
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_lowercase() {
|
||||
let result = find_key_branch('e');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Lowercase);
|
||||
assert_eq!(level_name, "Frequency Order");
|
||||
assert_eq!(pos, 1); // 'e' is first in the frequency order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_capitals() {
|
||||
let result = find_key_branch('T');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Capitals);
|
||||
assert_eq!(level_name, "Common Sentence Capitals");
|
||||
assert_eq!(pos, 1); // 'T' is first
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_unknown() {
|
||||
assert!(find_key_branch('\x00').is_none());
|
||||
}
|
||||
}
|
||||
|
||||
153
src/keyboard/display.rs
Normal file
153
src/keyboard/display.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
/// Centralized key display adapter for sentinel-char to display-name conversions.
|
||||
///
|
||||
/// **Sentinel boundary policy:**
|
||||
/// Sentinel chars (`'\x08'`, `'\t'`, `'\n'`) are allowed only at two boundaries:
|
||||
/// 1. **Input boundary** — `handle_key` in `src/main.rs` converts `KeyCode::Backspace/Tab/Enter`
|
||||
/// to sentinels for `depressed_keys` and drill input.
|
||||
/// 2. **Storage boundary** — `KeyStatsStore` and `drill_history` store sentinels as `char` keys.
|
||||
///
|
||||
/// All UI rendering, stats display, and business logic must consume these adapter functions
|
||||
/// rather than matching sentinels directly.
|
||||
|
||||
/// Human-readable display name for a key character (including sentinels).
|
||||
/// Returns `""` for printable chars — caller uses `ch.to_string()` for those.
|
||||
pub fn key_display_name(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Backspace",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Enter",
|
||||
' ' => "Space",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for compact UI contexts (heatmaps, compact keyboard).
|
||||
/// Returns `""` for printable chars.
|
||||
pub fn key_short_label(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Bksp",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Ent",
|
||||
' ' => "Spc",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// All sentinel chars used for non-printable keys.
|
||||
pub const MODIFIER_SENTINELS: &[char] = &['\x08', '\t', '\n'];
|
||||
|
||||
/// Sentinel char for Backspace.
|
||||
pub const BACKSPACE: char = '\x08';
|
||||
/// Sentinel char for Tab.
|
||||
pub const TAB: char = '\t';
|
||||
/// Sentinel char for Enter.
|
||||
pub const ENTER: char = '\n';
|
||||
/// Space character (not a sentinel, but treated as a special key for display).
|
||||
pub const SPACE: char = ' ';
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_key_display_name() {
|
||||
assert_eq!(key_display_name('\x08'), "Backspace");
|
||||
assert_eq!(key_display_name('\t'), "Tab");
|
||||
assert_eq!(key_display_name('\n'), "Enter");
|
||||
assert_eq!(key_display_name(' '), "Space");
|
||||
assert_eq!(key_display_name('a'), "");
|
||||
assert_eq!(key_display_name('1'), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_short_label() {
|
||||
assert_eq!(key_short_label('\x08'), "Bksp");
|
||||
assert_eq!(key_short_label('\t'), "Tab");
|
||||
assert_eq!(key_short_label('\n'), "Ent");
|
||||
assert_eq!(key_short_label(' '), "Spc");
|
||||
assert_eq!(key_short_label('z'), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modifier_sentinels() {
|
||||
assert_eq!(MODIFIER_SENTINELS.len(), 3);
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\x08'));
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\t'));
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\n'));
|
||||
}
|
||||
|
||||
/// Sentinel boundary enforcement test.
|
||||
///
|
||||
/// Verifies that `'\x08'` (the Backspace sentinel) does not leak into
|
||||
/// UI or business logic files outside allowed boundaries.
|
||||
///
|
||||
/// **Policy: `\x08`-only enforcement (accepted compromise)**
|
||||
///
|
||||
/// The plan originally proposed checking all three sentinels (`\x08`, `\t`, `\n`),
|
||||
/// but `'\t'` and `'\n'` have widespread legitimate uses as text content
|
||||
/// characters throughout the codebase: tab indentation in code generators
|
||||
/// (`code_syntax.rs`, `passage.rs`), newlines in text processing (`input.rs`,
|
||||
/// `typing_area.rs`, `drill.rs`), and key definitions in the skill tree
|
||||
/// (`skill_tree.rs`). Distinguishing "sentinel identity use" from "text content
|
||||
/// use" for `\t`/`\n` would require fragile heuristic pattern matching that
|
||||
/// would either miss real violations or produce false positives.
|
||||
///
|
||||
/// `'\x08'` has no legitimate text-content use, making it an unambiguous
|
||||
/// sentinel leakage signal. All UI/stats/business-logic files already use
|
||||
/// the `TAB`/`ENTER` adapter constants for sentinel-identity purposes, so
|
||||
/// the `\t`/`\n` policy is enforced by convention and code review.
|
||||
///
|
||||
/// Allowed files for `'\x08'`:
|
||||
/// - `display.rs` (this module — the adapter itself, defines BACKSPACE constant)
|
||||
/// - `main.rs` (input boundary — KeyCode::Backspace conversion)
|
||||
/// - `key_stats.rs` (storage boundary)
|
||||
/// - `drill.rs` (input processing boundary)
|
||||
/// - `app.rs` (milestone detection reads stats keyed by sentinel)
|
||||
#[test]
|
||||
fn test_sentinel_boundary_enforcement() {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let allowed_files = [
|
||||
"src/keyboard/display.rs",
|
||||
"src/main.rs",
|
||||
"src/engine/key_stats.rs",
|
||||
"src/session/drill.rs",
|
||||
"src/app.rs",
|
||||
];
|
||||
|
||||
fn collect_rs_files(dir: &Path, files: &mut Vec<String>) {
|
||||
let entries = fs::read_dir(dir).expect("failed to read source directory");
|
||||
for entry in entries {
|
||||
let entry = entry.expect("failed to read directory entry");
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rs_files(&path, files);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let normalized = path.to_string_lossy().replace('\\', "/");
|
||||
files.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for direct '\x08' literal in src/ — this is the clearest
|
||||
// sentinel leakage signal since \x08 has no legitimate text use.
|
||||
let mut rs_files = Vec::new();
|
||||
collect_rs_files(Path::new("src"), &mut rs_files);
|
||||
|
||||
let mut violations = Vec::new();
|
||||
for file in rs_files {
|
||||
let content = fs::read_to_string(&file).expect("failed to read source file");
|
||||
if content.contains(r"'\\x08'") && !allowed_files.iter().any(|&allowed| file == allowed)
|
||||
{
|
||||
violations.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Direct '\\x08' sentinel literal found outside allowed boundary files:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,21 @@ impl FingerAssignment {
|
||||
pub fn new(hand: Hand, finger: Finger) -> Self {
|
||||
Self { hand, finger }
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match (self.hand, self.finger) {
|
||||
(Hand::Left, Finger::Pinky) => "left pinky",
|
||||
(Hand::Left, Finger::Ring) => "left ring finger",
|
||||
(Hand::Left, Finger::Middle) => "left middle finger",
|
||||
(Hand::Left, Finger::Index) => "left index finger",
|
||||
(Hand::Left, Finger::Thumb) => "left thumb",
|
||||
(Hand::Right, Finger::Pinky) => "right pinky",
|
||||
(Hand::Right, Finger::Ring) => "right ring finger",
|
||||
(Hand::Right, Finger::Middle) => "right middle finger",
|
||||
(Hand::Right, Finger::Index) => "right index finger",
|
||||
(Hand::Right, Finger::Thumb) => "right thumb",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod display;
|
||||
pub mod finger;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
|
||||
667
src/main.rs
667
src/main.rs
@@ -14,8 +14,8 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use crossterm::event::{
|
||||
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
|
||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
|
||||
ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
@@ -28,8 +28,10 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||
|
||||
use app::{App, AppScreen, DrillMode};
|
||||
use engine::skill_tree::DrillScope;
|
||||
use app::{App, AppScreen, DrillMode, MilestoneKind};
|
||||
use engine::skill_tree::{DrillScope, find_key_branch};
|
||||
use keyboard::display::key_display_name;
|
||||
use keyboard::finger::Hand;
|
||||
use event::{AppEvent, EventHandler};
|
||||
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
|
||||
use generator::passage::{is_book_cached, passage_options};
|
||||
@@ -81,7 +83,10 @@ fn main() -> Result<()> {
|
||||
// Try to enable keyboard enhancement for Release event support
|
||||
let keyboard_enhanced = execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
|
||||
PushKeyboardEnhancementFlags(
|
||||
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
| KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
|
||||
)
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
@@ -153,8 +158,24 @@ fn run_app(
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
// Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK).
|
||||
// This only works in terminals with native Kitty keyboard protocol support
|
||||
// (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped
|
||||
// and crossterm infers SHIFT from character case, making it impossible to
|
||||
// distinguish Shift+a from CapsLock+a.
|
||||
app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK);
|
||||
|
||||
// Track depressed keys and shift state for keyboard diagram
|
||||
match (&key.code, key.kind) {
|
||||
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => {
|
||||
app.shift_held = true;
|
||||
app.last_key_time = Some(Instant::now());
|
||||
return; // Don't dispatch bare shift presses to screen handlers
|
||||
}
|
||||
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Release) => {
|
||||
app.shift_held = false;
|
||||
return;
|
||||
}
|
||||
(KeyCode::Char(ch), KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert(ch.to_ascii_lowercase());
|
||||
app.last_key_time = Some(Instant::now());
|
||||
@@ -164,6 +185,33 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
app.depressed_keys.remove(&ch.to_ascii_lowercase());
|
||||
return; // Don't process Release events as input
|
||||
}
|
||||
(KeyCode::Backspace, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\x08');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Backspace, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\x08');
|
||||
return;
|
||||
}
|
||||
(KeyCode::Tab, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\t');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Tab, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\t');
|
||||
return;
|
||||
}
|
||||
(KeyCode::Enter, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\n');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Enter, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\n');
|
||||
return;
|
||||
}
|
||||
(_, KeyEventKind::Release) => return,
|
||||
_ => {
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
@@ -193,6 +241,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key),
|
||||
AppScreen::CodeIntro => handle_code_intro_key(app, key),
|
||||
AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key),
|
||||
AppScreen::Keyboard => handle_keyboard_explorer_key(app, key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +268,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
KeyCode::Char('t') => app.go_to_skill_tree(),
|
||||
KeyCode::Char('b') => app.go_to_keyboard(),
|
||||
KeyCode::Char('s') => app.go_to_stats(),
|
||||
KeyCode::Char('c') => app.go_to_settings(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
||||
@@ -244,8 +294,9 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
3 => app.go_to_skill_tree(),
|
||||
4 => app.go_to_stats(),
|
||||
5 => app.go_to_settings(),
|
||||
4 => app.go_to_keyboard(),
|
||||
5 => app.go_to_stats(),
|
||||
6 => app.go_to_settings(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
@@ -253,6 +304,25 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
|
||||
fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
// If a milestone overlay is showing, dismiss it on any key press
|
||||
if !app.milestone_queue.is_empty() {
|
||||
app.milestone_queue.pop_front();
|
||||
|
||||
// Determine what to do with the dismissing key
|
||||
match milestone_dismiss_action(key.code) {
|
||||
MilestoneDismissAction::EscAndExit => {
|
||||
// Esc clears entire queue and exits drill
|
||||
app.milestone_queue.clear();
|
||||
// Fall through to normal Esc handling below
|
||||
}
|
||||
MilestoneDismissAction::Replay => {
|
||||
// Char/Tab/Enter: dismiss and replay into drill
|
||||
// Fall through to normal key handling below
|
||||
}
|
||||
MilestoneDismissAction::DismissOnly => return, // Backspace and others
|
||||
}
|
||||
}
|
||||
|
||||
// Route Enter/Tab as typed characters during active drills
|
||||
if app.drill.is_some() {
|
||||
match key.code {
|
||||
@@ -284,6 +354,21 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum MilestoneDismissAction {
|
||||
Replay,
|
||||
DismissOnly,
|
||||
EscAndExit,
|
||||
}
|
||||
|
||||
fn milestone_dismiss_action(code: KeyCode) -> MilestoneDismissAction {
|
||||
match code {
|
||||
KeyCode::Esc => MilestoneDismissAction::EscAndExit,
|
||||
KeyCode::Char(_) | KeyCode::Tab | KeyCode::Enter => MilestoneDismissAction::Replay,
|
||||
_ => MilestoneDismissAction::DismissOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char('r') => app.retry_drill(),
|
||||
@@ -863,6 +948,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||
AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app),
|
||||
AppScreen::CodeIntro => render_code_intro(frame, app),
|
||||
AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app),
|
||||
AppScreen::Keyboard => render_keyboard_explorer(frame, app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,7 +972,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
};
|
||||
let total_keys = app.skill_tree.total_unique_keys;
|
||||
let unlocked = app.skill_tree.total_unlocked_count();
|
||||
let mastered = app.skill_tree.total_confident_keys(&app.key_stats);
|
||||
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
|
||||
let header_info = format!(
|
||||
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}",
|
||||
streak_text,
|
||||
@@ -913,7 +999,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
frame.render_widget(&app.menu, menu_area);
|
||||
|
||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit ",
|
||||
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)]));
|
||||
frame.render_widget(footer, layout[2]);
|
||||
@@ -952,7 +1038,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
} else {
|
||||
let header_title = format!(" {mode_name} Drill ");
|
||||
let focus_text = if app.drill_mode == DrillMode::Adaptive {
|
||||
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||
let focused = app
|
||||
.skill_tree
|
||||
.focused_key(app.drill_scope, &app.ranked_key_stats);
|
||||
if let Some(focused) = focused {
|
||||
format!(" | Focus: '{focused}'")
|
||||
} else {
|
||||
@@ -1010,9 +1098,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
|
||||
let kbd_height = if show_kbd {
|
||||
if tier.compact_keyboard() {
|
||||
5 // 3 rows + 2 border
|
||||
6 // 3 rows + 2 border + 1 modifier space
|
||||
} else {
|
||||
7 // 4 rows + 2 border + 1 label space
|
||||
8 // 5 rows (4 + space bar) + 2 border + 1 spacing
|
||||
}
|
||||
} else {
|
||||
0
|
||||
@@ -1039,7 +1127,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
if app.drill_mode == DrillMode::Adaptive {
|
||||
let progress_widget = ui::components::branch_progress_list::BranchProgressList {
|
||||
skill_tree: &app.skill_tree,
|
||||
key_stats: &app.key_stats,
|
||||
key_stats: &app.ranked_key_stats,
|
||||
drill_scope: app.drill_scope,
|
||||
active_branches: &active_branches,
|
||||
theme: app.theme,
|
||||
@@ -1070,11 +1158,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
if show_kbd {
|
||||
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_height = if tier.compact_keyboard() { 5 } else { 7 };
|
||||
let _ = kbd_height; // Height managed by constraints
|
||||
let kbd = KeyboardDiagram::new(
|
||||
focused,
|
||||
next_char,
|
||||
&unlocked_keys,
|
||||
&app.depressed_keys,
|
||||
@@ -1082,7 +1166,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.compact(tier.compact_keyboard())
|
||||
.shift_held(app.shift_held);
|
||||
.shift_held(app.shift_held)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, main_layout[idx]);
|
||||
}
|
||||
|
||||
@@ -1101,6 +1186,221 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
frame.render_widget(footer, app_layout.footer);
|
||||
|
||||
// Render milestone overlay if present
|
||||
if let Some(milestone) = app.milestone_queue.front() {
|
||||
render_milestone_overlay(frame, app, milestone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_milestone_overlay(
|
||||
frame: &mut ratatui::Frame,
|
||||
app: &App,
|
||||
milestone: &app::KeyMilestonePopup,
|
||||
) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
// Determine overlay size based on terminal height:
|
||||
// Large (>=25): full keyboard diagram
|
||||
// Medium (>=15): compact keyboard diagram
|
||||
// Small (<15): text only
|
||||
let kbd_mode = overlay_keyboard_mode(area.height);
|
||||
let overlay_height = match kbd_mode {
|
||||
2 => 18u16.min(area.height.saturating_sub(2)),
|
||||
1 => 14u16.min(area.height.saturating_sub(2)),
|
||||
_ => 10u16.min(area.height.saturating_sub(2)),
|
||||
};
|
||||
let overlay_width = 60u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let left = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
||||
let top = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
||||
let overlay_area = Rect::new(left, top, overlay_width, overlay_height);
|
||||
|
||||
// Clear the area behind the overlay
|
||||
frame.render_widget(ratatui::widgets::Clear, overlay_area);
|
||||
|
||||
let title = match milestone.kind {
|
||||
MilestoneKind::Unlock => " Key Unlocked! ",
|
||||
MilestoneKind::Mastery => " Key Mastered! ",
|
||||
};
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(overlay_area);
|
||||
block.render(overlay_area, frame.buffer_mut());
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Key display line
|
||||
let key_action = match milestone.kind {
|
||||
MilestoneKind::Unlock => "unlocked",
|
||||
MilestoneKind::Mastery => "mastered",
|
||||
};
|
||||
|
||||
let key_names: Vec<String> = milestone
|
||||
.keys
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let name = keyboard::display::key_display_name(ch);
|
||||
if name.is_empty() {
|
||||
format!("'{ch}'")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let keys_str = key_names.join(", ");
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" You {key_action}: {keys_str}"),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
// Finger info (for unlocks)
|
||||
if matches!(milestone.kind, MilestoneKind::Unlock) {
|
||||
for (ch, finger_desc) in &milestone.finger_info {
|
||||
let key_label = {
|
||||
let name = keyboard::display::key_display_name(*ch);
|
||||
if name.is_empty() {
|
||||
format!("'{ch}'")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {key_label}: Use your {finger_desc}"),
|
||||
Style::default().fg(colors.fg()),
|
||||
)));
|
||||
|
||||
// Shift key guidance for shifted characters
|
||||
let fa = app.keyboard_model.finger_for_char(*ch);
|
||||
if ch.is_ascii_uppercase()
|
||||
|| (!ch.is_ascii_lowercase()
|
||||
&& !ch.is_ascii_digit()
|
||||
&& !ch.is_ascii_whitespace()
|
||||
&& *ch != ' ')
|
||||
{
|
||||
let shift_hint = if fa.hand == keyboard::finger::Hand::Left {
|
||||
"Hold Right Shift (right pinky)"
|
||||
} else {
|
||||
"Hold Left Shift (left pinky)"
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {shift_hint}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encouraging message (randomly selected at creation time)
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", milestone.message),
|
||||
Style::default().fg(colors.focused_key()),
|
||||
)));
|
||||
|
||||
// Keyboard diagram (if space permits)
|
||||
if kbd_mode > 0 {
|
||||
let min_kbd_height: u16 = if kbd_mode == 2 { 6 } else { 4 };
|
||||
let remaining = inner.height.saturating_sub(lines.len() as u16 + 2);
|
||||
if remaining >= min_kbd_height {
|
||||
let kbd_y_start = inner.y + lines.len() as u16 + 1;
|
||||
let kbd_height = remaining.min(if kbd_mode == 2 { 8 } else { 6 });
|
||||
let kbd_area = Rect::new(inner.x, kbd_y_start, inner.width, kbd_height);
|
||||
let milestone_key = milestone.keys.first().copied();
|
||||
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
|
||||
let is_shifted = milestone_key.is_some_and(|ch| {
|
||||
ch.is_ascii_uppercase()
|
||||
|| app.keyboard_model.shifted_to_base(ch).is_some()
|
||||
});
|
||||
let kbd = KeyboardDiagram::new(
|
||||
None,
|
||||
&unlocked_keys,
|
||||
&app.depressed_keys,
|
||||
app.theme,
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.selected_key(milestone_key)
|
||||
.compact(kbd_mode == 1)
|
||||
.shift_held(is_shifted)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, kbd_area);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the text content
|
||||
let text_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(1),
|
||||
);
|
||||
Paragraph::new(lines).render(text_area, frame.buffer_mut());
|
||||
|
||||
// Footer
|
||||
let footer_y = inner.y + inner.height.saturating_sub(1);
|
||||
if footer_y < inner.y + inner.height {
|
||||
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" Press any key to continue (Backspace dismisses only)",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
frame.render_widget(footer, footer_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_keyboard_mode(height: u16) -> u8 {
|
||||
if height >= 25 {
|
||||
2 // full
|
||||
} else if height >= 15 {
|
||||
1 // compact
|
||||
} else {
|
||||
0 // text only
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn milestone_dismiss_matrix_matches_spec() {
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Char('a')),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Tab),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Enter),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Backspace),
|
||||
MilestoneDismissAction::DismissOnly
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Esc),
|
||||
MilestoneDismissAction::EscAndExit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_mode_height_boundaries() {
|
||||
assert_eq!(overlay_keyboard_mode(14), 0);
|
||||
assert_eq!(overlay_keyboard_mode(15), 1);
|
||||
assert_eq!(overlay_keyboard_mode(24), 1);
|
||||
assert_eq!(overlay_keyboard_mode(25), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,7 +1422,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||
app.stats_tab,
|
||||
app.config.target_wpm,
|
||||
app.skill_tree.total_unlocked_count(),
|
||||
app.skill_tree.total_confident_keys(&app.key_stats),
|
||||
app.skill_tree.total_confident_keys(&app.ranked_key_stats),
|
||||
app.skill_tree.total_unique_keys,
|
||||
app.theme,
|
||||
app.history_selected,
|
||||
@@ -2062,10 +2362,337 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
||||
let centered = skill_tree_popup_rect(area);
|
||||
let widget = SkillTreeWidget::new(
|
||||
&app.skill_tree,
|
||||
&app.key_stats,
|
||||
&app.ranked_key_stats,
|
||||
app.skill_tree_selected,
|
||||
app.skill_tree_detail_scroll,
|
||||
app.theme,
|
||||
);
|
||||
frame.render_widget(widget, centered);
|
||||
}
|
||||
|
||||
fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.go_to_menu(),
|
||||
KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(),
|
||||
KeyCode::Char(ch) => {
|
||||
app.keyboard_explorer_selected = Some(ch);
|
||||
app.key_accuracy(ch, false);
|
||||
app.key_accuracy(ch, true);
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.keyboard_explorer_selected = Some('\t');
|
||||
app.key_accuracy('\t', false);
|
||||
app.key_accuracy('\t', true);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.keyboard_explorer_selected = Some('\n');
|
||||
app.key_accuracy('\n', false);
|
||||
app.key_accuracy('\n', true);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.keyboard_explorer_selected = Some('\x08');
|
||||
app.key_accuracy('\x08', false);
|
||||
app.key_accuracy('\x08', true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Length(8), // keyboard diagram
|
||||
Constraint::Min(3), // detail panel
|
||||
Constraint::Length(1), // footer
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let header_lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Keyboard Explorer ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"Press any key to see details",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
];
|
||||
let header = Paragraph::new(header_lines)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(header, layout[0]);
|
||||
|
||||
// Keyboard diagram
|
||||
let unlocked = app.skill_tree.unlocked_keys(DrillScope::Global);
|
||||
let kbd = KeyboardDiagram::new(
|
||||
None,
|
||||
&unlocked,
|
||||
&app.depressed_keys,
|
||||
app.theme,
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.selected_key(app.keyboard_explorer_selected)
|
||||
.shift_held(app.shift_held)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, layout[1]);
|
||||
|
||||
// Detail panel
|
||||
render_keyboard_detail_panel(frame, app, layout[2]);
|
||||
|
||||
// Footer
|
||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [ESC] Back ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)]));
|
||||
frame.render_widget(footer, layout[3]);
|
||||
}
|
||||
|
||||
fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rect) {
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
let selected = match app.keyboard_explorer_selected {
|
||||
Some(ch) => ch,
|
||||
None => {
|
||||
let hint = Paragraph::new(Line::from(Span::styled(
|
||||
"Press a key to see its details",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Center)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.title(" Key Details "),
|
||||
);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build display name for title
|
||||
let display_name = key_display_name(selected);
|
||||
let title = if display_name.is_empty() {
|
||||
format!(" Key Details: '{}' ", selected)
|
||||
} else {
|
||||
format!(" Key Details: {} ", display_name)
|
||||
};
|
||||
|
||||
let block = Block::bordered()
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Finger assignment
|
||||
let finger = app.keyboard_model.finger_for_char(selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Finger: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
finger.description(),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
|
||||
// Shift guidance for shifted characters
|
||||
let is_shifted = selected.is_uppercase()
|
||||
|| matches!(
|
||||
selected,
|
||||
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
|
||||
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
|
||||
);
|
||||
if is_shifted {
|
||||
let shift_guidance = if finger.hand == Hand::Left {
|
||||
"Hold Right Shift (right pinky)"
|
||||
} else {
|
||||
"Hold Left Shift (left pinky)"
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Shift: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(shift_guidance, Style::default().fg(colors.fg())),
|
||||
]));
|
||||
}
|
||||
|
||||
// Unlocked status
|
||||
let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global);
|
||||
let is_unlocked = unlocked_keys.contains(&selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Unlocked: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
if is_unlocked { "Yes" } else { "No" },
|
||||
Style::default().fg(if is_unlocked {
|
||||
colors.success()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
}),
|
||||
),
|
||||
]));
|
||||
|
||||
// Mastery / confidence (overall and ranked)
|
||||
let overall_confidence = app.key_stats.get_confidence(selected);
|
||||
let ranked_confidence = app.ranked_key_stats.get_confidence(selected);
|
||||
if overall_confidence > 0.0 || ranked_confidence > 0.0 {
|
||||
let overall_pct = (overall_confidence * 100.0).min(100.0);
|
||||
let ranked_pct = (ranked_confidence * 100.0).min(100.0);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Mastery: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!("overall {:>3.0}% ranked {:>3.0}%", overall_pct, ranked_pct),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Branch/Level info
|
||||
if let Some((branch, level_name, position)) = find_key_branch(selected) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Branch: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(branch.name, Style::default().fg(colors.fg())),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Level: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!("{} (key #{})", level_name, position),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Avg time / samples (overall and ranked)
|
||||
let overall_stat = app.key_stats.get_stat(selected);
|
||||
let ranked_stat = app.ranked_key_stats.get_stat(selected);
|
||||
if overall_stat.is_some() || ranked_stat.is_some() {
|
||||
let fmt_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
|
||||
if let Some(stat) = stat {
|
||||
if stat.sample_count > 0 {
|
||||
let best = if stat.best_time_ms < f64::MAX {
|
||||
stat.best_time_ms
|
||||
} else {
|
||||
stat.filtered_time_ms
|
||||
};
|
||||
return format!("{:.0}ms/{:.0}ms", stat.filtered_time_ms, best);
|
||||
}
|
||||
}
|
||||
"No data".to_string()
|
||||
};
|
||||
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> usize {
|
||||
stat.map(|s| s.sample_count).unwrap_or(0)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Avg Time: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_time(overall_stat),
|
||||
fmt_time(ranked_stat)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Samples: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_samples(overall_stat),
|
||||
fmt_samples(ranked_stat)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Accuracy (overall and ranked) from precomputed caches
|
||||
let overall_acc = app
|
||||
.explorer_accuracy_cache_overall
|
||||
.filter(|(key, _, _)| *key == selected);
|
||||
let ranked_acc = app
|
||||
.explorer_accuracy_cache_ranked
|
||||
.filter(|(key, _, _)| *key == selected);
|
||||
if overall_acc.is_some() || ranked_acc.is_some() {
|
||||
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String {
|
||||
if let Some((_, correct, total)) = entry {
|
||||
if total > 0 {
|
||||
let pct = (correct as f64 / total as f64) * 100.0;
|
||||
return format!("{:.1}% ({}/{})", pct, correct, total);
|
||||
}
|
||||
}
|
||||
"No data".to_string()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Accuracy: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_acc(overall_acc),
|
||||
fmt_acc(ranked_acc)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Ranked progression info (mirrors Skill Tree per-key bar semantics)
|
||||
if is_unlocked {
|
||||
let focus_key = app
|
||||
.skill_tree
|
||||
.focused_key(DrillScope::Global, &app.ranked_key_stats);
|
||||
let in_focus = focus_key == Some(selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
if in_focus { "In focus now" } else { "No" },
|
||||
Style::default().fg(if in_focus {
|
||||
colors.focused_key()
|
||||
} else {
|
||||
colors.fg()
|
||||
}),
|
||||
),
|
||||
]));
|
||||
|
||||
let conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
|
||||
let bar_width = 10usize;
|
||||
let filled = (conf * bar_width as f64).round() as usize;
|
||||
let bar = format!(
|
||||
"{}{}",
|
||||
"\u{2588}".repeat(filled),
|
||||
"\u{2591}".repeat(bar_width.saturating_sub(filled))
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Progress: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(
|
||||
format!(" {:>3.0}%", conf * 100.0),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// If no stats at all
|
||||
if overall_stat.is_none()
|
||||
&& ranked_stat.is_none()
|
||||
&& overall_acc.is_none()
|
||||
&& ranked_acc.is_none()
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No data yet",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ impl JsonStore {
|
||||
self.save("key_stats.json", data)
|
||||
}
|
||||
|
||||
pub fn load_ranked_key_stats(&self) -> KeyStatsData {
|
||||
self.load("key_stats_ranked.json")
|
||||
}
|
||||
|
||||
pub fn save_ranked_key_stats(&self, data: &KeyStatsData) -> Result<()> {
|
||||
self.save("key_stats_ranked.json", data)
|
||||
}
|
||||
|
||||
pub fn load_drill_history(&self) -> DrillHistoryData {
|
||||
self.load("lesson_history.json")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{Finger, Hand};
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
pub focused_key: Option<char>,
|
||||
pub selected_key: Option<char>,
|
||||
pub next_key: Option<char>,
|
||||
pub unlocked_keys: &'a [char],
|
||||
pub depressed_keys: &'a HashSet<char>,
|
||||
@@ -18,11 +18,11 @@ pub struct KeyboardDiagram<'a> {
|
||||
pub compact: bool,
|
||||
pub model: &'a KeyboardModel,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
}
|
||||
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
pub fn new(
|
||||
focused_key: Option<char>,
|
||||
next_key: Option<char>,
|
||||
unlocked_keys: &'a [char],
|
||||
depressed_keys: &'a HashSet<char>,
|
||||
@@ -30,7 +30,7 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
selected_key: None,
|
||||
next_key,
|
||||
unlocked_keys,
|
||||
depressed_keys,
|
||||
@@ -38,9 +38,20 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
compact: false,
|
||||
model,
|
||||
shift_held: false,
|
||||
caps_lock: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caps_lock(mut self, caps_lock: bool) -> Self {
|
||||
self.caps_lock = caps_lock;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected_key(mut self, key: Option<char>) -> Self {
|
||||
self.selected_key = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn compact(mut self, compact: bool) -> Self {
|
||||
self.compact = compact;
|
||||
self
|
||||
@@ -50,20 +61,15 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
self.shift_held = shift_held;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
|
||||
let assignment = model.finger_for_char(ch);
|
||||
match (assignment.hand, assignment.finger) {
|
||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
|
||||
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
|
||||
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
_ => Color::Rgb(120, 120, 120),
|
||||
/// Check if a key (by display or base char) matches the selected key.
|
||||
fn is_key_selected(&self, display_char: char, base_char: char) -> bool {
|
||||
self.selected_key == Some(display_char) || self.selected_key == Some(base_char)
|
||||
}
|
||||
|
||||
/// Check if a sentinel/modifier key matches the selected key.
|
||||
fn is_sentinel_selected(&self, sentinel: char) -> bool {
|
||||
self.selected_key == Some(sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +84,69 @@ fn brighten_color(color: Color) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend a color toward the background at the given ratio (0.0 = full bg, 1.0 = full color).
|
||||
fn blend_toward_bg(color: Color, bg: Color, ratio: f32) -> Color {
|
||||
match (color, bg) {
|
||||
(Color::Rgb(r, g, b), Color::Rgb(br, bg_g, bb)) => {
|
||||
let mix = |c: u8, base: u8| -> u8 {
|
||||
(base as f32 + (c as f32 - base as f32) * ratio).round() as u8
|
||||
};
|
||||
Color::Rgb(mix(r, br), mix(g, bg_g), mix(b, bb))
|
||||
}
|
||||
_ => color,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute style for a modifier key box (Tab, Enter, Shift, Space, Backspace).
|
||||
fn modifier_key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_selected: bool,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} else {
|
||||
Style::default().fg(colors.fg()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_selected: bool,
|
||||
is_unlocked: bool,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} else if is_unlocked {
|
||||
Style::default().fg(colors.fg()).bg(colors.bg())
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for KeyboardDiagram<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
@@ -90,7 +159,16 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
block.render(area, buf);
|
||||
|
||||
if self.compact {
|
||||
// Compact mode: letter rows only (rows 1-3 of the model)
|
||||
self.render_compact(inner, buf);
|
||||
} else {
|
||||
self.render_full(inner, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardDiagram<'_> {
|
||||
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let letter_rows = self.model.letter_rows();
|
||||
let key_width: u16 = 3;
|
||||
let min_width: u16 = 21;
|
||||
@@ -99,7 +177,7 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let offsets: &[u16] = &[0, 1, 3];
|
||||
let offsets: &[u16] = &[3, 4, 6];
|
||||
|
||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
@@ -109,6 +187,23 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
// Render leading modifier key
|
||||
match row_idx {
|
||||
0 => {
|
||||
let is_dep = self.depressed_keys.contains(&TAB);
|
||||
let is_next = self.next_key == Some(TAB);
|
||||
let is_sel = self.is_sentinel_selected(TAB);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
buf.set_string(inner.x, y, "[T]", style);
|
||||
}
|
||||
2 => {
|
||||
let is_dep = self.shift_held;
|
||||
let style = modifier_key_style(is_dep, false, false, colors);
|
||||
buf.set_string(inner.x, y, "[S]", style);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
@@ -125,32 +220,187 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
let is_sel = self.is_key_selected(display_char, base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
|
||||
|
||||
let display = format!("[{display_char}]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Render trailing modifier key
|
||||
let row_end_x = inner.x + offset + row.len() as u16 * key_width;
|
||||
match row_idx {
|
||||
1 => {
|
||||
if row_end_x + 3 <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&ENTER);
|
||||
let is_next = self.next_key == Some(ENTER);
|
||||
let is_sel = self.is_sentinel_selected(ENTER);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
buf.set_string(row_end_x, y, "[E]", style);
|
||||
}
|
||||
} else {
|
||||
// Full mode: all 4 rows
|
||||
}
|
||||
2 => {
|
||||
if row_end_x + 3 <= inner.x + inner.width {
|
||||
let is_dep = self.shift_held;
|
||||
let style = modifier_key_style(is_dep, false, false, colors);
|
||||
buf.set_string(row_end_x, y, "[S]", style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace at end of first row
|
||||
if inner.height >= 3 {
|
||||
let y = inner.y;
|
||||
let row_end_x = inner.x + offsets[0] + letter_rows[0].len() as u16 * key_width;
|
||||
if row_end_x + 3 <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&BACKSPACE);
|
||||
let is_next = self.next_key == Some(BACKSPACE);
|
||||
let is_sel = self.is_sentinel_selected(BACKSPACE);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
buf.set_string(row_end_x, y, "[B]", style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_full(&self, inner: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let key_width: u16 = 5;
|
||||
let min_width: u16 = 69;
|
||||
let min_width: u16 = 75;
|
||||
|
||||
if inner.height < 4 || inner.width < min_width {
|
||||
// Fallback to compact-style if too narrow for full
|
||||
self.render_full_fallback(inner, buf);
|
||||
return;
|
||||
}
|
||||
|
||||
let offsets: &[u16] = &[0, 5, 5, 6];
|
||||
|
||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
// Render leading modifier keys
|
||||
match row_idx {
|
||||
1 => {
|
||||
if offset >= 5 {
|
||||
let is_dep = self.depressed_keys.contains(&TAB);
|
||||
let is_next = self.next_key == Some(TAB);
|
||||
let is_sel = self.is_sentinel_selected(TAB);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
let label = format!("[{}]", display::key_short_label(TAB));
|
||||
buf.set_string(inner.x, y, &label, style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if offset >= 5 {
|
||||
if self.caps_lock {
|
||||
let style = Style::default()
|
||||
.fg(colors.warning())
|
||||
.bg(colors.accent_dim());
|
||||
buf.set_string(inner.x, y, "[Cap]", style);
|
||||
} else {
|
||||
let style = Style::default().fg(colors.text_pending()).bg(colors.bg());
|
||||
buf.set_string(inner.x, y, "[ ]", style);
|
||||
}
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if offset >= 6 {
|
||||
let is_dep = self.shift_held;
|
||||
let style = modifier_key_style(is_dep, false, false, colors);
|
||||
buf.set_string(inner.x, y, "[Shft]", style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
let is_sel = self.is_key_selected(display_char, base_char);
|
||||
|
||||
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Render trailing modifier keys
|
||||
let after_x = inner.x + offset + row.len() as u16 * key_width;
|
||||
match row_idx {
|
||||
0 => {
|
||||
if after_x + 6 <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&BACKSPACE);
|
||||
let is_next = self.next_key == Some(BACKSPACE);
|
||||
let is_sel = self.is_sentinel_selected(BACKSPACE);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
let label = format!("[{}]", display::key_short_label(BACKSPACE));
|
||||
buf.set_string(after_x, y, &label, style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if after_x + 7 <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&ENTER);
|
||||
let is_next = self.next_key == Some(ENTER);
|
||||
let is_sel = self.is_sentinel_selected(ENTER);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
let label = format!("[{}]", display::key_display_name(ENTER));
|
||||
buf.set_string(after_x, y, &label, style);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if after_x + 6 <= inner.x + inner.width {
|
||||
let is_dep = self.shift_held;
|
||||
let style = modifier_key_style(is_dep, false, false, colors);
|
||||
buf.set_string(after_x, y, "[Shft]", style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Space bar row (row 4)
|
||||
let space_y = inner.y + 4;
|
||||
if space_y < inner.y + inner.height {
|
||||
let space_name = display::key_display_name(SPACE);
|
||||
let space_label = format!("[ {space_name} ]");
|
||||
let space_width = space_label.len() as u16;
|
||||
let space_x = inner.x + (inner.width.saturating_sub(space_width)) / 2;
|
||||
if space_x + space_width <= inner.x + inner.width {
|
||||
let is_dep = self.depressed_keys.contains(&SPACE);
|
||||
let is_next = self.next_key == Some(SPACE);
|
||||
let is_sel = self.is_sentinel_selected(SPACE);
|
||||
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
|
||||
buf.set_string(space_x, space_y, space_label, style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let letter_rows = self.model.letter_rows();
|
||||
let key_width: u16 = 5;
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
@@ -183,140 +433,15 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
let is_sel = self.is_key_selected(display_char, base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Row offsets for full layout (staggered keyboard)
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Modifier labels at row edges (visual only)
|
||||
let label_style = Style::default().fg(colors.text_pending());
|
||||
let after_x = inner.x + offset + row.len() as u16 * key_width + 1;
|
||||
match row_idx {
|
||||
0 => {
|
||||
// Backspace after number row
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Bksp", label_style);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Tab before top row, backslash already in row
|
||||
if offset >= 3 {
|
||||
buf.set_string(inner.x, y, "Tab", label_style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Enter after home row
|
||||
if after_x + 5 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Enter", label_style);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Shift before and after bottom row
|
||||
if offset >= 5 {
|
||||
buf.set_string(inner.x, y, "Shft", label_style);
|
||||
}
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Shft", label_style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_focused: bool,
|
||||
is_unlocked: bool,
|
||||
base_char: char,
|
||||
model: &KeyboardModel,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(model, base_char))
|
||||
} else {
|
||||
brighten_color(colors.accent_dim())
|
||||
};
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(finger_color(model, base_char))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ impl<'a> Menu<'a> {
|
||||
label: "Skill Tree".to_string(),
|
||||
description: "View progression branches and launch drills".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "b".to_string(),
|
||||
label: "Keyboard".to_string(),
|
||||
description: "Explore keyboard layout and key statistics".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "s".to_string(),
|
||||
label: "Statistics".to_string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
@@ -176,7 +177,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -191,7 +193,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -676,7 +679,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -732,6 +735,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
format_accuracy_cell_label(label, accuracy, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +837,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -842,6 +889,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
format_timing_cell_label(label, time_ms, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +1044,7 @@ impl StatsDashboard<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Collect all keys from keyboard model
|
||||
// Collect all keys from keyboard model + modifier keys
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
@@ -961,6 +1052,10 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
// Include modifier/whitespace keys
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1034,6 +1129,9 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1178,6 +1276,21 @@ fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_accuracy_cell_label(label: &str, accuracy: f64, key_width: u16) -> String {
|
||||
if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{pct:>3}")
|
||||
} else {
|
||||
format!("{label}{pct:>2}")
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if time_ms <= 0.0 {
|
||||
colors.text_pending()
|
||||
@@ -1241,6 +1354,51 @@ fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timing_cell_label(label: &str, time_ms: f64, key_width: u16) -> String {
|
||||
if time_ms > 0.0 {
|
||||
let ms = time_ms.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{ms:>4}")
|
||||
} else {
|
||||
format!("{label}{:>3}", ms.min(999))
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute labels across `total_width`, with the first flush-left
|
||||
/// and the last flush-right, and equal gaps between the rest.
|
||||
fn spread_labels(labels: &[String], total_width: u16) -> Vec<u16> {
|
||||
let n = labels.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
if n == 1 {
|
||||
return vec![0];
|
||||
}
|
||||
let total_label_width: u16 = labels.iter().map(|l| l.len() as u16).sum();
|
||||
let last_width = labels.last().map(|l| l.len() as u16).unwrap_or(0);
|
||||
let spare = total_width.saturating_sub(total_label_width);
|
||||
let gaps = (n - 1) as u16;
|
||||
let gap = if gaps > 0 { spare / gaps } else { 0 };
|
||||
let remainder = if gaps > 0 { spare % gaps } else { 0 };
|
||||
|
||||
let mut positions = Vec::with_capacity(n);
|
||||
let mut x: u16 = 0;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
if i == n - 1 {
|
||||
// Last label flush-right
|
||||
x = total_width.saturating_sub(last_width);
|
||||
}
|
||||
positions.push(x);
|
||||
x += label.len() as u16 + gap + if (i as u16) < remainder { 1 } else { 0 };
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
label: &str,
|
||||
ratio: f64,
|
||||
|
||||
@@ -111,7 +111,9 @@ impl Widget for TypingArea<'_> {
|
||||
token.display.clone()
|
||||
}
|
||||
} else if idx == self.drill.cursor && target_ch == ' ' {
|
||||
"\u{00b7}".to_string()
|
||||
// Keep an actual space at cursor position so soft-wrap break opportunities
|
||||
// remain stable at word boundaries.
|
||||
" ".to_string()
|
||||
} else {
|
||||
token.display.clone()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user