Skill tree integration + tons of random fixes

This commit is contained in:
2026-02-17 04:00:58 +00:00
parent edd2f7e6b5
commit a61ed77ed6
17 changed files with 2610 additions and 710 deletions

View File

@@ -0,0 +1,197 @@
# Skill Tree Integration Fixes & UI Improvements
## Context
After adding a skill tree progression system, several parts of the app weren't fully integrated. This plan addresses 7 issues: progress bar confusion, broken skill tree bars, missing selectability, duplicate displays, incomplete keyboard visualization, code drill formatting issues, and a missing menu shortcut.
## Architecture Foundations
### A. Layout-Driven Keyboard Model
**Files:** `src/keyboard/layout.rs`, new `src/keyboard/model.rs`
The existing `KeyboardLayout` in `layout.rs` only stores `Vec<Vec<char>>` (base layer). We need a shared model used by both drill and stats keyboards.
Create `src/keyboard/model.rs`:
- `PhysicalKey { base: char, shifted: char }` - represents one physical key with both layers
- `KeyboardModel { rows: Vec<Vec<PhysicalKey>> }` - full keyboard definition
- Factory methods: `KeyboardModel::qwerty()`, `::dvorak()`, `::colemak()` - each returns the full layout
- Helper: `base_to_shifted(ch) -> Option<char>` and `shifted_to_base(ch) -> Option<char>` derived from the model
- Helper: `physical_key_for(ch) -> Option<&PhysicalKey>` - lookup by either base or shifted char
The QWERTY model:
```
Row 0 (number): (`~) (1!) (2@) (3#) (4$) (5%) (6^) (7&) (8*) (9() (0)) (-_) (=+)
Row 1 (top): (qQ) (wW) (eE) (rR) (tT) (yY) (uU) (iI) (oO) (pP) ([{) (]}) (\|)
Row 2 (home): (aA) (sS) (dD) (fF) (gG) (hH) (jJ) (kK) (lL) (;:) ('")
Row 3 (bottom): (zZ) (xX) (cC) (vV) (bB) (nN) (mM) (,<) (.>) (/?)
```
Update `KeyboardLayout` to use `KeyboardModel` internally (or replace it).
Replace `qwerty_finger(ch)` with a layout-aware API:
- `KeyboardModel::finger_for(&self, key: &PhysicalKey) -> FingerAssignment` - each layout defines finger assignments per physical key position (row, col)
- For shifted chars, callers first resolve to physical key via `physical_key_for(ch)`, then look up finger
- This eliminates the QWERTY-only char match and works for Dvorak/Colemak
Load the active layout from `config.keyboard_layout` and pass it through to all keyboard rendering.
### B. Dual Progress Metrics
**File:** `src/engine/skill_tree.rs`
Add `branch_unlocked_count(id: BranchId) -> usize` method:
- Lowercase: delegates to `lowercase_unlocked_count()`
- Others: sums `keys.len()` for levels `0..=current_level` when InProgress; all keys when Complete; 0 otherwise
All UI uses two metrics per branch:
- **Unlocked**: `branch_unlocked_count(id)` / `branch_total_keys(id)` - how far through the branch
- **Mastered**: `branch_confident_keys(id, stats)` / `branch_total_keys(id)` - how many keys at confidence >= 1.0
### C. Code Language Config
**File:** `src/config.rs`
Replace the implicit `code_languages: Vec<String>` usage with a clearer model:
- Add `code_language: String` field (single language: "rust", "python", "javascript", "go", "all")
- Keep `code_languages` for backwards compat but derive from `code_language`
- Settings cycling and code generation both read `code_language`
- "all" picks a random language per drill in `generate_text()`
---
## Implementation Changes (in order)
### 1. Fix missing `[c] Settings` shortcut in menu footer
**File:** `src/main.rs` (`render_menu` function)
- Change footer string to: `" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit "`
- Verify no other footers are missing hints by checking all `render_*` functions
### 2. Fix duplicate fraction display on Lowercase branch
**File:** `src/ui/components/skill_tree.rs` (`render_branch_list`)
- Currently shows `"6/26 0/26 keys"` because status_text and confident/total are concatenated
- Change to single display: `"6/26 unlocked"` when no mastered keys, or `"6/26 unlocked (3 mastered)"` when some exist
- Apply same pattern to all branches: `"Lvl 1/3 5/10 unlocked (2 mastered)"`
### 3. Make Lowercase a-z selectable in skill tree
**Files:** `src/ui/components/skill_tree.rs`, `src/main.rs` (`handle_skill_tree_key`)
- Add `BranchId::Lowercase` to `selectable_branches()` at index 0
- Merge the separate root Lowercase rendering (currently in `render_branch_list` lines 113-170) into the main branch loop
- Apply selection highlighting to Lowercase using same `is_selected` logic as other branches
- Keep "Branches (unlocked after a-z)" separator after Lowercase (index 0) and before Capitals (index 1)
- Detail panel for Lowercase: show progressive unlock state `"Unlocked 6/26 letters"` instead of `"Level 1/1"`. Show each unlocked key with its confidence, locked keys dimmed
- Enter on InProgress Lowercase starts branch drill (existing `start_branch_drill` handles this)
- Update `branch_list_height` calculation to account for the merged layout
### 4. Fix skill tree progress bars - combined unlocked/mastered bar
**Files:** `src/engine/skill_tree.rs`, `src/ui/components/skill_tree.rs`
- Add `branch_unlocked_count()` method (see Architecture B above)
- Change progress bars to a **combined dual-metric bar**: the bar is divided into three segments:
- Filled (accent color): mastered keys (confidence >= 1.0)
- Filled (dimmer color): unlocked but not yet mastered
- Empty (background): locked keys
- This works because mastered <= unlocked <= total always holds
- Update `progress_bar_str` to accept two ratios and render with two fill colors
- **Rounding rule**: compute cell counts from raw counts (not ratios) to avoid rounding violations:
- `mastered_cells = (mastered * width / total)` (floor)
- `unlocked_cells = (unlocked * width / total).max(mastered_cells)` (floor, clamped)
- `empty_cells = width - unlocked_cells`
- This guarantees `mastered_cells <= unlocked_cells <= width` with no overlap
- Text label shows: `"6/26 unlocked, 3 mastered"`
### 5. Add per-key mastery display in skill tree detail panel (phase 2 if time allows)
**File:** `src/ui/components/skill_tree.rs` (`render_detail_panel`)
- In the detail view for the selected branch, show a mini progress bar per key
- Each key shows: `char [====----] 75%` where the bar represents confidence (0-100%)
- Keys already at confidence >= 1.0 show as fully filled with success color
- Keys not yet unlocked show dimmed with "locked" label
- Focused key is highlighted (existing logic already identifies it)
- Layout: keys in their level groups, each on its own line with the mini bar
- Note: This adds UI complexity. Implement after core issues (1-4, 6-8) are stable.
### 6. Replace drill screen progress bar with per-branch progress
**Files:** `src/main.rs` (`render_drill`), new `src/ui/components/branch_progress_list.rs`
Create a new `BranchProgressList` widget (not stretching the existing `ProgressBar`):
- Shows one compact line per active branch (InProgress or Complete), plus an overall line
- Each line: `" ▶ Lowercase [████░░░░] 6/26"`
- Uses the combined dual-metric bar from Issue 4 (mastered vs unlocked segments)
- Active drill branch (from `app.drill_scope`) is highlighted with accent color and `▶` prefix
- Other branches use dimmer color and `·` prefix
Layout budgeting by `LayoutTier` (unbordered, plain lines to maximize density):
- **Wide** (height >= 25): show all active branches (InProgress/Complete). `Constraint::Length(active_count.min(6) as u16 + 1)` (+1 for "Overall" line)
- **Wide** (height 20-24): show active drill branch + overall only. `Constraint::Length(2)`
- **Medium**: show active drill branch only. `Constraint::Length(1)`
- **Narrow**: hide progress (current behavior)
### 7. Full keyboard visualization
**Files:** `src/keyboard/model.rs` (new), `src/keyboard/layout.rs` (update), `src/ui/components/keyboard_diagram.rs`, `src/ui/components/stats_dashboard.rs`, `src/main.rs`, `src/app.rs`
#### 7a. Build KeyboardModel (Architecture A above)
#### 7b. Drill keyboard
- `KeyboardDiagram` takes `&KeyboardModel` instead of hardcoded `ROWS`
- Add `shift_held: bool` field
- **Shift state handling**: Primary source is `key.modifiers.contains(KeyModifiers::SHIFT)` checked on every Press event. Set `app.shift_held = true` when modifier present, `false` when absent. Additionally, on tick (100ms), if `shift_held` is true and no key event has been received in 200ms, clear it as a fallback. This means: shifted display appears when a shifted key is pressed, and naturally clears on the next unshifted keypress or after timeout. Acceptance: brief flicker (1-2 frames) on quick shift+key combos is acceptable; sustained wrong state is not.
- When `shift_held`, display `physical_key.shifted` for each key; otherwise `physical_key.base`
- Full mode: 4 rows (number, top, home, bottom) + visual-only labels for Tab/Backspace/Shift/Enter at row edges
- Compact mode: 3 rows letters only (current behavior, but driven from `KeyboardModel`)
- Height: `Constraint::Length(7)` for full (4 rows + 2 border + label), `Constraint::Length(5)` for compact
- Replace `finger_color(ch)` with layout-aware `finger_for(model, physical_key) -> FingerAssignment` that works for any layout (see 7a)
- `is_unlocked` check: map the displayed char against `unlocked_keys` list
#### 7c. Stats keyboard heatmap
- Two sub-rows per physical row: top = shifted layer (dimmer styling), bottom = base layer
- Each cell shows char + accuracy % (existing format)
- Height: `Constraint::Length(12)` (4 physical rows x 2 sub-rows + 2 borders + header)
- Load from `KeyboardModel` based on `config.keyboard_layout`
- Accuracy lookup: use existing `get_key_accuracy(char)` for each layer independently
- **Width fallback**: if terminal width < 70, collapse to base layer only (hide shifted sub-rows). Existing min-width guard pattern from `render_keyboard_heatmap` (width < 50 => skip) is preserved.
### 8. Code drill improvements
**Files:** `src/generator/code_syntax.rs`, `src/app.rs`, `src/main.rs`, `src/config.rs`
#### 8a. Multi-line embedded snippets
- Reformat all snippets in `rust_snippets()`, `python_snippets()`, `javascript_snippets()`, `go_snippets()` to be multi-line with realistic formatting
- Go: use `\t` for indentation (gofmt convention)
- Rust/Python/JavaScript: use 4 spaces
- Keep Tab key input as literal `\t` (do NOT convert to spaces) - this is needed for whitespace branch progression and the typing area already renders tabs properly
- Add basic validation for fetched snippets: require at least one newline and reject snippets that are all on one line (filter in `extract_code_snippets`)
#### 8b. Language selection screen
- Add `AppScreen::CodeLanguageSelect` to `AppScreen` enum
- Add `code_language_selected: usize` to `App`
- Screen flow: Menu `'2'` or Enter on "Code Drill" -> `CodeLanguageSelect` -> select language -> start drill
- ESC from language select returns to Menu
- Direct hotkeys in language select: `1`=Rust, `2`=Python, `3`=JavaScript, `4`=Go, `5`=All
- Enter confirms selection
- Arrow keys / j/k navigate
- Default selection: whichever language matches current `config.code_language`
- On confirm: update `config.code_language`, save config, set `drill_mode = Code`, start drill
- Render: centered bordered box with language list, highlighting selected item, showing `(current)` next to the default
#### 8c. Config changes
- Add `code_language: String` field to Config with default "rust"
- Settings screen language cycling updates `code_language`
- `generate_text` for Code mode reads `code_language` (if "all", picks random)
---
## Verification
- `cargo build` -- no compilation errors
- `cargo test` -- existing tests pass; add tests for:
- `branch_unlocked_count` returns correct values for each branch state
- `KeyboardModel::qwerty()` covers all skill tree chars
- Selection bounds don't panic with Lowercase in `selectable_branches`
- Manual testing checklist:
- Menu footer shows `[c] Settings`
- Skill tree: Lowercase is selectable with arrow keys, Enter starts drill
- Skill tree: single fraction display, no duplicate numbers
- Skill tree: progress bars show dual unlocked/mastered segments
- Skill tree detail: per-key mastery bars shown
- Drill: branch progress bars visible, active branch highlighted
- Drill keyboard: full layout visible, keys shift on Shift press
- Stats keyboard: both layers shown
- Code drill: language selection appears, snippets have proper newlines/indentation
- Non-adaptive drills: ESC still shows partial result correctly
- Dvorak/Colemak: keyboard renders correctly when layout config changed

View File

@@ -1,6 +1,7 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::time::Instant; use std::time::Instant;
use rand::Rng;
use rand::SeedableRng; use rand::SeedableRng;
use rand::rngs::SmallRng; use rand::rngs::SmallRng;
@@ -19,6 +20,7 @@ use crate::generator::passage::PassageGenerator;
use crate::generator::phonetic::PhoneticGenerator; use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::punctuate; use crate::generator::punctuate;
use crate::generator::transition_table::TransitionTable; use crate::generator::transition_table::TransitionTable;
use crate::keyboard::model::KeyboardModel;
use crate::session::drill::DrillState; use crate::session::drill::DrillState;
use crate::session::input::{self, KeystrokeEvent}; use crate::session::input::{self, KeystrokeEvent};
@@ -36,6 +38,7 @@ pub enum AppScreen {
StatsDashboard, StatsDashboard,
Settings, Settings,
SkillTree, SkillTree,
CodeLanguageSelect,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -82,6 +85,11 @@ pub struct App {
pub history_selected: usize, pub history_selected: usize,
pub history_confirm_delete: bool, pub history_confirm_delete: bool,
pub skill_tree_selected: usize, pub skill_tree_selected: usize,
pub skill_tree_detail_scroll: usize,
pub drill_source_info: Option<String>,
pub code_language_selected: usize,
pub shift_held: bool,
pub keyboard_model: KeyboardModel,
rng: SmallRng, rng: SmallRng,
transition_table: TransitionTable, transition_table: TransitionTable,
#[allow(dead_code)] #[allow(dead_code)]
@@ -132,6 +140,7 @@ impl App {
let dictionary = Dictionary::load(); let dictionary = Dictionary::load();
let transition_table = TransitionTable::build_from_words(&dictionary.words_list()); let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
let keyboard_model = KeyboardModel::from_name(&config.keyboard_layout);
let mut app = Self { let mut app = Self {
screen: AppScreen::Menu, screen: AppScreen::Menu,
@@ -156,6 +165,11 @@ impl App {
history_selected: 0, history_selected: 0,
history_confirm_delete: false, history_confirm_delete: false,
skill_tree_selected: 0, skill_tree_selected: 0,
skill_tree_detail_scroll: 0,
drill_source_info: None,
code_language_selected: 0,
shift_held: false,
keyboard_model,
rng: SmallRng::from_entropy(), rng: SmallRng::from_entropy(),
transition_table, transition_table,
dictionary, dictionary,
@@ -165,13 +179,14 @@ impl App {
} }
pub fn start_drill(&mut self) { pub fn start_drill(&mut self) {
let text = self.generate_text(); let (text, source_info) = self.generate_text();
self.drill = Some(DrillState::new(&text)); self.drill = Some(DrillState::new(&text));
self.drill_source_info = source_info;
self.drill_events.clear(); self.drill_events.clear();
self.screen = AppScreen::Drill; self.screen = AppScreen::Drill;
} }
fn generate_text(&mut self) -> String { fn generate_text(&mut self) -> (String, Option<String>) {
let word_count = self.config.word_count; let word_count = self.config.word_count;
let mode = self.drill_mode; let mode = self.drill_mode;
@@ -291,25 +306,28 @@ impl App {
text = insert_line_breaks(&text); text = insert_line_breaks(&text);
} }
text (text, None)
} }
DrillMode::Code => { DrillMode::Code => {
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
let lang = self let lang = if self.config.code_language == "all" {
.config let langs = ["rust", "python", "javascript", "go"];
.code_languages let idx = self.rng.gen_range(0..langs.len());
.first() langs[idx].to_string()
.cloned() } else {
.unwrap_or_else(|| "rust".to_string()); self.config.code_language.clone()
};
let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = CodeSyntaxGenerator::new(rng, &lang); let mut generator = CodeSyntaxGenerator::new(rng, &lang);
generator.generate(&filter, None, word_count) let text = generator.generate(&filter, None, word_count);
(text, Some(generator.last_source().to_string()))
} }
DrillMode::Passage => { DrillMode::Passage => {
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PassageGenerator::new(rng); let mut generator = PassageGenerator::new(rng);
generator.generate(&filter, None, word_count) let text = generator.generate(&filter, None, word_count);
(text, Some(generator.last_source().to_string()))
} }
} }
} }
@@ -414,6 +432,7 @@ impl App {
pub fn go_to_menu(&mut self) { pub fn go_to_menu(&mut self) {
self.screen = AppScreen::Menu; self.screen = AppScreen::Menu;
self.drill = None; self.drill = None;
self.drill_source_info = None;
self.drill_events.clear(); self.drill_events.clear();
} }
@@ -498,6 +517,7 @@ impl App {
pub fn go_to_skill_tree(&mut self) { pub fn go_to_skill_tree(&mut self) {
self.skill_tree_selected = 0; self.skill_tree_selected = 0;
self.skill_tree_detail_scroll = 0;
self.screen = AppScreen::SkillTree; self.screen = AppScreen::SkillTree;
} }
@@ -513,6 +533,15 @@ impl App {
self.start_drill(); self.start_drill();
} }
pub fn go_to_code_language_select(&mut self) {
let langs = ["rust", "python", "javascript", "go", "all"];
self.code_language_selected = langs
.iter()
.position(|&l| l == self.config.code_language)
.unwrap_or(0);
self.screen = AppScreen::CodeLanguageSelect;
}
pub fn go_to_settings(&mut self) { pub fn go_to_settings(&mut self) {
self.settings_selected = 0; self.settings_selected = 0;
self.screen = AppScreen::Settings; self.screen = AppScreen::Settings;
@@ -542,16 +571,13 @@ impl App {
self.config.word_count = (self.config.word_count + 5).min(100); self.config.word_count = (self.config.word_count + 5).min(100);
} }
3 => { 3 => {
let langs = ["rust", "python", "javascript", "go"]; let langs = ["rust", "python", "javascript", "go", "all"];
let current = self let idx = langs
.config .iter()
.code_languages .position(|&l| l == self.config.code_language)
.first() .unwrap_or(0);
.map(|s| s.as_str())
.unwrap_or("rust");
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
let next = (idx + 1) % langs.len(); let next = (idx + 1) % langs.len();
self.config.code_languages = vec![langs[next].to_string()]; self.config.code_language = langs[next].to_string();
} }
_ => {} _ => {}
} }
@@ -581,16 +607,13 @@ impl App {
self.config.word_count = self.config.word_count.saturating_sub(5).max(5); self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
} }
3 => { 3 => {
let langs = ["rust", "python", "javascript", "go"]; let langs = ["rust", "python", "javascript", "go", "all"];
let current = self let idx = langs
.config .iter()
.code_languages .position(|&l| l == self.config.code_language)
.first() .unwrap_or(0);
.map(|s| s.as_str())
.unwrap_or("rust");
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 }; let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
self.config.code_languages = vec![langs[next].to_string()]; self.config.code_language = langs[next].to_string();
} }
_ => {} _ => {}
} }

View File

@@ -12,10 +12,10 @@ pub struct Config {
pub theme: String, pub theme: String,
#[serde(default = "default_keyboard_layout")] #[serde(default = "default_keyboard_layout")]
pub keyboard_layout: String, pub keyboard_layout: String,
#[serde(default = "default_code_languages")]
pub code_languages: Vec<String>,
#[serde(default = "default_word_count")] #[serde(default = "default_word_count")]
pub word_count: usize, pub word_count: usize,
#[serde(default = "default_code_language")]
pub code_language: String,
} }
fn default_target_wpm() -> u32 { fn default_target_wpm() -> u32 {
@@ -27,12 +27,12 @@ fn default_theme() -> String {
fn default_keyboard_layout() -> String { fn default_keyboard_layout() -> String {
"qwerty".to_string() "qwerty".to_string()
} }
fn default_code_languages() -> Vec<String> {
vec!["rust".to_string()]
}
fn default_word_count() -> usize { fn default_word_count() -> usize {
20 20
} }
fn default_code_language() -> String {
"rust".to_string()
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
@@ -40,8 +40,8 @@ impl Default for Config {
target_wpm: default_target_wpm(), target_wpm: default_target_wpm(),
theme: default_theme(), theme: default_theme(),
keyboard_layout: default_keyboard_layout(), keyboard_layout: default_keyboard_layout(),
code_languages: default_code_languages(),
word_count: default_word_count(), word_count: default_word_count(),
code_language: default_code_language(),
} }
} }
} }

View File

@@ -7,6 +7,7 @@ pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 {
(speed * complexity) / (errors + 1.0) * (length / 50.0) (speed * complexity) / (errors + 1.0) * (length / 50.0)
} }
#[allow(dead_code)]
pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 { pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 {
(unlocked_count as f64 / total_keys as f64).max(0.1) (unlocked_count as f64 / total_keys as f64).max(0.1)
} }

View File

@@ -28,6 +28,7 @@ impl BranchId {
} }
} }
#[allow(dead_code)]
pub fn from_key(key: &str) -> Option<Self> { pub fn from_key(key: &str) -> Option<Self> {
match key { match key {
"lowercase" => Some(BranchId::Lowercase), "lowercase" => Some(BranchId::Lowercase),
@@ -615,6 +616,7 @@ impl SkillTree {
} }
/// Get all branch definitions with their current progress (for UI). /// Get all branch definitions with their current progress (for UI).
#[allow(dead_code)]
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> { pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
ALL_BRANCHES ALL_BRANCHES
.iter() .iter()
@@ -622,12 +624,49 @@ impl SkillTree {
.collect() .collect()
} }
/// Number of unlocked keys in a branch.
pub fn branch_unlocked_count(&self, id: BranchId) -> usize {
let def = get_branch_definition(id);
let bp = self.branch_progress(id);
match bp.status {
BranchStatus::Complete => def.levels.iter().map(|l| l.keys.len()).sum(),
BranchStatus::InProgress => {
if id == BranchId::Lowercase {
self.lowercase_unlocked_count()
} else {
def.levels
.iter()
.enumerate()
.filter(|(i, _)| *i <= bp.current_level)
.map(|(_, l)| l.keys.len())
.sum()
}
}
_ => 0,
}
}
/// Total keys defined in a branch (across all levels). /// Total keys defined in a branch (across all levels).
pub fn branch_total_keys(id: BranchId) -> usize { pub fn branch_total_keys(id: BranchId) -> usize {
let def = get_branch_definition(id); let def = get_branch_definition(id);
def.levels.iter().map(|l| l.keys.len()).sum() def.levels.iter().map(|l| l.keys.len()).sum()
} }
/// Count of unique confident keys across all branches.
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
let mut keys: HashSet<char> = HashSet::new();
for branch_def in ALL_BRANCHES {
for level in branch_def.levels {
for &ch in level.keys {
if stats.get_confidence(ch) >= 1.0 {
keys.insert(ch);
}
}
}
}
keys.len()
}
/// Count of confident keys in a branch. /// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize { pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
let def = get_branch_definition(id); let def = get_branch_definition(id);
@@ -881,4 +920,52 @@ mod tests {
assert!(keys.contains(&'J')); // Capitals L2 (current level) assert!(keys.contains(&'J')); // Capitals L2 (current level)
assert!(!keys.contains(&'O')); // Capitals L3 (locked) assert!(!keys.contains(&'O')); // Capitals L3 (locked)
} }
#[test]
fn test_branch_unlocked_count() {
let tree = SkillTree::default();
// Lowercase starts InProgress with LOWERCASE_MIN_KEYS
assert_eq!(
tree.branch_unlocked_count(BranchId::Lowercase),
LOWERCASE_MIN_KEYS
);
// Locked branches return 0
assert_eq!(tree.branch_unlocked_count(BranchId::Capitals), 0);
assert_eq!(tree.branch_unlocked_count(BranchId::Numbers), 0);
// InProgress non-lowercase branch
let mut tree2 = SkillTree::default();
let bp = tree2.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 1;
// Level 0 (8 keys) + Level 1 (10 keys)
assert_eq!(tree2.branch_unlocked_count(BranchId::Capitals), 18);
// Complete branch returns all keys
let mut tree3 = SkillTree::default();
tree3.branch_progress_mut(BranchId::Numbers).status = BranchStatus::Complete;
assert_eq!(tree3.branch_unlocked_count(BranchId::Numbers), 10);
}
#[test]
fn test_selectable_branches_bounds() {
use crate::ui::components::skill_tree::selectable_branches;
let branches = selectable_branches();
assert!(!branches.is_empty());
assert_eq!(branches[0], BranchId::Lowercase);
let tree = SkillTree::default();
// Accessing branch_progress for every selectable branch should not panic
for &branch_id in &branches {
let _ = tree.branch_progress(branch_id);
let _ = SkillTree::branch_total_keys(branch_id);
let _ = tree.branch_unlocked_count(branch_id);
}
// Selection at 0 and at max index should be valid
assert!(0 < branches.len());
assert!(branches.len() - 1 < branches.len());
}
} }

View File

@@ -9,6 +9,7 @@ pub struct CodeSyntaxGenerator {
rng: SmallRng, rng: SmallRng,
language: String, language: String,
fetched_snippets: Vec<String>, fetched_snippets: Vec<String>,
last_source: String,
} }
impl CodeSyntaxGenerator { impl CodeSyntaxGenerator {
@@ -17,11 +18,16 @@ impl CodeSyntaxGenerator {
rng, rng,
language: language.to_string(), language: language.to_string(),
fetched_snippets: Vec::new(), fetched_snippets: Vec::new(),
last_source: "Built-in snippets".to_string(),
}; };
generator.load_cached_snippets(); generator.load_cached_snippets();
generator generator
} }
pub fn last_source(&self) -> &str {
&self.last_source
}
fn load_cached_snippets(&mut self) { fn load_cached_snippets(&mut self) {
if let Some(cache) = DiskCache::new("code_cache") { if let Some(cache) = DiskCache::new("code_cache") {
let key = format!("{}_snippets", self.language); let key = format!("{}_snippets", self.language);
@@ -80,120 +86,119 @@ impl CodeSyntaxGenerator {
fn rust_snippets() -> Vec<&'static str> { fn rust_snippets() -> Vec<&'static str> {
vec![ vec![
"fn main() { println!(\"hello\"); }", "fn main() {\n println!(\"hello\");\n}",
"let mut x = 0; x += 1;", "let mut x = 0;\nx += 1;",
"for i in 0..10 { println!(\"{}\", i); }", "for i in 0..10 {\n println!(\"{}\", i);\n}",
"if x > 0 { return true; }", "if x > 0 {\n return true;\n}",
"match val { Some(x) => x, None => 0 }", "match val {\n Some(x) => x,\n None => 0,\n}",
"struct Point { x: f64, y: f64 }", "struct Point {\n x: f64,\n y: f64,\n}",
"impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }", "impl Point {\n fn new(x: f64, y: f64) -> Self {\n Self { x, y }\n }\n}",
"let v: Vec<i32> = vec![1, 2, 3];", "let v: Vec<i32> = vec![1, 2, 3];",
"fn add(a: i32, b: i32) -> i32 { a + b }", "fn add(a: i32, b: i32) -> i32 {\n a + b\n}",
"use std::collections::HashMap;", "use std::collections::HashMap;",
"pub fn process(input: &str) -> Result<String, Error> { Ok(input.to_string()) }", "pub fn process(input: &str) -> Result<String, Error> {\n Ok(input.to_string())\n}",
"let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::<Vec<_>>();", "let result = items\n .iter()\n .filter(|x| x > &0)\n .map(|x| x * 2)\n .collect::<Vec<_>>();",
"enum Color { Red, Green, Blue }", "enum Color {\n Red,\n Green,\n Blue,\n}",
"trait Display { fn show(&self) -> String; }", "trait Display {\n fn show(&self) -> String;\n}",
"while let Some(item) = stack.pop() { process(item); }", "while let Some(item) = stack.pop() {\n process(item);\n}",
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }", "#[derive(Debug, Clone)]\nstruct Config {\n name: String,\n value: i32,\n}",
"let handle = std::thread::spawn(|| { println!(\"thread\"); });", "let handle = std::thread::spawn(|| {\n println!(\"thread\");\n});",
"let mut map = HashMap::new(); map.insert(\"key\", 42);", "let mut map = HashMap::new();\nmap.insert(\"key\", 42);",
"fn factorial(n: u64) -> u64 { if n <= 1 { 1 } else { n * factorial(n - 1) } }", "fn factorial(n: u64) -> u64 {\n if n <= 1 {\n 1\n } else {\n n * factorial(n - 1)\n }\n}",
"impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { None } }", "impl Iterator for Counter {\n type Item = u32;\n\n fn next(&mut self) -> Option<Self::Item> {\n None\n }\n}",
"async fn fetch(url: &str) -> Result<String> { let body = reqwest::get(url).await?.text().await?; Ok(body) }", "async fn fetch(url: &str) -> Result<String> {\n let body = reqwest::get(url)\n .await?\n .text()\n .await?;\n Ok(body)\n}",
"let closure = |x: i32, y: i32| -> i32 { x + y };", "let closure = |x: i32, y: i32| -> i32 {\n x + y\n};",
"mod tests { use super::*; #[test] fn it_works() { assert_eq!(2 + 2, 4); } }", "#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn it_works() {\n assert_eq!(2 + 2, 4);\n }\n}",
"pub struct Builder { name: Option<String> } impl Builder { pub fn name(mut self, n: &str) -> Self { self.name = Some(n.into()); self } }", "pub struct Builder {\n name: Option<String>,\n}\n\nimpl Builder {\n pub fn name(mut self, n: &str) -> Self {\n self.name = Some(n.into());\n self\n }\n}",
"use std::sync::{Arc, Mutex}; let data = Arc::new(Mutex::new(vec![1, 2, 3]));", "use std::sync::{Arc, Mutex};\nlet data = Arc::new(Mutex::new(vec![1, 2, 3]));",
"if let Ok(value) = \"42\".parse::<i32>() { println!(\"parsed: {}\", value); }", "if let Ok(value) = \"42\".parse::<i32>() {\n println!(\"parsed: {}\", value);\n}",
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }", "fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {\n if x.len() > y.len() {\n x\n } else {\n y\n }\n}",
"type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;", "type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;",
"macro_rules! vec_of_strings { ($($x:expr),*) => { vec![$($x.to_string()),*] }; }", "macro_rules! vec_of_strings {\n ($($x:expr),*) => {\n vec![$($x.to_string()),*]\n };\n}",
"let (tx, rx) = std::sync::mpsc::channel(); tx.send(42).unwrap();", "let (tx, rx) = std::sync::mpsc::channel();\ntx.send(42).unwrap();",
] ]
} }
fn python_snippets() -> Vec<&'static str> { fn python_snippets() -> Vec<&'static str> {
vec![ vec![
"def main(): print(\"hello\")", "def main():\n print(\"hello\")",
"for i in range(10): print(i)", "for i in range(10):\n print(i)",
"if x > 0: return True", "if x > 0:\n return True",
"class Point: def __init__(self, x, y): self.x = x", "class Point:\n def __init__(self, x, y):\n self.x = x\n self.y = y",
"import os; path = os.path.join(\"a\", \"b\")", "import os\npath = os.path.join(\"a\", \"b\")",
"result = [x * 2 for x in items if x > 0]", "result = [\n x * 2\n for x in items\n if x > 0\n]",
"with open(\"file.txt\") as f: data = f.read()", "with open(\"file.txt\") as f:\n data = f.read()",
"def add(a: int, b: int) -> int: return a + b", "def add(a: int, b: int) -> int:\n return a + b",
"try: result = process(data) except ValueError as e: print(e)", "try:\n result = process(data)\nexcept ValueError as e:\n print(e)",
"from collections import defaultdict", "from collections import defaultdict",
"lambda x: x * 2 + 1", "lambda x: x * 2 + 1",
"dict_comp = {k: v for k, v in pairs.items()}", "dict_comp = {\n k: v\n for k, v in pairs.items()\n}",
"async def fetch(url): async with aiohttp.ClientSession() as session: return await session.get(url)", "async def fetch(url):\n async with aiohttp.ClientSession() as session:\n return await session.get(url)",
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)", "def fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)",
"@property def name(self): return self._name", "@property\ndef name(self):\n return self._name",
"from dataclasses import dataclass; @dataclass class Config: name: str; value: int = 0", "from dataclasses import dataclass\n\n@dataclass\nclass Config:\n name: str\n value: int = 0",
"yield from range(10)", "yield from range(10)",
"sorted(items, key=lambda x: x.name, reverse=True)", "sorted(\n items,\n key=lambda x: x.name,\n reverse=True,\n)",
"from typing import Optional, List, Dict", "from typing import Optional, List, Dict",
"with contextlib.suppress(FileNotFoundError): os.remove(\"temp.txt\")", "with contextlib.suppress(FileNotFoundError):\n os.remove(\"temp.txt\")",
"class Meta(type): def __new__(cls, name, bases, attrs): return super().__new__(cls, name, bases, attrs)", "class Meta(type):\n def __new__(cls, name, bases, attrs):\n return super().__new__(\n cls, name, bases, attrs\n )",
"from functools import lru_cache; @lru_cache(maxsize=128) def expensive(n): return sum(range(n))", "from functools import lru_cache\n\n@lru_cache(maxsize=128)\ndef expensive(n):\n return sum(range(n))",
"from pathlib import Path; files = list(Path(\".\").glob(\"**/*.py\"))", "from pathlib import Path\nfiles = list(Path(\".\").glob(\"**/*.py\"))",
"assert isinstance(result, dict), f\"Expected dict, got {type(result)}\"", "assert isinstance(result, dict), \\\n f\"Expected dict, got {type(result)}\"",
"values = {*set_a, *set_b}; merged = {**dict_a, **dict_b}", "values = {*set_a, *set_b}\nmerged = {**dict_a, **dict_b}",
] ]
} }
fn javascript_snippets() -> Vec<&'static str> { fn javascript_snippets() -> Vec<&'static str> {
vec![ vec![
"const x = 42; console.log(x);", "const x = 42;\nconsole.log(x);",
"function add(a, b) { return a + b; }", "function add(a, b) {\n return a + b;\n}",
"const arr = [1, 2, 3].map(x => x * 2);", "const arr = [1, 2, 3].map(\n x => x * 2\n);",
"if (x > 0) { return true; }", "if (x > 0) {\n return true;\n}",
"for (let i = 0; i < 10; i++) { console.log(i); }", "for (let i = 0; i < 10; i++) {\n console.log(i);\n}",
"class Point { constructor(x, y) { this.x = x; this.y = y; } }", "class Point {\n constructor(x, y) {\n this.x = x;\n this.y = y;\n }\n}",
"const { name, age } = person;", "const { name, age } = person;",
"async function fetch(url) { const res = await get(url); return res.json(); }", "async function fetch(url) {\n const res = await get(url);\n return res.json();\n}",
"const obj = { ...defaults, ...overrides };", "const obj = {\n ...defaults,\n ...overrides,\n};",
"try { parse(data); } catch (e) { console.error(e); }", "try {\n parse(data);\n} catch (e) {\n console.error(e);\n}",
"export default function handler(req, res) { res.send(\"ok\"); }", "export default function handler(req, res) {\n res.send(\"ok\");\n}",
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);", "const result = items\n .filter(x => x > 0)\n .reduce((a, b) => a + b, 0);",
"const promise = new Promise((resolve, reject) => { setTimeout(resolve, 1000); });", "const promise = new Promise(\n (resolve, reject) => {\n setTimeout(resolve, 1000);\n }\n);",
"const [first, ...rest] = array;", "const [first, ...rest] = array;",
"class EventEmitter { constructor() { this.listeners = new Map(); } }", "class EventEmitter {\n constructor() {\n this.listeners = new Map();\n }\n}",
"const proxy = new Proxy(target, { get(obj, prop) { return obj[prop]; } });", "const proxy = new Proxy(target, {\n get(obj, prop) {\n return obj[prop];\n }\n});",
"for await (const chunk of stream) { process(chunk); }", "for await (const chunk of stream) {\n process(chunk);\n}",
"const memoize = (fn) => { const cache = new Map(); return (...args) => cache.get(args) ?? fn(...args); };", "const memoize = (fn) => {\n const cache = new Map();\n return (...args) => {\n return cache.get(args) ?? fn(...args);\n };\n};",
"import { useState, useEffect } from 'react'; const [state, setState] = useState(null);", "import { useState, useEffect } from 'react';\nconst [state, setState] = useState(null);",
"const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);", "const pipe = (...fns) => (x) =>\n fns.reduce((v, f) => f(v), x);",
"Object.entries(obj).forEach(([key, value]) => { console.log(key, value); });", "Object.entries(obj).forEach(\n ([key, value]) => {\n console.log(key, value);\n }\n);",
"const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; };", "const debounce = (fn, ms) => {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(\n () => fn(...args),\n ms\n );\n };\n};",
"const observable = new Observable(subscriber => { subscriber.next(1); subscriber.complete(); });", "const observable = new Observable(\n subscriber => {\n subscriber.next(1);\n subscriber.complete();\n }\n);",
"Symbol.iterator",
] ]
} }
fn go_snippets() -> Vec<&'static str> { fn go_snippets() -> Vec<&'static str> {
vec![ vec![
"func main() { fmt.Println(\"hello\") }", "func main() {\n\tfmt.Println(\"hello\")\n}",
"for i := 0; i < 10; i++ { fmt.Println(i) }", "for i := 0; i < 10; i++ {\n\tfmt.Println(i)\n}",
"if err != nil { return err }", "if err != nil {\n\treturn err\n}",
"type Point struct { X float64; Y float64 }", "type Point struct {\n\tX float64\n\tY float64\n}",
"func add(a, b int) int { return a + b }", "func add(a, b int) int {\n\treturn a + b\n}",
"import \"fmt\"", "import \"fmt\"",
"result := make([]int, 0, 10)", "result := make([]int, 0, 10)",
"switch val { case 1: return \"one\" default: return \"other\" }", "switch val {\ncase 1:\n\treturn \"one\"\ndefault:\n\treturn \"other\"\n}",
"go func() { ch <- result }()", "go func() {\n\tch <- result\n}()",
"defer file.Close()", "defer file.Close()",
"type Reader interface { Read(p []byte) (n int, err error) }", "type Reader interface {\n\tRead(p []byte) (n int, err error)\n}",
"ctx, cancel := context.WithTimeout(context.Background(), time.Second)", "ctx, cancel := context.WithTimeout(\n\tcontext.Background(),\n\ttime.Second,\n)",
"var wg sync.WaitGroup; wg.Add(1); go func() { defer wg.Done() }()", "var wg sync.WaitGroup\nwg.Add(1)\ngo func() {\n\tdefer wg.Done()\n}()",
"func (p *Point) Distance() float64 { return math.Sqrt(p.X*p.X + p.Y*p.Y) }", "func (p *Point) Distance() float64 {\n\treturn math.Sqrt(p.X*p.X + p.Y*p.Y)\n}",
"select { case msg := <-ch: process(msg) case <-time.After(time.Second): timeout() }", "select {\ncase msg := <-ch:\n\tprocess(msg)\ncase <-time.After(time.Second):\n\ttimeout()\n}",
"json.NewEncoder(w).Encode(response)", "json.NewEncoder(w).Encode(response)",
"http.HandleFunc(\"/api\", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\"ok\")) })", "http.HandleFunc(\"/api\",\n\tfunc(w http.ResponseWriter, r *http.Request) {\n\t\tw.Write([]byte(\"ok\"))\n\t},\n)",
"func Map[T, U any](s []T, f func(T) U) []U { r := make([]U, len(s)); for i, v := range s { r[i] = f(v) }; return r }", "func Map[T, U any](s []T, f func(T) U) []U {\n\tr := make([]U, len(s))\n\tfor i, v := range s {\n\t\tr[i] = f(v)\n\t}\n\treturn r\n}",
"var once sync.Once; once.Do(func() { initialize() })", "var once sync.Once\nonce.Do(func() {\n\tinitialize()\n})",
"buf := bytes.NewBuffer(nil); buf.WriteString(\"hello\")", "buf := bytes.NewBuffer(nil)\nbuf.WriteString(\"hello\")",
] ]
} }
@@ -224,6 +229,7 @@ impl TextGenerator for CodeSyntaxGenerator {
let mut result = Vec::new(); let mut result = Vec::new();
let target_words = word_count; let target_words = word_count;
let mut current_words = 0; let mut current_words = 0;
let mut used_fetched = false;
let total_available = embedded.len() + self.fetched_snippets.len(); let total_available = embedded.len() + self.fetched_snippets.len();
@@ -234,6 +240,7 @@ impl TextGenerator for CodeSyntaxGenerator {
embedded[idx] embedded[idx]
} else if !self.fetched_snippets.is_empty() { } else if !self.fetched_snippets.is_empty() {
let f_idx = (idx - embedded.len()) % self.fetched_snippets.len(); let f_idx = (idx - embedded.len()) % self.fetched_snippets.len();
used_fetched = true;
&self.fetched_snippets[f_idx] &self.fetched_snippets[f_idx]
} else { } else {
embedded[idx % embedded.len()] embedded[idx % embedded.len()]
@@ -243,6 +250,12 @@ impl TextGenerator for CodeSyntaxGenerator {
result.push(snippet.to_string()); result.push(snippet.to_string());
} }
self.last_source = if used_fetched {
format!("GitHub source cache ({})", self.language)
} else {
format!("Built-in snippets ({})", self.language)
};
result.join("\n\n") result.join("\n\n")
} }
} }
@@ -286,7 +299,9 @@ fn extract_code_snippets(source: &str) -> Vec<String> {
// Preserve original newlines and indentation // Preserve original newlines and indentation
let snippet = snippet_lines.join("\n"); let snippet = snippet_lines.join("\n");
let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count(); let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count();
if char_count >= 20 && snippet.len() <= 800 { // Require at least one newline (reject single-line snippets)
let has_newline = snippet.contains('\n');
if char_count >= 20 && snippet.len() <= 800 && has_newline {
snippets.push(snippet); snippets.push(snippet);
} }
} }

View File

@@ -95,22 +95,26 @@ const GUTENBERG_IDS: &[(u32, &str)] = &[
]; ];
pub struct PassageGenerator { pub struct PassageGenerator {
current_idx: usize,
fetched_passages: Vec<String>, fetched_passages: Vec<String>,
rng: SmallRng, rng: SmallRng,
last_source: String,
} }
impl PassageGenerator { impl PassageGenerator {
pub fn new(rng: SmallRng) -> Self { pub fn new(rng: SmallRng) -> Self {
let mut generator = Self { let mut generator = Self {
current_idx: 0,
fetched_passages: Vec::new(), fetched_passages: Vec::new(),
rng, rng,
last_source: "Built-in passage library".to_string(),
}; };
generator.load_cached_passages(); generator.load_cached_passages();
generator generator
} }
pub fn last_source(&self) -> &str {
&self.last_source
}
fn load_cached_passages(&mut self) { fn load_cached_passages(&mut self) {
if let Some(cache) = DiskCache::new("passages") { if let Some(cache) = DiskCache::new("passages") {
for &(_, name) in GUTENBERG_IDS { for &(_, name) in GUTENBERG_IDS {
@@ -158,26 +162,27 @@ impl TextGenerator for PassageGenerator {
_focused: Option<char>, _focused: Option<char>,
_word_count: usize, _word_count: usize,
) -> String { ) -> String {
// Try to fetch a new Gutenberg book in the background (first few calls) // Opportunistically fetch Gutenberg passages for source variety.
if self.fetched_passages.len() < 50 && self.current_idx < 3 { if self.fetched_passages.len() < 50 && self.rng.gen_bool(0.35) {
self.try_fetch_gutenberg(); self.try_fetch_gutenberg();
} }
let total_passages = PASSAGES.len() + self.fetched_passages.len(); let total_passages = PASSAGES.len() + self.fetched_passages.len();
if total_passages == 0 { if total_passages == 0 {
self.current_idx += 1; self.last_source = "Built-in passage library".to_string();
return PASSAGES[0].to_string(); return PASSAGES[0].to_string();
} }
// Mix embedded and fetched passages // Randomly mix embedded and fetched passages.
let idx = self.current_idx % total_passages; let idx = self.rng.gen_range(0..total_passages);
self.current_idx += 1;
if idx < PASSAGES.len() { if idx < PASSAGES.len() {
self.last_source = "Built-in passage library".to_string();
PASSAGES[idx].to_string() PASSAGES[idx].to_string()
} else { } else {
let fetched_idx = idx - PASSAGES.len(); let fetched_idx = idx - PASSAGES.len();
self.last_source = "Project Gutenberg (cached)".to_string();
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone() self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
} }
} }

View File

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

819
src/keyboard/model.rs Normal file
View File

@@ -0,0 +1,819 @@
use crate::keyboard::finger::{Finger, FingerAssignment, Hand};
#[derive(Clone, Debug)]
pub struct PhysicalKey {
pub base: char,
pub shifted: char,
}
#[derive(Clone, Debug)]
pub struct KeyboardModel {
pub rows: Vec<Vec<PhysicalKey>>,
}
impl KeyboardModel {
pub fn qwerty() -> Self {
Self {
rows: vec![
vec![
PhysicalKey {
base: '`',
shifted: '~',
},
PhysicalKey {
base: '1',
shifted: '!',
},
PhysicalKey {
base: '2',
shifted: '@',
},
PhysicalKey {
base: '3',
shifted: '#',
},
PhysicalKey {
base: '4',
shifted: '$',
},
PhysicalKey {
base: '5',
shifted: '%',
},
PhysicalKey {
base: '6',
shifted: '^',
},
PhysicalKey {
base: '7',
shifted: '&',
},
PhysicalKey {
base: '8',
shifted: '*',
},
PhysicalKey {
base: '9',
shifted: '(',
},
PhysicalKey {
base: '0',
shifted: ')',
},
PhysicalKey {
base: '-',
shifted: '_',
},
PhysicalKey {
base: '=',
shifted: '+',
},
],
vec![
PhysicalKey {
base: 'q',
shifted: 'Q',
},
PhysicalKey {
base: 'w',
shifted: 'W',
},
PhysicalKey {
base: 'e',
shifted: 'E',
},
PhysicalKey {
base: 'r',
shifted: 'R',
},
PhysicalKey {
base: 't',
shifted: 'T',
},
PhysicalKey {
base: 'y',
shifted: 'Y',
},
PhysicalKey {
base: 'u',
shifted: 'U',
},
PhysicalKey {
base: 'i',
shifted: 'I',
},
PhysicalKey {
base: 'o',
shifted: 'O',
},
PhysicalKey {
base: 'p',
shifted: 'P',
},
PhysicalKey {
base: '[',
shifted: '{',
},
PhysicalKey {
base: ']',
shifted: '}',
},
PhysicalKey {
base: '\\',
shifted: '|',
},
],
vec![
PhysicalKey {
base: 'a',
shifted: 'A',
},
PhysicalKey {
base: 's',
shifted: 'S',
},
PhysicalKey {
base: 'd',
shifted: 'D',
},
PhysicalKey {
base: 'f',
shifted: 'F',
},
PhysicalKey {
base: 'g',
shifted: 'G',
},
PhysicalKey {
base: 'h',
shifted: 'H',
},
PhysicalKey {
base: 'j',
shifted: 'J',
},
PhysicalKey {
base: 'k',
shifted: 'K',
},
PhysicalKey {
base: 'l',
shifted: 'L',
},
PhysicalKey {
base: ';',
shifted: ':',
},
PhysicalKey {
base: '\'',
shifted: '"',
},
],
vec![
PhysicalKey {
base: 'z',
shifted: 'Z',
},
PhysicalKey {
base: 'x',
shifted: 'X',
},
PhysicalKey {
base: 'c',
shifted: 'C',
},
PhysicalKey {
base: 'v',
shifted: 'V',
},
PhysicalKey {
base: 'b',
shifted: 'B',
},
PhysicalKey {
base: 'n',
shifted: 'N',
},
PhysicalKey {
base: 'm',
shifted: 'M',
},
PhysicalKey {
base: ',',
shifted: '<',
},
PhysicalKey {
base: '.',
shifted: '>',
},
PhysicalKey {
base: '/',
shifted: '?',
},
],
],
}
}
pub fn dvorak() -> Self {
Self {
rows: vec![
vec![
PhysicalKey {
base: '`',
shifted: '~',
},
PhysicalKey {
base: '1',
shifted: '!',
},
PhysicalKey {
base: '2',
shifted: '@',
},
PhysicalKey {
base: '3',
shifted: '#',
},
PhysicalKey {
base: '4',
shifted: '$',
},
PhysicalKey {
base: '5',
shifted: '%',
},
PhysicalKey {
base: '6',
shifted: '^',
},
PhysicalKey {
base: '7',
shifted: '&',
},
PhysicalKey {
base: '8',
shifted: '*',
},
PhysicalKey {
base: '9',
shifted: '(',
},
PhysicalKey {
base: '0',
shifted: ')',
},
PhysicalKey {
base: '[',
shifted: '{',
},
PhysicalKey {
base: ']',
shifted: '}',
},
],
vec![
PhysicalKey {
base: '\'',
shifted: '"',
},
PhysicalKey {
base: ',',
shifted: '<',
},
PhysicalKey {
base: '.',
shifted: '>',
},
PhysicalKey {
base: 'p',
shifted: 'P',
},
PhysicalKey {
base: 'y',
shifted: 'Y',
},
PhysicalKey {
base: 'f',
shifted: 'F',
},
PhysicalKey {
base: 'g',
shifted: 'G',
},
PhysicalKey {
base: 'c',
shifted: 'C',
},
PhysicalKey {
base: 'r',
shifted: 'R',
},
PhysicalKey {
base: 'l',
shifted: 'L',
},
PhysicalKey {
base: '/',
shifted: '?',
},
PhysicalKey {
base: '=',
shifted: '+',
},
PhysicalKey {
base: '\\',
shifted: '|',
},
],
vec![
PhysicalKey {
base: 'a',
shifted: 'A',
},
PhysicalKey {
base: 'o',
shifted: 'O',
},
PhysicalKey {
base: 'e',
shifted: 'E',
},
PhysicalKey {
base: 'u',
shifted: 'U',
},
PhysicalKey {
base: 'i',
shifted: 'I',
},
PhysicalKey {
base: 'd',
shifted: 'D',
},
PhysicalKey {
base: 'h',
shifted: 'H',
},
PhysicalKey {
base: 't',
shifted: 'T',
},
PhysicalKey {
base: 'n',
shifted: 'N',
},
PhysicalKey {
base: 's',
shifted: 'S',
},
PhysicalKey {
base: '-',
shifted: '_',
},
],
vec![
PhysicalKey {
base: ';',
shifted: ':',
},
PhysicalKey {
base: 'q',
shifted: 'Q',
},
PhysicalKey {
base: 'j',
shifted: 'J',
},
PhysicalKey {
base: 'k',
shifted: 'K',
},
PhysicalKey {
base: 'x',
shifted: 'X',
},
PhysicalKey {
base: 'b',
shifted: 'B',
},
PhysicalKey {
base: 'm',
shifted: 'M',
},
PhysicalKey {
base: 'w',
shifted: 'W',
},
PhysicalKey {
base: 'v',
shifted: 'V',
},
PhysicalKey {
base: 'z',
shifted: 'Z',
},
],
],
}
}
pub fn colemak() -> Self {
Self {
rows: vec![
vec![
PhysicalKey {
base: '`',
shifted: '~',
},
PhysicalKey {
base: '1',
shifted: '!',
},
PhysicalKey {
base: '2',
shifted: '@',
},
PhysicalKey {
base: '3',
shifted: '#',
},
PhysicalKey {
base: '4',
shifted: '$',
},
PhysicalKey {
base: '5',
shifted: '%',
},
PhysicalKey {
base: '6',
shifted: '^',
},
PhysicalKey {
base: '7',
shifted: '&',
},
PhysicalKey {
base: '8',
shifted: '*',
},
PhysicalKey {
base: '9',
shifted: '(',
},
PhysicalKey {
base: '0',
shifted: ')',
},
PhysicalKey {
base: '-',
shifted: '_',
},
PhysicalKey {
base: '=',
shifted: '+',
},
],
vec![
PhysicalKey {
base: 'q',
shifted: 'Q',
},
PhysicalKey {
base: 'w',
shifted: 'W',
},
PhysicalKey {
base: 'f',
shifted: 'F',
},
PhysicalKey {
base: 'p',
shifted: 'P',
},
PhysicalKey {
base: 'g',
shifted: 'G',
},
PhysicalKey {
base: 'j',
shifted: 'J',
},
PhysicalKey {
base: 'l',
shifted: 'L',
},
PhysicalKey {
base: 'u',
shifted: 'U',
},
PhysicalKey {
base: 'y',
shifted: 'Y',
},
PhysicalKey {
base: ';',
shifted: ':',
},
PhysicalKey {
base: '[',
shifted: '{',
},
PhysicalKey {
base: ']',
shifted: '}',
},
PhysicalKey {
base: '\\',
shifted: '|',
},
],
vec![
PhysicalKey {
base: 'a',
shifted: 'A',
},
PhysicalKey {
base: 'r',
shifted: 'R',
},
PhysicalKey {
base: 's',
shifted: 'S',
},
PhysicalKey {
base: 't',
shifted: 'T',
},
PhysicalKey {
base: 'd',
shifted: 'D',
},
PhysicalKey {
base: 'h',
shifted: 'H',
},
PhysicalKey {
base: 'n',
shifted: 'N',
},
PhysicalKey {
base: 'e',
shifted: 'E',
},
PhysicalKey {
base: 'i',
shifted: 'I',
},
PhysicalKey {
base: 'o',
shifted: 'O',
},
PhysicalKey {
base: '\'',
shifted: '"',
},
],
vec![
PhysicalKey {
base: 'z',
shifted: 'Z',
},
PhysicalKey {
base: 'x',
shifted: 'X',
},
PhysicalKey {
base: 'c',
shifted: 'C',
},
PhysicalKey {
base: 'v',
shifted: 'V',
},
PhysicalKey {
base: 'b',
shifted: 'B',
},
PhysicalKey {
base: 'k',
shifted: 'K',
},
PhysicalKey {
base: 'm',
shifted: 'M',
},
PhysicalKey {
base: ',',
shifted: '<',
},
PhysicalKey {
base: '.',
shifted: '>',
},
PhysicalKey {
base: '/',
shifted: '?',
},
],
],
}
}
pub fn from_name(name: &str) -> Self {
match name {
"dvorak" => Self::dvorak(),
"colemak" => Self::colemak(),
_ => Self::qwerty(),
}
}
/// Given a base character, return its shifted counterpart.
#[allow(dead_code)]
pub fn base_to_shifted(&self, ch: char) -> Option<char> {
self.physical_key_for(ch)
.filter(|pk| pk.base == ch)
.map(|pk| pk.shifted)
}
/// Given a shifted character, return its base counterpart.
#[allow(dead_code)]
pub fn shifted_to_base(&self, ch: char) -> Option<char> {
self.physical_key_for(ch)
.filter(|pk| pk.shifted == ch)
.map(|pk| pk.base)
}
pub fn physical_key_for(&self, ch: char) -> Option<&PhysicalKey> {
self.find_key_position(ch).map(|(r, c)| &self.rows[r][c])
}
fn find_key_position(&self, ch: char) -> Option<(usize, usize)> {
for (row_idx, row) in self.rows.iter().enumerate() {
for (col_idx, key) in row.iter().enumerate() {
if key.base == ch || key.shifted == ch {
return Some((row_idx, col_idx));
}
}
}
None
}
/// Get the finger assignment for a physical key by its row/col position.
/// Uses QWERTY-style finger assignments based on column position.
pub fn finger_for_position(&self, row: usize, col: usize) -> FingerAssignment {
// Map column to finger based on standard touch-typing
// Row 0 (number row) has 13 keys, rows 1-3 have varying counts
// We use column position relative to the keyboard
let total_cols = self.rows[row].len();
// For the number row and top row (13 keys each in QWERTY)
// left pinky: cols 0-1, left ring: col 2, left middle: col 3,
// left index: cols 4-5, right index: cols 6-7,
// right middle: col 8, right ring: col 9, right pinky: cols 10+
match row {
0 => {
// Number row
match col {
0 | 1 => FingerAssignment::new(Hand::Left, Finger::Pinky),
2 => FingerAssignment::new(Hand::Left, Finger::Ring),
3 => FingerAssignment::new(Hand::Left, Finger::Middle),
4 | 5 => FingerAssignment::new(Hand::Left, Finger::Index),
6 | 7 => FingerAssignment::new(Hand::Right, Finger::Index),
8 => FingerAssignment::new(Hand::Right, Finger::Middle),
9 => FingerAssignment::new(Hand::Right, Finger::Ring),
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
}
}
1 => {
// Top row (q-row in QWERTY)
match col {
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
}
}
2 => {
// Home row
match col {
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
}
}
3 => {
// Bottom row
let _ = total_cols;
match col {
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
}
}
_ => FingerAssignment::new(Hand::Right, Finger::Index),
}
}
/// Get finger assignment for a character, looking it up in the model.
pub fn finger_for_char(&self, ch: char) -> FingerAssignment {
if let Some((row_idx, col_idx)) = self.find_key_position(ch) {
self.finger_for_position(row_idx, col_idx)
} else {
FingerAssignment::new(Hand::Right, Finger::Index)
}
}
/// Letter-only rows (rows 1-3) for compact keyboard display.
pub fn letter_rows(&self) -> &[Vec<PhysicalKey>] {
if self.rows.len() > 1 {
&self.rows[1..]
} else {
&self.rows
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qwerty_covers_all_skill_tree_chars() {
let model = KeyboardModel::qwerty();
// All chars used in skill tree branches
let skill_tree_chars: Vec<char> = vec![
// Lowercase
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q',
'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', // Capitals
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q',
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', // Numbers
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // Prose punctuation
'.', ',', '\'', ';', ':', '"', '-', '?', '!', '(', ')', // Code symbols
'=', '+', '*', '/', '{', '}', '[', ']', '<', '>', '&', '|', '^', '~', '@', '#', '$',
'%', '_', '\\', '`',
];
for ch in &skill_tree_chars {
assert!(
model.physical_key_for(*ch).is_some(),
"KeyboardModel::qwerty() missing char: {:?}",
ch
);
}
}
#[test]
fn test_base_to_shifted_and_back() {
let model = KeyboardModel::qwerty();
assert_eq!(model.base_to_shifted('a'), Some('A'));
assert_eq!(model.base_to_shifted('1'), Some('!'));
assert_eq!(model.base_to_shifted('['), Some('{'));
assert_eq!(model.shifted_to_base('A'), Some('a'));
assert_eq!(model.shifted_to_base('!'), Some('1'));
assert_eq!(model.shifted_to_base('{'), Some('['));
// base_to_shifted on a shifted char returns None
assert_eq!(model.base_to_shifted('A'), None);
// shifted_to_base on a base char returns None
assert_eq!(model.shifted_to_base('a'), None);
}
#[test]
fn test_qwerty_has_four_rows() {
let model = KeyboardModel::qwerty();
assert_eq!(model.rows.len(), 4);
assert_eq!(model.rows[0].len(), 13); // number row
assert_eq!(model.rows[1].len(), 13); // top row
assert_eq!(model.rows[2].len(), 11); // home row
assert_eq!(model.rows[3].len(), 10); // bottom row
}
#[test]
fn test_finger_for_char_works_for_all_chars() {
let model = KeyboardModel::qwerty();
// Just verify it doesn't panic for various chars
let _ = model.finger_for_char('a');
let _ = model.finger_for_char('A');
let _ = model.finger_for_char('1');
let _ = model.finger_for_char('!');
let _ = model.finger_for_char('{');
}
}

View File

@@ -23,7 +23,7 @@ use crossterm::terminal::{
}; };
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
@@ -34,8 +34,7 @@ use event::{AppEvent, EventHandler};
use session::result::DrillResult; use session::result::DrillResult;
use ui::components::dashboard::Dashboard; use ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::keyboard_diagram::KeyboardDiagram;
use ui::components::progress_bar::ProgressBar; use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
use ui::components::skill_tree::{SkillTreeWidget, selectable_branches};
use ui::components::stats_dashboard::StatsDashboard; use ui::components::stats_dashboard::StatsDashboard;
use ui::components::stats_sidebar::StatsSidebar; use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea; use ui::components::typing_area::TypingArea;
@@ -87,6 +86,7 @@ fn main() -> Result<()> {
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
let events = EventHandler::new(Duration::from_millis(100)); let events = EventHandler::new(Duration::from_millis(100));
@@ -124,6 +124,10 @@ fn run_app(
app.depressed_keys.clear(); app.depressed_keys.clear();
app.last_key_time = None; app.last_key_time = None;
} }
// Clear shift_held after 200ms as fallback
if last.elapsed() > Duration::from_millis(200) && app.shift_held {
app.shift_held = false;
}
} }
} }
AppEvent::Resize(_, _) => {} AppEvent::Resize(_, _) => {}
@@ -136,18 +140,21 @@ fn run_app(
} }
fn handle_key(app: &mut App, key: KeyEvent) { fn handle_key(app: &mut App, key: KeyEvent) {
// Track depressed keys for keyboard diagram // Track depressed keys and shift state for keyboard diagram
match (&key.code, key.kind) { match (&key.code, key.kind) {
(KeyCode::Char(ch), KeyEventKind::Press) => { (KeyCode::Char(ch), KeyEventKind::Press) => {
app.depressed_keys.insert(ch.to_ascii_lowercase()); app.depressed_keys.insert(ch.to_ascii_lowercase());
app.last_key_time = Some(Instant::now()); app.last_key_time = Some(Instant::now());
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
} }
(KeyCode::Char(ch), KeyEventKind::Release) => { (KeyCode::Char(ch), KeyEventKind::Release) => {
app.depressed_keys.remove(&ch.to_ascii_lowercase()); app.depressed_keys.remove(&ch.to_ascii_lowercase());
return; // Don't process Release events as input return; // Don't process Release events as input
} }
(_, KeyEventKind::Release) => return, (_, KeyEventKind::Release) => return,
_ => {} _ => {
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
}
} }
// Only process Press events — ignore Repeat to avoid inflating input // Only process Press events — ignore Repeat to avoid inflating input
@@ -167,6 +174,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key),
AppScreen::Settings => handle_settings_key(app, key), AppScreen::Settings => handle_settings_key(app, key),
AppScreen::SkillTree => handle_skill_tree_key(app, key), AppScreen::SkillTree => handle_skill_tree_key(app, key),
AppScreen::CodeLanguageSelect => handle_code_language_key(app, key),
} }
} }
@@ -179,9 +187,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
app.start_drill(); app.start_drill();
} }
KeyCode::Char('2') => { KeyCode::Char('2') => {
app.drill_mode = DrillMode::Code; app.go_to_code_language_select();
app.drill_scope = DrillScope::Global;
app.start_drill();
} }
KeyCode::Char('3') => { KeyCode::Char('3') => {
app.drill_mode = DrillMode::Passage; app.drill_mode = DrillMode::Passage;
@@ -200,9 +206,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
app.start_drill(); app.start_drill();
} }
1 => { 1 => {
app.drill_mode = DrillMode::Code; app.go_to_code_language_select();
app.drill_scope = DrillScope::Global;
app.start_drill();
} }
2 => { 2 => {
app.drill_mode = DrillMode::Passage; app.drill_mode = DrillMode::Passage;
@@ -270,6 +274,8 @@ fn handle_result_key(app: &mut App, key: KeyEvent) {
} }
fn handle_stats_key(app: &mut App, key: KeyEvent) { fn handle_stats_key(app: &mut App, key: KeyEvent) {
const STATS_TAB_COUNT: usize = 5;
// Confirmation dialog takes priority // Confirmation dialog takes priority
if app.history_confirm_delete { if app.history_confirm_delete {
match key.code { match key.code {
@@ -306,10 +312,12 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('2') => {} // already on history KeyCode::Char('2') => {} // already on history
KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::Char('4') => app.stats_tab = 3,
KeyCode::Char('5') => app.stats_tab = 4,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
KeyCode::BackTab => { KeyCode::BackTab => {
app.stats_tab = if app.stats_tab == 0 { app.stats_tab = if app.stats_tab == 0 {
2 STATS_TAB_COUNT - 1
} else { } else {
app.stats_tab - 1 app.stats_tab - 1
} }
@@ -324,10 +332,12 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('2') => app.stats_tab = 1, KeyCode::Char('2') => app.stats_tab = 1,
KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::Char('4') => app.stats_tab = 3,
KeyCode::Char('5') => app.stats_tab = 4,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
KeyCode::BackTab => { KeyCode::BackTab => {
app.stats_tab = if app.stats_tab == 0 { app.stats_tab = if app.stats_tab == 0 {
2 STATS_TAB_COUNT - 1
} else { } else {
app.stats_tab - 1 app.stats_tab - 1
} }
@@ -362,18 +372,97 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
} }
} }
fn handle_code_language_key(app: &mut App, key: KeyEvent) {
const LANGS: &[&str] = &["rust", "python", "javascript", "go", "all"];
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Up | KeyCode::Char('k') => {
app.code_language_selected = app.code_language_selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if app.code_language_selected + 1 < LANGS.len() {
app.code_language_selected += 1;
}
}
KeyCode::Char('1') => {
app.code_language_selected = 0;
start_code_drill(app, LANGS);
}
KeyCode::Char('2') => {
app.code_language_selected = 1;
start_code_drill(app, LANGS);
}
KeyCode::Char('3') => {
app.code_language_selected = 2;
start_code_drill(app, LANGS);
}
KeyCode::Char('4') => {
app.code_language_selected = 3;
start_code_drill(app, LANGS);
}
KeyCode::Char('5') => {
app.code_language_selected = 4;
start_code_drill(app, LANGS);
}
KeyCode::Enter => {
start_code_drill(app, LANGS);
}
_ => {}
}
}
fn start_code_drill(app: &mut App, langs: &[&str]) {
if app.code_language_selected < langs.len() {
app.config.code_language = langs[app.code_language_selected].to_string();
let _ = app.config.save();
app.drill_mode = DrillMode::Code;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
}
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) { fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
const DETAIL_SCROLL_STEP: usize = 10;
let max_scroll = skill_tree_detail_max_scroll(app);
app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll);
let branches = selectable_branches(); let branches = selectable_branches();
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1); app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1);
app.skill_tree_detail_scroll = 0;
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
if app.skill_tree_selected + 1 < branches.len() { if app.skill_tree_selected + 1 < branches.len() {
app.skill_tree_selected += 1; app.skill_tree_selected += 1;
app.skill_tree_detail_scroll = 0;
} }
} }
KeyCode::PageUp => {
app.skill_tree_detail_scroll = app
.skill_tree_detail_scroll
.saturating_sub(DETAIL_SCROLL_STEP);
}
KeyCode::PageDown => {
let max_scroll = skill_tree_detail_max_scroll(app);
app.skill_tree_detail_scroll = app
.skill_tree_detail_scroll
.saturating_add(DETAIL_SCROLL_STEP)
.min(max_scroll);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.skill_tree_detail_scroll = app
.skill_tree_detail_scroll
.saturating_sub(DETAIL_SCROLL_STEP);
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let max_scroll = skill_tree_detail_max_scroll(app);
app.skill_tree_detail_scroll = app
.skill_tree_detail_scroll
.saturating_add(DETAIL_SCROLL_STEP)
.min(max_scroll);
}
KeyCode::Enter => { KeyCode::Enter => {
if app.skill_tree_selected < branches.len() { if app.skill_tree_selected < branches.len() {
let branch_id = branches[app.skill_tree_selected]; let branch_id = branches[app.skill_tree_selected];
@@ -389,6 +478,37 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
} }
} }
fn skill_tree_detail_max_scroll(app: &App) -> usize {
let (w, h) = crossterm::terminal::size().unwrap_or((120, 40));
let screen = Rect::new(0, 0, w, h);
let centered = ui::layout::centered_rect(70, 90, screen);
let inner = Rect::new(
centered.x.saturating_add(1),
centered.y.saturating_add(1),
centered.width.saturating_sub(2),
centered.height.saturating_sub(2),
);
let branches = selectable_branches();
if branches.is_empty() {
return 0;
}
let branch_list_height = branches.len() as u16 * 2 + 1;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
let selected = app.skill_tree_selected.min(branches.len().saturating_sub(1));
let total_lines = detail_line_count(branches[selected]);
total_lines.saturating_sub(detail_height)
}
fn render(frame: &mut ratatui::Frame, app: &App) { fn render(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let colors = &app.theme.colors; let colors = &app.theme.colors;
@@ -403,6 +523,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::StatsDashboard => render_stats(frame, app),
AppScreen::Settings => render_settings(frame, app), AppScreen::Settings => render_settings(frame, app),
AppScreen::SkillTree => render_skill_tree(frame, app), AppScreen::SkillTree => render_skill_tree(frame, app),
AppScreen::CodeLanguageSelect => render_code_language_select(frame, app),
} }
} }
@@ -454,7 +575,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(&app.menu, menu_area); frame.render_widget(&app.menu, menu_area);
let footer = Paragraph::new(Line::from(vec![Span::styled( let footer = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [t] Skill Tree [s] Stats [q] Quit ", " [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit ",
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
)])); )]));
frame.render_widget(footer, layout[2]); frame.render_widget(footer, layout[2]);
@@ -492,11 +613,15 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(header, app_layout.header); frame.render_widget(header, app_layout.header);
} else { } else {
let header_title = format!(" {mode_name} Drill "); let header_title = format!(" {mode_name} Drill ");
let focus_text = if 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.key_stats);
let focus_text = if let Some(focused) = focused { if let Some(focused) = focused {
format!(" | Focus: '{focused}'") format!(" | Focus: '{focused}'")
} else { } else {
String::new() String::new()
}
} else {
String::new()
}; };
let header = Paragraph::new(Line::from(vec![ let header = Paragraph::new(Line::from(vec![
Span::styled( Span::styled(
@@ -521,12 +646,46 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let show_kbd = tier.show_keyboard(area.height); let show_kbd = tier.show_keyboard(area.height);
let show_progress = tier.show_progress_bar(area.height); let show_progress = tier.show_progress_bar(area.height);
// Compute active branch count for progress area height
let active_branches: Vec<engine::skill_tree::BranchId> =
engine::skill_tree::BranchId::all()
.iter()
.copied()
.filter(|&id| {
matches!(
app.skill_tree.branch_status(id),
engine::skill_tree::BranchStatus::InProgress
| engine::skill_tree::BranchStatus::Complete
)
})
.collect();
let progress_height = if show_progress && area.height >= 25 {
(active_branches.len().min(6) as u16 + 1).max(2) // +1 for overall line
} else if show_progress && area.height >= 20 {
2 // active branch + overall
} else if show_progress {
1 // active branch only
} else {
0
};
let kbd_height = if show_kbd {
if tier.compact_keyboard() {
5 // 3 rows + 2 border
} else {
7 // 4 rows + 2 border + 1 label space
}
} else {
0
};
let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)]; let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)];
if show_progress { if progress_height > 0 {
constraints.push(Constraint::Length(3)); constraints.push(Constraint::Length(progress_height));
} }
if show_kbd { if show_kbd {
constraints.push(Constraint::Length(5)); constraints.push(Constraint::Length(kbd_height));
} }
let main_layout = Layout::default() let main_layout = Layout::default()
@@ -538,12 +697,30 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(typing, main_layout[0]); frame.render_widget(typing, main_layout[0]);
let mut idx = 1; let mut idx = 1;
if show_progress { if progress_height > 0 {
let unlocked = app.skill_tree.total_unlocked_count() as f64; if app.drill_mode == DrillMode::Adaptive {
let total = app.skill_tree.total_unique_keys as f64; let progress_widget = ui::components::branch_progress_list::BranchProgressList {
let progress_val = (unlocked / total).min(1.0); skill_tree: &app.skill_tree,
let progress = ProgressBar::new("Key Progress", progress_val, app.theme); key_stats: &app.key_stats,
frame.render_widget(progress, main_layout[idx]); drill_scope: app.drill_scope,
active_branches: &active_branches,
theme: app.theme,
height: progress_height,
};
frame.render_widget(progress_widget, main_layout[idx]);
} else {
let source = app.drill_source_info.as_deref().unwrap_or("unknown source");
let label = if app.drill_mode == DrillMode::Code {
" Code source "
} else {
" Passage source "
};
let source_info = Paragraph::new(Line::from(vec![
Span::styled(label, Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)),
Span::styled(source, Style::default().fg(colors.text_pending())),
]));
frame.render_widget(source_info, main_layout[idx]);
}
idx += 1; idx += 1;
} }
@@ -551,14 +728,18 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let next_char = drill.target.get(drill.cursor).copied(); let next_char = drill.target.get(drill.cursor).copied();
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope); let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats); 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( let kbd = KeyboardDiagram::new(
focused, focused,
next_char, next_char,
&unlocked_keys, &unlocked_keys,
&app.depressed_keys, &app.depressed_keys,
app.theme, app.theme,
&app.keyboard_model,
) )
.compact(tier.compact_keyboard()); .compact(tier.compact_keyboard())
.shift_held(app.shift_held);
frame.render_widget(kbd, main_layout[idx]); frame.render_widget(kbd, main_layout[idx]);
} }
@@ -600,6 +781,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
app.theme, app.theme,
app.history_selected, app.history_selected,
app.history_confirm_delete, app.history_confirm_delete,
&app.keyboard_model,
); );
frame.render_widget(dashboard, area); frame.render_widget(dashboard, area);
} }
@@ -618,13 +800,8 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
block.render(centered, frame.buffer_mut()); block.render(centered, frame.buffer_mut());
let available_themes = ui::theme::Theme::available_themes(); let available_themes = ui::theme::Theme::available_themes();
let languages_all = ["rust", "python", "javascript", "go"]; let languages_all = ["rust", "python", "javascript", "go", "all"];
let current_lang = app let current_lang = &app.config.code_language;
.config
.code_languages
.first()
.map(|s| s.as_str())
.unwrap_or("rust");
let fields: Vec<(String, String)> = vec![ let fields: Vec<(String, String)> = vec![
( (
@@ -636,7 +813,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
"Word Count".to_string(), "Word Count".to_string(),
format!("{}", app.config.word_count), format!("{}", app.config.word_count),
), ),
("Code Language".to_string(), current_lang.to_string()), ("Code Language".to_string(), current_lang.clone()),
]; ];
let layout = Layout::default() let layout = Layout::default()
@@ -706,6 +883,54 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
footer.render(layout[3], frame.buffer_mut()); footer.render(layout[3], frame.buffer_mut());
} }
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
let centered = ui::layout::centered_rect(40, 50, area);
let block = Block::bordered()
.title(" Select Code Language ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(centered);
block.render(centered, frame.buffer_mut());
let langs = ["Rust", "Python", "JavaScript", "Go", "All (random)"];
let lang_keys = ["rust", "python", "javascript", "go", "all"];
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(""));
for (i, &lang) in langs.iter().enumerate() {
let is_selected = i == app.code_language_selected;
let is_current = lang_keys[i] == app.config.code_language;
let indicator = if is_selected { " > " } else { " " };
let current_marker = if is_current { " (current)" } else { "" };
let style = if is_selected {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.fg())
};
lines.push(Line::from(Span::styled(
format!("{indicator}[{}] {lang}{current_marker}", i + 1),
style,
)));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" [1-5] Select [Enter] Confirm [ESC] Back",
Style::default().fg(colors.text_pending()),
)));
Paragraph::new(lines).render(inner, frame.buffer_mut());
}
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) { fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let centered = ui::layout::centered_rect(70, 90, area); let centered = ui::layout::centered_rect(70, 90, area);
@@ -713,6 +938,7 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
&app.skill_tree, &app.skill_tree,
&app.key_stats, &app.key_stats,
app.skill_tree_selected, app.skill_tree_selected,
app.skill_tree_detail_scroll,
app.theme, app.theme,
); );
frame.render_widget(widget, centered); frame.render_widget(widget, centered);

