Key milestone overlays + keyboard diagram improvements

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

View File

@@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
@@ -54,6 +54,7 @@ pub enum AppScreen {
PassageDownloadProgress,
CodeIntro,
CodeDownloadProgress,
Keyboard,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -75,6 +76,32 @@ pub enum CodeDownloadCompleteAction {
ReturnToSettings,
}
pub enum MilestoneKind {
Unlock,
Mastery,
}
pub struct KeyMilestonePopup {
pub kind: MilestoneKind,
pub keys: Vec<char>,
pub finger_info: Vec<(char, String)>,
pub message: &'static str,
}
const UNLOCK_MESSAGES: &[&str] = &[
"Nice work! Keep building your typing skills.",
"Another key added to your arsenal!",
"Your keyboard is growing! Keep it up.",
"One step closer to full keyboard mastery!",
];
const MASTERY_MESSAGES: &[&str] = &[
"This key is now at full confidence!",
"You've got this key down pat!",
"Muscle memory locked in!",
"One more key conquered!",
];
struct DownloadJob {
downloaded_bytes: Arc<AtomicU64>,
total_bytes: Arc<AtomicU64>,
@@ -109,6 +136,7 @@ pub struct App {
pub theme: &'static Theme,
pub config: Config,
pub key_stats: KeyStatsStore,
pub ranked_key_stats: KeyStatsStore,
pub skill_tree: SkillTree,
pub profile: ProfileData,
pub store: Option<JsonStore>,
@@ -154,7 +182,12 @@ pub struct App {
pub code_download_attempted: bool,
pub code_download_action: CodeDownloadCompleteAction,
pub shift_held: bool,
pub caps_lock: bool,
pub keyboard_model: KeyboardModel,
pub milestone_queue: VecDeque<KeyMilestonePopup>,
pub keyboard_explorer_selected: Option<char>,
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
rng: SmallRng,
transition_table: TransitionTable,
#[allow(dead_code)]
@@ -176,20 +209,22 @@ impl App {
let store = JsonStore::new().ok();
let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
// load_profile returns None if file exists but can't parse (schema mismatch)
let pd = s.load_profile();
match pd {
Some(pd) if !pd.needs_reset() => {
let ksd = s.load_key_stats();
let rksd = s.load_ranked_key_stats();
let lhd = s.load_drill_history();
let st = SkillTree::new(pd.skill_tree.clone());
(ksd.stats, st, pd, lhd.drills)
(ksd.stats, rksd.stats, st, pd, lhd.drills)
}
_ => {
// Schema mismatch or parse failure: full reset of all stores
(
KeyStatsStore::default(),
KeyStatsStore::default(),
SkillTree::default(),
ProfileData::default(),
@@ -199,6 +234,7 @@ impl App {
}
} else {
(
KeyStatsStore::default(),
KeyStatsStore::default(),
SkillTree::default(),
ProfileData::default(),
@@ -208,6 +244,8 @@ impl App {
let mut key_stats_with_target = key_stats;
key_stats_with_target.target_cpm = config.target_cpm();
let mut ranked_key_stats_with_target = ranked_key_stats;
ranked_key_stats_with_target.target_cpm = config.target_cpm();
let dictionary = Dictionary::load();
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
@@ -231,6 +269,7 @@ impl App {
theme,
config,
key_stats: key_stats_with_target,
ranked_key_stats: ranked_key_stats_with_target,
skill_tree,
profile,
store,
@@ -276,7 +315,12 @@ impl App {
code_download_attempted: false,
code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
shift_held: false,
caps_lock: false,
keyboard_model,
milestone_queue: VecDeque::new(),
keyboard_explorer_selected: None,
explorer_accuracy_cache_overall: None,
explorer_accuracy_cache_ranked: None,
rng: SmallRng::from_entropy(),
transition_table,
dictionary,
@@ -303,7 +347,7 @@ impl App {
DrillMode::Adaptive => {
let scope = self.drill_scope;
let all_keys = self.skill_tree.unlocked_keys(scope);
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
let focused = self.skill_tree.focused_key(scope, &self.ranked_key_stats);
// Generate base lowercase text using only lowercase keys from scope
let lowercase_keys: Vec<char> = all_keys
@@ -493,13 +537,65 @@ impl App {
false,
);
// Update timing stats for all drill modes
let before_stats = if ranked {
Some(self.ranked_key_stats.clone())
} else {
None
};
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
if ranked {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
}
}
self.skill_tree.update(&self.key_stats);
let update = self
.skill_tree
.update(&self.ranked_key_stats, before_stats.as_ref());
// Queue milestone overlays for newly unlocked keys
if !update.newly_unlocked.is_empty() {
let finger_info: Vec<(char, String)> = update
.newly_unlocked
.iter()
.map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).description();
(ch, desc.to_string())
})
.collect();
let msg = UNLOCK_MESSAGES[self.rng.gen_range(0..UNLOCK_MESSAGES.len())];
self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Unlock,
keys: update.newly_unlocked,
finger_info,
message: msg,
});
}
// Queue milestone overlays for newly mastered keys
if !update.newly_mastered.is_empty() {
let finger_info: Vec<(char, String)> = update
.newly_mastered
.iter()
.map(|&ch| {
let desc = self.keyboard_model.finger_for_char(ch).description();
(ch, desc.to_string())
})
.collect();
let msg = MASTERY_MESSAGES[self.rng.gen_range(0..MASTERY_MESSAGES.len())];
self.milestone_queue.push_back(KeyMilestonePopup {
kind: MilestoneKind::Mastery,
keys: update.newly_mastered,
finger_info,
message: msg,
});
}
}
let complexity = self.skill_tree.complexity();
@@ -554,6 +650,13 @@ impl App {
true,
);
// Update timing stats for all completed keystrokes
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
self.drill_history.push(result.clone());
if self.drill_history.len() > 500 {
self.drill_history.remove(0);
@@ -572,6 +675,10 @@ impl App {
schema_version: 2,
stats: self.key_stats.clone(),
});
let _ = store.save_ranked_key_stats(&KeyStatsData {
schema_version: 2,
stats: self.ranked_key_stats.clone(),
});
let _ = store.save_drill_history(&DrillHistoryData {
schema_version: 2,
drills: self.drill_history.clone(),
@@ -628,6 +735,8 @@ impl App {
// Reset all derived state
self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm();
self.ranked_key_stats = KeyStatsStore::default();
self.ranked_key_stats.target_cpm = self.config.target_cpm();
self.skill_tree = SkillTree::default();
self.profile.total_score = 0.0;
self.profile.total_drills = 0;
@@ -637,14 +746,20 @@ impl App {
// Replay each remaining session oldest->newest
for result in &self.drill_history {
// Update timing stats for all sessions
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
// Only update skill tree for ranked sessions
if result.ranked {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
self.ranked_key_stats.update_key(kt.key, kt.time_ms);
}
}
self.skill_tree.update(&self.key_stats);
self.skill_tree.update(&self.ranked_key_stats, None);
}
// Partial sessions are visible in history but do not affect profile/streak activity.
@@ -700,6 +815,47 @@ impl App {
self.start_drill();
}
pub fn go_to_keyboard(&mut self) {
self.keyboard_explorer_selected = None;
self.explorer_accuracy_cache_overall = None;
self.explorer_accuracy_cache_ranked = None;
self.screen = AppScreen::Keyboard;
}
pub fn key_accuracy(&mut self, ch: char, ranked_only: bool) -> (usize, usize) {
let cache = if ranked_only {
self.explorer_accuracy_cache_ranked
} else {
self.explorer_accuracy_cache_overall
};
if let Some((cached_key, correct, total)) = cache {
if cached_key == ch {
return (correct, total);
}
}
let mut correct = 0usize;
let mut total = 0usize;
for result in &self.drill_history {
if ranked_only && !result.ranked {
continue;
}
for kt in &result.per_key_times {
if kt.key == ch {
total += 1;
if kt.correct {
correct += 1;
}
}
}
}
if ranked_only {
self.explorer_accuracy_cache_ranked = Some((ch, correct, total));
} else {
self.explorer_accuracy_cache_overall = Some((ch, correct, total));
}
(correct, total)
}
pub fn go_to_code_language_select(&mut self) {
let options = code_language_options();
self.code_language_selected = options
@@ -1153,6 +1309,7 @@ impl App {
0 => {
self.config.target_wpm = (self.config.target_wpm + 5).min(200);
self.key_stats.target_cpm = self.config.target_cpm();
self.ranked_key_stats.target_cpm = self.config.target_cpm();
}
1 => {
let themes = Theme::available_themes();
@@ -1219,6 +1376,7 @@ impl App {
0 => {
self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10);
self.key_stats.target_cpm = self.config.target_cpm();
self.ranked_key_stats.target_cpm = self.config.target_cpm();
}
1 => {
let themes = Theme::available_themes();

View File

@@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
/// Events returned by `SkillTree::update` describing what changed.
pub struct SkillTreeUpdate {
pub newly_unlocked: Vec<char>,
pub newly_mastered: Vec<char>,
}
// --- Branch ID ---
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -188,6 +194,19 @@ pub const ALL_BRANCHES: &[BranchDefinition] = &[
},
];
/// Find which branch and level a key belongs to.
/// Returns (branch_def, level_name, 1-based position in level).
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
for branch in ALL_BRANCHES {
for level in branch.levels {
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
return Some((branch, level.name, pos + 1));
}
}
}
None
}
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
ALL_BRANCHES
.iter()
@@ -487,7 +506,19 @@ impl SkillTree {
/// Update skill tree progress based on current key stats.
/// Call after updating KeyStatsStore.
pub fn update(&mut self, stats: &KeyStatsStore) {
///
/// `before_stats` is an optional snapshot of key stats *before* this drill's data was added.
/// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0).
/// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered.
pub fn update(
&mut self,
stats: &KeyStatsStore,
before_stats: Option<&KeyStatsStore>,
) -> SkillTreeUpdate {
// Snapshot unlocked keys before tree structure changes
let before_unlocked: HashSet<char> =
self.unlocked_keys(DrillScope::Global).into_iter().collect();
// Update lowercase branch (progressive unlock)
self.update_lowercase(stats);
@@ -518,6 +549,34 @@ impl SkillTree {
}
self.update_branch_level(branch_def, stats);
}
// Snapshot after
let after_unlocked: HashSet<char> =
self.unlocked_keys(DrillScope::Global).into_iter().collect();
let newly_unlocked: Vec<char> = after_unlocked
.difference(&before_unlocked)
.copied()
.collect();
// Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats,
// but now have confidence >= 1.0 in current stats
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
before_unlocked
.iter()
.filter(|&&ch| {
before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0
})
.copied()
.collect()
} else {
Vec::new()
};
SkillTreeUpdate {
newly_unlocked,
newly_mastered,
}
}
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
@@ -731,7 +790,7 @@ mod tests {
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
tree.update(&stats);
tree.update(&stats, None);
// Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global);
@@ -750,7 +809,7 @@ mod tests {
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
tree.update(&stats);
tree.update(&stats, None);
}
assert_eq!(
@@ -805,7 +864,7 @@ mod tests {
// Make level 1 capitals confident: T I A S W H B M
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
tree.update(&stats);
tree.update(&stats, None);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
assert_eq!(
@@ -829,7 +888,7 @@ mod tests {
// Update multiple times for level advancement
for _ in 0..5 {
tree.update(&stats);
tree.update(&stats, None);
}
assert_eq!(
@@ -968,4 +1027,69 @@ mod tests {
assert!(0 < branches.len());
assert!(branches.len() - 1 < branches.len());
}
#[test]
fn test_update_returns_newly_unlocked() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
let result = tree.update(&stats, None);
// Should unlock 7th key ('s')
assert!(
result.newly_unlocked.contains(&'s'),
"newly_unlocked: {:?}",
result.newly_unlocked
);
}
#[test]
fn test_update_returns_newly_mastered() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Snapshot before any key stats are added
let before_stats = stats.clone();
// Make first 5 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']);
let result = tree.update(&stats, Some(&before_stats));
// The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered
for &ch in &['e', 't', 'a', 'o', 'i'] {
assert!(
result.newly_mastered.contains(&ch),
"expected {} in newly_mastered: {:?}",
ch,
result.newly_mastered
);
}
}
#[test]
fn test_find_key_branch_lowercase() {
let result = find_key_branch('e');
assert!(result.is_some());
let (branch, level_name, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Lowercase);
assert_eq!(level_name, "Frequency Order");
assert_eq!(pos, 1); // 'e' is first in the frequency order
}
#[test]
fn test_find_key_branch_capitals() {
let result = find_key_branch('T');
assert!(result.is_some());
let (branch, level_name, pos) = result.unwrap();
assert_eq!(branch.id, BranchId::Capitals);
assert_eq!(level_name, "Common Sentence Capitals");
assert_eq!(pos, 1); // 'T' is first
}
#[test]
fn test_find_key_branch_unknown() {
assert!(find_key_branch('\x00').is_none());
}
}

153
src/keyboard/display.rs Normal file
View File

@@ -0,0 +1,153 @@
/// Centralized key display adapter for sentinel-char to display-name conversions.
///
/// **Sentinel boundary policy:**
/// Sentinel chars (`'\x08'`, `'\t'`, `'\n'`) are allowed only at two boundaries:
/// 1. **Input boundary** — `handle_key` in `src/main.rs` converts `KeyCode::Backspace/Tab/Enter`
/// to sentinels for `depressed_keys` and drill input.
/// 2. **Storage boundary** — `KeyStatsStore` and `drill_history` store sentinels as `char` keys.
///
/// All UI rendering, stats display, and business logic must consume these adapter functions
/// rather than matching sentinels directly.
/// Human-readable display name for a key character (including sentinels).
/// Returns `""` for printable chars — caller uses `ch.to_string()` for those.
pub fn key_display_name(ch: char) -> &'static str {
match ch {
'\x08' => "Backspace",
'\t' => "Tab",
'\n' => "Enter",
' ' => "Space",
_ => "",
}
}
/// Short label for compact UI contexts (heatmaps, compact keyboard).
/// Returns `""` for printable chars.
pub fn key_short_label(ch: char) -> &'static str {
match ch {
'\x08' => "Bksp",
'\t' => "Tab",
'\n' => "Ent",
' ' => "Spc",
_ => "",
}
}
/// All sentinel chars used for non-printable keys.
pub const MODIFIER_SENTINELS: &[char] = &['\x08', '\t', '\n'];
/// Sentinel char for Backspace.
pub const BACKSPACE: char = '\x08';
/// Sentinel char for Tab.
pub const TAB: char = '\t';
/// Sentinel char for Enter.
pub const ENTER: char = '\n';
/// Space character (not a sentinel, but treated as a special key for display).
pub const SPACE: char = ' ';
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_key_display_name() {
assert_eq!(key_display_name('\x08'), "Backspace");
assert_eq!(key_display_name('\t'), "Tab");
assert_eq!(key_display_name('\n'), "Enter");
assert_eq!(key_display_name(' '), "Space");
assert_eq!(key_display_name('a'), "");
assert_eq!(key_display_name('1'), "");
}
#[test]
fn test_key_short_label() {
assert_eq!(key_short_label('\x08'), "Bksp");
assert_eq!(key_short_label('\t'), "Tab");
assert_eq!(key_short_label('\n'), "Ent");
assert_eq!(key_short_label(' '), "Spc");
assert_eq!(key_short_label('z'), "");
}
#[test]
fn test_modifier_sentinels() {
assert_eq!(MODIFIER_SENTINELS.len(), 3);
assert!(MODIFIER_SENTINELS.contains(&'\x08'));
assert!(MODIFIER_SENTINELS.contains(&'\t'));
assert!(MODIFIER_SENTINELS.contains(&'\n'));
}
/// Sentinel boundary enforcement test.
///
/// Verifies that `'\x08'` (the Backspace sentinel) does not leak into
/// UI or business logic files outside allowed boundaries.
///
/// **Policy: `\x08`-only enforcement (accepted compromise)**
///
/// The plan originally proposed checking all three sentinels (`\x08`, `\t`, `\n`),
/// but `'\t'` and `'\n'` have widespread legitimate uses as text content
/// characters throughout the codebase: tab indentation in code generators
/// (`code_syntax.rs`, `passage.rs`), newlines in text processing (`input.rs`,
/// `typing_area.rs`, `drill.rs`), and key definitions in the skill tree
/// (`skill_tree.rs`). Distinguishing "sentinel identity use" from "text content
/// use" for `\t`/`\n` would require fragile heuristic pattern matching that
/// would either miss real violations or produce false positives.
///
/// `'\x08'` has no legitimate text-content use, making it an unambiguous
/// sentinel leakage signal. All UI/stats/business-logic files already use
/// the `TAB`/`ENTER` adapter constants for sentinel-identity purposes, so
/// the `\t`/`\n` policy is enforced by convention and code review.
///
/// Allowed files for `'\x08'`:
/// - `display.rs` (this module — the adapter itself, defines BACKSPACE constant)
/// - `main.rs` (input boundary — KeyCode::Backspace conversion)
/// - `key_stats.rs` (storage boundary)
/// - `drill.rs` (input processing boundary)
/// - `app.rs` (milestone detection reads stats keyed by sentinel)
#[test]
fn test_sentinel_boundary_enforcement() {
use std::fs;
use std::path::Path;
let allowed_files = [
"src/keyboard/display.rs",
"src/main.rs",
"src/engine/key_stats.rs",
"src/session/drill.rs",
"src/app.rs",
];
fn collect_rs_files(dir: &Path, files: &mut Vec<String>) {
let entries = fs::read_dir(dir).expect("failed to read source directory");
for entry in entries {
let entry = entry.expect("failed to read directory entry");
let path = entry.path();
if path.is_dir() {
collect_rs_files(&path, files);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let normalized = path.to_string_lossy().replace('\\', "/");
files.push(normalized);
}
}
}
// Search for direct '\x08' literal in src/ — this is the clearest
// sentinel leakage signal since \x08 has no legitimate text use.
let mut rs_files = Vec::new();
collect_rs_files(Path::new("src"), &mut rs_files);
let mut violations = Vec::new();
for file in rs_files {
let content = fs::read_to_string(&file).expect("failed to read source file");
if content.contains(r"'\\x08'") && !allowed_files.iter().any(|&allowed| file == allowed)
{
violations.push(file);
}
}
assert!(
violations.is_empty(),
"Direct '\\x08' sentinel literal found outside allowed boundary files:\n{}",
violations.join("\n")
);
}
}

View File

@@ -26,6 +26,21 @@ impl FingerAssignment {
pub fn new(hand: Hand, finger: Finger) -> Self {
Self { hand, finger }
}
pub fn description(&self) -> &'static str {
match (self.hand, self.finger) {
(Hand::Left, Finger::Pinky) => "left pinky",
(Hand::Left, Finger::Ring) => "left ring finger",
(Hand::Left, Finger::Middle) => "left middle finger",
(Hand::Left, Finger::Index) => "left index finger",
(Hand::Left, Finger::Thumb) => "left thumb",
(Hand::Right, Finger::Pinky) => "right pinky",
(Hand::Right, Finger::Ring) => "right ring finger",
(Hand::Right, Finger::Middle) => "right middle finger",
(Hand::Right, Finger::Index) => "right index finger",
(Hand::Right, Finger::Thumb) => "right thumb",
}
}
}
#[allow(dead_code)]

View File

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

View File

@@ -14,8 +14,8 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use clap::Parser;
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use crossterm::terminal::{
@@ -28,8 +28,10 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use app::{App, AppScreen, DrillMode};
use engine::skill_tree::DrillScope;
use app::{App, AppScreen, DrillMode, MilestoneKind};
use engine::skill_tree::{DrillScope, find_key_branch};
use keyboard::display::key_display_name;
use keyboard::finger::Hand;
use event::{AppEvent, EventHandler};
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
use generator::passage::{is_book_cached, passage_options};
@@ -81,7 +83,10 @@ fn main() -> Result<()> {
// Try to enable keyboard enhancement for Release event support
let keyboard_enhanced = execute!(
io::stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
)
)
.is_ok();
@@ -153,8 +158,24 @@ fn run_app(
}
fn handle_key(app: &mut App, key: KeyEvent) {
// Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK).
// This only works in terminals with native Kitty keyboard protocol support
// (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped
// and crossterm infers SHIFT from character case, making it impossible to
// distinguish Shift+a from CapsLock+a.
app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK);
// Track depressed keys and shift state for keyboard diagram
match (&key.code, key.kind) {
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => {
app.shift_held = true;
app.last_key_time = Some(Instant::now());
return; // Don't dispatch bare shift presses to screen handlers
}
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Release) => {
app.shift_held = false;
return;
}
(KeyCode::Char(ch), KeyEventKind::Press) => {
app.depressed_keys.insert(ch.to_ascii_lowercase());
app.last_key_time = Some(Instant::now());
@@ -164,6 +185,33 @@ fn handle_key(app: &mut App, key: KeyEvent) {
app.depressed_keys.remove(&ch.to_ascii_lowercase());
return; // Don't process Release events as input
}
(KeyCode::Backspace, KeyEventKind::Press) => {
app.depressed_keys.insert('\x08');
app.last_key_time = Some(Instant::now());
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
}
(KeyCode::Backspace, KeyEventKind::Release) => {
app.depressed_keys.remove(&'\x08');
return;
}
(KeyCode::Tab, KeyEventKind::Press) => {
app.depressed_keys.insert('\t');
app.last_key_time = Some(Instant::now());
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
}
(KeyCode::Tab, KeyEventKind::Release) => {
app.depressed_keys.remove(&'\t');
return;
}
(KeyCode::Enter, KeyEventKind::Press) => {
app.depressed_keys.insert('\n');
app.last_key_time = Some(Instant::now());
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
}
(KeyCode::Enter, KeyEventKind::Release) => {
app.depressed_keys.remove(&'\n');
return;
}
(_, KeyEventKind::Release) => return,
_ => {
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
@@ -193,6 +241,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key),
AppScreen::CodeIntro => handle_code_intro_key(app, key),
AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key),
AppScreen::Keyboard => handle_keyboard_explorer_key(app, key),
}
}
@@ -219,6 +268,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
}
}
KeyCode::Char('t') => app.go_to_skill_tree(),
KeyCode::Char('b') => app.go_to_keyboard(),
KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(),
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
@@ -244,8 +294,9 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
}
}
3 => app.go_to_skill_tree(),
4 => app.go_to_stats(),
5 => app.go_to_settings(),
4 => app.go_to_keyboard(),
5 => app.go_to_stats(),
6 => app.go_to_settings(),
_ => {}
},
_ => {}
@@ -253,6 +304,25 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
}
fn handle_drill_key(app: &mut App, key: KeyEvent) {
// If a milestone overlay is showing, dismiss it on any key press
if !app.milestone_queue.is_empty() {
app.milestone_queue.pop_front();
// Determine what to do with the dismissing key
match milestone_dismiss_action(key.code) {
MilestoneDismissAction::EscAndExit => {
// Esc clears entire queue and exits drill
app.milestone_queue.clear();
// Fall through to normal Esc handling below
}
MilestoneDismissAction::Replay => {
// Char/Tab/Enter: dismiss and replay into drill
// Fall through to normal key handling below
}
MilestoneDismissAction::DismissOnly => return, // Backspace and others
}
}
// Route Enter/Tab as typed characters during active drills
if app.drill.is_some() {
match key.code {
@@ -284,6 +354,21 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MilestoneDismissAction {
Replay,
DismissOnly,
EscAndExit,
}
fn milestone_dismiss_action(code: KeyCode) -> MilestoneDismissAction {
match code {
KeyCode::Esc => MilestoneDismissAction::EscAndExit,
KeyCode::Char(_) | KeyCode::Tab | KeyCode::Enter => MilestoneDismissAction::Replay,
_ => MilestoneDismissAction::DismissOnly,
}
}
fn handle_result_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Char('r') => app.retry_drill(),
@@ -863,6 +948,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app),
AppScreen::CodeIntro => render_code_intro(frame, app),
AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app),
AppScreen::Keyboard => render_keyboard_explorer(frame, app),
}
}
@@ -886,7 +972,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
};
let total_keys = app.skill_tree.total_unique_keys;
let unlocked = app.skill_tree.total_unlocked_count();
let mastered = app.skill_tree.total_confident_keys(&app.key_stats);
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
let header_info = format!(
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}",
streak_text,
@@ -913,7 +999,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(&app.menu, menu_area);
let footer = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit ",
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
Style::default().fg(colors.text_pending()),
)]));
frame.render_widget(footer, layout[2]);
@@ -952,7 +1038,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
} else {
let header_title = format!(" {mode_name} Drill ");
let focus_text = if app.drill_mode == DrillMode::Adaptive {
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let focused = app
.skill_tree
.focused_key(app.drill_scope, &app.ranked_key_stats);
if let Some(focused) = focused {
format!(" | Focus: '{focused}'")
} else {
@@ -1010,9 +1098,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let kbd_height = if show_kbd {
if tier.compact_keyboard() {
5 // 3 rows + 2 border
6 // 3 rows + 2 border + 1 modifier space
} else {
7 // 4 rows + 2 border + 1 label space
8 // 5 rows (4 + space bar) + 2 border + 1 spacing
}
} else {
0
@@ -1039,7 +1127,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
if app.drill_mode == DrillMode::Adaptive {
let progress_widget = ui::components::branch_progress_list::BranchProgressList {
skill_tree: &app.skill_tree,
key_stats: &app.key_stats,
key_stats: &app.ranked_key_stats,
drill_scope: app.drill_scope,
active_branches: &active_branches,
theme: app.theme,
@@ -1070,11 +1158,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
if show_kbd {
let next_char = drill.target.get(drill.cursor).copied();
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let kbd_height = if tier.compact_keyboard() { 5 } else { 7 };
let _ = kbd_height; // Height managed by constraints
let kbd = KeyboardDiagram::new(
focused,
next_char,
&unlocked_keys,
&app.depressed_keys,
@@ -1082,7 +1166,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
&app.keyboard_model,
)
.compact(tier.compact_keyboard())
.shift_held(app.shift_held);
.shift_held(app.shift_held)
.caps_lock(app.caps_lock);
frame.render_widget(kbd, main_layout[idx]);
}
@@ -1101,6 +1186,221 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
Style::default().fg(colors.text_pending()),
)));
frame.render_widget(footer, app_layout.footer);
// Render milestone overlay if present
if let Some(milestone) = app.milestone_queue.front() {
render_milestone_overlay(frame, app, milestone);
}
}
}
fn render_milestone_overlay(
frame: &mut ratatui::Frame,
app: &App,
milestone: &app::KeyMilestonePopup,
) {
let area = frame.area();
let colors = &app.theme.colors;
// Determine overlay size based on terminal height:
// Large (>=25): full keyboard diagram
// Medium (>=15): compact keyboard diagram
// Small (<15): text only
let kbd_mode = overlay_keyboard_mode(area.height);
let overlay_height = match kbd_mode {
2 => 18u16.min(area.height.saturating_sub(2)),
1 => 14u16.min(area.height.saturating_sub(2)),
_ => 10u16.min(area.height.saturating_sub(2)),
};
let overlay_width = 60u16.min(area.width.saturating_sub(4));
let left = area.x + (area.width.saturating_sub(overlay_width)) / 2;
let top = area.y + (area.height.saturating_sub(overlay_height)) / 2;
let overlay_area = Rect::new(left, top, overlay_width, overlay_height);
// Clear the area behind the overlay
frame.render_widget(ratatui::widgets::Clear, overlay_area);
let title = match milestone.kind {
MilestoneKind::Unlock => " Key Unlocked! ",
MilestoneKind::Mastery => " Key Mastered! ",
};
let block = Block::bordered()
.title(title)
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(overlay_area);
block.render(overlay_area, frame.buffer_mut());
let mut lines: Vec<Line> = Vec::new();
// Key display line
let key_action = match milestone.kind {
MilestoneKind::Unlock => "unlocked",
MilestoneKind::Mastery => "mastered",
};
let key_names: Vec<String> = milestone
.keys
.iter()
.map(|&ch| {
let name = keyboard::display::key_display_name(ch);
if name.is_empty() {
format!("'{ch}'")
} else {
name.to_string()
}
})
.collect();
let keys_str = key_names.join(", ");
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" You {key_action}: {keys_str}"),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)));
// Finger info (for unlocks)
if matches!(milestone.kind, MilestoneKind::Unlock) {
for (ch, finger_desc) in &milestone.finger_info {
let key_label = {
let name = keyboard::display::key_display_name(*ch);
if name.is_empty() {
format!("'{ch}'")
} else {
name.to_string()
}
};
lines.push(Line::from(Span::styled(
format!(" {key_label}: Use your {finger_desc}"),
Style::default().fg(colors.fg()),
)));
// Shift key guidance for shifted characters
let fa = app.keyboard_model.finger_for_char(*ch);
if ch.is_ascii_uppercase()
|| (!ch.is_ascii_lowercase()
&& !ch.is_ascii_digit()
&& !ch.is_ascii_whitespace()
&& *ch != ' ')
{
let shift_hint = if fa.hand == keyboard::finger::Hand::Left {
"Hold Right Shift (right pinky)"
} else {
"Hold Left Shift (left pinky)"
};
lines.push(Line::from(Span::styled(
format!(" {shift_hint}"),
Style::default().fg(colors.text_pending()),
)));
}
}
}
// Encouraging message (randomly selected at creation time)
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}", milestone.message),
Style::default().fg(colors.focused_key()),
)));
// Keyboard diagram (if space permits)
if kbd_mode > 0 {
let min_kbd_height: u16 = if kbd_mode == 2 { 6 } else { 4 };
let remaining = inner.height.saturating_sub(lines.len() as u16 + 2);
if remaining >= min_kbd_height {
let kbd_y_start = inner.y + lines.len() as u16 + 1;
let kbd_height = remaining.min(if kbd_mode == 2 { 8 } else { 6 });
let kbd_area = Rect::new(inner.x, kbd_y_start, inner.width, kbd_height);
let milestone_key = milestone.keys.first().copied();
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
let is_shifted = milestone_key.is_some_and(|ch| {
ch.is_ascii_uppercase()
|| app.keyboard_model.shifted_to_base(ch).is_some()
});
let kbd = KeyboardDiagram::new(
None,
&unlocked_keys,
&app.depressed_keys,
app.theme,
&app.keyboard_model,
)
.selected_key(milestone_key)
.compact(kbd_mode == 1)
.shift_held(is_shifted)
.caps_lock(app.caps_lock);
frame.render_widget(kbd, kbd_area);
}
}
// Render the text content
let text_area = Rect::new(
inner.x,
inner.y,
inner.width,
inner.height.saturating_sub(1),
);
Paragraph::new(lines).render(text_area, frame.buffer_mut());
// Footer
let footer_y = inner.y + inner.height.saturating_sub(1);
if footer_y < inner.y + inner.height {
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
let footer = Paragraph::new(Line::from(Span::styled(
" Press any key to continue (Backspace dismisses only)",
Style::default().fg(colors.text_pending()),
)));
frame.render_widget(footer, footer_area);
}
}
fn overlay_keyboard_mode(height: u16) -> u8 {
if height >= 25 {
2 // full
} else if height >= 15 {
1 // compact
} else {
0 // text only
}
}
#[cfg(test)]
mod review_tests {
use super::*;
#[test]
fn milestone_dismiss_matrix_matches_spec() {
assert_eq!(
milestone_dismiss_action(KeyCode::Char('a')),
MilestoneDismissAction::Replay
);
assert_eq!(
milestone_dismiss_action(KeyCode::Tab),
MilestoneDismissAction::Replay
);
assert_eq!(
milestone_dismiss_action(KeyCode::Enter),
MilestoneDismissAction::Replay
);
assert_eq!(
milestone_dismiss_action(KeyCode::Backspace),
MilestoneDismissAction::DismissOnly
);
assert_eq!(
milestone_dismiss_action(KeyCode::Esc),
MilestoneDismissAction::EscAndExit
);
}
#[test]
fn overlay_mode_height_boundaries() {
assert_eq!(overlay_keyboard_mode(14), 0);
assert_eq!(overlay_keyboard_mode(15), 1);
assert_eq!(overlay_keyboard_mode(24), 1);
assert_eq!(overlay_keyboard_mode(25), 2);
}
}
@@ -1122,7 +1422,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
app.stats_tab,
app.config.target_wpm,
app.skill_tree.total_unlocked_count(),
app.skill_tree.total_confident_keys(&app.key_stats),
app.skill_tree.total_confident_keys(&app.ranked_key_stats),
app.skill_tree.total_unique_keys,
app.theme,
app.history_selected,
@@ -2062,10 +2362,337 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
let centered = skill_tree_popup_rect(area);
let widget = SkillTreeWidget::new(
&app.skill_tree,
&app.key_stats,
&app.ranked_key_stats,
app.skill_tree_selected,
app.skill_tree_detail_scroll,
app.theme,
);
frame.render_widget(widget, centered);
}
fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.go_to_menu(),
KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(),
KeyCode::Char(ch) => {
app.keyboard_explorer_selected = Some(ch);
app.key_accuracy(ch, false);
app.key_accuracy(ch, true);
}
KeyCode::Tab => {
app.keyboard_explorer_selected = Some('\t');
app.key_accuracy('\t', false);
app.key_accuracy('\t', true);
}
KeyCode::Enter => {
app.keyboard_explorer_selected = Some('\n');
app.key_accuracy('\n', false);
app.key_accuracy('\n', true);
}
KeyCode::Backspace => {
app.keyboard_explorer_selected = Some('\x08');
app.key_accuracy('\x08', false);
app.key_accuracy('\x08', true);
}
_ => {}
}
}
fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // header
Constraint::Length(8), // keyboard diagram
Constraint::Min(3), // detail panel
Constraint::Length(1), // footer
])
.split(area);
// Header
let header_lines = vec![
Line::from(""),
Line::from(Span::styled(
" Keyboard Explorer ",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Press any key to see details",
Style::default().fg(colors.text_pending()),
)),
];
let header = Paragraph::new(header_lines)
.alignment(ratatui::layout::Alignment::Center);
frame.render_widget(header, layout[0]);
// Keyboard diagram
let unlocked = app.skill_tree.unlocked_keys(DrillScope::Global);
let kbd = KeyboardDiagram::new(
None,
&unlocked,
&app.depressed_keys,
app.theme,
&app.keyboard_model,
)
.selected_key(app.keyboard_explorer_selected)
.shift_held(app.shift_held)
.caps_lock(app.caps_lock);
frame.render_widget(kbd, layout[1]);
// Detail panel
render_keyboard_detail_panel(frame, app, layout[2]);
// Footer
let footer = Paragraph::new(Line::from(vec![Span::styled(
" [ESC] Back ",
Style::default().fg(colors.text_pending()),
)]));
frame.render_widget(footer, layout[3]);
}
fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rect) {
let colors = &app.theme.colors;
let selected = match app.keyboard_explorer_selected {
Some(ch) => ch,
None => {
let hint = Paragraph::new(Line::from(Span::styled(
"Press a key to see its details",
Style::default().fg(colors.text_pending()),
)))
.alignment(ratatui::layout::Alignment::Center)
.block(
Block::bordered()
.border_style(Style::default().fg(colors.border()))
.title(" Key Details "),
);
frame.render_widget(hint, area);
return;
}
};
// Build display name for title
let display_name = key_display_name(selected);
let title = if display_name.is_empty() {
format!(" Key Details: '{}' ", selected)
} else {
format!(" Key Details: {} ", display_name)
};
let block = Block::bordered()
.border_style(Style::default().fg(colors.border()))
.title(Span::styled(
title,
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
// Finger assignment
let finger = app.keyboard_model.finger_for_char(selected);
lines.push(Line::from(vec![
Span::styled(" Finger: ", Style::default().fg(colors.text_pending())),
Span::styled(
finger.description(),
Style::default().fg(colors.fg()),
),
]));
// Shift guidance for shifted characters
let is_shifted = selected.is_uppercase()
|| matches!(
selected,
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
);
if is_shifted {
let shift_guidance = if finger.hand == Hand::Left {
"Hold Right Shift (right pinky)"
} else {
"Hold Left Shift (left pinky)"
};
lines.push(Line::from(vec![
Span::styled(" Shift: ", Style::default().fg(colors.text_pending())),
Span::styled(shift_guidance, Style::default().fg(colors.fg())),
]));
}
// Unlocked status
let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global);
let is_unlocked = unlocked_keys.contains(&selected);
lines.push(Line::from(vec![
Span::styled(" Unlocked: ", Style::default().fg(colors.text_pending())),
Span::styled(
if is_unlocked { "Yes" } else { "No" },
Style::default().fg(if is_unlocked {
colors.success()
} else {
colors.text_pending()
}),
),
]));
// Mastery / confidence (overall and ranked)
let overall_confidence = app.key_stats.get_confidence(selected);
let ranked_confidence = app.ranked_key_stats.get_confidence(selected);
if overall_confidence > 0.0 || ranked_confidence > 0.0 {
let overall_pct = (overall_confidence * 100.0).min(100.0);
let ranked_pct = (ranked_confidence * 100.0).min(100.0);
lines.push(Line::from(vec![
Span::styled(" Mastery: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!("overall {:>3.0}% ranked {:>3.0}%", overall_pct, ranked_pct),
Style::default().fg(colors.fg()),
),
]));
}
// Branch/Level info
if let Some((branch, level_name, position)) = find_key_branch(selected) {
lines.push(Line::from(vec![
Span::styled(" Branch: ", Style::default().fg(colors.text_pending())),
Span::styled(branch.name, Style::default().fg(colors.fg())),
]));
lines.push(Line::from(vec![
Span::styled(" Level: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!("{} (key #{})", level_name, position),
Style::default().fg(colors.fg()),
),
]));
}
// Avg time / samples (overall and ranked)
let overall_stat = app.key_stats.get_stat(selected);
let ranked_stat = app.ranked_key_stats.get_stat(selected);
if overall_stat.is_some() || ranked_stat.is_some() {
let fmt_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
if let Some(stat) = stat {
if stat.sample_count > 0 {
let best = if stat.best_time_ms < f64::MAX {
stat.best_time_ms
} else {
stat.filtered_time_ms
};
return format!("{:.0}ms/{:.0}ms", stat.filtered_time_ms, best);
}
}
"No data".to_string()
};
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> usize {
stat.map(|s| s.sample_count).unwrap_or(0)
};
lines.push(Line::from(vec![
Span::styled(" Avg Time: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!(
"overall {} ranked {}",
fmt_time(overall_stat),
fmt_time(ranked_stat)
),
Style::default().fg(colors.fg()),
),
]));
lines.push(Line::from(vec![
Span::styled(" Samples: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!(
"overall {} ranked {}",
fmt_samples(overall_stat),
fmt_samples(ranked_stat)
),
Style::default().fg(colors.fg()),
),
]));
}
// Accuracy (overall and ranked) from precomputed caches
let overall_acc = app
.explorer_accuracy_cache_overall
.filter(|(key, _, _)| *key == selected);
let ranked_acc = app
.explorer_accuracy_cache_ranked
.filter(|(key, _, _)| *key == selected);
if overall_acc.is_some() || ranked_acc.is_some() {
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String {
if let Some((_, correct, total)) = entry {
if total > 0 {
let pct = (correct as f64 / total as f64) * 100.0;
return format!("{:.1}% ({}/{})", pct, correct, total);
}
}
"No data".to_string()
};
lines.push(Line::from(vec![
Span::styled(" Accuracy: ", Style::default().fg(colors.text_pending())),
Span::styled(
format!(
"overall {} ranked {}",
fmt_acc(overall_acc),
fmt_acc(ranked_acc)
),
Style::default().fg(colors.fg()),
),
]));
}
// Ranked progression info (mirrors Skill Tree per-key bar semantics)
if is_unlocked {
let focus_key = app
.skill_tree
.focused_key(DrillScope::Global, &app.ranked_key_stats);
let in_focus = focus_key == Some(selected);
lines.push(Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(colors.text_pending())),
Span::styled(
if in_focus { "In focus now" } else { "No" },
Style::default().fg(if in_focus {
colors.focused_key()
} else {
colors.fg()
}),
),
]));
let conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
let bar_width = 10usize;
let filled = (conf * bar_width as f64).round() as usize;
let bar = format!(
"{}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(bar_width.saturating_sub(filled))
);
lines.push(Line::from(vec![
Span::styled(" Progress: ", Style::default().fg(colors.text_pending())),
Span::styled(bar, Style::default().fg(colors.accent())),
Span::styled(
format!(" {:>3.0}%", conf * 100.0),
Style::default().fg(colors.fg()),
),
]));
}
// If no stats at all
if overall_stat.is_none()
&& ranked_stat.is_none()
&& overall_acc.is_none()
&& ranked_acc.is_none()
{
lines.push(Line::from(Span::styled(
" No data yet",
Style::default().fg(colors.text_pending()),
)));
}
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
}

