Skill tree integration + tons of random fixes
This commit is contained in:
@@ -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
|
||||||
81
src/app.rs
81
src/app.rs
@@ -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();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
819
src/keyboard/model.rs
Normal 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('{');
|
||||||
|
}
|
||||||
|
}
|
||||||
300
src/main.rs
300
src/main.rs
@@ -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,9 +613,13 @@ 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 focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
let focus_text = if app.drill_mode == DrillMode::Adaptive {
|
||||||
let focus_text = if let Some(focused) = focused {
|
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||||
format!(" | Focus: '{focused}'")
|
if let Some(focused) = focused {
|
||||||
|
format!(" | Focus: '{focused}'")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
133
src/ui/components/branch_progress_list.rs
Normal file
133
src/ui/components/branch_progress_list.rs
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,62 +89,234 @@ 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] };
|
|
||||||
|
|
||||||
for (row_idx, row) in 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);
|
let offsets: &[u16] = &[0, 1, 3];
|
||||||
|
|
||||||
for (col_idx, &key) in row.iter().enumerate() {
|
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
let y = inner.y + row_idx as u16;
|
||||||
if x + key_width > inner.x + inner.width {
|
if y >= inner.y + inner.height {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_depressed = self.depressed_keys.contains(&key);
|
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
|
||||||
let is_focused = self.focused_key == Some(key);
|
|
||||||
let is_next = self.next_key == Some(key);
|
|
||||||
|
|
||||||
// Priority: depressed > next_expected > focused > unlocked > locked
|
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||||
let style = if is_depressed {
|
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||||
let bg = if is_unlocked {
|
if x + key_width > inner.x + inner.width {
|
||||||
brighten_color(finger_color(key))
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_char = if self.shift_held {
|
||||||
|
physical_key.shifted
|
||||||
} else {
|
} else {
|
||||||
brighten_color(colors.accent_dim())
|
physical_key.base
|
||||||
};
|
};
|
||||||
Style::default()
|
let base_char = physical_key.base;
|
||||||
.fg(Color::White)
|
|
||||||
.bg(bg)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else if is_next {
|
|
||||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
|
||||||
} else if is_focused {
|
|
||||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
|
||||||
} else if is_unlocked {
|
|
||||||
Style::default().fg(colors.fg()).bg(finger_color(key))
|
|
||||||
} else {
|
|
||||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
|
||||||
};
|
|
||||||
|
|
||||||
let display = if self.compact {
|
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||||
format!("[{key}]")
|
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||||
} else {
|
|| self.unlocked_keys.contains(&base_char);
|
||||||
format!("[ {key} ]")
|
let is_focused = self.focused_key == Some(display_char)
|
||||||
};
|
|| self.focused_key == Some(base_char);
|
||||||
buf.set_string(x, y, &display, style);
|
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 {
|
||||||
|
brighten_color(finger_color(model, base_char))
|
||||||
|
} else {
|
||||||
|
brighten_color(colors.accent_dim())
|
||||||
|
};
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.bg(bg)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_next {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||||
|
} else if is_focused {
|
||||||
|
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||||
|
} else if is_unlocked {
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.fg())
|
||||||
|
.bg(finger_color(model, base_char))
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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),
|
||||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
|
||||||
} 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),
|
||||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
|
||||||
} 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",
|
}
|
||||||
bp.current_level + 1,
|
BranchStatus::InProgress => {
|
||||||
def.levels.len()
|
if branch_id == BranchId::Lowercase {
|
||||||
),
|
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||||
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
|
} else {
|
||||||
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
|
format!(
|
||||||
|
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
|
||||||
|
bp.current_level + 1,
|
||||||
|
def.levels.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,15 +188,22 @@ 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())),
|
||||||
lines.push(Line::from(Span::styled(
|
Span::styled(unlocked_bar, Style::default().fg(colors.accent())),
|
||||||
format!(" {}", progress_bar_str(pct, 30)),
|
Span::styled(empty_bar, Style::default().fg(colors.text_pending())),
|
||||||
style,
|
]));
|
||||||
)));
|
|
||||||
|
// Add separator after Lowercase (index 0)
|
||||||
|
if branch_id == BranchId::Lowercase {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
|
||||||
|
Style::default().fg(colors.border()),
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines);
|
let paragraph = Paragraph::new(lines);
|
||||||
@@ -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 {
|
||||||
BranchStatus::InProgress => {
|
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
||||||
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
|
||||||
|
format!("Unlocked {unlocked}/{total} letters")
|
||||||
|
} else {
|
||||||
|
match bp.status {
|
||||||
|
BranchStatus::InProgress => {
|
||||||
|
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
||||||
|
}
|
||||||
|
BranchStatus::Complete => {
|
||||||
|
format!("Level {}/{}", def.levels.len(), def.levels.len())
|
||||||
|
}
|
||||||
|
_ => format!("Level 0/{}", def.levels.len()),
|
||||||
}
|
}
|
||||||
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), 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 {
|
||||||
Style::default()
|
lines.push(Line::from(vec![
|
||||||
.fg(colors.bg())
|
Span::styled(
|
||||||
.bg(colors.focused_key())
|
format!(" {display} "),
|
||||||
.add_modifier(Modifier::BOLD)
|
Style::default().fg(colors.text_pending()),
|
||||||
} else if is_confident {
|
),
|
||||||
Style::default().fg(colors.text_correct())
|
Span::styled("locked", Style::default().fg(colors.text_pending())),
|
||||||
} else if level_status == "locked" {
|
]));
|
||||||
Style::default().fg(colors.text_pending())
|
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(colors.fg())
|
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 { "" };
|
||||||
|
|
||||||
key_spans.push(Span::styled(display, style));
|
let key_style = if is_focused {
|
||||||
key_spans.push(Span::raw(" "));
|
Style::default()
|
||||||
|
.fg(colors.bg())
|
||||||
|
.bg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_confident {
|
||||||
|
Style::default().fg(colors.text_correct())
|
||||||
|
} else {
|
||||||
|
Style::default().fg(colors.fg())
|
||||||
|
};
|
||||||
|
|
||||||
|
let bar_color = if is_confident {
|
||||||
|
colors.text_correct()
|
||||||
|
} else {
|
||||||
|
colors.accent()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {display} "), key_style),
|
||||||
|
Span::styled(bar, Style::default().fg(bar_color)),
|
||||||
|
Span::styled(
|
||||||
|
format!(" {pct_str}"),
|
||||||
|
Style::default().fg(colors.text_pending()),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
focus_label,
|
||||||
|
Style::default()
|
||||||
|
.fg(colors.focused_key())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
key_spans.push(Span::styled(
|
|
||||||
format!(" ({level_status})"),
|
|
||||||
Style::default().fg(colors.text_pending()),
|
|
||||||
));
|
|
||||||
|
|
||||||
lines.push(Line::from(key_spans));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Average confidence
|
let visible_height = area.height as usize;
|
||||||
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
if visible_height == 0 {
|
||||||
let avg_conf = if total_keys > 0 {
|
return;
|
||||||
let sum: f64 = def
|
}
|
||||||
.levels
|
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||||
.iter()
|
let scroll = self.detail_scroll.min(max_scroll);
|
||||||
.flat_map(|l| l.keys.iter())
|
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
|
||||||
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
|
let paragraph = Paragraph::new(visible_lines);
|
||||||
.sum();
|
|
||||||
sum / total_keys as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
format!(
|
|
||||||
" Avg Confidence: {} {:.0}%",
|
|
||||||
progress_bar_str(avg_conf, 20),
|
|
||||||
avg_conf * 100.0
|
|
||||||
),
|
|
||||||
Style::default().fg(colors.text_pending()),
|
|
||||||
)));
|
|
||||||
|
|
||||||
let paragraph = Paragraph::new(lines);
|
|
||||||
paragraph.render(area, buf);
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
self.render_tab(self.active_tab, layout[1], buf);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = physical_key.shifted;
|
||||||
|
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,
|
||||||
|
shifted_y,
|
||||||
|
&display,
|
||||||
|
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if x + key_width > inner.x + inner.width {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let key = physical_key.base;
|
||||||
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(x, base_y, &display, Style::default().fg(fg_color));
|
||||||
format!("{key}{pct:>3}")
|
|
||||||
} else {
|
|
||||||
format!("{key} ")
|
|
||||||
};
|
|
||||||
buf.set_string(x, y, &display, Style::default().fg(fg_color).bg(bg_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
|
||||||
}
|
}
|
||||||
@@ -870,70 +961,242 @@ impl StatsDashboard<'_> {
|
|||||||
buf.set_string(
|
buf.set_string(
|
||||||
inner.x,
|
inner.x,
|
||||||
inner.y,
|
inner.y,
|
||||||
" Not enough data",
|
" Not enough data",
|
||||||
Style::default().fg(colors.text_pending()),
|
Style::default().fg(colors.text_pending()),
|
||||||
);
|
);
|
||||||
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(
|
||||||
|
|||||||
Reference in New Issue
Block a user