View File

@@ -3,7 +3,8 @@ use std::collections::HashMap;
use chrono::{Datelike, NaiveDate, Utc}; use chrono::{Datelike, NaiveDate, Utc};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Widget}; use ratatui::widgets::{Block, Widget};
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
@@ -25,8 +26,13 @@ impl Widget for ActivityHeatmap<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Daily Activity (Sessions per Day) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Daily Activity (Sessions per Day) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -42,10 +48,11 @@ impl Widget for ActivityHeatmap<'_> {
} }
let today = Utc::now().date_naive(); let today = Utc::now().date_naive();
let end_date = today;
// Show ~26 weeks (half a year) // Show ~26 weeks (half a year)
let weeks_to_show = ((inner.width as usize).saturating_sub(3)) / 2; let weeks_to_show = ((inner.width as usize).saturating_sub(3)) / 2;
let weeks_to_show = weeks_to_show.min(26); let weeks_to_show = weeks_to_show.min(26);
let start_date = today - chrono::Duration::weeks(weeks_to_show as i64); let start_date = end_date - chrono::Duration::weeks(weeks_to_show as i64);
// Align to Monday // Align to Monday
let start_date = let start_date =
start_date - chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64); start_date - chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
@@ -71,7 +78,7 @@ impl Widget for ActivityHeatmap<'_> {
// Month labels // Month labels
let mut last_month = 0u32; let mut last_month = 0u32;
while current_date <= today { while current_date <= end_date {
let x = inner.x + 2 + col * 2; let x = inner.x + 2 + col * 2;
if x + 1 >= inner.x + inner.width { if x + 1 >= inner.x + inner.width {
break; break;
@@ -110,7 +117,7 @@ impl Widget for ActivityHeatmap<'_> {
// Render 7 days in this week column // Render 7 days in this week column
for day_offset in 0..7u16 { for day_offset in 0..7u16 {
let date = current_date + chrono::Duration::days(day_offset as i64); let date = current_date + chrono::Duration::days(day_offset as i64);
if date > today { if date > end_date {
break; break;
} }
let y = inner.y + 1 + day_offset; let y = inner.y + 1 + day_offset;

View File

@@ -0,0 +1,133 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
use crate::ui::theme::Theme;
pub struct BranchProgressList<'a> {
pub skill_tree: &'a SkillTree,
pub key_stats: &'a crate::engine::key_stats::KeyStatsStore,
pub drill_scope: DrillScope,
pub active_branches: &'a [BranchId],
pub theme: &'a Theme,
pub height: u16,
}
impl Widget for BranchProgressList<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new();
let drill_branch = match self.drill_scope {
DrillScope::Branch(id) => Some(id),
DrillScope::Global => None,
};
let show_all = self.height > 2;
if show_all {
for &branch_id in self.active_branches {
if lines.len() as u16 >= self.height.saturating_sub(1) {
break;
}
let def = get_branch_definition(branch_id);
let total = SkillTree::branch_total_keys(branch_id);
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
let is_active = drill_branch == Some(branch_id);
let prefix = if is_active {
" \u{25b6} "
} else {
" \u{00b7} "
};
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
let name = format!("{:<14}", def.name);
let label_color = if is_active {
colors.accent()
} else {
colors.text_pending()
};
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().fg(label_color)),
Span::styled(name, Style::default().fg(label_color)),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
Span::styled(u_bar, Style::default().fg(colors.accent())),
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {unlocked}/{total}"),
Style::default().fg(colors.text_pending()),
),
]));
}
} else if let Some(branch_id) = drill_branch {
let def = get_branch_definition(branch_id);
let total = SkillTree::branch_total_keys(branch_id);
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
lines.push(Line::from(vec![
Span::styled(
format!(" \u{25b6} {:<14}", def.name),
Style::default().fg(colors.accent()),
),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
Span::styled(u_bar, Style::default().fg(colors.accent())),
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {unlocked}/{total}"),
Style::default().fg(colors.text_pending()),
),
]));
}
// Overall line
if lines.len() < self.height as usize {
let total = self.skill_tree.total_unique_keys;
let unlocked = self.skill_tree.total_unlocked_count();
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
lines.push(Line::from(vec![
Span::styled(
format!(" {:<14}", "Overall"),
Style::default().fg(colors.fg()),
),
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
Span::styled(u_bar, Style::default().fg(colors.accent())),
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {unlocked}/{total}"),
Style::default().fg(colors.text_pending()),
),
]));
}
let paragraph = Paragraph::new(lines);
paragraph.render(area, buf);
}
}
fn compact_dual_bar_parts(
mastered: usize,
unlocked: usize,
total: usize,
width: usize,
) -> (String, String, String) {
if total == 0 {
return (String::new(), String::new(), "\u{2591}".repeat(width));
}
let mastered_cells = mastered * width / total;
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
let empty_cells = width - unlocked_cells;
(
"\u{2588}".repeat(mastered_cells),
"\u{2593}".repeat(unlocked_cells - mastered_cells),
"\u{2591}".repeat(empty_cells),
)
}

