Key milestone overlays + keyboard diagram improvements
Also splits out a separate store for ranked stats from overall key stats.
This commit is contained in:
174
src/app.rs
174
src/app.rs
@@ -1,4 +1,4 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
@@ -54,6 +54,7 @@ pub enum AppScreen {
|
||||
PassageDownloadProgress,
|
||||
CodeIntro,
|
||||
CodeDownloadProgress,
|
||||
Keyboard,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -75,6 +76,32 @@ pub enum CodeDownloadCompleteAction {
|
||||
ReturnToSettings,
|
||||
}
|
||||
|
||||
pub enum MilestoneKind {
|
||||
Unlock,
|
||||
Mastery,
|
||||
}
|
||||
|
||||
pub struct KeyMilestonePopup {
|
||||
pub kind: MilestoneKind,
|
||||
pub keys: Vec<char>,
|
||||
pub finger_info: Vec<(char, String)>,
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
const UNLOCK_MESSAGES: &[&str] = &[
|
||||
"Nice work! Keep building your typing skills.",
|
||||
"Another key added to your arsenal!",
|
||||
"Your keyboard is growing! Keep it up.",
|
||||
"One step closer to full keyboard mastery!",
|
||||
];
|
||||
|
||||
const MASTERY_MESSAGES: &[&str] = &[
|
||||
"This key is now at full confidence!",
|
||||
"You've got this key down pat!",
|
||||
"Muscle memory locked in!",
|
||||
"One more key conquered!",
|
||||
];
|
||||
|
||||
struct DownloadJob {
|
||||
downloaded_bytes: Arc<AtomicU64>,
|
||||
total_bytes: Arc<AtomicU64>,
|
||||
@@ -109,6 +136,7 @@ pub struct App {
|
||||
pub theme: &'static Theme,
|
||||
pub config: Config,
|
||||
pub key_stats: KeyStatsStore,
|
||||
pub ranked_key_stats: KeyStatsStore,
|
||||
pub skill_tree: SkillTree,
|
||||
pub profile: ProfileData,
|
||||
pub store: Option<JsonStore>,
|
||||
@@ -154,7 +182,12 @@ pub struct App {
|
||||
pub code_download_attempted: bool,
|
||||
pub code_download_action: CodeDownloadCompleteAction,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
pub keyboard_model: KeyboardModel,
|
||||
pub milestone_queue: VecDeque<KeyMilestonePopup>,
|
||||
pub keyboard_explorer_selected: Option<char>,
|
||||
pub explorer_accuracy_cache_overall: Option<(char, usize, usize)>,
|
||||
pub explorer_accuracy_cache_ranked: Option<(char, usize, usize)>,
|
||||
rng: SmallRng,
|
||||
transition_table: TransitionTable,
|
||||
#[allow(dead_code)]
|
||||
@@ -176,20 +209,22 @@ impl App {
|
||||
|
||||
let store = JsonStore::new().ok();
|
||||
|
||||
let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
|
||||
let (key_stats, ranked_key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
|
||||
// load_profile returns None if file exists but can't parse (schema mismatch)
|
||||
let pd = s.load_profile();
|
||||
|
||||
match pd {
|
||||
Some(pd) if !pd.needs_reset() => {
|
||||
let ksd = s.load_key_stats();
|
||||
let rksd = s.load_ranked_key_stats();
|
||||
let lhd = s.load_drill_history();
|
||||
let st = SkillTree::new(pd.skill_tree.clone());
|
||||
(ksd.stats, st, pd, lhd.drills)
|
||||
(ksd.stats, rksd.stats, st, pd, lhd.drills)
|
||||
}
|
||||
_ => {
|
||||
// Schema mismatch or parse failure: full reset of all stores
|
||||
(
|
||||
KeyStatsStore::default(),
|
||||
KeyStatsStore::default(),
|
||||
SkillTree::default(),
|
||||
ProfileData::default(),
|
||||
@@ -199,6 +234,7 @@ impl App {
|
||||
}
|
||||
} else {
|
||||
(
|
||||
KeyStatsStore::default(),
|
||||
KeyStatsStore::default(),
|
||||
SkillTree::default(),
|
||||
ProfileData::default(),
|
||||
@@ -208,6 +244,8 @@ impl App {
|
||||
|
||||
let mut key_stats_with_target = key_stats;
|
||||
key_stats_with_target.target_cpm = config.target_cpm();
|
||||
let mut ranked_key_stats_with_target = ranked_key_stats;
|
||||
ranked_key_stats_with_target.target_cpm = config.target_cpm();
|
||||
|
||||
let dictionary = Dictionary::load();
|
||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||
@@ -231,6 +269,7 @@ impl App {
|
||||
theme,
|
||||
config,
|
||||
key_stats: key_stats_with_target,
|
||||
ranked_key_stats: ranked_key_stats_with_target,
|
||||
skill_tree,
|
||||
profile,
|
||||
store,
|
||||
@@ -276,7 +315,12 @@ impl App {
|
||||
code_download_attempted: false,
|
||||
code_download_action: CodeDownloadCompleteAction::StartCodeDrill,
|
||||
shift_held: false,
|
||||
caps_lock: false,
|
||||
keyboard_model,
|
||||
milestone_queue: VecDeque::new(),
|
||||
keyboard_explorer_selected: None,
|
||||
explorer_accuracy_cache_overall: None,
|
||||
explorer_accuracy_cache_ranked: None,
|
||||
rng: SmallRng::from_entropy(),
|
||||
transition_table,
|
||||
dictionary,
|
||||
@@ -303,7 +347,7 @@ impl App {
|
||||
DrillMode::Adaptive => {
|
||||
let scope = self.drill_scope;
|
||||
let all_keys = self.skill_tree.unlocked_keys(scope);
|
||||
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
|
||||
let focused = self.skill_tree.focused_key(scope, &self.ranked_key_stats);
|
||||
|
||||
// Generate base lowercase text using only lowercase keys from scope
|
||||
let lowercase_keys: Vec<char> = all_keys
|
||||
@@ -493,13 +537,65 @@ impl App {
|
||||
false,
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -4,6 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
|
||||
/// Events returned by `SkillTree::update` describing what changed.
|
||||
pub struct SkillTreeUpdate {
|
||||
pub newly_unlocked: Vec<char>,
|
||||
pub newly_mastered: Vec<char>,
|
||||
}
|
||||
|
||||
// --- Branch ID ---
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -188,6 +194,19 @@ pub const ALL_BRANCHES: &[BranchDefinition] = &[
|
||||
},
|
||||
];
|
||||
|
||||
/// Find which branch and level a key belongs to.
|
||||
/// Returns (branch_def, level_name, 1-based position in level).
|
||||
pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static str, usize)> {
|
||||
for branch in ALL_BRANCHES {
|
||||
for level in branch.levels {
|
||||
if let Some(pos) = level.keys.iter().position(|&k| k == ch) {
|
||||
return Some((branch, level.name, pos + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
|
||||
ALL_BRANCHES
|
||||
.iter()
|
||||
@@ -487,7 +506,19 @@ impl SkillTree {
|
||||
|
||||
/// Update skill tree progress based on current key stats.
|
||||
/// Call after updating KeyStatsStore.
|
||||
pub fn update(&mut self, stats: &KeyStatsStore) {
|
||||
///
|
||||
/// `before_stats` is an optional snapshot of key stats *before* this drill's data was added.
|
||||
/// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0).
|
||||
/// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
stats: &KeyStatsStore,
|
||||
before_stats: Option<&KeyStatsStore>,
|
||||
) -> SkillTreeUpdate {
|
||||
// Snapshot unlocked keys before tree structure changes
|
||||
let before_unlocked: HashSet<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
|
||||
// Update lowercase branch (progressive unlock)
|
||||
self.update_lowercase(stats);
|
||||
|
||||
@@ -518,6 +549,34 @@ impl SkillTree {
|
||||
}
|
||||
self.update_branch_level(branch_def, stats);
|
||||
}
|
||||
|
||||
// Snapshot after
|
||||
let after_unlocked: HashSet<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
|
||||
let newly_unlocked: Vec<char> = after_unlocked
|
||||
.difference(&before_unlocked)
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
// Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats,
|
||||
// but now have confidence >= 1.0 in current stats
|
||||
let newly_mastered: Vec<char> = if let Some(before) = before_stats {
|
||||
before_unlocked
|
||||
.iter()
|
||||
.filter(|&&ch| {
|
||||
before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0
|
||||
})
|
||||
.copied()
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
SkillTreeUpdate {
|
||||
newly_unlocked,
|
||||
newly_mastered,
|
||||
}
|
||||
}
|
||||
|
||||
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
|
||||
@@ -731,7 +790,7 @@ mod tests {
|
||||
|
||||
// Make initial 6 keys confident
|
||||
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
|
||||
// Should unlock 7th key ('s')
|
||||
let keys = tree.unlocked_keys(DrillScope::Global);
|
||||
@@ -750,7 +809,7 @@ mod tests {
|
||||
|
||||
// Need to repeatedly update as each unlock requires all current keys confident
|
||||
for _ in 0..30 {
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
@@ -805,7 +864,7 @@ mod tests {
|
||||
|
||||
// Make level 1 capitals confident: T I A S W H B M
|
||||
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
|
||||
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
|
||||
assert_eq!(
|
||||
@@ -829,7 +888,7 @@ mod tests {
|
||||
|
||||
// Update multiple times for level advancement
|
||||
for _ in 0..5 {
|
||||
tree.update(&stats);
|
||||
tree.update(&stats, None);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
@@ -968,4 +1027,69 @@ mod tests {
|
||||
assert!(0 < branches.len());
|
||||
assert!(branches.len() - 1 < branches.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_returns_newly_unlocked() {
|
||||
let mut tree = SkillTree::default();
|
||||
let mut stats = KeyStatsStore::default();
|
||||
|
||||
// Make initial 6 keys confident
|
||||
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
|
||||
let result = tree.update(&stats, None);
|
||||
|
||||
// Should unlock 7th key ('s')
|
||||
assert!(
|
||||
result.newly_unlocked.contains(&'s'),
|
||||
"newly_unlocked: {:?}",
|
||||
result.newly_unlocked
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_returns_newly_mastered() {
|
||||
let mut tree = SkillTree::default();
|
||||
let mut stats = KeyStatsStore::default();
|
||||
|
||||
// Snapshot before any key stats are added
|
||||
let before_stats = stats.clone();
|
||||
|
||||
// Make first 5 keys confident
|
||||
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']);
|
||||
let result = tree.update(&stats, Some(&before_stats));
|
||||
|
||||
// The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered
|
||||
for &ch in &['e', 't', 'a', 'o', 'i'] {
|
||||
assert!(
|
||||
result.newly_mastered.contains(&ch),
|
||||
"expected {} in newly_mastered: {:?}",
|
||||
ch,
|
||||
result.newly_mastered
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_lowercase() {
|
||||
let result = find_key_branch('e');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Lowercase);
|
||||
assert_eq!(level_name, "Frequency Order");
|
||||
assert_eq!(pos, 1); // 'e' is first in the frequency order
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_capitals() {
|
||||
let result = find_key_branch('T');
|
||||
assert!(result.is_some());
|
||||
let (branch, level_name, pos) = result.unwrap();
|
||||
assert_eq!(branch.id, BranchId::Capitals);
|
||||
assert_eq!(level_name, "Common Sentence Capitals");
|
||||
assert_eq!(pos, 1); // 'T' is first
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_key_branch_unknown() {
|
||||
assert!(find_key_branch('\x00').is_none());
|
||||
}
|
||||
}
|
||||
|
||||
153
src/keyboard/display.rs
Normal file
153
src/keyboard/display.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
/// Centralized key display adapter for sentinel-char to display-name conversions.
|
||||
///
|
||||
/// **Sentinel boundary policy:**
|
||||
/// Sentinel chars (`'\x08'`, `'\t'`, `'\n'`) are allowed only at two boundaries:
|
||||
/// 1. **Input boundary** — `handle_key` in `src/main.rs` converts `KeyCode::Backspace/Tab/Enter`
|
||||
/// to sentinels for `depressed_keys` and drill input.
|
||||
/// 2. **Storage boundary** — `KeyStatsStore` and `drill_history` store sentinels as `char` keys.
|
||||
///
|
||||
/// All UI rendering, stats display, and business logic must consume these adapter functions
|
||||
/// rather than matching sentinels directly.
|
||||
|
||||
/// Human-readable display name for a key character (including sentinels).
|
||||
/// Returns `""` for printable chars — caller uses `ch.to_string()` for those.
|
||||
pub fn key_display_name(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Backspace",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Enter",
|
||||
' ' => "Space",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short label for compact UI contexts (heatmaps, compact keyboard).
|
||||
/// Returns `""` for printable chars.
|
||||
pub fn key_short_label(ch: char) -> &'static str {
|
||||
match ch {
|
||||
'\x08' => "Bksp",
|
||||
'\t' => "Tab",
|
||||
'\n' => "Ent",
|
||||
' ' => "Spc",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// All sentinel chars used for non-printable keys.
|
||||
pub const MODIFIER_SENTINELS: &[char] = &['\x08', '\t', '\n'];
|
||||
|
||||
/// Sentinel char for Backspace.
|
||||
pub const BACKSPACE: char = '\x08';
|
||||
/// Sentinel char for Tab.
|
||||
pub const TAB: char = '\t';
|
||||
/// Sentinel char for Enter.
|
||||
pub const ENTER: char = '\n';
|
||||
/// Space character (not a sentinel, but treated as a special key for display).
|
||||
pub const SPACE: char = ' ';
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_key_display_name() {
|
||||
assert_eq!(key_display_name('\x08'), "Backspace");
|
||||
assert_eq!(key_display_name('\t'), "Tab");
|
||||
assert_eq!(key_display_name('\n'), "Enter");
|
||||
assert_eq!(key_display_name(' '), "Space");
|
||||
assert_eq!(key_display_name('a'), "");
|
||||
assert_eq!(key_display_name('1'), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_short_label() {
|
||||
assert_eq!(key_short_label('\x08'), "Bksp");
|
||||
assert_eq!(key_short_label('\t'), "Tab");
|
||||
assert_eq!(key_short_label('\n'), "Ent");
|
||||
assert_eq!(key_short_label(' '), "Spc");
|
||||
assert_eq!(key_short_label('z'), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modifier_sentinels() {
|
||||
assert_eq!(MODIFIER_SENTINELS.len(), 3);
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\x08'));
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\t'));
|
||||
assert!(MODIFIER_SENTINELS.contains(&'\n'));
|
||||
}
|
||||
|
||||
/// Sentinel boundary enforcement test.
|
||||
///
|
||||
/// Verifies that `'\x08'` (the Backspace sentinel) does not leak into
|
||||
/// UI or business logic files outside allowed boundaries.
|
||||
///
|
||||
/// **Policy: `\x08`-only enforcement (accepted compromise)**
|
||||
///
|
||||
/// The plan originally proposed checking all three sentinels (`\x08`, `\t`, `\n`),
|
||||
/// but `'\t'` and `'\n'` have widespread legitimate uses as text content
|
||||
/// characters throughout the codebase: tab indentation in code generators
|
||||
/// (`code_syntax.rs`, `passage.rs`), newlines in text processing (`input.rs`,
|
||||
/// `typing_area.rs`, `drill.rs`), and key definitions in the skill tree
|
||||
/// (`skill_tree.rs`). Distinguishing "sentinel identity use" from "text content
|
||||
/// use" for `\t`/`\n` would require fragile heuristic pattern matching that
|
||||
/// would either miss real violations or produce false positives.
|
||||
///
|
||||
/// `'\x08'` has no legitimate text-content use, making it an unambiguous
|
||||
/// sentinel leakage signal. All UI/stats/business-logic files already use
|
||||
/// the `TAB`/`ENTER` adapter constants for sentinel-identity purposes, so
|
||||
/// the `\t`/`\n` policy is enforced by convention and code review.
|
||||
///
|
||||
/// Allowed files for `'\x08'`:
|
||||
/// - `display.rs` (this module — the adapter itself, defines BACKSPACE constant)
|
||||
/// - `main.rs` (input boundary — KeyCode::Backspace conversion)
|
||||
/// - `key_stats.rs` (storage boundary)
|
||||
/// - `drill.rs` (input processing boundary)
|
||||
/// - `app.rs` (milestone detection reads stats keyed by sentinel)
|
||||
#[test]
|
||||
fn test_sentinel_boundary_enforcement() {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
let allowed_files = [
|
||||
"src/keyboard/display.rs",
|
||||
"src/main.rs",
|
||||
"src/engine/key_stats.rs",
|
||||
"src/session/drill.rs",
|
||||
"src/app.rs",
|
||||
];
|
||||
|
||||
fn collect_rs_files(dir: &Path, files: &mut Vec<String>) {
|
||||
let entries = fs::read_dir(dir).expect("failed to read source directory");
|
||||
for entry in entries {
|
||||
let entry = entry.expect("failed to read directory entry");
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rs_files(&path, files);
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let normalized = path.to_string_lossy().replace('\\', "/");
|
||||
files.push(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search for direct '\x08' literal in src/ — this is the clearest
|
||||
// sentinel leakage signal since \x08 has no legitimate text use.
|
||||
let mut rs_files = Vec::new();
|
||||
collect_rs_files(Path::new("src"), &mut rs_files);
|
||||
|
||||
let mut violations = Vec::new();
|
||||
for file in rs_files {
|
||||
let content = fs::read_to_string(&file).expect("failed to read source file");
|
||||
if content.contains(r"'\\x08'") && !allowed_files.iter().any(|&allowed| file == allowed)
|
||||
{
|
||||
violations.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
violations.is_empty(),
|
||||
"Direct '\\x08' sentinel literal found outside allowed boundary files:\n{}",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,21 @@ impl FingerAssignment {
|
||||
pub fn new(hand: Hand, finger: Finger) -> Self {
|
||||
Self { hand, finger }
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match (self.hand, self.finger) {
|
||||
(Hand::Left, Finger::Pinky) => "left pinky",
|
||||
(Hand::Left, Finger::Ring) => "left ring finger",
|
||||
(Hand::Left, Finger::Middle) => "left middle finger",
|
||||
(Hand::Left, Finger::Index) => "left index finger",
|
||||
(Hand::Left, Finger::Thumb) => "left thumb",
|
||||
(Hand::Right, Finger::Pinky) => "right pinky",
|
||||
(Hand::Right, Finger::Ring) => "right ring finger",
|
||||
(Hand::Right, Finger::Middle) => "right middle finger",
|
||||
(Hand::Right, Finger::Index) => "right index finger",
|
||||
(Hand::Right, Finger::Thumb) => "right thumb",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod display;
|
||||
pub mod finger;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
|
||||
667
src/main.rs
667
src/main.rs
@@ -14,8 +14,8 @@ use std::time::{Duration, Instant};
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use crossterm::event::{
|
||||
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, KeyboardEnhancementFlags,
|
||||
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, KeyboardEnhancementFlags,
|
||||
ModifierKeyCode, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
|
||||
};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
@@ -28,8 +28,10 @@ use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
|
||||
|
||||
use app::{App, AppScreen, DrillMode};
|
||||
use engine::skill_tree::DrillScope;
|
||||
use app::{App, AppScreen, DrillMode, MilestoneKind};
|
||||
use engine::skill_tree::{DrillScope, find_key_branch};
|
||||
use keyboard::display::key_display_name;
|
||||
use keyboard::finger::Hand;
|
||||
use event::{AppEvent, EventHandler};
|
||||
use generator::code_syntax::{code_language_options, is_language_cached, language_by_key};
|
||||
use generator::passage::{is_book_cached, passage_options};
|
||||
@@ -81,7 +83,10 @@ fn main() -> Result<()> {
|
||||
// Try to enable keyboard enhancement for Release event support
|
||||
let keyboard_enhanced = execute!(
|
||||
io::stdout(),
|
||||
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::REPORT_EVENT_TYPES)
|
||||
PushKeyboardEnhancementFlags(
|
||||
KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
| KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES,
|
||||
)
|
||||
)
|
||||
.is_ok();
|
||||
|
||||
@@ -153,8 +158,24 @@ fn run_app(
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
// Track caps lock state via Kitty protocol metadata (KeyEventState::CAPS_LOCK).
|
||||
// This only works in terminals with native Kitty keyboard protocol support
|
||||
// (Kitty, WezTerm, foot, Ghostty). In tmux/mosh/SSH, the protocol is stripped
|
||||
// and crossterm infers SHIFT from character case, making it impossible to
|
||||
// distinguish Shift+a from CapsLock+a.
|
||||
app.caps_lock = key.state.contains(KeyEventState::CAPS_LOCK);
|
||||
|
||||
// Track depressed keys and shift state for keyboard diagram
|
||||
match (&key.code, key.kind) {
|
||||
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Press) => {
|
||||
app.shift_held = true;
|
||||
app.last_key_time = Some(Instant::now());
|
||||
return; // Don't dispatch bare shift presses to screen handlers
|
||||
}
|
||||
(KeyCode::Modifier(ModifierKeyCode::LeftShift | ModifierKeyCode::RightShift), KeyEventKind::Release) => {
|
||||
app.shift_held = false;
|
||||
return;
|
||||
}
|
||||
(KeyCode::Char(ch), KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert(ch.to_ascii_lowercase());
|
||||
app.last_key_time = Some(Instant::now());
|
||||
@@ -164,6 +185,33 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
app.depressed_keys.remove(&ch.to_ascii_lowercase());
|
||||
return; // Don't process Release events as input
|
||||
}
|
||||
(KeyCode::Backspace, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\x08');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Backspace, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\x08');
|
||||
return;
|
||||
}
|
||||
(KeyCode::Tab, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\t');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Tab, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\t');
|
||||
return;
|
||||
}
|
||||
(KeyCode::Enter, KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert('\n');
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Enter, KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&'\n');
|
||||
return;
|
||||
}
|
||||
(_, KeyEventKind::Release) => return,
|
||||
_ => {
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
@@ -193,6 +241,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
AppScreen::PassageDownloadProgress => handle_passage_download_progress_key(app, key),
|
||||
AppScreen::CodeIntro => handle_code_intro_key(app, key),
|
||||
AppScreen::CodeDownloadProgress => handle_code_download_progress_key(app, key),
|
||||
AppScreen::Keyboard => handle_keyboard_explorer_key(app, key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +268,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
KeyCode::Char('t') => app.go_to_skill_tree(),
|
||||
KeyCode::Char('b') => app.go_to_keyboard(),
|
||||
KeyCode::Char('s') => app.go_to_stats(),
|
||||
KeyCode::Char('c') => app.go_to_settings(),
|
||||
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
|
||||
@@ -244,8 +294,9 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
3 => app.go_to_skill_tree(),
|
||||
4 => app.go_to_stats(),
|
||||
5 => app.go_to_settings(),
|
||||
4 => app.go_to_keyboard(),
|
||||
5 => app.go_to_stats(),
|
||||
6 => app.go_to_settings(),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
@@ -253,6 +304,25 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
|
||||
fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
// If a milestone overlay is showing, dismiss it on any key press
|
||||
if !app.milestone_queue.is_empty() {
|
||||
app.milestone_queue.pop_front();
|
||||
|
||||
// Determine what to do with the dismissing key
|
||||
match milestone_dismiss_action(key.code) {
|
||||
MilestoneDismissAction::EscAndExit => {
|
||||
// Esc clears entire queue and exits drill
|
||||
app.milestone_queue.clear();
|
||||
// Fall through to normal Esc handling below
|
||||
}
|
||||
MilestoneDismissAction::Replay => {
|
||||
// Char/Tab/Enter: dismiss and replay into drill
|
||||
// Fall through to normal key handling below
|
||||
}
|
||||
MilestoneDismissAction::DismissOnly => return, // Backspace and others
|
||||
}
|
||||
}
|
||||
|
||||
// Route Enter/Tab as typed characters during active drills
|
||||
if app.drill.is_some() {
|
||||
match key.code {
|
||||
@@ -284,6 +354,21 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum MilestoneDismissAction {
|
||||
Replay,
|
||||
DismissOnly,
|
||||
EscAndExit,
|
||||
}
|
||||
|
||||
fn milestone_dismiss_action(code: KeyCode) -> MilestoneDismissAction {
|
||||
match code {
|
||||
KeyCode::Esc => MilestoneDismissAction::EscAndExit,
|
||||
KeyCode::Char(_) | KeyCode::Tab | KeyCode::Enter => MilestoneDismissAction::Replay,
|
||||
_ => MilestoneDismissAction::DismissOnly,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char('r') => app.retry_drill(),
|
||||
@@ -863,6 +948,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||
AppScreen::PassageDownloadProgress => render_passage_download_progress(frame, app),
|
||||
AppScreen::CodeIntro => render_code_intro(frame, app),
|
||||
AppScreen::CodeDownloadProgress => render_code_download_progress(frame, app),
|
||||
AppScreen::Keyboard => render_keyboard_explorer(frame, app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,7 +972,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
};
|
||||
let total_keys = app.skill_tree.total_unique_keys;
|
||||
let unlocked = app.skill_tree.total_unlocked_count();
|
||||
let mastered = app.skill_tree.total_confident_keys(&app.key_stats);
|
||||
let mastered = app.skill_tree.total_confident_keys(&app.ranked_key_stats);
|
||||
let header_info = format!(
|
||||
" Key Progress {unlocked}/{total_keys} ({mastered} mastered){}",
|
||||
streak_text,
|
||||
@@ -913,7 +999,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
frame.render_widget(&app.menu, menu_area);
|
||||
|
||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit ",
|
||||
" [1-3] Start [t] Skill Tree [b] Keyboard [s] Stats [c] Settings [q] Quit ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)]));
|
||||
frame.render_widget(footer, layout[2]);
|
||||
@@ -952,7 +1038,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
} else {
|
||||
let header_title = format!(" {mode_name} Drill ");
|
||||
let focus_text = if app.drill_mode == DrillMode::Adaptive {
|
||||
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||
let focused = app
|
||||
.skill_tree
|
||||
.focused_key(app.drill_scope, &app.ranked_key_stats);
|
||||
if let Some(focused) = focused {
|
||||
format!(" | Focus: '{focused}'")
|
||||
} else {
|
||||
@@ -1010,9 +1098,9 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
|
||||
let kbd_height = if show_kbd {
|
||||
if tier.compact_keyboard() {
|
||||
5 // 3 rows + 2 border
|
||||
6 // 3 rows + 2 border + 1 modifier space
|
||||
} else {
|
||||
7 // 4 rows + 2 border + 1 label space
|
||||
8 // 5 rows (4 + space bar) + 2 border + 1 spacing
|
||||
}
|
||||
} else {
|
||||
0
|
||||
@@ -1039,7 +1127,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
if app.drill_mode == DrillMode::Adaptive {
|
||||
let progress_widget = ui::components::branch_progress_list::BranchProgressList {
|
||||
skill_tree: &app.skill_tree,
|
||||
key_stats: &app.key_stats,
|
||||
key_stats: &app.ranked_key_stats,
|
||||
drill_scope: app.drill_scope,
|
||||
active_branches: &active_branches,
|
||||
theme: app.theme,
|
||||
@@ -1070,11 +1158,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
if show_kbd {
|
||||
let next_char = drill.target.get(drill.cursor).copied();
|
||||
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
|
||||
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||
let kbd_height = if tier.compact_keyboard() { 5 } else { 7 };
|
||||
let _ = kbd_height; // Height managed by constraints
|
||||
let kbd = KeyboardDiagram::new(
|
||||
focused,
|
||||
next_char,
|
||||
&unlocked_keys,
|
||||
&app.depressed_keys,
|
||||
@@ -1082,7 +1166,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.compact(tier.compact_keyboard())
|
||||
.shift_held(app.shift_held);
|
||||
.shift_held(app.shift_held)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, main_layout[idx]);
|
||||
}
|
||||
|
||||
@@ -1101,6 +1186,221 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
frame.render_widget(footer, app_layout.footer);
|
||||
|
||||
// Render milestone overlay if present
|
||||
if let Some(milestone) = app.milestone_queue.front() {
|
||||
render_milestone_overlay(frame, app, milestone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_milestone_overlay(
|
||||
frame: &mut ratatui::Frame,
|
||||
app: &App,
|
||||
milestone: &app::KeyMilestonePopup,
|
||||
) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
// Determine overlay size based on terminal height:
|
||||
// Large (>=25): full keyboard diagram
|
||||
// Medium (>=15): compact keyboard diagram
|
||||
// Small (<15): text only
|
||||
let kbd_mode = overlay_keyboard_mode(area.height);
|
||||
let overlay_height = match kbd_mode {
|
||||
2 => 18u16.min(area.height.saturating_sub(2)),
|
||||
1 => 14u16.min(area.height.saturating_sub(2)),
|
||||
_ => 10u16.min(area.height.saturating_sub(2)),
|
||||
};
|
||||
let overlay_width = 60u16.min(area.width.saturating_sub(4));
|
||||
|
||||
let left = area.x + (area.width.saturating_sub(overlay_width)) / 2;
|
||||
let top = area.y + (area.height.saturating_sub(overlay_height)) / 2;
|
||||
let overlay_area = Rect::new(left, top, overlay_width, overlay_height);
|
||||
|
||||
// Clear the area behind the overlay
|
||||
frame.render_widget(ratatui::widgets::Clear, overlay_area);
|
||||
|
||||
let title = match milestone.kind {
|
||||
MilestoneKind::Unlock => " Key Unlocked! ",
|
||||
MilestoneKind::Mastery => " Key Mastered! ",
|
||||
};
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(title)
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(overlay_area);
|
||||
block.render(overlay_area, frame.buffer_mut());
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Key display line
|
||||
let key_action = match milestone.kind {
|
||||
MilestoneKind::Unlock => "unlocked",
|
||||
MilestoneKind::Mastery => "mastered",
|
||||
};
|
||||
|
||||
let key_names: Vec<String> = milestone
|
||||
.keys
|
||||
.iter()
|
||||
.map(|&ch| {
|
||||
let name = keyboard::display::key_display_name(ch);
|
||||
if name.is_empty() {
|
||||
format!("'{ch}'")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let keys_str = key_names.join(", ");
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" You {key_action}: {keys_str}"),
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
|
||||
// Finger info (for unlocks)
|
||||
if matches!(milestone.kind, MilestoneKind::Unlock) {
|
||||
for (ch, finger_desc) in &milestone.finger_info {
|
||||
let key_label = {
|
||||
let name = keyboard::display::key_display_name(*ch);
|
||||
if name.is_empty() {
|
||||
format!("'{ch}'")
|
||||
} else {
|
||||
name.to_string()
|
||||
}
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {key_label}: Use your {finger_desc}"),
|
||||
Style::default().fg(colors.fg()),
|
||||
)));
|
||||
|
||||
// Shift key guidance for shifted characters
|
||||
let fa = app.keyboard_model.finger_for_char(*ch);
|
||||
if ch.is_ascii_uppercase()
|
||||
|| (!ch.is_ascii_lowercase()
|
||||
&& !ch.is_ascii_digit()
|
||||
&& !ch.is_ascii_whitespace()
|
||||
&& *ch != ' ')
|
||||
{
|
||||
let shift_hint = if fa.hand == keyboard::finger::Hand::Left {
|
||||
"Hold Right Shift (right pinky)"
|
||||
} else {
|
||||
"Hold Left Shift (left pinky)"
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {shift_hint}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Encouraging message (randomly selected at creation time)
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", milestone.message),
|
||||
Style::default().fg(colors.focused_key()),
|
||||
)));
|
||||
|
||||
// Keyboard diagram (if space permits)
|
||||
if kbd_mode > 0 {
|
||||
let min_kbd_height: u16 = if kbd_mode == 2 { 6 } else { 4 };
|
||||
let remaining = inner.height.saturating_sub(lines.len() as u16 + 2);
|
||||
if remaining >= min_kbd_height {
|
||||
let kbd_y_start = inner.y + lines.len() as u16 + 1;
|
||||
let kbd_height = remaining.min(if kbd_mode == 2 { 8 } else { 6 });
|
||||
let kbd_area = Rect::new(inner.x, kbd_y_start, inner.width, kbd_height);
|
||||
let milestone_key = milestone.keys.first().copied();
|
||||
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
|
||||
let is_shifted = milestone_key.is_some_and(|ch| {
|
||||
ch.is_ascii_uppercase()
|
||||
|| app.keyboard_model.shifted_to_base(ch).is_some()
|
||||
});
|
||||
let kbd = KeyboardDiagram::new(
|
||||
None,
|
||||
&unlocked_keys,
|
||||
&app.depressed_keys,
|
||||
app.theme,
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.selected_key(milestone_key)
|
||||
.compact(kbd_mode == 1)
|
||||
.shift_held(is_shifted)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, kbd_area);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the text content
|
||||
let text_area = Rect::new(
|
||||
inner.x,
|
||||
inner.y,
|
||||
inner.width,
|
||||
inner.height.saturating_sub(1),
|
||||
);
|
||||
Paragraph::new(lines).render(text_area, frame.buffer_mut());
|
||||
|
||||
// Footer
|
||||
let footer_y = inner.y + inner.height.saturating_sub(1);
|
||||
if footer_y < inner.y + inner.height {
|
||||
let footer_area = Rect::new(inner.x, footer_y, inner.width, 1);
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
" Press any key to continue (Backspace dismisses only)",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
frame.render_widget(footer, footer_area);
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay_keyboard_mode(height: u16) -> u8 {
|
||||
if height >= 25 {
|
||||
2 // full
|
||||
} else if height >= 15 {
|
||||
1 // compact
|
||||
} else {
|
||||
0 // text only
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn milestone_dismiss_matrix_matches_spec() {
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Char('a')),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Tab),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Enter),
|
||||
MilestoneDismissAction::Replay
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Backspace),
|
||||
MilestoneDismissAction::DismissOnly
|
||||
);
|
||||
assert_eq!(
|
||||
milestone_dismiss_action(KeyCode::Esc),
|
||||
MilestoneDismissAction::EscAndExit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_mode_height_boundaries() {
|
||||
assert_eq!(overlay_keyboard_mode(14), 0);
|
||||
assert_eq!(overlay_keyboard_mode(15), 1);
|
||||
assert_eq!(overlay_keyboard_mode(24), 1);
|
||||
assert_eq!(overlay_keyboard_mode(25), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1122,7 +1422,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||
app.stats_tab,
|
||||
app.config.target_wpm,
|
||||
app.skill_tree.total_unlocked_count(),
|
||||
app.skill_tree.total_confident_keys(&app.key_stats),
|
||||
app.skill_tree.total_confident_keys(&app.ranked_key_stats),
|
||||
app.skill_tree.total_unique_keys,
|
||||
app.theme,
|
||||
app.history_selected,
|
||||
@@ -2062,10 +2362,337 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
||||
let centered = skill_tree_popup_rect(area);
|
||||
let widget = SkillTreeWidget::new(
|
||||
&app.skill_tree,
|
||||
&app.key_stats,
|
||||
&app.ranked_key_stats,
|
||||
app.skill_tree_selected,
|
||||
app.skill_tree_detail_scroll,
|
||||
app.theme,
|
||||
);
|
||||
frame.render_widget(widget, centered);
|
||||
}
|
||||
|
||||
fn handle_keyboard_explorer_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => app.go_to_menu(),
|
||||
KeyCode::Char('q') if app.keyboard_explorer_selected.is_none() => app.go_to_menu(),
|
||||
KeyCode::Char(ch) => {
|
||||
app.keyboard_explorer_selected = Some(ch);
|
||||
app.key_accuracy(ch, false);
|
||||
app.key_accuracy(ch, true);
|
||||
}
|
||||
KeyCode::Tab => {
|
||||
app.keyboard_explorer_selected = Some('\t');
|
||||
app.key_accuracy('\t', false);
|
||||
app.key_accuracy('\t', true);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.keyboard_explorer_selected = Some('\n');
|
||||
app.key_accuracy('\n', false);
|
||||
app.key_accuracy('\n', true);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.keyboard_explorer_selected = Some('\x08');
|
||||
app.key_accuracy('\x08', false);
|
||||
app.key_accuracy('\x08', true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keyboard_explorer(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Length(8), // keyboard diagram
|
||||
Constraint::Min(3), // detail panel
|
||||
Constraint::Length(1), // footer
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let header_lines = vec![
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
" Keyboard Explorer ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
"Press any key to see details",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)),
|
||||
];
|
||||
let header = Paragraph::new(header_lines)
|
||||
.alignment(ratatui::layout::Alignment::Center);
|
||||
frame.render_widget(header, layout[0]);
|
||||
|
||||
// Keyboard diagram
|
||||
let unlocked = app.skill_tree.unlocked_keys(DrillScope::Global);
|
||||
let kbd = KeyboardDiagram::new(
|
||||
None,
|
||||
&unlocked,
|
||||
&app.depressed_keys,
|
||||
app.theme,
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.selected_key(app.keyboard_explorer_selected)
|
||||
.shift_held(app.shift_held)
|
||||
.caps_lock(app.caps_lock);
|
||||
frame.render_widget(kbd, layout[1]);
|
||||
|
||||
// Detail panel
|
||||
render_keyboard_detail_panel(frame, app, layout[2]);
|
||||
|
||||
// Footer
|
||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [ESC] Back ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)]));
|
||||
frame.render_widget(footer, layout[3]);
|
||||
}
|
||||
|
||||
fn render_keyboard_detail_panel(frame: &mut ratatui::Frame, app: &App, area: Rect) {
|
||||
let colors = &app.theme.colors;
|
||||
|
||||
let selected = match app.keyboard_explorer_selected {
|
||||
Some(ch) => ch,
|
||||
None => {
|
||||
let hint = Paragraph::new(Line::from(Span::styled(
|
||||
"Press a key to see its details",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)))
|
||||
.alignment(ratatui::layout::Alignment::Center)
|
||||
.block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.title(" Key Details "),
|
||||
);
|
||||
frame.render_widget(hint, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build display name for title
|
||||
let display_name = key_display_name(selected);
|
||||
let title = if display_name.is_empty() {
|
||||
format!(" Key Details: '{}' ", selected)
|
||||
} else {
|
||||
format!(" Key Details: {} ", display_name)
|
||||
};
|
||||
|
||||
let block = Block::bordered()
|
||||
.border_style(Style::default().fg(colors.border()))
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Finger assignment
|
||||
let finger = app.keyboard_model.finger_for_char(selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Finger: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
finger.description(),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
|
||||
// Shift guidance for shifted characters
|
||||
let is_shifted = selected.is_uppercase()
|
||||
|| matches!(
|
||||
selected,
|
||||
'!' | '@' | '#' | '$' | '%' | '^' | '&' | '*' | '(' | ')' | '_' | '+'
|
||||
| '{' | '}' | '|' | ':' | '"' | '<' | '>' | '?' | '~'
|
||||
);
|
||||
if is_shifted {
|
||||
let shift_guidance = if finger.hand == Hand::Left {
|
||||
"Hold Right Shift (right pinky)"
|
||||
} else {
|
||||
"Hold Left Shift (left pinky)"
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Shift: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(shift_guidance, Style::default().fg(colors.fg())),
|
||||
]));
|
||||
}
|
||||
|
||||
// Unlocked status
|
||||
let unlocked_keys = app.skill_tree.unlocked_keys(DrillScope::Global);
|
||||
let is_unlocked = unlocked_keys.contains(&selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Unlocked: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
if is_unlocked { "Yes" } else { "No" },
|
||||
Style::default().fg(if is_unlocked {
|
||||
colors.success()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
}),
|
||||
),
|
||||
]));
|
||||
|
||||
// Mastery / confidence (overall and ranked)
|
||||
let overall_confidence = app.key_stats.get_confidence(selected);
|
||||
let ranked_confidence = app.ranked_key_stats.get_confidence(selected);
|
||||
if overall_confidence > 0.0 || ranked_confidence > 0.0 {
|
||||
let overall_pct = (overall_confidence * 100.0).min(100.0);
|
||||
let ranked_pct = (ranked_confidence * 100.0).min(100.0);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Mastery: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!("overall {:>3.0}% ranked {:>3.0}%", overall_pct, ranked_pct),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Branch/Level info
|
||||
if let Some((branch, level_name, position)) = find_key_branch(selected) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Branch: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(branch.name, Style::default().fg(colors.fg())),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Level: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!("{} (key #{})", level_name, position),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Avg time / samples (overall and ranked)
|
||||
let overall_stat = app.key_stats.get_stat(selected);
|
||||
let ranked_stat = app.ranked_key_stats.get_stat(selected);
|
||||
if overall_stat.is_some() || ranked_stat.is_some() {
|
||||
let fmt_time = |stat: Option<&crate::engine::key_stats::KeyStat>| -> String {
|
||||
if let Some(stat) = stat {
|
||||
if stat.sample_count > 0 {
|
||||
let best = if stat.best_time_ms < f64::MAX {
|
||||
stat.best_time_ms
|
||||
} else {
|
||||
stat.filtered_time_ms
|
||||
};
|
||||
return format!("{:.0}ms/{:.0}ms", stat.filtered_time_ms, best);
|
||||
}
|
||||
}
|
||||
"No data".to_string()
|
||||
};
|
||||
let fmt_samples = |stat: Option<&crate::engine::key_stats::KeyStat>| -> usize {
|
||||
stat.map(|s| s.sample_count).unwrap_or(0)
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Avg Time: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_time(overall_stat),
|
||||
fmt_time(ranked_stat)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Samples: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_samples(overall_stat),
|
||||
fmt_samples(ranked_stat)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Accuracy (overall and ranked) from precomputed caches
|
||||
let overall_acc = app
|
||||
.explorer_accuracy_cache_overall
|
||||
.filter(|(key, _, _)| *key == selected);
|
||||
let ranked_acc = app
|
||||
.explorer_accuracy_cache_ranked
|
||||
.filter(|(key, _, _)| *key == selected);
|
||||
if overall_acc.is_some() || ranked_acc.is_some() {
|
||||
let fmt_acc = |entry: Option<(char, usize, usize)>| -> String {
|
||||
if let Some((_, correct, total)) = entry {
|
||||
if total > 0 {
|
||||
let pct = (correct as f64 / total as f64) * 100.0;
|
||||
return format!("{:.1}% ({}/{})", pct, correct, total);
|
||||
}
|
||||
}
|
||||
"No data".to_string()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Accuracy: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(
|
||||
"overall {} ranked {}",
|
||||
fmt_acc(overall_acc),
|
||||
fmt_acc(ranked_acc)
|
||||
),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Ranked progression info (mirrors Skill Tree per-key bar semantics)
|
||||
if is_unlocked {
|
||||
let focus_key = app
|
||||
.skill_tree
|
||||
.focused_key(DrillScope::Global, &app.ranked_key_stats);
|
||||
let in_focus = focus_key == Some(selected);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Focus: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
if in_focus { "In focus now" } else { "No" },
|
||||
Style::default().fg(if in_focus {
|
||||
colors.focused_key()
|
||||
} else {
|
||||
colors.fg()
|
||||
}),
|
||||
),
|
||||
]));
|
||||
|
||||
let conf = app.ranked_key_stats.get_confidence(selected).min(1.0);
|
||||
let bar_width = 10usize;
|
||||
let filled = (conf * bar_width as f64).round() as usize;
|
||||
let bar = format!(
|
||||
"{}{}",
|
||||
"\u{2588}".repeat(filled),
|
||||
"\u{2591}".repeat(bar_width.saturating_sub(filled))
|
||||
);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Progress: ", Style::default().fg(colors.text_pending())),
|
||||
Span::styled(bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(
|
||||
format!(" {:>3.0}%", conf * 100.0),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// If no stats at all
|
||||
if overall_stat.is_none()
|
||||
&& ranked_stat.is_none()
|
||||
&& overall_acc.is_none()
|
||||
&& ranked_acc.is_none()
|
||||
{
|
||||
lines.push(Line::from(Span::styled(
|
||||
" No data yet",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
frame.render_widget(paragraph, inner);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,14 @@ impl JsonStore {
|
||||
self.save("key_stats.json", data)
|
||||
}
|
||||
|
||||
pub fn load_ranked_key_stats(&self) -> KeyStatsData {
|
||||
self.load("key_stats_ranked.json")
|
||||
}
|
||||
|
||||
pub fn save_ranked_key_stats(&self, data: &KeyStatsData) -> Result<()> {
|
||||
self.save("key_stats_ranked.json", data)
|
||||
}
|
||||
|
||||
pub fn load_drill_history(&self) -> DrillHistoryData {
|
||||
self.load("lesson_history.json")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{Finger, Hand};
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
pub focused_key: Option<char>,
|
||||
pub selected_key: Option<char>,
|
||||
pub next_key: Option<char>,
|
||||
pub unlocked_keys: &'a [char],
|
||||
pub depressed_keys: &'a HashSet<char>,
|
||||
@@ -18,11 +18,11 @@ pub struct KeyboardDiagram<'a> {
|
||||
pub compact: bool,
|
||||
pub model: &'a KeyboardModel,
|
||||
pub shift_held: bool,
|
||||
pub caps_lock: bool,
|
||||
}
|
||||
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
pub fn new(
|
||||
focused_key: Option<char>,
|
||||
next_key: Option<char>,
|
||||
unlocked_keys: &'a [char],
|
||||
depressed_keys: &'a HashSet<char>,
|
||||
@@ -30,7 +30,7 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
selected_key: None,
|
||||
next_key,
|
||||
unlocked_keys,
|
||||
depressed_keys,
|
||||
@@ -38,9 +38,20 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
compact: false,
|
||||
model,
|
||||
shift_held: false,
|
||||
caps_lock: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caps_lock(mut self, caps_lock: bool) -> Self {
|
||||
self.caps_lock = caps_lock;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn selected_key(mut self, key: Option<char>) -> Self {
|
||||
self.selected_key = key;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn compact(mut self, compact: bool) -> Self {
|
||||
self.compact = compact;
|
||||
self
|
||||
@@ -50,20 +61,15 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
self.shift_held = shift_held;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn finger_color(model: &KeyboardModel, ch: char) -> Color {
|
||||
let assignment = model.finger_for_char(ch);
|
||||
match (assignment.hand, assignment.finger) {
|
||||
(Hand::Left, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
(Hand::Left, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Left, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Left, Finger::Index) => Color::Rgb(80, 140, 180),
|
||||
(Hand::Right, Finger::Index) => Color::Rgb(100, 140, 200),
|
||||
(Hand::Right, Finger::Middle) => Color::Rgb(120, 160, 80),
|
||||
(Hand::Right, Finger::Ring) => Color::Rgb(180, 140, 80),
|
||||
(Hand::Right, Finger::Pinky) => Color::Rgb(180, 100, 100),
|
||||
_ => Color::Rgb(120, 120, 120),
|
||||
/// Check if a key (by display or base char) matches the selected key.
|
||||
fn is_key_selected(&self, display_char: char, base_char: char) -> bool {
|
||||
self.selected_key == Some(display_char) || self.selected_key == Some(base_char)
|
||||
}
|
||||
|
||||
/// Check if a sentinel/modifier key matches the selected key.
|
||||
fn is_sentinel_selected(&self, sentinel: char) -> bool {
|
||||
self.selected_key == Some(sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +84,69 @@ fn brighten_color(color: Color) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend a color toward the background at the given ratio (0.0 = full bg, 1.0 = full color).
|
||||
fn blend_toward_bg(color: Color, bg: Color, ratio: f32) -> Color {
|
||||
match (color, bg) {
|
||||
(Color::Rgb(r, g, b), Color::Rgb(br, bg_g, bb)) => {
|
||||
let mix = |c: u8, base: u8| -> u8 {
|
||||
(base as f32 + (c as f32 - base as f32) * ratio).round() as u8
|
||||
};
|
||||
Color::Rgb(mix(r, br), mix(g, bg_g), mix(b, bb))
|
||||
}
|
||||
_ => color,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute style for a modifier key box (Tab, Enter, Shift, Space, Backspace).
|
||||
fn modifier_key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_selected: bool,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} else {
|
||||
Style::default().fg(colors.fg()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_selected: bool,
|
||||
is_unlocked: bool,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(brighten_color(colors.accent_dim()))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
let bg = blend_toward_bg(colors.accent(), colors.bg(), 0.35);
|
||||
Style::default().fg(colors.accent()).bg(bg)
|
||||
} else if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(colors.accent_dim())
|
||||
} else if is_unlocked {
|
||||
Style::default().fg(colors.fg()).bg(colors.bg())
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for KeyboardDiagram<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
@@ -90,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ impl<'a> Menu<'a> {
|
||||
label: "Skill Tree".to_string(),
|
||||
description: "View progression branches and launch drills".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "b".to_string(),
|
||||
label: "Keyboard".to_string(),
|
||||
description: "Explore keyboard layout and key statistics".to_string(),
|
||||
},
|
||||
MenuItem {
|
||||
key: "s".to_string(),
|
||||
label: "Statistics".to_string(),
|
||||
|
||||
@@ -6,6 +6,7 @@ use ratatui::widgets::{Block, Clear, Paragraph, Widget};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::display::{self, BACKSPACE, ENTER, SPACE, TAB};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
@@ -176,7 +177,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -191,7 +193,8 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
// Give keyboard as much height as available (up to 12), reserving 6 for lists below
|
||||
let kbd_height: u16 = area.height.saturating_sub(6).min(12).max(7);
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
@@ -676,7 +679,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -732,6 +735,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
format_accuracy_cell_label(label, accuracy, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -790,7 +837,7 @@ impl StatsDashboard<'_> {
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let show_shifted = inner.height >= 10; // 4 base + 4 shifted + 1 mod row + 1 spare
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
@@ -842,6 +889,50 @@ impl StatsDashboard<'_> {
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Modifier key stats row below the keyboard, spread across keyboard width
|
||||
let kbd_width = all_rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| {
|
||||
let off = offsets.get(i).copied().unwrap_or(0);
|
||||
off + row.len() as u16 * key_step
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(inner.width)
|
||||
.min(inner.width);
|
||||
let mod_y = if show_shifted {
|
||||
inner.y + all_rows.len() as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + all_rows.len() as u16
|
||||
};
|
||||
if mod_y < inner.y + inner.height {
|
||||
let mod_keys: &[(char, &str)] = &[
|
||||
(TAB, display::key_short_label(TAB)),
|
||||
(SPACE, display::key_short_label(SPACE)),
|
||||
(ENTER, display::key_short_label(ENTER)),
|
||||
(BACKSPACE, display::key_short_label(BACKSPACE)),
|
||||
];
|
||||
let labels: Vec<String> = mod_keys
|
||||
.iter()
|
||||
.map(|&(key, label)| {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
format_timing_cell_label(label, time_ms, key_width)
|
||||
})
|
||||
.collect();
|
||||
let positions = spread_labels(&labels, kbd_width);
|
||||
for (i, &(key, _)) in mod_keys.iter().enumerate() {
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
buf.set_string(
|
||||
inner.x + positions[i],
|
||||
mod_y,
|
||||
&labels[i],
|
||||
Style::default().fg(fg_color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,7 +1044,7 @@ impl StatsDashboard<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Collect all keys from keyboard model
|
||||
// Collect all keys from keyboard model + modifier keys
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
@@ -961,6 +1052,10 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
// Include modifier/whitespace keys
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1034,6 +1129,9 @@ impl StatsDashboard<'_> {
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
all_keys.insert(SPACE);
|
||||
all_keys.insert(TAB);
|
||||
all_keys.insert(ENTER);
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
@@ -1178,6 +1276,21 @@ fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_accuracy_cell_label(label: &str, accuracy: f64, key_width: u16) -> String {
|
||||
if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{pct:>3}")
|
||||
} else {
|
||||
format!("{label}{pct:>2}")
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if time_ms <= 0.0 {
|
||||
colors.text_pending()
|
||||
@@ -1241,6 +1354,51 @@ fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timing_cell_label(label: &str, time_ms: f64, key_width: u16) -> String {
|
||||
if time_ms > 0.0 {
|
||||
let ms = time_ms.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{label}{ms:>4}")
|
||||
} else {
|
||||
format!("{label}{:>3}", ms.min(999))
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{label} ")
|
||||
} else {
|
||||
format!("{label} ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Distribute labels across `total_width`, with the first flush-left
|
||||
/// and the last flush-right, and equal gaps between the rest.
|
||||
fn spread_labels(labels: &[String], total_width: u16) -> Vec<u16> {
|
||||
let n = labels.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
if n == 1 {
|
||||
return vec![0];
|
||||
}
|
||||
let total_label_width: u16 = labels.iter().map(|l| l.len() as u16).sum();
|
||||
let last_width = labels.last().map(|l| l.len() as u16).unwrap_or(0);
|
||||
let spare = total_width.saturating_sub(total_label_width);
|
||||
let gaps = (n - 1) as u16;
|
||||
let gap = if gaps > 0 { spare / gaps } else { 0 };
|
||||
let remainder = if gaps > 0 { spare % gaps } else { 0 };
|
||||
|
||||
let mut positions = Vec::with_capacity(n);
|
||||
let mut x: u16 = 0;
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
if i == n - 1 {
|
||||
// Last label flush-right
|
||||
x = total_width.saturating_sub(last_width);
|
||||
}
|
||||
positions.push(x);
|
||||
x += label.len() as u16 + gap + if (i as u16) < remainder { 1 } else { 0 };
|
||||
}
|
||||
positions
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
label: &str,
|
||||
ratio: f64,
|
||||
|
||||
@@ -111,7 +111,9 @@ impl Widget for TypingArea<'_> {
|
||||
token.display.clone()
|
||||
}
|
||||
} else if idx == self.drill.cursor && target_ch == ' ' {
|
||||
"\u{00b7}".to_string()
|
||||
// Keep an actual space at cursor position so soft-wrap break opportunities
|
||||
// remain stable at word boundaries.
|
||||
" ".to_string()
|
||||
} else {
|
||||
token.display.clone()
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user