Skill tree integration + tons of random fixes
This commit is contained in:
81
src/app.rs
81
src/app.rs
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::SmallRng;
|
||||
|
||||
@@ -19,6 +20,7 @@ use crate::generator::passage::PassageGenerator;
|
||||
use crate::generator::phonetic::PhoneticGenerator;
|
||||
use crate::generator::punctuate;
|
||||
use crate::generator::transition_table::TransitionTable;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
|
||||
use crate::session::drill::DrillState;
|
||||
use crate::session::input::{self, KeystrokeEvent};
|
||||
@@ -36,6 +38,7 @@ pub enum AppScreen {
|
||||
StatsDashboard,
|
||||
Settings,
|
||||
SkillTree,
|
||||
CodeLanguageSelect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -82,6 +85,11 @@ pub struct App {
|
||||
pub history_selected: usize,
|
||||
pub history_confirm_delete: bool,
|
||||
pub skill_tree_selected: usize,
|
||||
pub skill_tree_detail_scroll: usize,
|
||||
pub drill_source_info: Option<String>,
|
||||
pub code_language_selected: usize,
|
||||
pub shift_held: bool,
|
||||
pub keyboard_model: KeyboardModel,
|
||||
rng: SmallRng,
|
||||
transition_table: TransitionTable,
|
||||
#[allow(dead_code)]
|
||||
@@ -132,6 +140,7 @@ impl App {
|
||||
|
||||
let dictionary = Dictionary::load();
|
||||
let transition_table = TransitionTable::build_from_words(&dictionary.words_list());
|
||||
let keyboard_model = KeyboardModel::from_name(&config.keyboard_layout);
|
||||
|
||||
let mut app = Self {
|
||||
screen: AppScreen::Menu,
|
||||
@@ -156,6 +165,11 @@ impl App {
|
||||
history_selected: 0,
|
||||
history_confirm_delete: false,
|
||||
skill_tree_selected: 0,
|
||||
skill_tree_detail_scroll: 0,
|
||||
drill_source_info: None,
|
||||
code_language_selected: 0,
|
||||
shift_held: false,
|
||||
keyboard_model,
|
||||
rng: SmallRng::from_entropy(),
|
||||
transition_table,
|
||||
dictionary,
|
||||
@@ -165,13 +179,14 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn start_drill(&mut self) {
|
||||
let text = self.generate_text();
|
||||
let (text, source_info) = self.generate_text();
|
||||
self.drill = Some(DrillState::new(&text));
|
||||
self.drill_source_info = source_info;
|
||||
self.drill_events.clear();
|
||||
self.screen = AppScreen::Drill;
|
||||
}
|
||||
|
||||
fn generate_text(&mut self) -> String {
|
||||
fn generate_text(&mut self) -> (String, Option<String>) {
|
||||
let word_count = self.config.word_count;
|
||||
let mode = self.drill_mode;
|
||||
|
||||
@@ -291,25 +306,28 @@ impl App {
|
||||
text = insert_line_breaks(&text);
|
||||
}
|
||||
|
||||
text
|
||||
(text, None)
|
||||
}
|
||||
DrillMode::Code => {
|
||||
let filter = CharFilter::new(('a'..='z').collect());
|
||||
let lang = self
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "rust".to_string());
|
||||
let lang = if self.config.code_language == "all" {
|
||||
let langs = ["rust", "python", "javascript", "go"];
|
||||
let idx = self.rng.gen_range(0..langs.len());
|
||||
langs[idx].to_string()
|
||||
} else {
|
||||
self.config.code_language.clone()
|
||||
};
|
||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||
let mut generator = CodeSyntaxGenerator::new(rng, &lang);
|
||||
generator.generate(&filter, None, word_count)
|
||||
let text = generator.generate(&filter, None, word_count);
|
||||
(text, Some(generator.last_source().to_string()))
|
||||
}
|
||||
DrillMode::Passage => {
|
||||
let filter = CharFilter::new(('a'..='z').collect());
|
||||
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
|
||||
let mut generator = PassageGenerator::new(rng);
|
||||
generator.generate(&filter, None, word_count)
|
||||
let text = generator.generate(&filter, None, word_count);
|
||||
(text, Some(generator.last_source().to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,6 +432,7 @@ impl App {
|
||||
pub fn go_to_menu(&mut self) {
|
||||
self.screen = AppScreen::Menu;
|
||||
self.drill = None;
|
||||
self.drill_source_info = None;
|
||||
self.drill_events.clear();
|
||||
}
|
||||
|
||||
@@ -498,6 +517,7 @@ impl App {
|
||||
|
||||
pub fn go_to_skill_tree(&mut self) {
|
||||
self.skill_tree_selected = 0;
|
||||
self.skill_tree_detail_scroll = 0;
|
||||
self.screen = AppScreen::SkillTree;
|
||||
}
|
||||
|
||||
@@ -513,6 +533,15 @@ impl App {
|
||||
self.start_drill();
|
||||
}
|
||||
|
||||
pub fn go_to_code_language_select(&mut self) {
|
||||
let langs = ["rust", "python", "javascript", "go", "all"];
|
||||
self.code_language_selected = langs
|
||||
.iter()
|
||||
.position(|&l| l == self.config.code_language)
|
||||
.unwrap_or(0);
|
||||
self.screen = AppScreen::CodeLanguageSelect;
|
||||
}
|
||||
|
||||
pub fn go_to_settings(&mut self) {
|
||||
self.settings_selected = 0;
|
||||
self.screen = AppScreen::Settings;
|
||||
@@ -542,16 +571,13 @@ impl App {
|
||||
self.config.word_count = (self.config.word_count + 5).min(100);
|
||||
}
|
||||
3 => {
|
||||
let langs = ["rust", "python", "javascript", "go"];
|
||||
let current = self
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||
let langs = ["rust", "python", "javascript", "go", "all"];
|
||||
let idx = langs
|
||||
.iter()
|
||||
.position(|&l| l == self.config.code_language)
|
||||
.unwrap_or(0);
|
||||
let next = (idx + 1) % langs.len();
|
||||
self.config.code_languages = vec![langs[next].to_string()];
|
||||
self.config.code_language = langs[next].to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -581,16 +607,13 @@ impl App {
|
||||
self.config.word_count = self.config.word_count.saturating_sub(5).max(5);
|
||||
}
|
||||
3 => {
|
||||
let langs = ["rust", "python", "javascript", "go"];
|
||||
let current = self
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
let idx = langs.iter().position(|&l| l == current).unwrap_or(0);
|
||||
let langs = ["rust", "python", "javascript", "go", "all"];
|
||||
let idx = langs
|
||||
.iter()
|
||||
.position(|&l| l == self.config.code_language)
|
||||
.unwrap_or(0);
|
||||
let next = if idx == 0 { langs.len() - 1 } else { idx - 1 };
|
||||
self.config.code_languages = vec![langs[next].to_string()];
|
||||
self.config.code_language = langs[next].to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ pub struct Config {
|
||||
pub theme: String,
|
||||
#[serde(default = "default_keyboard_layout")]
|
||||
pub keyboard_layout: String,
|
||||
#[serde(default = "default_code_languages")]
|
||||
pub code_languages: Vec<String>,
|
||||
#[serde(default = "default_word_count")]
|
||||
pub word_count: usize,
|
||||
#[serde(default = "default_code_language")]
|
||||
pub code_language: String,
|
||||
}
|
||||
|
||||
fn default_target_wpm() -> u32 {
|
||||
@@ -27,12 +27,12 @@ fn default_theme() -> String {
|
||||
fn default_keyboard_layout() -> String {
|
||||
"qwerty".to_string()
|
||||
}
|
||||
fn default_code_languages() -> Vec<String> {
|
||||
vec!["rust".to_string()]
|
||||
}
|
||||
fn default_word_count() -> usize {
|
||||
20
|
||||
}
|
||||
fn default_code_language() -> String {
|
||||
"rust".to_string()
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
@@ -40,8 +40,8 @@ impl Default for Config {
|
||||
target_wpm: default_target_wpm(),
|
||||
theme: default_theme(),
|
||||
keyboard_layout: default_keyboard_layout(),
|
||||
code_languages: default_code_languages(),
|
||||
word_count: default_word_count(),
|
||||
code_language: default_code_language(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 {
|
||||
(speed * complexity) / (errors + 1.0) * (length / 50.0)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 {
|
||||
(unlocked_count as f64 / total_keys as f64).max(0.1)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ impl BranchId {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
match key {
|
||||
"lowercase" => Some(BranchId::Lowercase),
|
||||
@@ -615,6 +616,7 @@ impl SkillTree {
|
||||
}
|
||||
|
||||
/// Get all branch definitions with their current progress (for UI).
|
||||
#[allow(dead_code)]
|
||||
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
|
||||
ALL_BRANCHES
|
||||
.iter()
|
||||
@@ -622,12 +624,49 @@ impl SkillTree {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Number of unlocked keys in a branch.
|
||||
pub fn branch_unlocked_count(&self, id: BranchId) -> usize {
|
||||
let def = get_branch_definition(id);
|
||||
let bp = self.branch_progress(id);
|
||||
match bp.status {
|
||||
BranchStatus::Complete => def.levels.iter().map(|l| l.keys.len()).sum(),
|
||||
BranchStatus::InProgress => {
|
||||
if id == BranchId::Lowercase {
|
||||
self.lowercase_unlocked_count()
|
||||
} else {
|
||||
def.levels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i <= bp.current_level)
|
||||
.map(|(_, l)| l.keys.len())
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total keys defined in a branch (across all levels).
|
||||
pub fn branch_total_keys(id: BranchId) -> usize {
|
||||
let def = get_branch_definition(id);
|
||||
def.levels.iter().map(|l| l.keys.len()).sum()
|
||||
}
|
||||
|
||||
/// Count of unique confident keys across all branches.
|
||||
pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize {
|
||||
let mut keys: HashSet<char> = HashSet::new();
|
||||
for branch_def in ALL_BRANCHES {
|
||||
for level in branch_def.levels {
|
||||
for &ch in level.keys {
|
||||
if stats.get_confidence(ch) >= 1.0 {
|
||||
keys.insert(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keys.len()
|
||||
}
|
||||
|
||||
/// Count of confident keys in a branch.
|
||||
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
|
||||
let def = get_branch_definition(id);
|
||||
@@ -881,4 +920,52 @@ mod tests {
|
||||
assert!(keys.contains(&'J')); // Capitals L2 (current level)
|
||||
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_branch_unlocked_count() {
|
||||
let tree = SkillTree::default();
|
||||
// Lowercase starts InProgress with LOWERCASE_MIN_KEYS
|
||||
assert_eq!(
|
||||
tree.branch_unlocked_count(BranchId::Lowercase),
|
||||
LOWERCASE_MIN_KEYS
|
||||
);
|
||||
|
||||
// Locked branches return 0
|
||||
assert_eq!(tree.branch_unlocked_count(BranchId::Capitals), 0);
|
||||
assert_eq!(tree.branch_unlocked_count(BranchId::Numbers), 0);
|
||||
|
||||
// InProgress non-lowercase branch
|
||||
let mut tree2 = SkillTree::default();
|
||||
let bp = tree2.branch_progress_mut(BranchId::Capitals);
|
||||
bp.status = BranchStatus::InProgress;
|
||||
bp.current_level = 1;
|
||||
// Level 0 (8 keys) + Level 1 (10 keys)
|
||||
assert_eq!(tree2.branch_unlocked_count(BranchId::Capitals), 18);
|
||||
|
||||
// Complete branch returns all keys
|
||||
let mut tree3 = SkillTree::default();
|
||||
tree3.branch_progress_mut(BranchId::Numbers).status = BranchStatus::Complete;
|
||||
assert_eq!(tree3.branch_unlocked_count(BranchId::Numbers), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selectable_branches_bounds() {
|
||||
use crate::ui::components::skill_tree::selectable_branches;
|
||||
|
||||
let branches = selectable_branches();
|
||||
assert!(!branches.is_empty());
|
||||
assert_eq!(branches[0], BranchId::Lowercase);
|
||||
|
||||
let tree = SkillTree::default();
|
||||
// Accessing branch_progress for every selectable branch should not panic
|
||||
for &branch_id in &branches {
|
||||
let _ = tree.branch_progress(branch_id);
|
||||
let _ = SkillTree::branch_total_keys(branch_id);
|
||||
let _ = tree.branch_unlocked_count(branch_id);
|
||||
}
|
||||
|
||||
// Selection at 0 and at max index should be valid
|
||||
assert!(0 < branches.len());
|
||||
assert!(branches.len() - 1 < branches.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct CodeSyntaxGenerator {
|
||||
rng: SmallRng,
|
||||
language: String,
|
||||
fetched_snippets: Vec<String>,
|
||||
last_source: String,
|
||||
}
|
||||
|
||||
impl CodeSyntaxGenerator {
|
||||
@@ -17,11 +18,16 @@ impl CodeSyntaxGenerator {
|
||||
rng,
|
||||
language: language.to_string(),
|
||||
fetched_snippets: Vec::new(),
|
||||
last_source: "Built-in snippets".to_string(),
|
||||
};
|
||||
generator.load_cached_snippets();
|
||||
generator
|
||||
}
|
||||
|
||||
pub fn last_source(&self) -> &str {
|
||||
&self.last_source
|
||||
}
|
||||
|
||||
fn load_cached_snippets(&mut self) {
|
||||
if let Some(cache) = DiskCache::new("code_cache") {
|
||||
let key = format!("{}_snippets", self.language);
|
||||
@@ -80,120 +86,119 @@ impl CodeSyntaxGenerator {
|
||||
|
||||
fn rust_snippets() -> Vec<&'static str> {
|
||||
vec![
|
||||
"fn main() { println!(\"hello\"); }",
|
||||
"let mut x = 0; x += 1;",
|
||||
"for i in 0..10 { println!(\"{}\", i); }",
|
||||
"if x > 0 { return true; }",
|
||||
"match val { Some(x) => x, None => 0 }",
|
||||
"struct Point { x: f64, y: f64 }",
|
||||
"impl Point { fn new(x: f64, y: f64) -> Self { Self { x, y } } }",
|
||||
"fn main() {\n println!(\"hello\");\n}",
|
||||
"let mut x = 0;\nx += 1;",
|
||||
"for i in 0..10 {\n println!(\"{}\", i);\n}",
|
||||
"if x > 0 {\n return true;\n}",
|
||||
"match val {\n Some(x) => x,\n None => 0,\n}",
|
||||
"struct Point {\n x: f64,\n y: f64,\n}",
|
||||
"impl Point {\n fn new(x: f64, y: f64) -> Self {\n Self { x, y }\n }\n}",
|
||||
"let v: Vec<i32> = vec![1, 2, 3];",
|
||||
"fn add(a: i32, b: i32) -> i32 { a + b }",
|
||||
"fn add(a: i32, b: i32) -> i32 {\n a + b\n}",
|
||||
"use std::collections::HashMap;",
|
||||
"pub fn process(input: &str) -> Result<String, Error> { Ok(input.to_string()) }",
|
||||
"let result = items.iter().filter(|x| x > &0).map(|x| x * 2).collect::<Vec<_>>();",
|
||||
"enum Color { Red, Green, Blue }",
|
||||
"trait Display { fn show(&self) -> String; }",
|
||||
"while let Some(item) = stack.pop() { process(item); }",
|
||||
"#[derive(Debug, Clone)] struct Config { name: String, value: i32 }",
|
||||
"let handle = std::thread::spawn(|| { println!(\"thread\"); });",
|
||||
"let mut map = HashMap::new(); map.insert(\"key\", 42);",
|
||||
"fn factorial(n: u64) -> u64 { if n <= 1 { 1 } else { n * factorial(n - 1) } }",
|
||||
"impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { None } }",
|
||||
"async fn fetch(url: &str) -> Result<String> { let body = reqwest::get(url).await?.text().await?; Ok(body) }",
|
||||
"let closure = |x: i32, y: i32| -> i32 { x + y };",
|
||||
"mod tests { use super::*; #[test] fn it_works() { assert_eq!(2 + 2, 4); } }",
|
||||
"pub struct Builder { name: Option<String> } impl Builder { pub fn name(mut self, n: &str) -> Self { self.name = Some(n.into()); self } }",
|
||||
"use std::sync::{Arc, Mutex}; let data = Arc::new(Mutex::new(vec![1, 2, 3]));",
|
||||
"if let Ok(value) = \"42\".parse::<i32>() { println!(\"parsed: {}\", value); }",
|
||||
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }",
|
||||
"pub fn process(input: &str) -> Result<String, Error> {\n Ok(input.to_string())\n}",
|
||||
"let result = items\n .iter()\n .filter(|x| x > &0)\n .map(|x| x * 2)\n .collect::<Vec<_>>();",
|
||||
"enum Color {\n Red,\n Green,\n Blue,\n}",
|
||||
"trait Display {\n fn show(&self) -> String;\n}",
|
||||
"while let Some(item) = stack.pop() {\n process(item);\n}",
|
||||
"#[derive(Debug, Clone)]\nstruct Config {\n name: String,\n value: i32,\n}",
|
||||
"let handle = std::thread::spawn(|| {\n println!(\"thread\");\n});",
|
||||
"let mut map = HashMap::new();\nmap.insert(\"key\", 42);",
|
||||
"fn factorial(n: u64) -> u64 {\n if n <= 1 {\n 1\n } else {\n n * factorial(n - 1)\n }\n}",
|
||||
"impl Iterator for Counter {\n type Item = u32;\n\n fn next(&mut self) -> Option<Self::Item> {\n None\n }\n}",
|
||||
"async fn fetch(url: &str) -> Result<String> {\n let body = reqwest::get(url)\n .await?\n .text()\n .await?;\n Ok(body)\n}",
|
||||
"let closure = |x: i32, y: i32| -> i32 {\n x + y\n};",
|
||||
"#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn it_works() {\n assert_eq!(2 + 2, 4);\n }\n}",
|
||||
"pub struct Builder {\n name: Option<String>,\n}\n\nimpl Builder {\n pub fn name(mut self, n: &str) -> Self {\n self.name = Some(n.into());\n self\n }\n}",
|
||||
"use std::sync::{Arc, Mutex};\nlet data = Arc::new(Mutex::new(vec![1, 2, 3]));",
|
||||
"if let Ok(value) = \"42\".parse::<i32>() {\n println!(\"parsed: {}\", value);\n}",
|
||||
"fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {\n if x.len() > y.len() {\n x\n } else {\n y\n }\n}",
|
||||
"type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;",
|
||||
"macro_rules! vec_of_strings { ($($x:expr),*) => { vec![$($x.to_string()),*] }; }",
|
||||
"let (tx, rx) = std::sync::mpsc::channel(); tx.send(42).unwrap();",
|
||||
"macro_rules! vec_of_strings {\n ($($x:expr),*) => {\n vec![$($x.to_string()),*]\n };\n}",
|
||||
"let (tx, rx) = std::sync::mpsc::channel();\ntx.send(42).unwrap();",
|
||||
]
|
||||
}
|
||||
|
||||
fn python_snippets() -> Vec<&'static str> {
|
||||
vec![
|
||||
"def main(): print(\"hello\")",
|
||||
"for i in range(10): print(i)",
|
||||
"if x > 0: return True",
|
||||
"class Point: def __init__(self, x, y): self.x = x",
|
||||
"import os; path = os.path.join(\"a\", \"b\")",
|
||||
"result = [x * 2 for x in items if x > 0]",
|
||||
"with open(\"file.txt\") as f: data = f.read()",
|
||||
"def add(a: int, b: int) -> int: return a + b",
|
||||
"try: result = process(data) except ValueError as e: print(e)",
|
||||
"def main():\n print(\"hello\")",
|
||||
"for i in range(10):\n print(i)",
|
||||
"if x > 0:\n return True",
|
||||
"class Point:\n def __init__(self, x, y):\n self.x = x\n self.y = y",
|
||||
"import os\npath = os.path.join(\"a\", \"b\")",
|
||||
"result = [\n x * 2\n for x in items\n if x > 0\n]",
|
||||
"with open(\"file.txt\") as f:\n data = f.read()",
|
||||
"def add(a: int, b: int) -> int:\n return a + b",
|
||||
"try:\n result = process(data)\nexcept ValueError as e:\n print(e)",
|
||||
"from collections import defaultdict",
|
||||
"lambda x: x * 2 + 1",
|
||||
"dict_comp = {k: v for k, v in pairs.items()}",
|
||||
"async def fetch(url): async with aiohttp.ClientSession() as session: return await session.get(url)",
|
||||
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
|
||||
"@property def name(self): return self._name",
|
||||
"from dataclasses import dataclass; @dataclass class Config: name: str; value: int = 0",
|
||||
"dict_comp = {\n k: v\n for k, v in pairs.items()\n}",
|
||||
"async def fetch(url):\n async with aiohttp.ClientSession() as session:\n return await session.get(url)",
|
||||
"def fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)",
|
||||
"@property\ndef name(self):\n return self._name",
|
||||
"from dataclasses import dataclass\n\n@dataclass\nclass Config:\n name: str\n value: int = 0",
|
||||
"yield from range(10)",
|
||||
"sorted(items, key=lambda x: x.name, reverse=True)",
|
||||
"sorted(\n items,\n key=lambda x: x.name,\n reverse=True,\n)",
|
||||
"from typing import Optional, List, Dict",
|
||||
"with contextlib.suppress(FileNotFoundError): os.remove(\"temp.txt\")",
|
||||
"class Meta(type): def __new__(cls, name, bases, attrs): return super().__new__(cls, name, bases, attrs)",
|
||||
"from functools import lru_cache; @lru_cache(maxsize=128) def expensive(n): return sum(range(n))",
|
||||
"from pathlib import Path; files = list(Path(\".\").glob(\"**/*.py\"))",
|
||||
"assert isinstance(result, dict), f\"Expected dict, got {type(result)}\"",
|
||||
"values = {*set_a, *set_b}; merged = {**dict_a, **dict_b}",
|
||||
"with contextlib.suppress(FileNotFoundError):\n os.remove(\"temp.txt\")",
|
||||
"class Meta(type):\n def __new__(cls, name, bases, attrs):\n return super().__new__(\n cls, name, bases, attrs\n )",
|
||||
"from functools import lru_cache\n\n@lru_cache(maxsize=128)\ndef expensive(n):\n return sum(range(n))",
|
||||
"from pathlib import Path\nfiles = list(Path(\".\").glob(\"**/*.py\"))",
|
||||
"assert isinstance(result, dict), \\\n f\"Expected dict, got {type(result)}\"",
|
||||
"values = {*set_a, *set_b}\nmerged = {**dict_a, **dict_b}",
|
||||
]
|
||||
}
|
||||
|
||||
fn javascript_snippets() -> Vec<&'static str> {
|
||||
vec![
|
||||
"const x = 42; console.log(x);",
|
||||
"function add(a, b) { return a + b; }",
|
||||
"const arr = [1, 2, 3].map(x => x * 2);",
|
||||
"if (x > 0) { return true; }",
|
||||
"for (let i = 0; i < 10; i++) { console.log(i); }",
|
||||
"class Point { constructor(x, y) { this.x = x; this.y = y; } }",
|
||||
"const x = 42;\nconsole.log(x);",
|
||||
"function add(a, b) {\n return a + b;\n}",
|
||||
"const arr = [1, 2, 3].map(\n x => x * 2\n);",
|
||||
"if (x > 0) {\n return true;\n}",
|
||||
"for (let i = 0; i < 10; i++) {\n console.log(i);\n}",
|
||||
"class Point {\n constructor(x, y) {\n this.x = x;\n this.y = y;\n }\n}",
|
||||
"const { name, age } = person;",
|
||||
"async function fetch(url) { const res = await get(url); return res.json(); }",
|
||||
"const obj = { ...defaults, ...overrides };",
|
||||
"try { parse(data); } catch (e) { console.error(e); }",
|
||||
"export default function handler(req, res) { res.send(\"ok\"); }",
|
||||
"const result = items.filter(x => x > 0).reduce((a, b) => a + b, 0);",
|
||||
"const promise = new Promise((resolve, reject) => { setTimeout(resolve, 1000); });",
|
||||
"async function fetch(url) {\n const res = await get(url);\n return res.json();\n}",
|
||||
"const obj = {\n ...defaults,\n ...overrides,\n};",
|
||||
"try {\n parse(data);\n} catch (e) {\n console.error(e);\n}",
|
||||
"export default function handler(req, res) {\n res.send(\"ok\");\n}",
|
||||
"const result = items\n .filter(x => x > 0)\n .reduce((a, b) => a + b, 0);",
|
||||
"const promise = new Promise(\n (resolve, reject) => {\n setTimeout(resolve, 1000);\n }\n);",
|
||||
"const [first, ...rest] = array;",
|
||||
"class EventEmitter { constructor() { this.listeners = new Map(); } }",
|
||||
"const proxy = new Proxy(target, { get(obj, prop) { return obj[prop]; } });",
|
||||
"for await (const chunk of stream) { process(chunk); }",
|
||||
"const memoize = (fn) => { const cache = new Map(); return (...args) => cache.get(args) ?? fn(...args); };",
|
||||
"import { useState, useEffect } from 'react'; const [state, setState] = useState(null);",
|
||||
"const pipe = (...fns) => (x) => fns.reduce((v, f) => f(v), x);",
|
||||
"Object.entries(obj).forEach(([key, value]) => { console.log(key, value); });",
|
||||
"const debounce = (fn, ms) => { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; };",
|
||||
"const observable = new Observable(subscriber => { subscriber.next(1); subscriber.complete(); });",
|
||||
"Symbol.iterator",
|
||||
"class EventEmitter {\n constructor() {\n this.listeners = new Map();\n }\n}",
|
||||
"const proxy = new Proxy(target, {\n get(obj, prop) {\n return obj[prop];\n }\n});",
|
||||
"for await (const chunk of stream) {\n process(chunk);\n}",
|
||||
"const memoize = (fn) => {\n const cache = new Map();\n return (...args) => {\n return cache.get(args) ?? fn(...args);\n };\n};",
|
||||
"import { useState, useEffect } from 'react';\nconst [state, setState] = useState(null);",
|
||||
"const pipe = (...fns) => (x) =>\n fns.reduce((v, f) => f(v), x);",
|
||||
"Object.entries(obj).forEach(\n ([key, value]) => {\n console.log(key, value);\n }\n);",
|
||||
"const debounce = (fn, ms) => {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(\n () => fn(...args),\n ms\n );\n };\n};",
|
||||
"const observable = new Observable(\n subscriber => {\n subscriber.next(1);\n subscriber.complete();\n }\n);",
|
||||
]
|
||||
}
|
||||
|
||||
fn go_snippets() -> Vec<&'static str> {
|
||||
vec![
|
||||
"func main() { fmt.Println(\"hello\") }",
|
||||
"for i := 0; i < 10; i++ { fmt.Println(i) }",
|
||||
"if err != nil { return err }",
|
||||
"type Point struct { X float64; Y float64 }",
|
||||
"func add(a, b int) int { return a + b }",
|
||||
"func main() {\n\tfmt.Println(\"hello\")\n}",
|
||||
"for i := 0; i < 10; i++ {\n\tfmt.Println(i)\n}",
|
||||
"if err != nil {\n\treturn err\n}",
|
||||
"type Point struct {\n\tX float64\n\tY float64\n}",
|
||||
"func add(a, b int) int {\n\treturn a + b\n}",
|
||||
"import \"fmt\"",
|
||||
"result := make([]int, 0, 10)",
|
||||
"switch val { case 1: return \"one\" default: return \"other\" }",
|
||||
"go func() { ch <- result }()",
|
||||
"switch val {\ncase 1:\n\treturn \"one\"\ndefault:\n\treturn \"other\"\n}",
|
||||
"go func() {\n\tch <- result\n}()",
|
||||
"defer file.Close()",
|
||||
"type Reader interface { Read(p []byte) (n int, err error) }",
|
||||
"ctx, cancel := context.WithTimeout(context.Background(), time.Second)",
|
||||
"var wg sync.WaitGroup; wg.Add(1); go func() { defer wg.Done() }()",
|
||||
"func (p *Point) Distance() float64 { return math.Sqrt(p.X*p.X + p.Y*p.Y) }",
|
||||
"select { case msg := <-ch: process(msg) case <-time.After(time.Second): timeout() }",
|
||||
"type Reader interface {\n\tRead(p []byte) (n int, err error)\n}",
|
||||
"ctx, cancel := context.WithTimeout(\n\tcontext.Background(),\n\ttime.Second,\n)",
|
||||
"var wg sync.WaitGroup\nwg.Add(1)\ngo func() {\n\tdefer wg.Done()\n}()",
|
||||
"func (p *Point) Distance() float64 {\n\treturn math.Sqrt(p.X*p.X + p.Y*p.Y)\n}",
|
||||
"select {\ncase msg := <-ch:\n\tprocess(msg)\ncase <-time.After(time.Second):\n\ttimeout()\n}",
|
||||
"json.NewEncoder(w).Encode(response)",
|
||||
"http.HandleFunc(\"/api\", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(\"ok\")) })",
|
||||
"func Map[T, U any](s []T, f func(T) U) []U { r := make([]U, len(s)); for i, v := range s { r[i] = f(v) }; return r }",
|
||||
"var once sync.Once; once.Do(func() { initialize() })",
|
||||
"buf := bytes.NewBuffer(nil); buf.WriteString(\"hello\")",
|
||||
"http.HandleFunc(\"/api\",\n\tfunc(w http.ResponseWriter, r *http.Request) {\n\t\tw.Write([]byte(\"ok\"))\n\t},\n)",
|
||||
"func Map[T, U any](s []T, f func(T) U) []U {\n\tr := make([]U, len(s))\n\tfor i, v := range s {\n\t\tr[i] = f(v)\n\t}\n\treturn r\n}",
|
||||
"var once sync.Once\nonce.Do(func() {\n\tinitialize()\n})",
|
||||
"buf := bytes.NewBuffer(nil)\nbuf.WriteString(\"hello\")",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -224,6 +229,7 @@ impl TextGenerator for CodeSyntaxGenerator {
|
||||
let mut result = Vec::new();
|
||||
let target_words = word_count;
|
||||
let mut current_words = 0;
|
||||
let mut used_fetched = false;
|
||||
|
||||
let total_available = embedded.len() + self.fetched_snippets.len();
|
||||
|
||||
@@ -234,6 +240,7 @@ impl TextGenerator for CodeSyntaxGenerator {
|
||||
embedded[idx]
|
||||
} else if !self.fetched_snippets.is_empty() {
|
||||
let f_idx = (idx - embedded.len()) % self.fetched_snippets.len();
|
||||
used_fetched = true;
|
||||
&self.fetched_snippets[f_idx]
|
||||
} else {
|
||||
embedded[idx % embedded.len()]
|
||||
@@ -243,6 +250,12 @@ impl TextGenerator for CodeSyntaxGenerator {
|
||||
result.push(snippet.to_string());
|
||||
}
|
||||
|
||||
self.last_source = if used_fetched {
|
||||
format!("GitHub source cache ({})", self.language)
|
||||
} else {
|
||||
format!("Built-in snippets ({})", self.language)
|
||||
};
|
||||
|
||||
result.join("\n\n")
|
||||
}
|
||||
}
|
||||
@@ -286,7 +299,9 @@ fn extract_code_snippets(source: &str) -> Vec<String> {
|
||||
// Preserve original newlines and indentation
|
||||
let snippet = snippet_lines.join("\n");
|
||||
let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count();
|
||||
if char_count >= 20 && snippet.len() <= 800 {
|
||||
// Require at least one newline (reject single-line snippets)
|
||||
let has_newline = snippet.contains('\n');
|
||||
if char_count >= 20 && snippet.len() <= 800 && has_newline {
|
||||
snippets.push(snippet);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,22 +95,26 @@ const GUTENBERG_IDS: &[(u32, &str)] = &[
|
||||
];
|
||||
|
||||
pub struct PassageGenerator {
|
||||
current_idx: usize,
|
||||
fetched_passages: Vec<String>,
|
||||
rng: SmallRng,
|
||||
last_source: String,
|
||||
}
|
||||
|
||||
impl PassageGenerator {
|
||||
pub fn new(rng: SmallRng) -> Self {
|
||||
let mut generator = Self {
|
||||
current_idx: 0,
|
||||
fetched_passages: Vec::new(),
|
||||
rng,
|
||||
last_source: "Built-in passage library".to_string(),
|
||||
};
|
||||
generator.load_cached_passages();
|
||||
generator
|
||||
}
|
||||
|
||||
pub fn last_source(&self) -> &str {
|
||||
&self.last_source
|
||||
}
|
||||
|
||||
fn load_cached_passages(&mut self) {
|
||||
if let Some(cache) = DiskCache::new("passages") {
|
||||
for &(_, name) in GUTENBERG_IDS {
|
||||
@@ -158,26 +162,27 @@ impl TextGenerator for PassageGenerator {
|
||||
_focused: Option<char>,
|
||||
_word_count: usize,
|
||||
) -> String {
|
||||
// Try to fetch a new Gutenberg book in the background (first few calls)
|
||||
if self.fetched_passages.len() < 50 && self.current_idx < 3 {
|
||||
// Opportunistically fetch Gutenberg passages for source variety.
|
||||
if self.fetched_passages.len() < 50 && self.rng.gen_bool(0.35) {
|
||||
self.try_fetch_gutenberg();
|
||||
}
|
||||
|
||||
let total_passages = PASSAGES.len() + self.fetched_passages.len();
|
||||
|
||||
if total_passages == 0 {
|
||||
self.current_idx += 1;
|
||||
self.last_source = "Built-in passage library".to_string();
|
||||
return PASSAGES[0].to_string();
|
||||
}
|
||||
|
||||
// Mix embedded and fetched passages
|
||||
let idx = self.current_idx % total_passages;
|
||||
self.current_idx += 1;
|
||||
// Randomly mix embedded and fetched passages.
|
||||
let idx = self.rng.gen_range(0..total_passages);
|
||||
|
||||
if idx < PASSAGES.len() {
|
||||
self.last_source = "Built-in passage library".to_string();
|
||||
PASSAGES[idx].to_string()
|
||||
} else {
|
||||
let fetched_idx = idx - PASSAGES.len();
|
||||
self.last_source = "Project Gutenberg (cached)".to_string();
|
||||
self.fetched_passages[fetched_idx % self.fetched_passages.len()].clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod finger;
|
||||
pub mod layout;
|
||||
pub mod model;
|
||||
|
||||
819
src/keyboard/model.rs
Normal file
819
src/keyboard/model.rs
Normal file
@@ -0,0 +1,819 @@
|
||||
use crate::keyboard::finger::{Finger, FingerAssignment, Hand};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PhysicalKey {
|
||||
pub base: char,
|
||||
pub shifted: char,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyboardModel {
|
||||
pub rows: Vec<Vec<PhysicalKey>>,
|
||||
}
|
||||
|
||||
impl KeyboardModel {
|
||||
pub fn qwerty() -> Self {
|
||||
Self {
|
||||
rows: vec![
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: '`',
|
||||
shifted: '~',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '1',
|
||||
shifted: '!',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '2',
|
||||
shifted: '@',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '3',
|
||||
shifted: '#',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '4',
|
||||
shifted: '$',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '5',
|
||||
shifted: '%',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '6',
|
||||
shifted: '^',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '7',
|
||||
shifted: '&',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '8',
|
||||
shifted: '*',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '9',
|
||||
shifted: '(',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '0',
|
||||
shifted: ')',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '-',
|
||||
shifted: '_',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '=',
|
||||
shifted: '+',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'q',
|
||||
shifted: 'Q',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'w',
|
||||
shifted: 'W',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'e',
|
||||
shifted: 'E',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'r',
|
||||
shifted: 'R',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 't',
|
||||
shifted: 'T',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'y',
|
||||
shifted: 'Y',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'u',
|
||||
shifted: 'U',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'i',
|
||||
shifted: 'I',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'o',
|
||||
shifted: 'O',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'p',
|
||||
shifted: 'P',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '[',
|
||||
shifted: '{',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ']',
|
||||
shifted: '}',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '\\',
|
||||
shifted: '|',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'a',
|
||||
shifted: 'A',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 's',
|
||||
shifted: 'S',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'd',
|
||||
shifted: 'D',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'f',
|
||||
shifted: 'F',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'g',
|
||||
shifted: 'G',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'h',
|
||||
shifted: 'H',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'j',
|
||||
shifted: 'J',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'k',
|
||||
shifted: 'K',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'l',
|
||||
shifted: 'L',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ';',
|
||||
shifted: ':',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '\'',
|
||||
shifted: '"',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'z',
|
||||
shifted: 'Z',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'x',
|
||||
shifted: 'X',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'c',
|
||||
shifted: 'C',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'v',
|
||||
shifted: 'V',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'b',
|
||||
shifted: 'B',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'n',
|
||||
shifted: 'N',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'm',
|
||||
shifted: 'M',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ',',
|
||||
shifted: '<',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '.',
|
||||
shifted: '>',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '/',
|
||||
shifted: '?',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dvorak() -> Self {
|
||||
Self {
|
||||
rows: vec![
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: '`',
|
||||
shifted: '~',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '1',
|
||||
shifted: '!',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '2',
|
||||
shifted: '@',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '3',
|
||||
shifted: '#',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '4',
|
||||
shifted: '$',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '5',
|
||||
shifted: '%',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '6',
|
||||
shifted: '^',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '7',
|
||||
shifted: '&',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '8',
|
||||
shifted: '*',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '9',
|
||||
shifted: '(',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '0',
|
||||
shifted: ')',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '[',
|
||||
shifted: '{',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ']',
|
||||
shifted: '}',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: '\'',
|
||||
shifted: '"',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ',',
|
||||
shifted: '<',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '.',
|
||||
shifted: '>',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'p',
|
||||
shifted: 'P',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'y',
|
||||
shifted: 'Y',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'f',
|
||||
shifted: 'F',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'g',
|
||||
shifted: 'G',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'c',
|
||||
shifted: 'C',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'r',
|
||||
shifted: 'R',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'l',
|
||||
shifted: 'L',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '/',
|
||||
shifted: '?',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '=',
|
||||
shifted: '+',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '\\',
|
||||
shifted: '|',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'a',
|
||||
shifted: 'A',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'o',
|
||||
shifted: 'O',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'e',
|
||||
shifted: 'E',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'u',
|
||||
shifted: 'U',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'i',
|
||||
shifted: 'I',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'd',
|
||||
shifted: 'D',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'h',
|
||||
shifted: 'H',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 't',
|
||||
shifted: 'T',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'n',
|
||||
shifted: 'N',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 's',
|
||||
shifted: 'S',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '-',
|
||||
shifted: '_',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: ';',
|
||||
shifted: ':',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'q',
|
||||
shifted: 'Q',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'j',
|
||||
shifted: 'J',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'k',
|
||||
shifted: 'K',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'x',
|
||||
shifted: 'X',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'b',
|
||||
shifted: 'B',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'm',
|
||||
shifted: 'M',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'w',
|
||||
shifted: 'W',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'v',
|
||||
shifted: 'V',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'z',
|
||||
shifted: 'Z',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn colemak() -> Self {
|
||||
Self {
|
||||
rows: vec![
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: '`',
|
||||
shifted: '~',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '1',
|
||||
shifted: '!',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '2',
|
||||
shifted: '@',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '3',
|
||||
shifted: '#',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '4',
|
||||
shifted: '$',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '5',
|
||||
shifted: '%',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '6',
|
||||
shifted: '^',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '7',
|
||||
shifted: '&',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '8',
|
||||
shifted: '*',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '9',
|
||||
shifted: '(',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '0',
|
||||
shifted: ')',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '-',
|
||||
shifted: '_',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '=',
|
||||
shifted: '+',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'q',
|
||||
shifted: 'Q',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'w',
|
||||
shifted: 'W',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'f',
|
||||
shifted: 'F',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'p',
|
||||
shifted: 'P',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'g',
|
||||
shifted: 'G',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'j',
|
||||
shifted: 'J',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'l',
|
||||
shifted: 'L',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'u',
|
||||
shifted: 'U',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'y',
|
||||
shifted: 'Y',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ';',
|
||||
shifted: ':',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '[',
|
||||
shifted: '{',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ']',
|
||||
shifted: '}',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '\\',
|
||||
shifted: '|',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'a',
|
||||
shifted: 'A',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'r',
|
||||
shifted: 'R',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 's',
|
||||
shifted: 'S',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 't',
|
||||
shifted: 'T',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'd',
|
||||
shifted: 'D',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'h',
|
||||
shifted: 'H',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'n',
|
||||
shifted: 'N',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'e',
|
||||
shifted: 'E',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'i',
|
||||
shifted: 'I',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'o',
|
||||
shifted: 'O',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '\'',
|
||||
shifted: '"',
|
||||
},
|
||||
],
|
||||
vec![
|
||||
PhysicalKey {
|
||||
base: 'z',
|
||||
shifted: 'Z',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'x',
|
||||
shifted: 'X',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'c',
|
||||
shifted: 'C',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'v',
|
||||
shifted: 'V',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'b',
|
||||
shifted: 'B',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'k',
|
||||
shifted: 'K',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: 'm',
|
||||
shifted: 'M',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: ',',
|
||||
shifted: '<',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '.',
|
||||
shifted: '>',
|
||||
},
|
||||
PhysicalKey {
|
||||
base: '/',
|
||||
shifted: '?',
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Self {
|
||||
match name {
|
||||
"dvorak" => Self::dvorak(),
|
||||
"colemak" => Self::colemak(),
|
||||
_ => Self::qwerty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a base character, return its shifted counterpart.
|
||||
#[allow(dead_code)]
|
||||
pub fn base_to_shifted(&self, ch: char) -> Option<char> {
|
||||
self.physical_key_for(ch)
|
||||
.filter(|pk| pk.base == ch)
|
||||
.map(|pk| pk.shifted)
|
||||
}
|
||||
|
||||
/// Given a shifted character, return its base counterpart.
|
||||
#[allow(dead_code)]
|
||||
pub fn shifted_to_base(&self, ch: char) -> Option<char> {
|
||||
self.physical_key_for(ch)
|
||||
.filter(|pk| pk.shifted == ch)
|
||||
.map(|pk| pk.base)
|
||||
}
|
||||
|
||||
pub fn physical_key_for(&self, ch: char) -> Option<&PhysicalKey> {
|
||||
self.find_key_position(ch).map(|(r, c)| &self.rows[r][c])
|
||||
}
|
||||
|
||||
fn find_key_position(&self, ch: char) -> Option<(usize, usize)> {
|
||||
for (row_idx, row) in self.rows.iter().enumerate() {
|
||||
for (col_idx, key) in row.iter().enumerate() {
|
||||
if key.base == ch || key.shifted == ch {
|
||||
return Some((row_idx, col_idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the finger assignment for a physical key by its row/col position.
|
||||
/// Uses QWERTY-style finger assignments based on column position.
|
||||
pub fn finger_for_position(&self, row: usize, col: usize) -> FingerAssignment {
|
||||
// Map column to finger based on standard touch-typing
|
||||
// Row 0 (number row) has 13 keys, rows 1-3 have varying counts
|
||||
// We use column position relative to the keyboard
|
||||
let total_cols = self.rows[row].len();
|
||||
|
||||
// For the number row and top row (13 keys each in QWERTY)
|
||||
// left pinky: cols 0-1, left ring: col 2, left middle: col 3,
|
||||
// left index: cols 4-5, right index: cols 6-7,
|
||||
// right middle: col 8, right ring: col 9, right pinky: cols 10+
|
||||
match row {
|
||||
0 => {
|
||||
// Number row
|
||||
match col {
|
||||
0 | 1 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||
2 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||
3 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||
4 | 5 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||
6 | 7 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||
8 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||
9 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Top row (q-row in QWERTY)
|
||||
match col {
|
||||
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Home row
|
||||
match col {
|
||||
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Bottom row
|
||||
let _ = total_cols;
|
||||
match col {
|
||||
0 => FingerAssignment::new(Hand::Left, Finger::Pinky),
|
||||
1 => FingerAssignment::new(Hand::Left, Finger::Ring),
|
||||
2 => FingerAssignment::new(Hand::Left, Finger::Middle),
|
||||
3 | 4 => FingerAssignment::new(Hand::Left, Finger::Index),
|
||||
5 | 6 => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||
7 => FingerAssignment::new(Hand::Right, Finger::Middle),
|
||||
8 => FingerAssignment::new(Hand::Right, Finger::Ring),
|
||||
_ => FingerAssignment::new(Hand::Right, Finger::Pinky),
|
||||
}
|
||||
}
|
||||
_ => FingerAssignment::new(Hand::Right, Finger::Index),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get finger assignment for a character, looking it up in the model.
|
||||
pub fn finger_for_char(&self, ch: char) -> FingerAssignment {
|
||||
if let Some((row_idx, col_idx)) = self.find_key_position(ch) {
|
||||
self.finger_for_position(row_idx, col_idx)
|
||||
} else {
|
||||
FingerAssignment::new(Hand::Right, Finger::Index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Letter-only rows (rows 1-3) for compact keyboard display.
|
||||
pub fn letter_rows(&self) -> &[Vec<PhysicalKey>] {
|
||||
if self.rows.len() > 1 {
|
||||
&self.rows[1..]
|
||||
} else {
|
||||
&self.rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qwerty_covers_all_skill_tree_chars() {
|
||||
let model = KeyboardModel::qwerty();
|
||||
|
||||
// All chars used in skill tree branches
|
||||
let skill_tree_chars: Vec<char> = vec![
|
||||
// Lowercase
|
||||
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q',
|
||||
'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', // Capitals
|
||||
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q',
|
||||
'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', // Numbers
|
||||
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', // Prose punctuation
|
||||
'.', ',', '\'', ';', ':', '"', '-', '?', '!', '(', ')', // Code symbols
|
||||
'=', '+', '*', '/', '{', '}', '[', ']', '<', '>', '&', '|', '^', '~', '@', '#', '$',
|
||||
'%', '_', '\\', '`',
|
||||
];
|
||||
|
||||
for ch in &skill_tree_chars {
|
||||
assert!(
|
||||
model.physical_key_for(*ch).is_some(),
|
||||
"KeyboardModel::qwerty() missing char: {:?}",
|
||||
ch
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_to_shifted_and_back() {
|
||||
let model = KeyboardModel::qwerty();
|
||||
|
||||
assert_eq!(model.base_to_shifted('a'), Some('A'));
|
||||
assert_eq!(model.base_to_shifted('1'), Some('!'));
|
||||
assert_eq!(model.base_to_shifted('['), Some('{'));
|
||||
assert_eq!(model.shifted_to_base('A'), Some('a'));
|
||||
assert_eq!(model.shifted_to_base('!'), Some('1'));
|
||||
assert_eq!(model.shifted_to_base('{'), Some('['));
|
||||
|
||||
// base_to_shifted on a shifted char returns None
|
||||
assert_eq!(model.base_to_shifted('A'), None);
|
||||
// shifted_to_base on a base char returns None
|
||||
assert_eq!(model.shifted_to_base('a'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qwerty_has_four_rows() {
|
||||
let model = KeyboardModel::qwerty();
|
||||
assert_eq!(model.rows.len(), 4);
|
||||
assert_eq!(model.rows[0].len(), 13); // number row
|
||||
assert_eq!(model.rows[1].len(), 13); // top row
|
||||
assert_eq!(model.rows[2].len(), 11); // home row
|
||||
assert_eq!(model.rows[3].len(), 10); // bottom row
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_finger_for_char_works_for_all_chars() {
|
||||
let model = KeyboardModel::qwerty();
|
||||
// Just verify it doesn't panic for various chars
|
||||
let _ = model.finger_for_char('a');
|
||||
let _ = model.finger_for_char('A');
|
||||
let _ = model.finger_for_char('1');
|
||||
let _ = model.finger_for_char('!');
|
||||
let _ = model.finger_for_char('{');
|
||||
}
|
||||
}
|
||||
300
src/main.rs
300
src/main.rs
@@ -23,7 +23,7 @@ use crossterm::terminal::{
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
@@ -34,8 +34,7 @@ use event::{AppEvent, EventHandler};
|
||||
use session::result::DrillResult;
|
||||
use ui::components::dashboard::Dashboard;
|
||||
use ui::components::keyboard_diagram::KeyboardDiagram;
|
||||
use ui::components::progress_bar::ProgressBar;
|
||||
use ui::components::skill_tree::{SkillTreeWidget, selectable_branches};
|
||||
use ui::components::skill_tree::{SkillTreeWidget, detail_line_count, selectable_branches};
|
||||
use ui::components::stats_dashboard::StatsDashboard;
|
||||
use ui::components::stats_sidebar::StatsSidebar;
|
||||
use ui::components::typing_area::TypingArea;
|
||||
@@ -87,6 +86,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.hide_cursor()?;
|
||||
|
||||
let events = EventHandler::new(Duration::from_millis(100));
|
||||
|
||||
@@ -124,6 +124,10 @@ fn run_app(
|
||||
app.depressed_keys.clear();
|
||||
app.last_key_time = None;
|
||||
}
|
||||
// Clear shift_held after 200ms as fallback
|
||||
if last.elapsed() > Duration::from_millis(200) && app.shift_held {
|
||||
app.shift_held = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::Resize(_, _) => {}
|
||||
@@ -136,18 +140,21 @@ fn run_app(
|
||||
}
|
||||
|
||||
fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
// Track depressed keys for keyboard diagram
|
||||
// Track depressed keys and shift state for keyboard diagram
|
||||
match (&key.code, key.kind) {
|
||||
(KeyCode::Char(ch), KeyEventKind::Press) => {
|
||||
app.depressed_keys.insert(ch.to_ascii_lowercase());
|
||||
app.last_key_time = Some(Instant::now());
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
(KeyCode::Char(ch), KeyEventKind::Release) => {
|
||||
app.depressed_keys.remove(&ch.to_ascii_lowercase());
|
||||
return; // Don't process Release events as input
|
||||
}
|
||||
(_, KeyEventKind::Release) => return,
|
||||
_ => {}
|
||||
_ => {
|
||||
app.shift_held = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
}
|
||||
}
|
||||
|
||||
// Only process Press events — ignore Repeat to avoid inflating input
|
||||
@@ -167,6 +174,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
|
||||
AppScreen::StatsDashboard => handle_stats_key(app, key),
|
||||
AppScreen::Settings => handle_settings_key(app, key),
|
||||
AppScreen::SkillTree => handle_skill_tree_key(app, key),
|
||||
AppScreen::CodeLanguageSelect => handle_code_language_key(app, key),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,9 +187,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
app.start_drill();
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
app.drill_mode = DrillMode::Code;
|
||||
app.drill_scope = DrillScope::Global;
|
||||
app.start_drill();
|
||||
app.go_to_code_language_select();
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
app.drill_mode = DrillMode::Passage;
|
||||
@@ -200,9 +206,7 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
|
||||
app.start_drill();
|
||||
}
|
||||
1 => {
|
||||
app.drill_mode = DrillMode::Code;
|
||||
app.drill_scope = DrillScope::Global;
|
||||
app.start_drill();
|
||||
app.go_to_code_language_select();
|
||||
}
|
||||
2 => {
|
||||
app.drill_mode = DrillMode::Passage;
|
||||
@@ -270,6 +274,8 @@ fn handle_result_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
|
||||
fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||
const STATS_TAB_COUNT: usize = 5;
|
||||
|
||||
// Confirmation dialog takes priority
|
||||
if app.history_confirm_delete {
|
||||
match key.code {
|
||||
@@ -306,10 +312,12 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||
KeyCode::Char('1') => app.stats_tab = 0,
|
||||
KeyCode::Char('2') => {} // already on history
|
||||
KeyCode::Char('3') => app.stats_tab = 2,
|
||||
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
|
||||
KeyCode::Char('4') => app.stats_tab = 3,
|
||||
KeyCode::Char('5') => app.stats_tab = 4,
|
||||
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
|
||||
KeyCode::BackTab => {
|
||||
app.stats_tab = if app.stats_tab == 0 {
|
||||
2
|
||||
STATS_TAB_COUNT - 1
|
||||
} else {
|
||||
app.stats_tab - 1
|
||||
}
|
||||
@@ -324,10 +332,12 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
|
||||
KeyCode::Char('1') => app.stats_tab = 0,
|
||||
KeyCode::Char('2') => app.stats_tab = 1,
|
||||
KeyCode::Char('3') => app.stats_tab = 2,
|
||||
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
|
||||
KeyCode::Char('4') => app.stats_tab = 3,
|
||||
KeyCode::Char('5') => app.stats_tab = 4,
|
||||
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % STATS_TAB_COUNT,
|
||||
KeyCode::BackTab => {
|
||||
app.stats_tab = if app.stats_tab == 0 {
|
||||
2
|
||||
STATS_TAB_COUNT - 1
|
||||
} else {
|
||||
app.stats_tab - 1
|
||||
}
|
||||
@@ -362,18 +372,97 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_code_language_key(app: &mut App, key: KeyEvent) {
|
||||
const LANGS: &[&str] = &["rust", "python", "javascript", "go", "all"];
|
||||
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.code_language_selected = app.code_language_selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if app.code_language_selected + 1 < LANGS.len() {
|
||||
app.code_language_selected += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
app.code_language_selected = 0;
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
app.code_language_selected = 1;
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
KeyCode::Char('3') => {
|
||||
app.code_language_selected = 2;
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
KeyCode::Char('4') => {
|
||||
app.code_language_selected = 3;
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
KeyCode::Char('5') => {
|
||||
app.code_language_selected = 4;
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
start_code_drill(app, LANGS);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_code_drill(app: &mut App, langs: &[&str]) {
|
||||
if app.code_language_selected < langs.len() {
|
||||
app.config.code_language = langs[app.code_language_selected].to_string();
|
||||
let _ = app.config.save();
|
||||
app.drill_mode = DrillMode::Code;
|
||||
app.drill_scope = DrillScope::Global;
|
||||
app.start_drill();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
|
||||
const DETAIL_SCROLL_STEP: usize = 10;
|
||||
let max_scroll = skill_tree_detail_max_scroll(app);
|
||||
app.skill_tree_detail_scroll = app.skill_tree_detail_scroll.min(max_scroll);
|
||||
let branches = selectable_branches();
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1);
|
||||
app.skill_tree_detail_scroll = 0;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
if app.skill_tree_selected + 1 < branches.len() {
|
||||
app.skill_tree_selected += 1;
|
||||
app.skill_tree_detail_scroll = 0;
|
||||
}
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
app.skill_tree_detail_scroll = app
|
||||
.skill_tree_detail_scroll
|
||||
.saturating_sub(DETAIL_SCROLL_STEP);
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
let max_scroll = skill_tree_detail_max_scroll(app);
|
||||
app.skill_tree_detail_scroll = app
|
||||
.skill_tree_detail_scroll
|
||||
.saturating_add(DETAIL_SCROLL_STEP)
|
||||
.min(max_scroll);
|
||||
}
|
||||
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
app.skill_tree_detail_scroll = app
|
||||
.skill_tree_detail_scroll
|
||||
.saturating_sub(DETAIL_SCROLL_STEP);
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
let max_scroll = skill_tree_detail_max_scroll(app);
|
||||
app.skill_tree_detail_scroll = app
|
||||
.skill_tree_detail_scroll
|
||||
.saturating_add(DETAIL_SCROLL_STEP)
|
||||
.min(max_scroll);
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if app.skill_tree_selected < branches.len() {
|
||||
let branch_id = branches[app.skill_tree_selected];
|
||||
@@ -389,6 +478,37 @@ fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_tree_detail_max_scroll(app: &App) -> usize {
|
||||
let (w, h) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||
let screen = Rect::new(0, 0, w, h);
|
||||
let centered = ui::layout::centered_rect(70, 90, screen);
|
||||
let inner = Rect::new(
|
||||
centered.x.saturating_add(1),
|
||||
centered.y.saturating_add(1),
|
||||
centered.width.saturating_sub(2),
|
||||
centered.height.saturating_sub(2),
|
||||
);
|
||||
|
||||
let branches = selectable_branches();
|
||||
if branches.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let branch_list_height = branches.len() as u16 * 2 + 1;
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(inner);
|
||||
let detail_height = layout.get(2).map(|r| r.height as usize).unwrap_or(0);
|
||||
let selected = app.skill_tree_selected.min(branches.len().saturating_sub(1));
|
||||
let total_lines = detail_line_count(branches[selected]);
|
||||
total_lines.saturating_sub(detail_height)
|
||||
}
|
||||
|
||||
fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
@@ -403,6 +523,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
|
||||
AppScreen::StatsDashboard => render_stats(frame, app),
|
||||
AppScreen::Settings => render_settings(frame, app),
|
||||
AppScreen::SkillTree => render_skill_tree(frame, app),
|
||||
AppScreen::CodeLanguageSelect => render_code_language_select(frame, app),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +575,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
|
||||
frame.render_widget(&app.menu, menu_area);
|
||||
|
||||
let footer = Paragraph::new(Line::from(vec![Span::styled(
|
||||
" [1-3] Start [t] Skill Tree [s] Stats [q] Quit ",
|
||||
" [1-3] Start [t] Skill Tree [s] Stats [c] Settings [q] Quit ",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)]));
|
||||
frame.render_widget(footer, layout[2]);
|
||||
@@ -492,9 +613,13 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
frame.render_widget(header, app_layout.header);
|
||||
} else {
|
||||
let header_title = format!(" {mode_name} Drill ");
|
||||
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||
let focus_text = if let Some(focused) = focused {
|
||||
format!(" | Focus: '{focused}'")
|
||||
let focus_text = if app.drill_mode == DrillMode::Adaptive {
|
||||
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
|
||||
if let Some(focused) = focused {
|
||||
format!(" | Focus: '{focused}'")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -521,12 +646,46 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
let show_kbd = tier.show_keyboard(area.height);
|
||||
let show_progress = tier.show_progress_bar(area.height);
|
||||
|
||||
// Compute active branch count for progress area height
|
||||
let active_branches: Vec<engine::skill_tree::BranchId> =
|
||||
engine::skill_tree::BranchId::all()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&id| {
|
||||
matches!(
|
||||
app.skill_tree.branch_status(id),
|
||||
engine::skill_tree::BranchStatus::InProgress
|
||||
| engine::skill_tree::BranchStatus::Complete
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let progress_height = if show_progress && area.height >= 25 {
|
||||
(active_branches.len().min(6) as u16 + 1).max(2) // +1 for overall line
|
||||
} else if show_progress && area.height >= 20 {
|
||||
2 // active branch + overall
|
||||
} else if show_progress {
|
||||
1 // active branch only
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let kbd_height = if show_kbd {
|
||||
if tier.compact_keyboard() {
|
||||
5 // 3 rows + 2 border
|
||||
} else {
|
||||
7 // 4 rows + 2 border + 1 label space
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let mut constraints: Vec<Constraint> = vec![Constraint::Min(5)];
|
||||
if show_progress {
|
||||
constraints.push(Constraint::Length(3));
|
||||
if progress_height > 0 {
|
||||
constraints.push(Constraint::Length(progress_height));
|
||||
}
|
||||
if show_kbd {
|
||||
constraints.push(Constraint::Length(5));
|
||||
constraints.push(Constraint::Length(kbd_height));
|
||||
}
|
||||
|
||||
let main_layout = Layout::default()
|
||||
@@ -538,12 +697,30 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
frame.render_widget(typing, main_layout[0]);
|
||||
|
||||
let mut idx = 1;
|
||||
if show_progress {
|
||||
let unlocked = app.skill_tree.total_unlocked_count() as f64;
|
||||
let total = app.skill_tree.total_unique_keys as f64;
|
||||
let progress_val = (unlocked / total).min(1.0);
|
||||
let progress = ProgressBar::new("Key Progress", progress_val, app.theme);
|
||||
frame.render_widget(progress, main_layout[idx]);
|
||||
if progress_height > 0 {
|
||||
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,
|
||||
drill_scope: app.drill_scope,
|
||||
active_branches: &active_branches,
|
||||
theme: app.theme,
|
||||
height: progress_height,
|
||||
};
|
||||
frame.render_widget(progress_widget, main_layout[idx]);
|
||||
} else {
|
||||
let source = app.drill_source_info.as_deref().unwrap_or("unknown source");
|
||||
let label = if app.drill_mode == DrillMode::Code {
|
||||
" Code source "
|
||||
} else {
|
||||
" Passage source "
|
||||
};
|
||||
let source_info = Paragraph::new(Line::from(vec![
|
||||
Span::styled(label, Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(source, Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
frame.render_widget(source_info, main_layout[idx]);
|
||||
}
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
@@ -551,14 +728,18 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
|
||||
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,
|
||||
app.theme,
|
||||
&app.keyboard_model,
|
||||
)
|
||||
.compact(tier.compact_keyboard());
|
||||
.compact(tier.compact_keyboard())
|
||||
.shift_held(app.shift_held);
|
||||
frame.render_widget(kbd, main_layout[idx]);
|
||||
}
|
||||
|
||||
@@ -600,6 +781,7 @@ fn render_stats(frame: &mut ratatui::Frame, app: &App) {
|
||||
app.theme,
|
||||
app.history_selected,
|
||||
app.history_confirm_delete,
|
||||
&app.keyboard_model,
|
||||
);
|
||||
frame.render_widget(dashboard, area);
|
||||
}
|
||||
@@ -618,13 +800,8 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
block.render(centered, frame.buffer_mut());
|
||||
|
||||
let available_themes = ui::theme::Theme::available_themes();
|
||||
let languages_all = ["rust", "python", "javascript", "go"];
|
||||
let current_lang = app
|
||||
.config
|
||||
.code_languages
|
||||
.first()
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("rust");
|
||||
let languages_all = ["rust", "python", "javascript", "go", "all"];
|
||||
let current_lang = &app.config.code_language;
|
||||
|
||||
let fields: Vec<(String, String)> = vec![
|
||||
(
|
||||
@@ -636,7 +813,7 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
"Word Count".to_string(),
|
||||
format!("{}", app.config.word_count),
|
||||
),
|
||||
("Code Language".to_string(), current_lang.to_string()),
|
||||
("Code Language".to_string(), current_lang.clone()),
|
||||
];
|
||||
|
||||
let layout = Layout::default()
|
||||
@@ -706,6 +883,54 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
|
||||
footer.render(layout[3], frame.buffer_mut());
|
||||
}
|
||||
|
||||
fn render_code_language_select(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let colors = &app.theme.colors;
|
||||
let centered = ui::layout::centered_rect(40, 50, area);
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Select Code Language ")
|
||||
.border_style(Style::default().fg(colors.accent()))
|
||||
.style(Style::default().bg(colors.bg()));
|
||||
let inner = block.inner(centered);
|
||||
block.render(centered, frame.buffer_mut());
|
||||
|
||||
let langs = ["Rust", "Python", "JavaScript", "Go", "All (random)"];
|
||||
let lang_keys = ["rust", "python", "javascript", "go", "all"];
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (i, &lang) in langs.iter().enumerate() {
|
||||
let is_selected = i == app.code_language_selected;
|
||||
let is_current = lang_keys[i] == app.config.code_language;
|
||||
|
||||
let indicator = if is_selected { " > " } else { " " };
|
||||
let current_marker = if is_current { " (current)" } else { "" };
|
||||
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("{indicator}[{}] {lang}{current_marker}", i + 1),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
" [1-5] Select [Enter] Confirm [ESC] Back",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
|
||||
Paragraph::new(lines).render(inner, frame.buffer_mut());
|
||||
}
|
||||
|
||||
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let centered = ui::layout::centered_rect(70, 90, area);
|
||||
@@ -713,6 +938,7 @@ fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
|
||||
&app.skill_tree,
|
||||
&app.key_stats,
|
||||
app.skill_tree_selected,
|
||||
app.skill_tree_detail_scroll,
|
||||
app.theme,
|
||||
);
|
||||
frame.render_widget(widget, centered);
|
||||
|
||||
@@ -3,7 +3,8 @@ use std::collections::HashMap;
|
||||
use chrono::{Datelike, NaiveDate, Utc};
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::session::result::DrillResult;
|
||||
@@ -25,8 +26,13 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Daily Activity (Sessions per Day) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Daily Activity (Sessions per Day) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -42,10 +48,11 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
}
|
||||
|
||||
let today = Utc::now().date_naive();
|
||||
let end_date = today;
|
||||
// Show ~26 weeks (half a year)
|
||||
let weeks_to_show = ((inner.width as usize).saturating_sub(3)) / 2;
|
||||
let weeks_to_show = weeks_to_show.min(26);
|
||||
let start_date = today - chrono::Duration::weeks(weeks_to_show as i64);
|
||||
let start_date = end_date - chrono::Duration::weeks(weeks_to_show as i64);
|
||||
// Align to Monday
|
||||
let start_date =
|
||||
start_date - chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
|
||||
@@ -71,7 +78,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
// Month labels
|
||||
let mut last_month = 0u32;
|
||||
|
||||
while current_date <= today {
|
||||
while current_date <= end_date {
|
||||
let x = inner.x + 2 + col * 2;
|
||||
if x + 1 >= inner.x + inner.width {
|
||||
break;
|
||||
@@ -110,7 +117,7 @@ impl Widget for ActivityHeatmap<'_> {
|
||||
// Render 7 days in this week column
|
||||
for day_offset in 0..7u16 {
|
||||
let date = current_date + chrono::Duration::days(day_offset as i64);
|
||||
if date > today {
|
||||
if date > end_date {
|
||||
break;
|
||||
}
|
||||
let y = inner.y + 1 + day_offset;
|
||||
|
||||
133
src/ui/components/branch_progress_list.rs
Normal file
133
src/ui/components/branch_progress_list.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Paragraph, Widget};
|
||||
|
||||
use crate::engine::skill_tree::{BranchId, DrillScope, SkillTree, get_branch_definition};
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct BranchProgressList<'a> {
|
||||
pub skill_tree: &'a SkillTree,
|
||||
pub key_stats: &'a crate::engine::key_stats::KeyStatsStore,
|
||||
pub drill_scope: DrillScope,
|
||||
pub active_branches: &'a [BranchId],
|
||||
pub theme: &'a Theme,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
impl Widget for BranchProgressList<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
let drill_branch = match self.drill_scope {
|
||||
DrillScope::Branch(id) => Some(id),
|
||||
DrillScope::Global => None,
|
||||
};
|
||||
|
||||
let show_all = self.height > 2;
|
||||
|
||||
if show_all {
|
||||
for &branch_id in self.active_branches {
|
||||
if lines.len() as u16 >= self.height.saturating_sub(1) {
|
||||
break;
|
||||
}
|
||||
let def = get_branch_definition(branch_id);
|
||||
let total = SkillTree::branch_total_keys(branch_id);
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||
let mastered = self
|
||||
.skill_tree
|
||||
.branch_confident_keys(branch_id, self.key_stats);
|
||||
let is_active = drill_branch == Some(branch_id);
|
||||
let prefix = if is_active {
|
||||
" \u{25b6} "
|
||||
} else {
|
||||
" \u{00b7} "
|
||||
};
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
let name = format!("{:<14}", def.name);
|
||||
let label_color = if is_active {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.text_pending()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix, Style::default().fg(label_color)),
|
||||
Span::styled(name, Style::default().fg(label_color)),
|
||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
} else if let Some(branch_id) = drill_branch {
|
||||
let def = get_branch_definition(branch_id);
|
||||
let total = SkillTree::branch_total_keys(branch_id);
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||
let mastered = self
|
||||
.skill_tree
|
||||
.branch_confident_keys(branch_id, self.key_stats);
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" \u{25b6} {:<14}", def.name),
|
||||
Style::default().fg(colors.accent()),
|
||||
),
|
||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
// Overall line
|
||||
if lines.len() < self.height as usize {
|
||||
let total = self.skill_tree.total_unique_keys;
|
||||
let unlocked = self.skill_tree.total_unlocked_count();
|
||||
let mastered = self.skill_tree.total_confident_keys(self.key_stats);
|
||||
let (m_bar, u_bar, e_bar) = compact_dual_bar_parts(mastered, unlocked, total, 12);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {:<14}", "Overall"),
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
Span::styled(m_bar, Style::default().fg(colors.text_correct())),
|
||||
Span::styled(u_bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(e_bar, Style::default().fg(colors.text_pending())),
|
||||
Span::styled(
|
||||
format!(" {unlocked}/{total}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_dual_bar_parts(
|
||||
mastered: usize,
|
||||
unlocked: usize,
|
||||
total: usize,
|
||||
width: usize,
|
||||
) -> (String, String, String) {
|
||||
if total == 0 {
|
||||
return (String::new(), String::new(), "\u{2591}".repeat(width));
|
||||
}
|
||||
let mastered_cells = mastered * width / total;
|
||||
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
|
||||
let empty_cells = width - unlocked_cells;
|
||||
(
|
||||
"\u{2588}".repeat(mastered_cells),
|
||||
"\u{2593}".repeat(unlocked_cells - mastered_cells),
|
||||
"\u{2591}".repeat(empty_cells),
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,8 @@ use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::keyboard::finger::{self, Finger, Hand};
|
||||
use crate::keyboard::finger::{Finger, Hand};
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct KeyboardDiagram<'a> {
|
||||
@@ -15,6 +16,8 @@ pub struct KeyboardDiagram<'a> {
|
||||
pub depressed_keys: &'a HashSet<char>,
|
||||
pub theme: &'a Theme,
|
||||
pub compact: bool,
|
||||
pub model: &'a KeyboardModel,
|
||||
pub shift_held: bool,
|
||||
}
|
||||
|
||||
impl<'a> KeyboardDiagram<'a> {
|
||||
@@ -24,6 +27,7 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
unlocked_keys: &'a [char],
|
||||
depressed_keys: &'a HashSet<char>,
|
||||
theme: &'a Theme,
|
||||
model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
focused_key,
|
||||
@@ -32,6 +36,8 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
depressed_keys,
|
||||
theme,
|
||||
compact: false,
|
||||
model,
|
||||
shift_held: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +45,15 @@ impl<'a> KeyboardDiagram<'a> {
|
||||
self.compact = compact;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn shift_held(mut self, shift_held: bool) -> Self {
|
||||
self.shift_held = shift_held;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
const ROWS: &[&[char]] = &[
|
||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
|
||||
fn finger_color(ch: char) -> Color {
|
||||
let assignment = finger::qwerty_finger(ch);
|
||||
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),
|
||||
@@ -84,62 +89,234 @@ impl Widget for KeyboardDiagram<'_> {
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let key_width: u16 = if self.compact { 3 } else { 5 };
|
||||
let min_width: u16 = if self.compact { 21 } else { 30 };
|
||||
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] = if self.compact { &[0, 1, 3] } else { &[1, 3, 5] };
|
||||
|
||||
for (row_idx, row) in ROWS.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
if inner.height < 3 || inner.width < min_width {
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
let offsets: &[u16] = &[0, 1, 3];
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
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 is_depressed = self.depressed_keys.contains(&key);
|
||||
let is_unlocked = self.unlocked_keys.contains(&key);
|
||||
let is_focused = self.focused_key == Some(key);
|
||||
let is_next = self.next_key == Some(key);
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
// Priority: depressed > next_expected > focused > unlocked > locked
|
||||
let style = if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(key))
|
||||
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 {
|
||||
brighten_color(colors.accent_dim())
|
||||
physical_key.base
|
||||
};
|
||||
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(key))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let display = if self.compact {
|
||||
format!("[{key}]")
|
||||
} else {
|
||||
format!("[ {key} ]")
|
||||
};
|
||||
buf.set_string(x, y, &display, style);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Full mode: all 4 rows
|
||||
let key_width: u16 = 5;
|
||||
let min_width: u16 = 69;
|
||||
|
||||
if inner.height < 4 || inner.width < min_width {
|
||||
// Fallback to compact-style if too narrow for full
|
||||
let letter_rows = self.model.letter_rows();
|
||||
let key_width: u16 = 5;
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
|
||||
if inner.height < 3 || inner.width < 30 {
|
||||
return;
|
||||
}
|
||||
|
||||
for (row_idx, row) in letter_rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Row offsets for full layout (staggered keyboard)
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in self.model.rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let display_char = if self.shift_held {
|
||||
physical_key.shifted
|
||||
} else {
|
||||
physical_key.base
|
||||
};
|
||||
let base_char = physical_key.base;
|
||||
|
||||
let is_depressed = self.depressed_keys.contains(&base_char);
|
||||
let is_unlocked = self.unlocked_keys.contains(&display_char)
|
||||
|| self.unlocked_keys.contains(&base_char);
|
||||
let is_focused = self.focused_key == Some(display_char)
|
||||
|| self.focused_key == Some(base_char);
|
||||
let is_next =
|
||||
self.next_key == Some(display_char) || self.next_key == Some(base_char);
|
||||
|
||||
let style = key_style(
|
||||
is_depressed,
|
||||
is_next,
|
||||
is_focused,
|
||||
is_unlocked,
|
||||
base_char,
|
||||
self.model,
|
||||
colors,
|
||||
);
|
||||
|
||||
let display = format!("[ {display_char} ]");
|
||||
buf.set_string(x, y, &display, style);
|
||||
}
|
||||
|
||||
// Modifier labels at row edges (visual only)
|
||||
let label_style = Style::default().fg(colors.text_pending());
|
||||
let after_x = inner.x + offset + row.len() as u16 * key_width + 1;
|
||||
match row_idx {
|
||||
0 => {
|
||||
// Backspace after number row
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Bksp", label_style);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
// Tab before top row, backslash already in row
|
||||
if offset >= 3 {
|
||||
buf.set_string(inner.x, y, "Tab", label_style);
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Enter after home row
|
||||
if after_x + 5 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Enter", label_style);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
// Shift before and after bottom row
|
||||
if offset >= 5 {
|
||||
buf.set_string(inner.x, y, "Shft", label_style);
|
||||
}
|
||||
if after_x + 4 <= inner.x + inner.width {
|
||||
buf.set_string(after_x, y, "Shft", label_style);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_style(
|
||||
is_depressed: bool,
|
||||
is_next: bool,
|
||||
is_focused: bool,
|
||||
is_unlocked: bool,
|
||||
base_char: char,
|
||||
model: &KeyboardModel,
|
||||
colors: &crate::ui::theme::ThemeColors,
|
||||
) -> Style {
|
||||
if is_depressed {
|
||||
let bg = if is_unlocked {
|
||||
brighten_color(finger_color(model, base_char))
|
||||
} else {
|
||||
brighten_color(colors.accent_dim())
|
||||
};
|
||||
Style::default()
|
||||
.fg(Color::White)
|
||||
.bg(bg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_next {
|
||||
Style::default().fg(colors.bg()).bg(colors.accent())
|
||||
} else if is_focused {
|
||||
Style::default().fg(colors.bg()).bg(colors.focused_key())
|
||||
} else if is_unlocked {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.bg(finger_color(model, base_char))
|
||||
} else {
|
||||
Style::default().fg(colors.text_pending()).bg(colors.bg())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod activity_heatmap;
|
||||
pub mod branch_progress_list;
|
||||
pub mod chart;
|
||||
pub mod dashboard;
|
||||
pub mod keyboard_diagram;
|
||||
pub mod menu;
|
||||
pub mod progress_bar;
|
||||
pub mod skill_tree;
|
||||
pub mod stats_dashboard;
|
||||
pub mod stats_sidebar;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
|
||||
use crate::ui::theme::Theme;
|
||||
|
||||
pub struct ProgressBar<'a> {
|
||||
pub label: String,
|
||||
pub ratio: f64,
|
||||
pub theme: &'a Theme,
|
||||
}
|
||||
|
||||
impl<'a> ProgressBar<'a> {
|
||||
pub fn new(label: &str, ratio: f64, theme: &'a Theme) -> Self {
|
||||
Self {
|
||||
label: label.to_string(),
|
||||
ratio: ratio.clamp(0.0, 1.0),
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ProgressBar<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(format!(" {} ", self.label))
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.width == 0 || inner.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let filled_width = (self.ratio * inner.width as f64) as u16;
|
||||
let label = format!("{:.0}%", self.ratio * 100.0);
|
||||
|
||||
for x in inner.x..inner.x + inner.width {
|
||||
let style = if x < inner.x + filled_width {
|
||||
Style::default().fg(colors.bg()).bg(colors.bar_filled())
|
||||
} else {
|
||||
Style::default().fg(colors.fg()).bg(colors.bar_empty())
|
||||
};
|
||||
buf[(x, inner.y)].set_style(style);
|
||||
}
|
||||
|
||||
let label_x = inner.x + (inner.width.saturating_sub(label.len() as u16)) / 2;
|
||||
buf.set_string(label_x, inner.y, &label, Style::default().fg(colors.fg()));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub struct SkillTreeWidget<'a> {
|
||||
skill_tree: &'a SkillTreeEngine,
|
||||
key_stats: &'a KeyStatsStore,
|
||||
selected: usize,
|
||||
detail_scroll: usize,
|
||||
theme: &'a Theme,
|
||||
}
|
||||
|
||||
@@ -22,20 +23,23 @@ impl<'a> SkillTreeWidget<'a> {
|
||||
skill_tree: &'a SkillTreeEngine,
|
||||
key_stats: &'a KeyStatsStore,
|
||||
selected: usize,
|
||||
detail_scroll: usize,
|
||||
theme: &'a Theme,
|
||||
) -> Self {
|
||||
Self {
|
||||
skill_tree,
|
||||
key_stats,
|
||||
selected,
|
||||
detail_scroll,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the list of selectable branch IDs (all non-Lowercase branches).
|
||||
/// Get the list of selectable branch IDs (Lowercase first, then other branches).
|
||||
pub fn selectable_branches() -> Vec<BranchId> {
|
||||
vec![
|
||||
BranchId::Lowercase,
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
@@ -44,6 +48,16 @@ pub fn selectable_branches() -> Vec<BranchId> {
|
||||
]
|
||||
}
|
||||
|
||||
pub fn detail_line_count(branch_id: BranchId) -> usize {
|
||||
let def = get_branch_definition(branch_id);
|
||||
// 1 line branch header + for each level: 1 line level header + 1 line per key
|
||||
1 + def
|
||||
.levels
|
||||
.iter()
|
||||
.map(|level| 1 + level.keys.len())
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
impl Widget for SkillTreeWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
@@ -57,7 +71,7 @@ impl Widget for SkillTreeWidget<'_> {
|
||||
|
||||
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
|
||||
let branches = selectable_branches();
|
||||
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches
|
||||
let branch_list_height = branches.len() as u16 * 2 + 1; // all branches * 2 lines + separator after Lowercase
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -86,15 +100,15 @@ impl Widget for SkillTreeWidget<'_> {
|
||||
let footer_text = if self.selected < branches.len() {
|
||||
let bp = self.skill_tree.branch_progress(branches[self.selected]);
|
||||
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
|
||||
" Complete a-z to unlock branches "
|
||||
" Complete a-z to unlock branches [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
|
||||
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress
|
||||
{
|
||||
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
|
||||
} else {
|
||||
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
" [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
|
||||
}
|
||||
} else {
|
||||
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
|
||||
" [\u{2191}\u{2193}/jk] Navigate [PgUp/PgDn or Ctrl+U/Ctrl+D] Scroll [q] Back "
|
||||
};
|
||||
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
@@ -110,72 +124,6 @@ impl SkillTreeWidget<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Root: Lowercase a-z
|
||||
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
|
||||
let lowercase_def = get_branch_definition(BranchId::Lowercase);
|
||||
let lowercase_total = lowercase_def
|
||||
.levels
|
||||
.iter()
|
||||
.map(|l| l.keys.len())
|
||||
.sum::<usize>();
|
||||
let lowercase_confident = self
|
||||
.skill_tree
|
||||
.branch_confident_keys(BranchId::Lowercase, self.key_stats);
|
||||
|
||||
let (prefix, style) = match lowercase_bp.status {
|
||||
BranchStatus::Complete => (
|
||||
"\u{2605} ",
|
||||
Style::default()
|
||||
.fg(colors.text_correct())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BranchStatus::InProgress => (
|
||||
"\u{25b6} ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
_ => (" ", Style::default().fg(colors.text_pending())),
|
||||
};
|
||||
|
||||
let status_text = match lowercase_bp.status {
|
||||
BranchStatus::Complete => "COMPLETE".to_string(),
|
||||
BranchStatus::InProgress => {
|
||||
let unlocked = self.skill_tree.lowercase_unlocked_count();
|
||||
format!("{unlocked}/{lowercase_total}")
|
||||
}
|
||||
_ => "LOCKED".to_string(),
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {prefix}{name}", name = lowercase_def.name),
|
||||
style,
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
]));
|
||||
|
||||
// Progress bar for lowercase
|
||||
let pct = if lowercase_total > 0 {
|
||||
lowercase_confident as f64 / lowercase_total as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", progress_bar_str(pct, 30)),
|
||||
style,
|
||||
)));
|
||||
|
||||
// Separator
|
||||
lines.push(Line::from(Span::styled(
|
||||
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
|
||||
Style::default().fg(colors.border()),
|
||||
)));
|
||||
|
||||
// Branches
|
||||
for (i, &branch_id) in branches.iter().enumerate() {
|
||||
let bp = self.skill_tree.branch_progress(branch_id);
|
||||
let def = get_branch_definition(branch_id);
|
||||
@@ -188,50 +136,46 @@ impl SkillTreeWidget<'_> {
|
||||
let (prefix, style) = match bp.status {
|
||||
BranchStatus::Complete => (
|
||||
"\u{2605} ",
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.text_correct())
|
||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(colors.text_correct())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
},
|
||||
Style::default()
|
||||
.fg(colors.text_correct())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BranchStatus::InProgress => (
|
||||
"\u{25b6} ",
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
},
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
BranchStatus::Available => (
|
||||
" ",
|
||||
if is_selected {
|
||||
Style::default()
|
||||
.fg(colors.fg())
|
||||
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
},
|
||||
Style::default().fg(colors.fg()),
|
||||
),
|
||||
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
|
||||
};
|
||||
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(branch_id);
|
||||
let mastered_text = if confident_keys > 0 {
|
||||
format!(" ({confident_keys} mastered)")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let status_text = match bp.status {
|
||||
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"),
|
||||
BranchStatus::InProgress => format!(
|
||||
"Lvl {}/{} {confident_keys}/{total_keys} keys",
|
||||
bp.current_level + 1,
|
||||
def.levels.len()
|
||||
),
|
||||
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
|
||||
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
|
||||
BranchStatus::Complete => {
|
||||
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||
}
|
||||
BranchStatus::InProgress => {
|
||||
if branch_id == BranchId::Lowercase {
|
||||
format!("{unlocked}/{total_keys} unlocked{mastered_text}")
|
||||
} else {
|
||||
format!(
|
||||
"Lvl {}/{} {unlocked}/{total_keys} unlocked{mastered_text}",
|
||||
bp.current_level + 1,
|
||||
def.levels.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
BranchStatus::Available => format!("0/{total_keys} unlocked"),
|
||||
BranchStatus::Locked => format!("Locked 0/{total_keys}"),
|
||||
};
|
||||
|
||||
let sel_indicator = if is_selected { "> " } else { " " };
|
||||
@@ -244,15 +188,22 @@ impl SkillTreeWidget<'_> {
|
||||
),
|
||||
]));
|
||||
|
||||
let pct = if total_keys > 0 {
|
||||
confident_keys as f64 / total_keys as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", progress_bar_str(pct, 30)),
|
||||
style,
|
||||
)));
|
||||
let (mastered_bar, unlocked_bar, empty_bar) =
|
||||
dual_progress_bar_parts(confident_keys, unlocked, total_keys, 30);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", style),
|
||||
Span::styled(mastered_bar, Style::default().fg(colors.text_correct())),
|
||||
Span::styled(unlocked_bar, Style::default().fg(colors.accent())),
|
||||
Span::styled(empty_bar, Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
|
||||
// Add separator after Lowercase (index 0)
|
||||
if branch_id == BranchId::Lowercase {
|
||||
lines.push(Line::from(Span::styled(
|
||||
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
|
||||
Style::default().fg(colors.border()),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
@@ -273,12 +224,20 @@ impl SkillTreeWidget<'_> {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
// Branch title with level info
|
||||
let level_text = match bp.status {
|
||||
BranchStatus::InProgress => {
|
||||
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
||||
let level_text = if branch_id == BranchId::Lowercase {
|
||||
let unlocked = self.skill_tree.branch_unlocked_count(BranchId::Lowercase);
|
||||
let total = SkillTreeEngine::branch_total_keys(BranchId::Lowercase);
|
||||
format!("Unlocked {unlocked}/{total} letters")
|
||||
} else {
|
||||
match bp.status {
|
||||
BranchStatus::InProgress => {
|
||||
format!("Level {}/{}", bp.current_level + 1, def.levels.len())
|
||||
}
|
||||
BranchStatus::Complete => {
|
||||
format!("Level {}/{}", def.levels.len(), def.levels.len())
|
||||
}
|
||||
_ => format!("Level 0/{}", def.levels.len()),
|
||||
}
|
||||
BranchStatus::Complete => format!("Level {}/{}", def.levels.len(), def.levels.len()),
|
||||
_ => format!("Level 0/{}", def.levels.len()),
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
@@ -293,11 +252,19 @@ impl SkillTreeWidget<'_> {
|
||||
),
|
||||
]));
|
||||
|
||||
// Per-level key breakdown
|
||||
// Per-level key breakdown with per-key mastery bars
|
||||
let focused = self
|
||||
.skill_tree
|
||||
.focused_key(DrillScope::Branch(branch_id), self.key_stats);
|
||||
|
||||
// For Lowercase, determine which keys are unlocked
|
||||
let lowercase_unlocked_keys: Vec<char> = if branch_id == BranchId::Lowercase {
|
||||
self.skill_tree
|
||||
.unlocked_keys(DrillScope::Branch(BranchId::Lowercase))
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for (level_idx, level) in def.levels.iter().enumerate() {
|
||||
let level_status =
|
||||
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
|
||||
@@ -308,79 +275,111 @@ impl SkillTreeWidget<'_> {
|
||||
"locked"
|
||||
};
|
||||
|
||||
let mut key_spans: Vec<Span> = Vec::new();
|
||||
key_spans.push(Span::styled(
|
||||
format!(" L{}: ", level_idx + 1),
|
||||
// Level header
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" L{}: {} ({level_status})", level_idx + 1, level.name),
|
||||
Style::default().fg(colors.fg()),
|
||||
));
|
||||
)));
|
||||
|
||||
// Per-key mastery bars
|
||||
for &key in level.keys {
|
||||
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
|
||||
let is_focused = focused == Some(key);
|
||||
let confidence = self.key_stats.get_confidence(key).min(1.0);
|
||||
let is_confident = confidence >= 1.0;
|
||||
|
||||
// For Lowercase, check if this specific key is unlocked
|
||||
let is_locked = if branch_id == BranchId::Lowercase {
|
||||
!lowercase_unlocked_keys.contains(&key)
|
||||
} else {
|
||||
level_status == "locked"
|
||||
};
|
||||
|
||||
let display = if key == '\n' {
|
||||
"\\n".to_string()
|
||||
} else if key == '\t' {
|
||||
"\\t".to_string()
|
||||
} else {
|
||||
key.to_string()
|
||||
format!(" {key}")
|
||||
};
|
||||
|
||||
let style = if is_focused {
|
||||
Style::default()
|
||||
.fg(colors.bg())
|
||||
.bg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_confident {
|
||||
Style::default().fg(colors.text_correct())
|
||||
} else if level_status == "locked" {
|
||||
Style::default().fg(colors.text_pending())
|
||||
if is_locked {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {display} "),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
Span::styled("locked", Style::default().fg(colors.text_pending())),
|
||||
]));
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
};
|
||||
let bar_width = 10;
|
||||
let filled = (confidence * bar_width as f64).round() as usize;
|
||||
let empty = bar_width - filled;
|
||||
let bar = format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty));
|
||||
let pct_str = format!("{:>3.0}%", confidence * 100.0);
|
||||
let focus_label = if is_focused { " in focus" } else { "" };
|
||||
|
||||
key_spans.push(Span::styled(display, style));
|
||||
key_spans.push(Span::raw(" "));
|
||||
let key_style = if is_focused {
|
||||
Style::default()
|
||||
.fg(colors.bg())
|
||||
.bg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if is_confident {
|
||||
Style::default().fg(colors.text_correct())
|
||||
} else {
|
||||
Style::default().fg(colors.fg())
|
||||
};
|
||||
|
||||
let bar_color = if is_confident {
|
||||
colors.text_correct()
|
||||
} else {
|
||||
colors.accent()
|
||||
};
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {display} "), key_style),
|
||||
Span::styled(bar, Style::default().fg(bar_color)),
|
||||
Span::styled(
|
||||
format!(" {pct_str}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
Span::styled(
|
||||
focus_label,
|
||||
Style::default()
|
||||
.fg(colors.focused_key())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
key_spans.push(Span::styled(
|
||||
format!(" ({level_status})"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
));
|
||||
|
||||
lines.push(Line::from(key_spans));
|
||||
}
|
||||
|
||||
// Average confidence
|
||||
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
|
||||
let avg_conf = if total_keys > 0 {
|
||||
let sum: f64 = def
|
||||
.levels
|
||||
.iter()
|
||||
.flat_map(|l| l.keys.iter())
|
||||
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
|
||||
.sum();
|
||||
sum / total_keys as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(
|
||||
" Avg Confidence: {} {:.0}%",
|
||||
progress_bar_str(avg_conf, 20),
|
||||
avg_conf * 100.0
|
||||
),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
)));
|
||||
|
||||
let paragraph = Paragraph::new(lines);
|
||||
let visible_height = area.height as usize;
|
||||
if visible_height == 0 {
|
||||
return;
|
||||
}
|
||||
let max_scroll = lines.len().saturating_sub(visible_height);
|
||||
let scroll = self.detail_scroll.min(max_scroll);
|
||||
let visible_lines: Vec<Line> = lines.into_iter().skip(scroll).take(visible_height).collect();
|
||||
let paragraph = Paragraph::new(visible_lines);
|
||||
paragraph.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_bar_str(pct: f64, width: usize) -> String {
|
||||
let filled = (pct * width as f64).round() as usize;
|
||||
let empty = width.saturating_sub(filled);
|
||||
format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty),)
|
||||
fn dual_progress_bar_parts(
|
||||
mastered: usize,
|
||||
unlocked: usize,
|
||||
total: usize,
|
||||
width: usize,
|
||||
) -> (String, String, String) {
|
||||
if total == 0 {
|
||||
return (String::new(), String::new(), "\u{2591}".repeat(width));
|
||||
}
|
||||
let mastered_cells = mastered * width / total;
|
||||
let unlocked_cells = (unlocked * width / total).max(mastered_cells);
|
||||
let empty_cells = width - unlocked_cells;
|
||||
(
|
||||
"\u{2588}".repeat(mastered_cells),
|
||||
"\u{2593}".repeat(unlocked_cells - mastered_cells),
|
||||
"\u{2591}".repeat(empty_cells),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Widget};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::engine::key_stats::KeyStatsStore;
|
||||
use crate::keyboard::model::KeyboardModel;
|
||||
use crate::session::result::DrillResult;
|
||||
use crate::ui::components::activity_heatmap::ActivityHeatmap;
|
||||
use crate::ui::theme::Theme;
|
||||
@@ -17,6 +19,7 @@ pub struct StatsDashboard<'a> {
|
||||
pub theme: &'a Theme,
|
||||
pub history_selected: usize,
|
||||
pub history_confirm_delete: bool,
|
||||
pub keyboard_model: &'a KeyboardModel,
|
||||
}
|
||||
|
||||
impl<'a> StatsDashboard<'a> {
|
||||
@@ -28,6 +31,7 @@ impl<'a> StatsDashboard<'a> {
|
||||
theme: &'a Theme,
|
||||
history_selected: usize,
|
||||
history_confirm_delete: bool,
|
||||
keyboard_model: &'a KeyboardModel,
|
||||
) -> Self {
|
||||
Self {
|
||||
history,
|
||||
@@ -37,6 +41,7 @@ impl<'a> StatsDashboard<'a> {
|
||||
theme,
|
||||
history_selected,
|
||||
history_confirm_delete,
|
||||
keyboard_model,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,7 +76,13 @@ impl Widget for StatsDashboard<'_> {
|
||||
.split(inner);
|
||||
|
||||
// Tab header
|
||||
let tabs = ["[1] Dashboard", "[2] History", "[3] Keystrokes"];
|
||||
let tabs = [
|
||||
"[1] Dashboard",
|
||||
"[2] History",
|
||||
"[3] Activity",
|
||||
"[4] Accuracy",
|
||||
"[5] Timing",
|
||||
];
|
||||
let tab_spans: Vec<Span> = tabs
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -88,29 +99,14 @@ impl Widget for StatsDashboard<'_> {
|
||||
.collect();
|
||||
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
|
||||
|
||||
// Tab content — wide mode shows two panels side by side
|
||||
let is_wide = area.width > 170;
|
||||
if is_wide {
|
||||
let panels = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
|
||||
// Left panel: active tab, Right panel: next tab
|
||||
let left_tab = self.active_tab;
|
||||
let right_tab = (self.active_tab + 1) % 3;
|
||||
|
||||
self.render_tab(left_tab, panels[0], buf);
|
||||
self.render_tab(right_tab, panels[1], buf);
|
||||
} else {
|
||||
self.render_tab(self.active_tab, layout[1], buf);
|
||||
}
|
||||
// Render only one tab at a time so each tab gets full breathing room.
|
||||
self.render_tab(self.active_tab, layout[1], buf);
|
||||
|
||||
// Footer
|
||||
let footer_text = if self.active_tab == 1 {
|
||||
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete"
|
||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab [j/k] Navigate [x] Delete"
|
||||
} else {
|
||||
" [ESC] Back [Tab] Next tab [1/2/3] Switch tab"
|
||||
" [ESC] Back [Tab] Next tab [1-5] Switch tab"
|
||||
};
|
||||
let footer = Paragraph::new(Line::from(Span::styled(
|
||||
footer_text,
|
||||
@@ -152,11 +148,53 @@ impl StatsDashboard<'_> {
|
||||
match tab {
|
||||
0 => self.render_dashboard_tab(area, buf),
|
||||
1 => self.render_history_tab(area, buf),
|
||||
2 => self.render_keystrokes_tab(area, buf),
|
||||
2 => self.render_activity_tab(area, buf),
|
||||
3 => self.render_accuracy_tab(area, buf),
|
||||
4 => self.render_timing_tab(area, buf),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_activity_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(9), Constraint::Length(4)])
|
||||
.split(area);
|
||||
ActivityHeatmap::new(self.history, self.theme).render(layout[0], buf);
|
||||
self.render_activity_stats(layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_accuracy_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
.split(area);
|
||||
self.render_keyboard_heatmap(layout[0], buf);
|
||||
let lists = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
self.render_worst_accuracy_keys(lists[0], buf);
|
||||
self.render_best_accuracy_keys(lists[1], buf);
|
||||
}
|
||||
|
||||
fn render_timing_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let kbd_height: u16 = if area.width >= 96 { 10 } else { 8 };
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(kbd_height), Constraint::Min(6)])
|
||||
.split(area);
|
||||
self.render_keyboard_timing(layout[0], buf);
|
||||
|
||||
let lists = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(layout[1]);
|
||||
self.render_slowest_keys(lists[0], buf);
|
||||
self.render_fastest_keys(lists[1], buf);
|
||||
}
|
||||
|
||||
fn render_dashboard_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
@@ -183,8 +221,13 @@ impl StatsDashboard<'_> {
|
||||
let time_str = format_duration(total_time);
|
||||
|
||||
let summary_block = Block::bordered()
|
||||
.title(" Summary ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Summary ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let summary_inner = summary_block.inner(layout[0]);
|
||||
summary_block.render(layout[0], buf);
|
||||
|
||||
@@ -243,8 +286,13 @@ impl StatsDashboard<'_> {
|
||||
|
||||
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
|
||||
let block = Block::bordered()
|
||||
.title(target_label)
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
target_label,
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -376,8 +424,13 @@ impl StatsDashboard<'_> {
|
||||
|
||||
if data.is_empty() {
|
||||
let block = Block::bordered()
|
||||
.title(" Accuracy % (Last 50 Drills) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
block.render(area, buf);
|
||||
return;
|
||||
}
|
||||
@@ -393,8 +446,13 @@ impl StatsDashboard<'_> {
|
||||
let chart = Chart::new(vec![dataset])
|
||||
.block(
|
||||
Block::bordered()
|
||||
.title(" Accuracy % (Last 50 Drills) ")
|
||||
.border_style(Style::default().fg(colors.border())),
|
||||
.title(Line::from(Span::styled(
|
||||
" Accuracy % (Last 50 Drills) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent())),
|
||||
)
|
||||
.x_axis(
|
||||
Axis::default()
|
||||
@@ -406,6 +464,11 @@ impl StatsDashboard<'_> {
|
||||
Axis::default()
|
||||
.title("Accuracy %")
|
||||
.style(Style::default().fg(colors.text_pending()))
|
||||
.labels(vec![
|
||||
Span::styled("80", Style::default().fg(colors.text_pending())),
|
||||
Span::styled("90", Style::default().fg(colors.text_pending())),
|
||||
Span::styled("100", Style::default().fg(colors.text_pending())),
|
||||
])
|
||||
.bounds([80.0, 100.0]),
|
||||
);
|
||||
|
||||
@@ -490,17 +553,17 @@ impl StatsDashboard<'_> {
|
||||
fn render_history_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(10), Constraint::Length(8)])
|
||||
.split(area);
|
||||
|
||||
// Recent tests bordered table
|
||||
let table_block = Block::bordered()
|
||||
.title(" Recent Sessions ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let table_inner = table_block.inner(layout[0]);
|
||||
table_block.render(layout[0], buf);
|
||||
.title(Line::from(Span::styled(
|
||||
" Recent Sessions ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let table_inner = table_block.inner(area);
|
||||
table_block.render(area, buf);
|
||||
|
||||
let header = Line::from(vec![Span::styled(
|
||||
" # WPM Raw Acc% Time Date Mode",
|
||||
@@ -566,190 +629,88 @@ impl StatsDashboard<'_> {
|
||||
}
|
||||
|
||||
Paragraph::new(lines).render(table_inner, buf);
|
||||
|
||||
// Per-key speed distribution
|
||||
self.render_per_key_speed(layout[1], buf);
|
||||
}
|
||||
|
||||
fn render_per_key_speed(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Avg Key Time by Character ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let columns_per_row: usize = 13;
|
||||
let col_width: u16 = 4;
|
||||
let row_height: u16 = 3;
|
||||
|
||||
if inner.width < columns_per_row as u16 * col_width || inner.height < row_height {
|
||||
return;
|
||||
}
|
||||
|
||||
let letters: Vec<char> = ('a'..='z').collect();
|
||||
let row_count = if inner.height >= row_height * 2 { 2 } else { 1 };
|
||||
let max_time = letters
|
||||
.iter()
|
||||
.filter_map(|&ch| self.key_stats.stats.get(&ch))
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.fold(0.0f64, f64::max)
|
||||
.max(1.0);
|
||||
|
||||
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||
|
||||
for (i, &ch) in letters.iter().take(columns_per_row * row_count).enumerate() {
|
||||
let row = i / columns_per_row;
|
||||
let col = i % columns_per_row;
|
||||
let x = inner.x + (col as u16 * col_width);
|
||||
let y = inner.y + row as u16 * row_height;
|
||||
|
||||
if x + col_width > inner.x + inner.width || y + 2 >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let time = self
|
||||
.key_stats
|
||||
.stats
|
||||
.get(&ch)
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let ratio = time / max_time;
|
||||
let color = if ratio < 0.3 {
|
||||
colors.success()
|
||||
} else if ratio < 0.6 {
|
||||
colors.accent()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
|
||||
// Letter label
|
||||
buf.set_string(x, y, &ch.to_string(), Style::default().fg(color));
|
||||
|
||||
// Bar indicator
|
||||
let bar_char = if time > 0.0 {
|
||||
let idx = ((ratio * 7.0).round() as usize).min(7);
|
||||
bar_chars[idx]
|
||||
} else {
|
||||
' '
|
||||
};
|
||||
buf.set_string(x, y + 1, &bar_char.to_string(), Style::default().fg(color));
|
||||
|
||||
// Time label on row 3, render seconds when value exceeds 999ms.
|
||||
if time > 0.0 {
|
||||
let time_label = if time > 999.0 {
|
||||
format!("({:.0}s)", time / 1000.0)
|
||||
} else {
|
||||
format!("{time:.0}")
|
||||
};
|
||||
let label = if time_label.len() > col_width as usize {
|
||||
let start = time_label.len() - col_width as usize;
|
||||
&time_label[start..]
|
||||
} else {
|
||||
&time_label
|
||||
};
|
||||
let label_x = x + col_width.saturating_sub(label.len() as u16);
|
||||
buf.set_string(
|
||||
label_x,
|
||||
y + 2,
|
||||
label,
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_keystrokes_tab(&self, area: Rect, buf: &mut Buffer) {
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(12), // Activity heatmap
|
||||
Constraint::Length(7), // Keyboard accuracy heatmap
|
||||
Constraint::Min(5), // Slowest/Fastest/Stats
|
||||
Constraint::Length(5), // Overall stats
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Activity heatmap
|
||||
let heatmap = ActivityHeatmap::new(self.history, self.theme);
|
||||
heatmap.render(layout[0], buf);
|
||||
|
||||
// Keyboard accuracy heatmap with percentages
|
||||
self.render_keyboard_heatmap(layout[1], buf);
|
||||
|
||||
// Slowest/Fastest/Worst keys
|
||||
let key_layout = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage(33),
|
||||
Constraint::Percentage(34),
|
||||
Constraint::Percentage(33),
|
||||
])
|
||||
.split(layout[2]);
|
||||
|
||||
self.render_slowest_keys(key_layout[0], buf);
|
||||
self.render_fastest_keys(key_layout[1], buf);
|
||||
self.render_worst_accuracy_keys(key_layout[2], buf);
|
||||
|
||||
// Overall stats
|
||||
self.render_overall_stats(layout[3], buf);
|
||||
}
|
||||
|
||||
fn render_keyboard_heatmap(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Keyboard Accuracy % ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Accuracy % ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 || inner.width < 50 {
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows: &[&[char]] = &[
|
||||
&['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
|
||||
&['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
|
||||
&['z', 'x', 'c', 'v', 'b', 'n', 'm'],
|
||||
];
|
||||
let offsets: &[u16] = &[1, 3, 5];
|
||||
let key_width: u16 = 5; // wider to fit accuracy %
|
||||
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
|
||||
(5, 6)
|
||||
} else if inner.width >= required_kbd_width(4, 5) {
|
||||
(4, 5)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let y = inner.y + row_idx as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
let base_y = if show_shifted {
|
||||
inner.y + row_idx as u16 * 2 + 1 // shifted on top, base below
|
||||
} else {
|
||||
inner.y + row_idx as u16
|
||||
};
|
||||
|
||||
if base_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
for (col_idx, &key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_width;
|
||||
// Shifted row (dimmer)
|
||||
if show_shifted {
|
||||
let shifted_y = base_y - 1;
|
||||
if shifted_y >= inner.y {
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.shifted;
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(
|
||||
x,
|
||||
shifted_y,
|
||||
&display,
|
||||
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base row
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.base;
|
||||
let accuracy = self.get_key_accuracy(key);
|
||||
let (fg_color, bg_color) = if accuracy <= 0.0 {
|
||||
(colors.text_pending(), colors.bg())
|
||||
} else if accuracy >= 98.0 {
|
||||
(colors.success(), colors.bg())
|
||||
} else if accuracy >= 90.0 {
|
||||
(colors.warning(), colors.bg())
|
||||
} else {
|
||||
(colors.error(), colors.bg())
|
||||
};
|
||||
let fg_color = accuracy_color(accuracy, colors);
|
||||
|
||||
let display = if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
format!("{key}{pct:>3}")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
};
|
||||
buf.set_string(x, y, &display, Style::default().fg(fg_color).bg(bg_color));
|
||||
let display = format_accuracy_cell(key, accuracy, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -775,12 +736,106 @@ impl StatsDashboard<'_> {
|
||||
correct as f64 / total as f64 * 100.0
|
||||
}
|
||||
|
||||
fn get_key_time_ms(&self, key: char) -> f64 {
|
||||
self.key_stats
|
||||
.stats
|
||||
.get(&key)
|
||||
.filter(|s| s.sample_count > 0)
|
||||
.map(|s| s.filtered_time_ms)
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn render_keyboard_timing(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Keyboard Timing (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
if inner.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (key_width, key_step) = if inner.width >= required_kbd_width(5, 6) {
|
||||
(5, 6)
|
||||
} else if inner.width >= required_kbd_width(4, 5) {
|
||||
(4, 5)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let show_shifted = inner.height >= 6;
|
||||
let all_rows = &self.keyboard_model.rows;
|
||||
let offsets: &[u16] = &[0, 2, 3, 4];
|
||||
|
||||
for (row_idx, row) in all_rows.iter().enumerate() {
|
||||
let base_y = if show_shifted {
|
||||
inner.y + row_idx as u16 * 2 + 1
|
||||
} else {
|
||||
inner.y + row_idx as u16
|
||||
};
|
||||
|
||||
if base_y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
|
||||
let offset = offsets.get(row_idx).copied().unwrap_or(0);
|
||||
|
||||
if show_shifted {
|
||||
let shifted_y = base_y - 1;
|
||||
if shifted_y >= inner.y {
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.shifted;
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(
|
||||
x,
|
||||
shifted_y,
|
||||
&display,
|
||||
Style::default().fg(fg_color).add_modifier(Modifier::DIM),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (col_idx, physical_key) in row.iter().enumerate() {
|
||||
let x = inner.x + offset + col_idx as u16 * key_step;
|
||||
if x + key_width > inner.x + inner.width {
|
||||
break;
|
||||
}
|
||||
|
||||
let key = physical_key.base;
|
||||
let time_ms = self.get_key_time_ms(key);
|
||||
let fg_color = timing_color(time_ms, colors);
|
||||
let display = format_timing_cell(key, time_ms, key_width);
|
||||
buf.set_string(x, base_y, &display, Style::default().fg(fg_color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_slowest_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Slowest Keys (ms) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Slowest Keys (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -793,13 +848,27 @@ impl StatsDashboard<'_> {
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let max_time = key_times.first().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(inner.x, y, &text, Style::default().fg(colors.error()));
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.error()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((time / max_time) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(
|
||||
inner.x + label_len,
|
||||
y,
|
||||
&bar,
|
||||
Style::default().fg(colors.error()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,8 +876,13 @@ impl StatsDashboard<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Fastest Keys (ms) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Fastest Keys (ms) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
@@ -821,13 +895,27 @@ impl StatsDashboard<'_> {
|
||||
.collect();
|
||||
key_times.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(5).enumerate() {
|
||||
let max_time = key_times.last().map(|(_, t)| *t).unwrap_or(1.0);
|
||||
|
||||
for (i, (ch, time)) in key_times.iter().take(inner.height as usize).enumerate() {
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let text = format!(" '{ch}' {time:.0}ms");
|
||||
buf.set_string(inner.x, y, &text, Style::default().fg(colors.success()));
|
||||
let label = format!(" {ch} {time:>4.0}ms ");
|
||||
let label_len = label.len() as u16;
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(colors.success()));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 && max_time > 0.0 {
|
||||
let filled = ((time / max_time) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(
|
||||
inner.x + label_len,
|
||||
y,
|
||||
&bar,
|
||||
Style::default().fg(colors.success()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,29 +923,32 @@ impl StatsDashboard<'_> {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Worst Accuracy Keys (%) ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Worst Accuracy (%) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
// Compute accuracy for each key
|
||||
let mut key_accuracies: Vec<(char, f64, usize)> = ('a'..='z')
|
||||
// Collect all keys from keyboard model
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
all_keys.insert(pk.base);
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
.filter_map(|ch| {
|
||||
let mut correct = 0usize;
|
||||
let mut total = 0usize;
|
||||
for result in self.history {
|
||||
for kt in &result.per_key_times {
|
||||
if kt.key == ch {
|
||||
total += 1;
|
||||
if kt.correct {
|
||||
correct += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if total >= 5 {
|
||||
let acc = correct as f64 / total as f64 * 100.0;
|
||||
Some((ch, acc, total))
|
||||
let accuracy = self.get_key_accuracy(ch);
|
||||
// Only include keys with enough data and imperfect accuracy
|
||||
if accuracy > 0.0 && accuracy < 100.0 {
|
||||
Some((ch, accuracy))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -870,70 +961,242 @@ impl StatsDashboard<'_> {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
" Not enough data",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, (ch, acc, _total)) in key_accuracies.iter().take(5).enumerate() {
|
||||
for (i, (ch, acc)) in key_accuracies
|
||||
.iter()
|
||||
.take(inner.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let badge = format!(" '{ch}' {acc:.1}%");
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 95.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
};
|
||||
buf.set_string(inner.x, y, &badge, Style::default().fg(color));
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(color));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overall_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
fn render_best_accuracy_keys(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
|
||||
let block = Block::bordered()
|
||||
.title(" Overall Totals ")
|
||||
.border_style(Style::default().fg(colors.border()));
|
||||
.title(Line::from(Span::styled(
|
||||
" Best Accuracy (%) ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let total_chars: usize = self.history.iter().map(|r| r.total_chars).sum();
|
||||
let total_correct: usize = self.history.iter().map(|r| r.correct).sum();
|
||||
let total_incorrect: usize = self.history.iter().map(|r| r.incorrect).sum();
|
||||
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
|
||||
let mut all_keys = std::collections::HashSet::new();
|
||||
for row in &self.keyboard_model.rows {
|
||||
for pk in row {
|
||||
all_keys.insert(pk.base);
|
||||
all_keys.insert(pk.shifted);
|
||||
}
|
||||
}
|
||||
|
||||
let mut key_accuracies: Vec<(char, f64)> = all_keys
|
||||
.into_iter()
|
||||
.filter_map(|ch| {
|
||||
let accuracy = self.get_key_accuracy(ch);
|
||||
if accuracy > 0.0 {
|
||||
Some((ch, accuracy))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
key_accuracies.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
|
||||
|
||||
if key_accuracies.is_empty() {
|
||||
buf.set_string(
|
||||
inner.x,
|
||||
inner.y,
|
||||
" Not enough data",
|
||||
Style::default().fg(colors.text_pending()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, (ch, acc)) in key_accuracies
|
||||
.iter()
|
||||
.take(inner.height as usize)
|
||||
.enumerate()
|
||||
{
|
||||
let y = inner.y + i as u16;
|
||||
if y >= inner.y + inner.height {
|
||||
break;
|
||||
}
|
||||
let label = format!(" {ch} {acc:>5.1}% ");
|
||||
let label_len = label.len() as u16;
|
||||
let color = if *acc >= 98.0 {
|
||||
colors.success()
|
||||
} else {
|
||||
colors.warning()
|
||||
};
|
||||
buf.set_string(inner.x, y, &label, Style::default().fg(color));
|
||||
let bar_space = inner.width.saturating_sub(label_len) as usize;
|
||||
if bar_space > 0 {
|
||||
let filled = ((acc / 100.0) * bar_space as f64).round() as usize;
|
||||
let bar = "\u{2588}".repeat(filled.min(bar_space));
|
||||
buf.set_string(inner.x + label_len, y, &bar, Style::default().fg(color));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_activity_stats(&self, area: Rect, buf: &mut Buffer) {
|
||||
let colors = &self.theme.colors;
|
||||
let block = Block::bordered()
|
||||
.title(Line::from(Span::styled(
|
||||
" Streaks ",
|
||||
Style::default()
|
||||
.fg(colors.accent())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)))
|
||||
.border_style(Style::default().fg(colors.accent()));
|
||||
let inner = block.inner(area);
|
||||
block.render(area, buf);
|
||||
|
||||
let mut active_days: BTreeSet<chrono::NaiveDate> = BTreeSet::new();
|
||||
for r in self.history {
|
||||
active_days.insert(r.timestamp.date_naive());
|
||||
}
|
||||
let (current_streak, best_streak) = compute_streaks(&active_days);
|
||||
let active_days_count = active_days.len();
|
||||
|
||||
let lines = vec![Line::from(vec![
|
||||
Span::styled(" Characters: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Current: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_chars}"),
|
||||
Style::default().fg(colors.accent()),
|
||||
format!("{current_streak}d"),
|
||||
Style::default()
|
||||
.fg(colors.success())
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Correct: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Best: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_correct}"),
|
||||
Style::default().fg(colors.success()),
|
||||
format!("{best_streak}d"),
|
||||
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled(" Errors: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(" Active Days: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format!("{total_incorrect}"),
|
||||
Style::default().fg(if total_incorrect > 0 {
|
||||
colors.error()
|
||||
} else {
|
||||
colors.success()
|
||||
}),
|
||||
),
|
||||
Span::styled(" Time: ", Style::default().fg(colors.fg())),
|
||||
Span::styled(
|
||||
format_duration(total_time),
|
||||
format!("{active_days_count}"),
|
||||
Style::default().fg(colors.text_pending()),
|
||||
),
|
||||
])];
|
||||
|
||||
Paragraph::new(lines).render(inner, buf);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn accuracy_color(accuracy: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if accuracy <= 0.0 {
|
||||
colors.text_pending()
|
||||
} else if accuracy >= 98.0 {
|
||||
colors.success()
|
||||
} else if accuracy >= 90.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_accuracy_cell(key: char, accuracy: f64, key_width: u16) -> String {
|
||||
if accuracy > 0.0 {
|
||||
let pct = accuracy.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{key}{pct:>3}")
|
||||
} else {
|
||||
format!("{key}{pct:>2}")
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{key} ")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_color(time_ms: f64, colors: &crate::ui::theme::ThemeColors) -> ratatui::style::Color {
|
||||
if time_ms <= 0.0 {
|
||||
colors.text_pending()
|
||||
} else if time_ms <= 200.0 {
|
||||
colors.success()
|
||||
} else if time_ms <= 400.0 {
|
||||
colors.warning()
|
||||
} else {
|
||||
colors.error()
|
||||
}
|
||||
}
|
||||
|
||||
fn required_kbd_width(key_width: u16, key_step: u16) -> u16 {
|
||||
let max_offset: u16 = 4;
|
||||
max_offset + 12 * key_step + key_width
|
||||
}
|
||||
|
||||
fn compute_streaks(active_days: &BTreeSet<chrono::NaiveDate>) -> (usize, usize) {
|
||||
if active_days.is_empty() {
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
let mut best = 1usize;
|
||||
let mut run = 1usize;
|
||||
let mut prev = None;
|
||||
for &day in active_days {
|
||||
if let Some(p) = prev {
|
||||
if day.signed_duration_since(p).num_days() == 1 {
|
||||
run += 1;
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
best = best.max(run);
|
||||
}
|
||||
prev = Some(day);
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let mut current = 0usize;
|
||||
let mut cursor = today;
|
||||
while active_days.contains(&cursor) {
|
||||
current += 1;
|
||||
cursor -= chrono::Duration::days(1);
|
||||
}
|
||||
|
||||
(current, best)
|
||||
}
|
||||
|
||||
fn format_timing_cell(key: char, time_ms: f64, key_width: u16) -> String {
|
||||
if time_ms > 0.0 {
|
||||
let ms = time_ms.round() as u32;
|
||||
if key_width >= 5 {
|
||||
format!("{key}{ms:>4}")
|
||||
} else {
|
||||
format!("{key}{:>3}", ms.min(999))
|
||||
}
|
||||
} else if key_width >= 5 {
|
||||
format!("{key} ")
|
||||
} else {
|
||||
format!("{key} ")
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text_bar(
|
||||
|
||||
Reference in New Issue
Block a user