View File

@@ -5,7 +5,8 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget}; use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{self, Finger, Hand}; use crate::keyboard::finger::{Finger, Hand};
use crate::keyboard::model::KeyboardModel;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> { pub struct KeyboardDiagram<'a> {
@@ -15,6 +16,8 @@ pub struct KeyboardDiagram<'a> {
pub depressed_keys: &'a HashSet<char>, pub depressed_keys: &'a HashSet<char>,
pub theme: &'a Theme, pub theme: &'a Theme,
pub compact: bool, pub compact: bool,
pub model: &'a KeyboardModel,
pub shift_held: bool,
} }
impl<'a> KeyboardDiagram<'a> { impl<'a> KeyboardDiagram<'a> {
@@ -24,6 +27,7 @@ impl<'a> KeyboardDiagram<'a> {
unlocked_keys: &'a [char], unlocked_keys: &'a [char],
depressed_keys: &'a HashSet<char>, depressed_keys: &'a HashSet<char>,
theme: &'a Theme, theme: &'a Theme,
model: &'a KeyboardModel,
) -> Self { ) -> Self {
Self { Self {
focused_key, focused_key,
@@ -32,6 +36,8 @@ impl<'a> KeyboardDiagram<'a> {
depressed_keys, depressed_keys,
theme, theme,
compact: false, compact: false,
model,
shift_held: false,
} }
} }
@@ -39,16 +45,15 @@ impl<'a> KeyboardDiagram<'a> {
self.compact = compact; self.compact = compact;
self self
} }
pub fn shift_held(mut self, shift_held: bool) -> Self {
self.shift_held = shift_held;
self
}
} }
const ROWS: &[&[char]] = &[ fn finger_color(model: &KeyboardModel, ch: char) -> Color {
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], let assignment = model.finger_for_char(ch);
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
];
fn finger_color(ch: char) -> Color {
let assignment = finger::qwerty_finger(ch);
match (assignment.hand, assignment.finger) { match (assignment.hand, assignment.finger) {
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100), (Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80), (Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
@@ -84,16 +89,19 @@ impl Widget for KeyboardDiagram<'_> {
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
let key_width: u16 = if self.compact { 3 } else { 5 }; if self.compact {
let min_width: u16 = if self.compact { 21 } else { 30 }; // Compact mode: letter rows only (rows 1-3 of the model)
let letter_rows = self.model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width { if inner.height < 3 || inner.width < min_width {
return; return;
} }
let offsets: &[u16] = if self.compact { &[0, 1, 3] } else { &[1, 3, 5] }; let offsets: &[u16] = &[0, 1, 3];
for (row_idx, row) in ROWS.iter().enumerate() { for (row_idx, row) in letter_rows.iter().enumerate() {
let y = inner.y + row_idx as u16; let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
@@ -101,21 +109,198 @@ impl Widget for KeyboardDiagram<'_> {
let offset = offsets.get(row_idx).copied().unwrap_or(0); let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, &key) in row.iter().enumerate() { for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width; let x = inner.x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width { if x + key_width > inner.x + inner.width {
break; break;
} }
let is_depressed = self.depressed_keys.contains(&key); let display_char = if self.shift_held {
let is_unlocked = self.unlocked_keys.contains(&key); physical_key.shifted
let is_focused = self.focused_key == Some(key); } else {
let is_next = self.next_key == Some(key); physical_key.base
};
let base_char = physical_key.base;
// Priority: depressed > next_expected > focused > unlocked > locked let is_depressed = self.depressed_keys.contains(&base_char);
let style = if is_depressed { 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);
}
}
} else {
// Full mode: all 4 rows
let key_width: u16 = 5;
let min_width: u16 = 69;
if inner.height < 4 || inner.width < min_width {
// Fallback to compact-style if too narrow for full
let letter_rows = self.model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
if inner.height < 3 || inner.width < 30 {
return;
}
for (row_idx, row) in letter_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);
}
}
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 { let bg = if is_unlocked {
brighten_color(finger_color(key)) brighten_color(finger_color(model, base_char))
} else { } else {
brighten_color(colors.accent_dim()) brighten_color(colors.accent_dim())
}; };
@@ -128,18 +313,10 @@ impl Widget for KeyboardDiagram<'_> {
} else if is_focused { } else if is_focused {
Style::default().fg(colors.bg()).bg(colors.focused_key()) Style::default().fg(colors.bg()).bg(colors.focused_key())
} else if is_unlocked { } else if is_unlocked {
Style::default().fg(colors.fg()).bg(finger_color(key)) Style::default()
.fg(colors.fg())
.bg(finger_color(model, base_char))
} else { } else {
Style::default().fg(colors.text_pending()).bg(colors.bg()) Style::default().fg(colors.text_pending()).bg(colors.bg())
};
let display = if self.compact {
format!("[{key}]")
} else {
format!("[ {key} ]")
};
buf.set_string(x, y, &display, style);
}
}
} }
} }