View File

@@ -74,6 +74,14 @@ impl JsonStore {
self.save("key_stats.json", data)
}
pub fn load_ranked_key_stats(&self) -> KeyStatsData {
self.load("key_stats_ranked.json")
}
pub fn save_ranked_key_stats(&self, data: &KeyStatsData) -> Result<()> {
self.save("key_stats_ranked.json", data)
}
pub fn load_drill_history(&self) -> DrillHistoryData {
self.load("lesson_history.json")
}

View File

@@ -5,12 +5,12 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::{Block, Widget};
use crate::keyboard::finger::{Finger, Hand};
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
use crate::keyboard::model::KeyboardModel;
use crate::ui::theme::Theme;
pub struct KeyboardDiagram<'a> {
pub focused_key: Option<char>,
pub selected_key: Option<char>,
pub next_key: Option<char>,
pub unlocked_keys: &'a [char],
pub depressed_keys: &'a HashSet<char>,
@@ -18,11 +18,11 @@ pub struct KeyboardDiagram<'a> {
pub compact: bool,
pub model: &'a KeyboardModel,
pub shift_held: bool,
pub caps_lock: bool,
}
impl<'a> KeyboardDiagram<'a> {
pub fn new(
focused_key: Option<char>,
next_key: Option<char>,
unlocked_keys: &'a [char],
depressed_keys: &'a HashSet<char>,
@@ -30,7 +30,7 @@ impl<'a> KeyboardDiagram<'a> {
model: &'a KeyboardModel,
) -> Self {
Self {
focused_key,
selected_key: None,
next_key,
unlocked_keys,
depressed_keys,
@@ -38,9 +38,20 @@ impl<'a> KeyboardDiagram<'a> {
compact: false,
model,
shift_held: false,
caps_lock: false,
}
}
pub fn caps_lock(mut self, caps_lock: bool) -> Self {
self.caps_lock = caps_lock;
self
}
pub fn selected_key(mut self, key: Option<char>) -> Self {
self.selected_key = key;
self
}
pub fn compact(mut self, compact: bool) -> Self {
self.compact = compact;
self
@@ -50,20 +61,15 @@ impl<'a> KeyboardDiagram<'a> {
self.shift_held = shift_held;
self
}
}
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
let assignment = model.finger_for_char(ch);
match (assignment.hand, assignment.finger) {
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
_ => Color::Rgb(120, 120, 120),
/// Check if a key (by display or base char) matches the selected key.
fn is_key_selected(&self, display_char: char, base_char: char) -> bool {
self.selected_key == Some(display_char) || self.selected_key == Some(base_char)
}
/// Check if a sentinel/modifier key matches the selected key.
fn is_sentinel_selected(&self, sentinel: char) -> bool {
self.selected_key == Some(sentinel)
}
}
@@ -78,6 +84,69 @@ fn brighten_color(color: Color) -> Color {
}
}
/// Blend a color toward the background at the given ratio (0.0 = full bg, 1.0 = full color).
fn blend_toward_bg(color: Color, bg: Color, ratio: f32) -> Color {
match (color, bg) {
(Color::Rgb(r, g, b), Color::Rgb(br, bg_g, bb)) => {
let mix = |c: u8, base: u8| -> u8 {
(base as f32 + (c as f32 - base as f32) * ratio).round() as u8
};
Color::Rgb(mix(r, br), mix(g, bg_g), mix(b, bb))
}
_ => color,
}
}
/// Compute style for a modifier key box (Tab, Enter, Shift, Space, Backspace).
fn modifier_key_style(
is_depressed: bool,
is_next: bool,
is_selected: bool,
colors: &crate::ui::theme::ThemeColors,
) -> Style {
if is_depressed {
Style::default()
.fg(Color::White)
.bg(brighten_color(colors.accent_dim()))
.add_modifier(Modifier::BOLD)
} else if is_next {
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
Style::default().fg(colors.accent()).bg(bg)
} else if is_selected {
Style::default()
.fg(colors.fg())
.bg(colors.accent_dim())
} else {
Style::default().fg(colors.fg()).bg(colors.bg())
}
}
fn key_style(
is_depressed: bool,
is_next: bool,
is_selected: bool,
is_unlocked: bool,
colors: &crate::ui::theme::ThemeColors,
) -> Style {
if is_depressed {
Style::default()
.fg(Color::White)
.bg(brighten_color(colors.accent_dim()))
.add_modifier(Modifier::BOLD)
} else if is_next {
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
Style::default().fg(colors.accent()).bg(bg)
} else if is_selected {
Style::default()
.fg(colors.fg())
.bg(colors.accent_dim())
} else if is_unlocked {
Style::default().fg(colors.fg()).bg(colors.bg())
} else {
Style::default().fg(colors.text_pending()).bg(colors.bg())
}
}
impl Widget for KeyboardDiagram<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
@@ -90,233 +159,289 @@ impl Widget for KeyboardDiagram<'_> {
block.render(area, buf);
if self.compact {
// 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 {
return;
}
let offsets: &[u16] = &[0, 1, 3];
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);
}
}
self.render_compact(inner, buf);
} 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);
}
}
_ => {}
}
}
self.render_full(inner, buf);
}
}
}
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())
impl KeyboardDiagram<'_> {
fn render_compact(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let letter_rows = self.model.letter_rows();
let key_width: u16 = 3;
let min_width: u16 = 21;
if inner.height < 3 || inner.width < min_width {
return;
}
let offsets: &[u16] = &[3, 4, 6];
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);
// Render leading modifier key
match row_idx {
0 => {
let is_dep = self.depressed_keys.contains(&TAB);
let is_next = self.next_key == Some(TAB);
let is_sel = self.is_sentinel_selected(TAB);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
buf.set_string(inner.x, y, "[T]", style);
}
2 => {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(inner.x, y, "[S]", style);
}
_ => {}
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width {
break;
}
let display_char = if self.shift_held {
physical_key.shifted
} else {
physical_key.base
};
let base_char = physical_key.base;
let is_depressed = self.depressed_keys.contains(&base_char);
let is_unlocked = self.unlocked_keys.contains(&display_char)
|| self.unlocked_keys.contains(&base_char);
let is_next =
self.next_key == Some(display_char) || self.next_key == Some(base_char);
let is_sel = self.is_key_selected(display_char, base_char);
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
let display = format!("[{display_char}]");
buf.set_string(x, y, &display, style);
}
// Render trailing modifier key
let row_end_x = inner.x + offset + row.len() as u16 * key_width;
match row_idx {
1 => {
if row_end_x + 3 <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&ENTER);
let is_next = self.next_key == Some(ENTER);
let is_sel = self.is_sentinel_selected(ENTER);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
buf.set_string(row_end_x, y, "[E]", style);
}
}
2 => {
if row_end_x + 3 <= inner.x + inner.width {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(row_end_x, y, "[S]", style);
}
}
_ => {}
}
}
// Backspace at end of first row
if inner.height >= 3 {
let y = inner.y;
let row_end_x = inner.x + offsets[0] + letter_rows[0].len() as u16 * key_width;
if row_end_x + 3 <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&BACKSPACE);
let is_next = self.next_key == Some(BACKSPACE);
let is_sel = self.is_sentinel_selected(BACKSPACE);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
buf.set_string(row_end_x, y, "[B]", style);
}
}
}
fn render_full(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let key_width: u16 = 5;
let min_width: u16 = 75;
if inner.height < 4 || inner.width < min_width {
self.render_full_fallback(inner, buf);
return;
}
let offsets: &[u16] = &[0, 5, 5, 6];
for (row_idx, row) in self.model.rows.iter().enumerate() {
let y = inner.y + row_idx as u16;
if y >= inner.y + inner.height {
break;
}
let offset = offsets.get(row_idx).copied().unwrap_or(0);
// Render leading modifier keys
match row_idx {
1 => {
if offset >= 5 {
let is_dep = self.depressed_keys.contains(&TAB);
let is_next = self.next_key == Some(TAB);
let is_sel = self.is_sentinel_selected(TAB);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
let label = format!("[{}]", display::key_short_label(TAB));
buf.set_string(inner.x, y, &label, style);
}
}
2 => {
if offset >= 5 {
if self.caps_lock {
let style = Style::default()
.fg(colors.warning())
.bg(colors.accent_dim());
buf.set_string(inner.x, y, "[Cap]", style);
} else {
let style = Style::default().fg(colors.text_pending()).bg(colors.bg());
buf.set_string(inner.x, y, "[ ]", style);
}
}
}
3 => {
if offset >= 6 {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(inner.x, y, "[Shft]", style);
}
}
_ => {}
}
for (col_idx, physical_key) in row.iter().enumerate() {
let x = inner.x + offset + col_idx as u16 * key_width;
if x + key_width > inner.x + inner.width {
break;
}
let display_char = if self.shift_held {
physical_key.shifted
} else {
physical_key.base
};
let base_char = physical_key.base;
let is_depressed = self.depressed_keys.contains(&base_char);
let is_unlocked = self.unlocked_keys.contains(&display_char)
|| self.unlocked_keys.contains(&base_char);
let is_next =
self.next_key == Some(display_char) || self.next_key == Some(base_char);
let is_sel = self.is_key_selected(display_char, base_char);
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
let display = format!("[ {display_char} ]");
buf.set_string(x, y, &display, style);
}
// Render trailing modifier keys
let after_x = inner.x + offset + row.len() as u16 * key_width;
match row_idx {
0 => {
if after_x + 6 <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&BACKSPACE);
let is_next = self.next_key == Some(BACKSPACE);
let is_sel = self.is_sentinel_selected(BACKSPACE);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
let label = format!("[{}]", display::key_short_label(BACKSPACE));
buf.set_string(after_x, y, &label, style);
}
}
2 => {
if after_x + 7 <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&ENTER);
let is_next = self.next_key == Some(ENTER);
let is_sel = self.is_sentinel_selected(ENTER);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
let label = format!("[{}]", display::key_display_name(ENTER));
buf.set_string(after_x, y, &label, style);
}
}
3 => {
if after_x + 6 <= inner.x + inner.width {
let is_dep = self.shift_held;
let style = modifier_key_style(is_dep, false, false, colors);
buf.set_string(after_x, y, "[Shft]", style);
}
}
_ => {}
}
}
// Space bar row (row 4)
let space_y = inner.y + 4;
if space_y < inner.y + inner.height {
let space_name = display::key_display_name(SPACE);
let space_label = format!("[ {space_name} ]");
let space_width = space_label.len() as u16;
let space_x = inner.x + (inner.width.saturating_sub(space_width)) / 2;
if space_x + space_width <= inner.x + inner.width {
let is_dep = self.depressed_keys.contains(&SPACE);
let is_next = self.next_key == Some(SPACE);
let is_sel = self.is_sentinel_selected(SPACE);
let style = modifier_key_style(is_dep, is_next, is_sel, colors);
buf.set_string(space_x, space_y, space_label, style);
}
}
}
fn render_full_fallback(&self, inner: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let letter_rows = self.model.letter_rows();
let key_width: u16 = 5;
let offsets: &[u16] = &[1, 3, 5];
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_next =
self.next_key == Some(display_char) || self.next_key == Some(base_char);
let is_sel = self.is_key_selected(display_char, base_char);
let style = key_style(is_depressed, is_next, is_sel, is_unlocked, colors);
let display = format!("[ {display_char} ]");
buf.set_string(x, y, &display, style);
}
}
}
}

