Key milestone overlays + keyboard diagram improvements

Also splits out a separate store for ranked stats from overall key
stats.
This commit is contained in:
2026-02-20 23:15:13 +00:00
parent 4e39e99732
commit 9e0411e1f4
12 changed files with 2185 additions and 279 deletions

View File

@@ -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")

View File

@@ -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();

View File

@@ -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
View 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")
);
}
}

View File

@@ -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)]

View File

@@ -1,3 +1,4 @@
pub mod display;
pub mod finger;
pub mod layout;
pub mod model;

View File

@@ -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);
}

View File

@@ -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")
}

View File

@@ -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())
}
}

View File

@@ -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(),

View File

@@ -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,

View File

@@ -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()
};