View File

@@ -1,9 +1,9 @@
pub mod activity_heatmap; pub mod activity_heatmap;
pub mod branch_progress_list;
pub mod chart; pub mod chart;
pub mod dashboard; pub mod dashboard;
pub mod keyboard_diagram; pub mod keyboard_diagram;
pub mod menu; pub mod menu;
pub mod progress_bar;
pub mod skill_tree; pub mod skill_tree;
pub mod stats_dashboard; pub mod stats_dashboard;
pub mod stats_sidebar; pub mod stats_sidebar;

View File

@@ -1,53 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Widget};
use crate::ui::theme::Theme;
pub struct ProgressBar<'a> {
pub label: String,
pub ratio: f64,
pub theme: &'a Theme,
}
impl<'a> ProgressBar<'a> {
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
Self {
label: label.to_string(),
ratio: ratio.clamp(0.0, 1.0),
theme,
}
}
}
impl Widget for ProgressBar<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(format!(" {} ", self.label))
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
if inner.width == 0 || inner.height == 0 {
return;
}
let filled_width = (self.ratio * inner.width as f64) as u16;
let label = format!("{:.0}%", self.ratio * 100.0);
for x in inner.x..inner.x + inner.width {
let style = if x < inner.x + filled_width {
Style::default().fg(colors.bg()).bg(colors.bar_filled())
} else {
Style::default().fg(colors.fg()).bg(colors.bar_empty())
};
buf[(x, inner.y)].set_style(style);
}
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
}
}