View File

@@ -42,6 +42,11 @@ impl<'a> Menu<'a> {
label: "Skill Tree".to_string(),
description: "View progression branches and launch drills".to_string(),
},
MenuItem {
key: "b".to_string(),
label: "Keyboard".to_string(),
description: "Explore keyboard layout and key statistics".to_string(),
},
MenuItem {
key: "s".to_string(),
label: "Statistics".to_string(),

View File

@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
use std::collections::{BTreeSet, HashMap};
use crate::engine::key_stats::KeyStatsStore;
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
use crate::keyboard::model::KeyboardModel;
use crate::session::result::DrillResult;
use crate::ui::components::activity_heatmap::ActivityHeatmap;
@@ -176,7 +177,8 @@ impl StatsDashboard<'_> {
}
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
@@ -191,7 +193,8 @@ impl StatsDashboard<'_> {
}
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
@@ -676,7 +679,7 @@ impl StatsDashboard<'_> {
} else {
return;
};
let show_shifted = inner.height >= 6;
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
@@ -732,6 +735,50 @@ impl StatsDashboard<'_> {
let display = format_accuracy_cell(key, accuracy, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
}
}
// Modifier key stats row below the keyboard, spread across keyboard width
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let mod_y = if show_shifted {
inner.y + all_rows.len() as u16 * 2 + 1
} else {
inner.y + all_rows.len() as u16
};
if mod_y < inner.y + inner.height {
let mod_keys: &[(char, &str)] = &[
(TAB, display::key_short_label(TAB)),
(SPACE, display::key_short_label(SPACE)),
(ENTER, display::key_short_label(ENTER)),
(BACKSPACE, display::key_short_label(BACKSPACE)),
];
let labels: Vec<String> = mod_keys
.iter()
.map(|&(key, label)| {
let accuracy = self.get_key_accuracy(key);
format_accuracy_cell_label(label, accuracy, key_width)
})
.collect();
let positions = spread_labels(&labels, kbd_width);
for (i, &(key, _)) in mod_keys.iter().enumerate() {
let accuracy = self.get_key_accuracy(key);
let fg_color = accuracy_color(accuracy, colors);
buf.set_string(
inner.x + positions[i],
mod_y,
&labels[i],
Style::default().fg(fg_color),
);
}
}
}
@@ -790,7 +837,7 @@ impl StatsDashboard<'_> {
} else {
return;
};
let show_shifted = inner.height >= 6;
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
let all_rows = &self.keyboard_model.rows;
let offsets: &[u16] = &[0, 2, 3, 4];
@@ -842,6 +889,50 @@ impl StatsDashboard<'_> {
let display = format_timing_cell(key, time_ms, key_width);
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
}
}
// Modifier key stats row below the keyboard, spread across keyboard width
let kbd_width = all_rows
.iter()
.enumerate()
.map(|(i, row)| {
let off = offsets.get(i).copied().unwrap_or(0);
off + row.len() as u16 * key_step
})
.max()
.unwrap_or(inner.width)
.min(inner.width);
let mod_y = if show_shifted {
inner.y + all_rows.len() as u16 * 2 + 1
} else {
inner.y + all_rows.len() as u16
};
if mod_y < inner.y + inner.height {
let mod_keys: &[(char, &str)] = &[
(TAB, display::key_short_label(TAB)),
(SPACE, display::key_short_label(SPACE)),
(ENTER, display::key_short_label(ENTER)),
(BACKSPACE, display::key_short_label(BACKSPACE)),
];
let labels: Vec<String> = mod_keys
.iter()
.map(|&(key, label)| {
let time_ms = self.get_key_time_ms(key);
format_timing_cell_label(label, time_ms, key_width)
})
.collect();
let positions = spread_labels(&labels, kbd_width);
for (i, &(key, _)) in mod_keys.iter().enumerate() {
let time_ms = self.get_key_time_ms(key);
let fg_color = timing_color(time_ms, colors);
buf.set_string(
inner.x + positions[i],
mod_y,
&labels[i],
Style::default().fg(fg_color),
);
}
}
}
@@ -953,7 +1044,7 @@ impl StatsDashboard<'_> {
let inner = block.inner(area);
block.render(area, buf);
// Collect all keys from keyboard model
// Collect all keys from keyboard model + modifier keys
let mut all_keys = std::collections::HashSet::new();
for row in &self.keyboard_model.rows {
for pk in row {
@@ -961,6 +1052,10 @@ impl StatsDashboard<'_> {
all_keys.insert(pk.shifted);
}
}
// Include modifier/whitespace keys
all_keys.insert(SPACE);
all_keys.insert(TAB);
all_keys.insert(ENTER);
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
@@ -1034,6 +1129,9 @@ impl StatsDashboard<'_> {
all_keys.insert(pk.shifted);
}
}
all_keys.insert(SPACE);
all_keys.insert(TAB);
all_keys.insert(ENTER);
let mut key_accuracies: Vec<(char, f64)> = all_keys
.into_iter()
@@ -1178,6 +1276,21 @@ fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
}
}
fn format_accuracy_cell_label(label: &str, accuracy: f64, key_width: u16) -> String {
if accuracy > 0.0 {
let pct = accuracy.round() as u32;
if key_width >= 5 {
format!("{label}{pct:>3}")
} else {
format!("{label}{pct:>2}")
}
} else if key_width >= 5 {
format!("{label} ")
} else {
format!("{label} ")
}
}
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
if time_ms <= 0.0 {
colors.text_pending()
@@ -1241,6 +1354,51 @@ fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
}
}
fn format_timing_cell_label(label: &str, time_ms: f64, key_width: u16) -> String {
if time_ms > 0.0 {
let ms = time_ms.round() as u32;
if key_width >= 5 {
format!("{label}{ms:>4}")
} else {
format!("{label}{:>3}", ms.min(999))
}
} else if key_width >= 5 {
format!("{label} ")
} else {
format!("{label} ")
}
}
/// Distribute labels across `total_width`, with the first flush-left
/// and the last flush-right, and equal gaps between the rest.
fn spread_labels(labels: &[String], total_width: u16) -> Vec<u16> {
let n = labels.len();
if n == 0 {
return vec![];
}
if n == 1 {
return vec![0];
}
let total_label_width: u16 = labels.iter().map(|l| l.len() as u16).sum();
let last_width = labels.last().map(|l| l.len() as u16).unwrap_or(0);
let spare = total_width.saturating_sub(total_label_width);
let gaps = (n - 1) as u16;
let gap = if gaps > 0 { spare / gaps } else { 0 };
let remainder = if gaps > 0 { spare % gaps } else { 0 };
let mut positions = Vec::with_capacity(n);
let mut x: u16 = 0;
for (i, label) in labels.iter().enumerate() {
if i == n - 1 {
// Last label flush-right
x = total_width.saturating_sub(last_width);
}
positions.push(x);
x += label.len() as u16 + gap + if (i as u16) < remainder { 1 } else { 0 };
}
positions
}
fn render_text_bar(
label: &str,
ratio: f64,

View File

@@ -111,7 +111,9 @@ impl Widget for TypingArea<'_> {
token.display.clone()
}
} else if idx == self.drill.cursor && target_ch == ' ' {
"\u{00b7}".to_string()
// Keep an actual space at cursor position so soft-wrap break opportunities
// remain stable at word boundaries.
" ".to_string()
} else {
token.display.clone()
};