View File

@@ -14,6 +14,7 @@ pub struct SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine, skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore, key_stats: &'a KeyStatsStore,
selected: usize, selected: usize,
detail_scroll: usize,
theme: &'a Theme, theme: &'a Theme,
} }
@@ -22,20 +23,23 @@ impl<'a> SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine, skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore, key_stats: &'a KeyStatsStore,
selected: usize, selected: usize,
detail_scroll: usize,
theme: &'a Theme, theme: &'a Theme,
) -> Self { ) -> Self {
Self { Self {
skill_tree, skill_tree,
key_stats, key_stats,
selected, selected,
detail_scroll,
theme, theme,
} }
} }
} }
/// Get the list of selectable branch IDs (all non-Lowercase branches). /// Get the list of selectable branch IDs (Lowercase first, then other branches).
pub fn selectable_branches() -> Vec<BranchId> { pub fn selectable_branches() -> Vec<BranchId> {
vec![ vec![
BranchId::Lowercase,
BranchId::Capitals, BranchId::Capitals,
BranchId::Numbers, BranchId::Numbers,
BranchId::ProsePunctuation, BranchId::ProsePunctuation,
@@ -44,6 +48,16 @@ pub fn selectable_branches() -> Vec<BranchId> {
] ]
} }
pub fn detail_line_count(branch_id: BranchId) -> usize {
let def = get_branch_definition(branch_id);
// 1 line branch header + for each level: 1 line level header + 1 line per key
1 + def
.levels
.iter()
.map(|level| 1 + level.keys.len())
.sum::<usize>()
}
impl Widget for SkillTreeWidget<'_> { impl Widget for SkillTreeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
@@ -57,7 +71,7 @@ impl Widget for SkillTreeWidget<'_> {
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2) // Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
let branches = selectable_branches(); let branches = selectable_branches();
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase
let layout = Layout::default() let layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -86,15 +100,15 @@ impl Widget for SkillTreeWidget<'_> {
let footer_text = if self.selected < branches.len() { let footer_text = if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]); let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked { if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
" Complete a-z to unlock branches " " Complete a-z to unlock branches [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress } else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress
{ {
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back " " [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
} else { } else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back " " [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
} }
} else { } else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back " " [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
}; };
let footer = Paragraph::new(Line::from(Span::styled( let footer = Paragraph::new(Line::from(Span::styled(
@@ -110,72 +124,6 @@ impl SkillTreeWidget<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
// Root: Lowercase a-z
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_total = lowercase_def
.levels
.iter()
.map(|l| l.keys.len())
.sum::<usize>();
let lowercase_confident = self
.skill_tree
.branch_confident_keys(BranchId::Lowercase, self.key_stats);
let (prefix, style) = match lowercase_bp.status {
BranchStatus::Complete => (
"\u{2605} ",
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD),
),
BranchStatus::InProgress => (
"\u{25b6} ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
_ => (" ", Style::default().fg(colors.text_pending())),
};
let status_text = match lowercase_bp.status {
BranchStatus::Complete => "COMPLETE".to_string(),
BranchStatus::InProgress => {
let unlocked = self.skill_tree.lowercase_unlocked_count();
format!("{unlocked}/{lowercase_total}")
}
_ => "LOCKED".to_string(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {prefix}{name}", name = lowercase_def.name),
style,
),
Span::styled(
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
Style::default().fg(colors.text_pending()),
),
]));
// Progress bar for lowercase
let pct = if lowercase_total > 0 {
lowercase_confident as f64 / lowercase_total as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
// Separator
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
)));
// Branches
for (i, &branch_id) in branches.iter().enumerate() { for (i, &branch_id) in branches.iter().enumerate() {
let bp = self.skill_tree.branch_progress(branch_id); let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id); let def = get_branch_definition(branch_id);
@@ -188,50 +136,46 @@ impl SkillTreeWidget<'_> {
let (prefix, style) = match bp.status { let (prefix, style) = match bp.status {
BranchStatus::Complete => ( BranchStatus::Complete => (
"\u{2605} ", "\u{2605} ",
if is_selected {
Style::default() Style::default()
.fg(colors.text_correct()) .fg(colors.text_correct())
.add_modifier(Modifier::BOLD | Modifier::REVERSED) .add_modifier(Modifier::BOLD),
} else {
Style::default()
.fg(colors.text_correct())
.add_modifier(Modifier::BOLD)
},
), ),
BranchStatus::InProgress => ( BranchStatus::InProgress => (
"\u{25b6} ", "\u{25b6} ",
if is_selected {
Style::default() Style::default()
.fg(colors.accent()) .fg(colors.accent())
.add_modifier(Modifier::BOLD | Modifier::REVERSED) .add_modifier(Modifier::BOLD),
} else {
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD)
},
), ),
BranchStatus::Available => ( BranchStatus::Available => (
" ", " ",
if is_selected { Style::default().fg(colors.fg()),
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.fg())
},
), ),
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())), BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
}; };
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
let mastered_text = if confident_keys > 0 {
format!(" ({confident_keys} mastered)")
} else {
String::new()
};
let status_text = match bp.status { let status_text = match bp.status {
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"), BranchStatus::Complete => {
BranchStatus::InProgress => format!( format!("{unlocked}/{total_keys} unlocked{mastered_text}")
"Lvl {}/{} {confident_keys}/{total_keys} keys", }
BranchStatus::InProgress => {
if branch_id == BranchId::Lowercase {
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
} else {
format!(
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
bp.current_level + 1, bp.current_level + 1,
def.levels.len() def.levels.len()
), )
BranchStatus::Available => format!("Available 0/{total_keys} keys"), }
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"), }
BranchStatus::Available => format!("0/{total_keys} unlocked"),
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
}; };
let sel_indicator = if is_selected { "> " } else { " " }; let sel_indicator = if is_selected { "> " } else { " " };
@@ -244,16 +188,23 @@ impl SkillTreeWidget<'_> {
), ),
])); ]));
let pct = if total_keys > 0 { let (mastered_bar, unlocked_bar, empty_bar) =
confident_keys as f64 / total_keys as f64 dual_progress_bar_parts(confident_keys, unlocked, total_keys, 30);
} else { lines.push(Line::from(vec![
0.0 Span::styled(" ", style),
}; Span::styled(mastered_bar, Style::default().fg(colors.text_correct())),
Span::styled(unlocked_bar, Style::default().fg(colors.accent())),
Span::styled(empty_bar, Style::default().fg(colors.text_pending())),
]));
// Add separator after Lowercase (index 0)
if branch_id == BranchId::Lowercase {
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)), " \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
style, Style::default().fg(colors.border()),
))); )));
} }
}
let paragraph = Paragraph::new(lines); let paragraph = Paragraph::new(lines);
paragraph.render(area, buf); paragraph.render(area, buf);
@@ -273,12 +224,20 @@ impl SkillTreeWidget<'_> {
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
// Branch title with level info // Branch title with level info
let level_text = match bp.status { let level_text = if branch_id == BranchId::Lowercase {
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
format!("Unlocked {unlocked}/{total} letters")
} else {
match bp.status {
BranchStatus::InProgress => { BranchStatus::InProgress => {
format!("Level {}/{}", bp.current_level + 1, def.levels.len()) format!("Level {}/{}", bp.current_level + 1, def.levels.len())
} }
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()), BranchStatus::Complete => {
format!("Level {}/{}", def.levels.len(), def.levels.len())
}
_ => format!("Level 0/{}", def.levels.len()), _ => format!("Level 0/{}", def.levels.len()),
}
}; };
lines.push(Line::from(vec![ lines.push(Line::from(vec![
Span::styled( Span::styled(
@@ -293,11 +252,19 @@ impl SkillTreeWidget<'_> {
), ),
])); ]));
// Per-level key breakdown // Per-level key breakdown with per-key mastery bars
let focused = self let focused = self
.skill_tree .skill_tree
.focused_key(DrillScope::Branch(branch_id), self.key_stats); .focused_key(DrillScope::Branch(branch_id), self.key_stats);
// For Lowercase, determine which keys are unlocked
let lowercase_unlocked_keys: Vec<char> = if branch_id == BranchId::Lowercase {
self.skill_tree
.unlocked_keys(DrillScope::Branch(BranchId::Lowercase))
} else {
Vec::new()
};
for (level_idx, level) in def.levels.iter().enumerate() { for (level_idx, level) in def.levels.iter().enumerate() {
let level_status = let level_status =
if bp.status == BranchStatus::Complete || level_idx < bp.current_level { if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
@@ -308,79 +275,111 @@ impl SkillTreeWidget<'_> {
"locked" "locked"
}; };
let mut key_spans: Vec<Span> = Vec::new(); // Level header
key_spans.push(Span::styled( lines.push(Line::from(Span::styled(
format!(" L{}: ", level_idx + 1), format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
Style::default().fg(colors.fg()), Style::default().fg(colors.fg()),
)); )));
// Per-key mastery bars
for &key in level.keys { for &key in level.keys {
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
let is_focused = focused == Some(key); let is_focused = focused == Some(key);
let confidence = self.key_stats.get_confidence(key).min(1.0);
let is_confident = confidence >= 1.0;
// For Lowercase, check if this specific key is unlocked
let is_locked = if branch_id == BranchId::Lowercase {
!lowercase_unlocked_keys.contains(&key)
} else {
level_status == "locked"
};
let display = if key == '\n' { let display = if key == '\n' {
"\\n".to_string() "\\n".to_string()
} else if key == '\t' { } else if key == '\t' {
"\\t".to_string() "\\t".to_string()
} else { } else {
key.to_string() format!(" {key}")
}; };
let style = if is_focused { if is_locked {
lines.push(Line::from(vec![
Span::styled(
format!(" {display} "),
Style::default().fg(colors.text_pending()),
),
Span::styled("locked", Style::default().fg(colors.text_pending())),
]));
} else {
let bar_width = 10;
let filled = (confidence * bar_width as f64).round() as usize;
let empty = bar_width - filled;
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
let pct_str = format!("{:>3.0}%", confidence * 100.0);
let focus_label = if is_focused { " in focus" } else { "" };
let key_style = if is_focused {
Style::default() Style::default()
.fg(colors.bg()) .fg(colors.bg())
.bg(colors.focused_key()) .bg(colors.focused_key())
.add_modifier(Modifier::BOLD) .add_modifier(Modifier::BOLD)
} else if is_confident { } else if is_confident {
Style::default().fg(colors.text_correct()) Style::default().fg(colors.text_correct())
} else if level_status == "locked" {
Style::default().fg(colors.text_pending())
} else { } else {
Style::default().fg(colors.fg()) Style::default().fg(colors.fg())
}; };
key_spans.push(Span::styled(display, style)); let bar_color = if is_confident {
key_spans.push(Span::raw(" ")); colors.text_correct()
}
key_spans.push(Span::styled(
format!(" ({level_status})"),
Style::default().fg(colors.text_pending()),
));
lines.push(Line::from(key_spans));
}
// Average confidence
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let avg_conf = if total_keys > 0 {
let sum: f64 = def
.levels
.iter()
.flat_map(|l| l.keys.iter())
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
.sum();
sum / total_keys as f64
} else { } else {
0.0 colors.accent()
}; };
lines.push(Line::from(Span::styled( lines.push(Line::from(vec![
format!( Span::styled(format!(" {display} "), key_style),
" Avg Confidence: {} {:.0}%", Span::styled(bar, Style::default().fg(bar_color)),
progress_bar_str(avg_conf, 20), Span::styled(
avg_conf * 100.0 format!(" {pct_str}"),
),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
))); ),
Span::styled(
focus_label,
Style::default()
.fg(colors.focused_key())
.add_modifier(Modifier::BOLD),
),
]));
}
}
}
let paragraph = Paragraph::new(lines); let visible_height = area.height as usize;
if visible_height == 0 {
return;
}
let max_scroll = lines.len().saturating_sub(visible_height);
let scroll = self.detail_scroll.min(max_scroll);
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
let paragraph = Paragraph::new(visible_lines);
paragraph.render(area, buf); paragraph.render(area, buf);
} }
} }
fn progress_bar_str(pct: f64, width: usize) -> String { fn dual_progress_bar_parts(
let filled = (pct * width as f64).round() as usize; mastered: usize,
let empty = width.saturating_sub(filled); unlocked: usize,
format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty),) total: usize,
width: usize,
) -> (String, String, String) {
if total == 0 {
return (String::new(), String::new(), "\u{2591}".repeat(width));
}
let mastered_cells = mastered * width / total;
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
let empty_cells = width - unlocked_cells;
(
"\u{2588}".repeat(mastered_cells),
"\u{2593}".repeat(unlocked_cells - mastered_cells),
"\u{2591}".repeat(empty_cells),
)
} }

View File

@@ -3,8 +3,10 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use std::collections::BTreeSet;
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult; use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap; use crate::ui::components::activity_heatmap::ActivityHeatmap;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
@@ -17,6 +19,7 @@ pub struct StatsDashboard<'a> {
pub theme: &'a Theme, pub theme: &'a Theme,
pub history_selected: usize, pub history_selected: usize,
pub history_confirm_delete: bool, pub history_confirm_delete: bool,
pub keyboard_model: &'a KeyboardModel,
} }
impl<'a> StatsDashboard<'a> { impl<'a> StatsDashboard<'a> {
@@ -28,6 +31,7 @@ impl<'a> StatsDashboard<'a> {
theme: &'a Theme, theme: &'a Theme,
history_selected: usize, history_selected: usize,
history_confirm_delete: bool, history_confirm_delete: bool,
keyboard_model: &'a KeyboardModel,
) -> Self { ) -> Self {
Self { Self {
history, history,
@@ -37,6 +41,7 @@ impl<'a> StatsDashboard<'a> {
theme, theme,
history_selected, history_selected,
history_confirm_delete, history_confirm_delete,
keyboard_model,
} }
} }
} }
@@ -71,7 +76,13 @@ impl Widget for StatsDashboard<'_> {
.split(inner); .split(inner);
// Tab header // Tab header
let tabs = ["[1] Dashboard", "[2] History", "[3] Keystrokes"]; let tabs = [
"[1] Dashboard",
"[2] History",
"[3] Activity",
"[4] Accuracy",
"[5] Timing",
];
let tab_spans: Vec<Span> = tabs let tab_spans: Vec<Span> = tabs
.iter() .iter()
.enumerate() .enumerate()
@@ -88,29 +99,14 @@ impl Widget for StatsDashboard<'_> {
.collect(); .collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf); Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
// Tab content — wide mode shows two panels side by side // Render only one tab at a time so each tab gets full breathing room.
let is_wide = area.width > 170;
if is_wide {
let panels = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
// Left panel: active tab, Right panel: next tab
let left_tab = self.active_tab;
let right_tab = (self.active_tab + 1) % 3;
self.render_tab(left_tab, panels[0], buf);
self.render_tab(right_tab, panels[1], buf);
} else {
self.render_tab(self.active_tab, layout[1], buf); self.render_tab(self.active_tab, layout[1], buf);
}
// Footer // Footer
let footer_text = if self.active_tab == 1 { let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete" " [ESC] Back [Tab] Next tab [1-5] Switch tab [j/k] Navigate [x] Delete"
} else { } else {
" [ESC] Back [Tab] Next tab [1/2/3] Switch tab" " [ESC] Back [Tab] Next tab [1-5] Switch tab"
}; };
let footer = Paragraph::new(Line::from(Span::styled( let footer = Paragraph::new(Line::from(Span::styled(
footer_text, footer_text,
@@ -152,11 +148,53 @@ impl StatsDashboard<'_> {
match tab { match tab {
0 => self.render_dashboard_tab(area, buf), 0 => self.render_dashboard_tab(area, buf),
1 => self.render_history_tab(area, buf), 1 => self.render_history_tab(area, buf),
2 => self.render_keystrokes_tab(area, buf), 2 => self.render_activity_tab(area, buf),
3 => self.render_accuracy_tab(area, buf),
4 => self.render_timing_tab(area, buf),
_ => {} _ => {}
} }
} }
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(9), Constraint::Length(4)])
.split(area);
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
self.render_activity_stats(layout[1], buf);
}
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
.split(area);
self.render_keyboard_heatmap(layout[0], buf);
let lists = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
self.render_worst_accuracy_keys(lists[0], buf);
self.render_best_accuracy_keys(lists[1], buf);
}
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
.split(area);
self.render_keyboard_timing(layout[0], buf);
let lists = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(layout[1]);
self.render_slowest_keys(lists[0], buf);
self.render_fastest_keys(lists[1], buf);
}
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) { fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
@@ -183,8 +221,13 @@ impl StatsDashboard<'_> {
let time_str = format_duration(total_time); let time_str = format_duration(total_time);
let summary_block = Block::bordered() let summary_block = Block::bordered()
.title(" Summary ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Summary ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let summary_inner = summary_block.inner(layout[0]); let summary_inner = summary_block.inner(layout[0]);
summary_block.render(layout[0], buf); summary_block.render(layout[0], buf);
@@ -243,8 +286,13 @@ impl StatsDashboard<'_> {
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm); let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
let block = Block::bordered() let block = Block::bordered()
.title(target_label) .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); target_label,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -376,8 +424,13 @@ impl StatsDashboard<'_> {
if data.is_empty() { if data.is_empty() {
let block = Block::bordered() let block = Block::bordered()
.title(" Accuracy % (Last 50 Drills) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Accuracy % (Last 50 Drills) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
block.render(area, buf); block.render(area, buf);
return; return;
} }
@@ -393,8 +446,13 @@ impl StatsDashboard<'_> {
let chart = Chart::new(vec![dataset]) let chart = Chart::new(vec![dataset])
.block( .block(
Block::bordered() Block::bordered()
.title(" Accuracy % (Last 50 Drills) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())), " Accuracy % (Last 50 Drills) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent())),
) )
.x_axis( .x_axis(
Axis::default() Axis::default()
@@ -406,6 +464,11 @@ impl StatsDashboard<'_> {
Axis::default() Axis::default()
.title("Accuracy %") .title("Accuracy %")
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.labels(vec![
Span::styled("80", Style::default().fg(colors.text_pending())),
Span::styled("90", Style::default().fg(colors.text_pending())),
Span::styled("100", Style::default().fg(colors.text_pending())),
])
.bounds([80.0, 100.0]), .bounds([80.0, 100.0]),
); );
@@ -490,17 +553,17 @@ impl StatsDashboard<'_> {
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) { fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(8)])
.split(area);
// Recent tests bordered table // Recent tests bordered table
let table_block = Block::bordered() let table_block = Block::bordered()
.title(" Recent Sessions ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Recent Sessions ",
let table_inner = table_block.inner(layout[0]); Style::default()
table_block.render(layout[0], buf); .fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let table_inner = table_block.inner(area);
table_block.render(area, buf);
let header = Line::from(vec![Span::styled( let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date Mode", " # WPM Raw Acc% Time Date Mode",
@@ -566,190 +629,88 @@ impl StatsDashboard<'_> {
} }
Paragraph::new(lines).render(table_inner, buf); Paragraph::new(lines).render(table_inner, buf);
// Per-key speed distribution
self.render_per_key_speed(layout[1], buf);
}
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Avg Key Time by Character ")
.border_style(Style::default().fg(colors.border()));
let inner = block.inner(area);
block.render(area, buf);
let columns_per_row: usize = 13;
let col_width: u16 = 4;
let row_height: u16 = 3;
if inner.width < columns_per_row as u16 * col_width || inner.height < row_height {
return;
}
let letters: Vec<char> = ('a'..='z').collect();
let row_count = if inner.height >= row_height * 2 { 2 } else { 1 };
let max_time = letters
.iter()
.filter_map(|&ch| self.key_stats.stats.get(&ch))
.map(|s| s.filtered_time_ms)
.fold(0.0f64, f64::max)
.max(1.0);
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
for (i, &ch) in letters.iter().take(columns_per_row * row_count).enumerate() {
let row = i / columns_per_row;
let col = i % columns_per_row;
let x = inner.x + (col as u16 * col_width);
let y = inner.y + row as u16 * row_height;
if x + col_width > inner.x + inner.width || y + 2 >= inner.y + inner.height {
break;
}
let time = self
.key_stats
.stats
.get(&ch)
.map(|s| s.filtered_time_ms)
.unwrap_or(0.0);
let ratio = time / max_time;
let color = if ratio < 0.3 {
colors.success()
} else if ratio < 0.6 {
colors.accent()
} else {
colors.error()
};
// Letter label
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
// Bar indicator
let bar_char = if time > 0.0 {
let idx = ((ratio * 7.0).round() as usize).min(7);
bar_chars[idx]
} else {
' '
};
buf.set_string(x, y + 1, &bar_char.to_string(), Style::default().fg(color));
// Time label on row 3, render seconds when value exceeds 999ms.
if time > 0.0 {
let time_label = if time > 999.0 {
format!("({:.0}s)", time / 1000.0)
} else {
format!("{time:.0}")
};
let label = if time_label.len() > col_width as usize {
let start = time_label.len() - col_width as usize;
&time_label[start..]
} else {
&time_label
};
let label_x = x + col_width.saturating_sub(label.len() as u16);
buf.set_string(
label_x,
y + 2,
label,
Style::default().fg(colors.text_pending()),
);
}
}
}
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(12), // Activity heatmap
Constraint::Length(7), // Keyboard accuracy heatmap
Constraint::Min(5), // Slowest/Fastest/Stats
Constraint::Length(5), // Overall stats
])
.split(area);
// Activity heatmap
let heatmap = ActivityHeatmap::new(self.history, self.theme);
heatmap.render(layout[0], buf);
// Keyboard accuracy heatmap with percentages
self.render_keyboard_heatmap(layout[1], buf);
// Slowest/Fastest/Worst keys
let key_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(layout[2]);
self.render_slowest_keys(key_layout[0], buf);
self.render_fastest_keys(key_layout[1], buf);
self.render_worst_accuracy_keys(key_layout[2], buf);
// Overall stats
self.render_overall_stats(layout[3], buf);
} }
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) { fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Keyboard Accuracy % ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Keyboard Accuracy % ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
if inner.height < 3 || inner.width < 50 { if inner.height < 3 {
return; return;
} }
let rows: &[&[char]] = &[ let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'], (5, 6)
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'], } else if inner.width >= required_kbd_width(4, 5) {
&['z', 'x', 'c', 'v', 'b', 'n', 'm'], (4, 5)
]; } else {
let offsets: &[u16] = &[1, 3, 5]; return;
let key_width: u16 = 5; // wider to fit accuracy % };
let show_shifted = inner.height >= 6;
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
for (row_idx, row) in rows.iter().enumerate() { for (row_idx, row) in all_rows.iter().enumerate() {
let y = inner.y + row_idx as u16; let base_y = if show_shifted {
if y >= inner.y + inner.height { inner.y + row_idx as u16 * 2 + 1 // shifted on top, base below
} else {
inner.y + row_idx as u16
};
if base_y >= inner.y + inner.height {
break; break;
} }
let offset = offsets.get(row_idx).copied().unwrap_or(0); let offset = offsets.get(row_idx).copied().unwrap_or(0);
for (col_idx, &key) in row.iter().enumerate() { // Shifted row (dimmer)
let x = inner.x + offset + col_idx as u16 * key_width; if show_shifted {
let shifted_y = base_y - 1;
if shifted_y >= inner.y {
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width { if x + key_width > inner.x + inner.width {
break; break;
} }
let key = physical_key.shifted;
let accuracy = self.get_key_accuracy(key); let accuracy = self.get_key_accuracy(key);
let (fg_color, bg_color) = if accuracy <= 0.0 { let fg_color = accuracy_color(accuracy, colors);
(colors.text_pending(), colors.bg())
} else if accuracy >= 98.0 {
(colors.success(), colors.bg())
} else if accuracy >= 90.0 {
(colors.warning(), colors.bg())
} else {
(colors.error(), colors.bg())
};
let display = if accuracy > 0.0 { let display = format_accuracy_cell(key, accuracy, key_width);
let pct = accuracy.round() as u32; buf.set_string(
format!("{key}{pct:>3}") x,
} else { shifted_y,
format!("{key} ") &display,
}; Style::default().fg(fg_color).add_modifier(Modifier::DIM),
buf.set_string(x, y, &display, Style::default().fg(fg_color).bg(bg_color)); );
}
}
}
// Base row
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
let key = physical_key.base;
let accuracy = self.get_key_accuracy(key);
let fg_color = accuracy_color(accuracy, colors);
let display = format_accuracy_cell(key, accuracy, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
} }
} }
} }
@@ -775,12 +736,106 @@ impl StatsDashboard<'_> {
correct as f64 / total as f64 * 100.0 correct as f64 / total as f64 * 100.0
} }
fn get_key_time_ms(&self, key: char) -> f64 {
self.key_stats
.stats
.get(&key)
.filter(|s| s.sample_count > 0)
.map(|s| s.filtered_time_ms)
.unwrap_or(0.0)
}
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(Line::from(Span::styled(
" Keyboard Timing (ms) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area);
block.render(area, buf);
if inner.height < 3 {
return;
}
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
(5, 6)
} else if inner.width >= required_kbd_width(4, 5) {
(4, 5)
} else {
return;
};
let show_shifted = inner.height >= 6;
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
for (row_idx, row) in all_rows.iter().enumerate() {
let base_y = if show_shifted {
inner.y + row_idx as u16 * 2 + 1
} else {
inner.y + row_idx as u16
};
if base_y >= inner.y + inner.height {
break;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
if show_shifted {
let shifted_y = base_y - 1;
if shifted_y >= inner.y {
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
let key = physical_key.shifted;
let time_ms = self.get_key_time_ms(key);
let fg_color = timing_color(time_ms, colors);
let display = format_timing_cell(key, time_ms, key_width);
buf.set_string(
x,
shifted_y,
&display,
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
);
}
}
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_step;
if x + key_width > inner.x + inner.width {
break;
}
let key = physical_key.base;
let time_ms = self.get_key_time_ms(key);
let fg_color = timing_color(time_ms, colors);
let display = format_timing_cell(key, time_ms, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
}
}
}
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) { fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Slowest Keys (ms) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Slowest Keys (ms) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -793,13 +848,27 @@ impl StatsDashboard<'_> {
.collect(); .collect();
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap()); key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() { let max_time = key_times.first().map(|(_, t)| *t).unwrap_or(1.0);
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
let y = inner.y + i as u16; let y = inner.y + i as u16;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
} }
let text = format!(" '{ch}' {time:.0}ms"); let label = format!(" {ch} {time:>4.0}ms ");
buf.set_string(inner.x, y, &text, Style::default().fg(colors.error())); let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
let bar_space = inner.width.saturating_sub(label_len) as usize;
if bar_space > 0 {
let filled = ((time / max_time) * bar_space as f64).round() as usize;
let bar = "\u{2588}".repeat(filled.min(bar_space));
buf.set_string(
inner.x + label_len,
y,
&bar,
Style::default().fg(colors.error()),
);
}
} }
} }
@@ -807,8 +876,13 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Fastest Keys (ms) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Fastest Keys (ms) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -821,13 +895,27 @@ impl StatsDashboard<'_> {
.collect(); .collect();
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap()); key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
for (i, (ch, time)) in key_times.iter().take(5).enumerate() { let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0);
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
let y = inner.y + i as u16; let y = inner.y + i as u16;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
} }
let text = format!(" '{ch}' {time:.0}ms"); let label = format!(" {ch} {time:>4.0}ms ");
buf.set_string(inner.x, y, &text, Style::default().fg(colors.success())); let label_len = label.len() as u16;
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
let bar_space = inner.width.saturating_sub(label_len) as usize;
if bar_space > 0 && max_time > 0.0 {
let filled = ((time / max_time) * bar_space as f64).round() as usize;
let bar = "\u{2588}".repeat(filled.min(bar_space));
buf.set_string(
inner.x + label_len,
y,
&bar,
Style::default().fg(colors.success()),
);
}
} }
} }
@@ -835,29 +923,32 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Worst Accuracy Keys (%) ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Worst Accuracy (%) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
// Compute accuracy for each key // Collect all keys from keyboard model
let mut key_accuracies: Vec<(char, f64, usize)> = ('a'..='z') let mut all_keys = std::collections::HashSet::new();
for row in &self.keyboard_model.rows {
for pk in row {
all_keys.insert(pk.base);
all_keys.insert(pk.shifted);
}
}
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
.filter_map(|ch| { .filter_map(|ch| {
let mut correct = 0usize; let accuracy = self.get_key_accuracy(ch);
let mut total = 0usize; // Only include keys with enough data and imperfect accuracy
for result in self.history { if accuracy > 0.0 && accuracy < 100.0 {
for kt in &result.per_key_times { Some((ch, accuracy))
if kt.key == ch {
total += 1;
if kt.correct {
correct += 1;
}
}
}
}
if total >= 5 {
let acc = correct as f64 / total as f64 * 100.0;
Some((ch, acc, total))
} else { } else {
None None
} }
@@ -876,64 +967,236 @@ impl StatsDashboard<'_> {
return; return;
} }
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() { for (i, (ch, acc)) in key_accuracies
.iter()
.take(inner.height as usize)
.enumerate()
{
let y = inner.y + i as u16; let y = inner.y + i as u16;
if y >= inner.y + inner.height { if y >= inner.y + inner.height {
break; break;
} }
let badge = format!(" '{ch}' {acc:.1}%"); let label = format!(" {ch} {acc:>5.1}% ");
let label_len = label.len() as u16;
let color = if *acc >= 95.0 { let color = if *acc >= 95.0 {
colors.warning() colors.warning()
} else { } else {
colors.error() colors.error()
}; };
buf.set_string(inner.x, y, &badge, Style::default().fg(color)); buf.set_string(inner.x, y, &label, Style::default().fg(color));
let bar_space = inner.width.saturating_sub(label_len) as usize;
if bar_space > 0 {
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
let bar = "\u{2588}".repeat(filled.min(bar_space));
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
}
} }
} }
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) { fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Overall Totals ") .title(Line::from(Span::styled(
.border_style(Style::default().fg(colors.border())); " Best Accuracy (%) ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum(); let mut all_keys = std::collections::HashSet::new();
let total_correct: usize = self.history.iter().map(|r| r.correct).sum(); for row in &self.keyboard_model.rows {
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum(); for pk in row {
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum(); all_keys.insert(pk.base);
all_keys.insert(pk.shifted);
}
}
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
.filter_map(|ch| {
let accuracy = self.get_key_accuracy(ch);
if accuracy > 0.0 {
Some((ch, accuracy))
} else {
None
}
})
.collect();
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
if key_accuracies.is_empty() {
buf.set_string(
inner.x,
inner.y,
" Not enough data",
Style::default().fg(colors.text_pending()),
);
return;
}
for (i, (ch, acc)) in key_accuracies
.iter()
.take(inner.height as usize)
.enumerate()
{
let y = inner.y + i as u16;
if y >= inner.y + inner.height {
break;
}
let label = format!(" {ch} {acc:>5.1}% ");
let label_len = label.len() as u16;
let color = if *acc >= 98.0 {
colors.success()
} else {
colors.warning()
};
buf.set_string(inner.x, y, &label, Style::default().fg(color));
let bar_space = inner.width.saturating_sub(label_len) as usize;
if bar_space > 0 {
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
let bar = "\u{2588}".repeat(filled.min(bar_space));
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
}
}
}
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(Line::from(Span::styled(
" Streaks ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.border_style(Style::default().fg(colors.accent()));
let inner = block.inner(area);
block.render(area, buf);
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
for r in self.history {
active_days.insert(r.timestamp.date_naive());
}
let (current_streak, best_streak) = compute_streaks(&active_days);
let active_days_count = active_days.len();
let lines = vec![Line::from(vec![ let lines = vec![Line::from(vec![
Span::styled(" Characters: ", Style::default().fg(colors.fg())), Span::styled(" Current: ", Style::default().fg(colors.fg())),
Span::styled( Span::styled(
format!("{total_chars}"), format!("{current_streak}d"),
Style::default().fg(colors.accent()), Style::default()
.fg(colors.success())
.add_modifier(Modifier::BOLD),
), ),
Span::styled(" Correct: ", Style::default().fg(colors.fg())), Span::styled(" Best: ", Style::default().fg(colors.fg())),
Span::styled( Span::styled(
format!("{total_correct}"), format!("{best_streak}d"),
Style::default().fg(colors.success()), Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
), ),
Span::styled(" Errors: ", Style::default().fg(colors.fg())), Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
Span::styled( Span::styled(
format!("{total_incorrect}"), format!("{active_days_count}"),
Style::default().fg(if total_incorrect > 0 {
colors.error()
} else {
colors.success()
}),
),
Span::styled(" Time: ", Style::default().fg(colors.fg())),
Span::styled(
format_duration(total_time),
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
), ),
])]; ])];
Paragraph::new(lines).render(inner, buf); Paragraph::new(lines).render(inner, buf);
} }
}
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
if accuracy <= 0.0 {
colors.text_pending()
} else if accuracy >= 98.0 {
colors.success()
} else if accuracy >= 90.0 {
colors.warning()
} else {
colors.error()
}
}
fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
if accuracy > 0.0 {
let pct = accuracy.round() as u32;
if key_width >= 5 {
format!("{key}{pct:>3}")
} else {
format!("{key}{pct:>2}")
}
} else if key_width >= 5 {
format!("{key} ")
} else {
format!("{key} ")
}
}
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
if time_ms <= 0.0 {
colors.text_pending()
} else if time_ms <= 200.0 {
colors.success()
} else if time_ms <= 400.0 {
colors.warning()
} else {
colors.error()
}
}
fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
let max_offset: u16 = 4;
max_offset + 12 * key_step + key_width
}
fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
if active_days.is_empty() {
return (0, 0);
}
let mut best = 1usize;
let mut run = 1usize;
let mut prev = None;
for &day in active_days {
if let Some(p) = prev {
if day.signed_duration_since(p).num_days() == 1 {
run += 1;
} else {
run = 1;
}
best = best.max(run);
}
prev = Some(day);
}
let today = chrono::Utc::now().date_naive();
let mut current = 0usize;
let mut cursor = today;
while active_days.contains(&cursor) {
current += 1;
cursor -= chrono::Duration::days(1);
}
(current, best)
}
fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
if time_ms > 0.0 {
let ms = time_ms.round() as u32;
if key_width >= 5 {
format!("{key}{ms:>4}")
} else {
format!("{key}{:>3}", ms.min(999))
}
} else if key_width >= 5 {
format!("{key} ")
} else {
format!("{key} ")
}
} }
fn render_text_bar( fn render_text_bar(