Add more themes and rustfmt

This commit is contained in:
2026-02-16 22:12:29 +00:00
parent 6d6815af02
commit edd2f7e6b5
36 changed files with 854 additions and 329 deletions

23
assets/themes/farout.toml Normal file
View File

@@ -0,0 +1,23 @@
name = "farout"
[colors]
bg = "#0f0908"
fg = "#E0CCAE"
text_correct = "#a4896f"
text_incorrect = "#bf472c"
text_incorrect_bg = "#392D2B"
text_pending = "#A67458"
text_cursor_bg = "#0f0908"
text_cursor_fg = "#f2a766"
focused_key = "#f2a766"
accent = "#d47d49"
accent_dim = "#392D2B"
border = "#392D2B"
border_focused = "#d47d49"
header_bg = "#392D2B"
header_fg = "#E0CCAE"
bar_filled = "#a67458"
bar_empty = "#392D2B"
error = "#bf472c"
warning = "#f2a766"
success = "#a4896f"

View File

@@ -0,0 +1,23 @@
name = "gruvbox-darkest"
[colors]
bg = "#121212"
fg = "#ebdbb2"
text_correct = "#b8bb26"
text_incorrect = "#fb4934"
text_incorrect_bg = "#462726"
text_pending = "#a89984"
text_cursor_bg = "#fabd2f"
text_cursor_fg = "#121212"
focused_key = "#fabd2f"
accent = "#83a598"
accent_dim = "#3c3836"
border = "#504945"
border_focused = "#83a598"
header_bg = "#3c3836"
header_fg = "#ebdbb2"
bar_filled = "#83a598"
bar_empty = "#3c3836"
error = "#fb4934"
warning = "#fabd2f"
success = "#b8bb26"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-dragon"
[colors]
bg = "#181616"
fg = "#c5c9c5"
text_correct = "#8a9a7b"
text_incorrect = "#c4746e"
text_incorrect_bg = "#43242B"
text_pending = "#a6a69c"
text_cursor_bg = "#2d4f67"
text_cursor_fg = "#c8c093"
focused_key = "#c4b28a"
accent = "#8ba4b0"
accent_dim = "#282727"
border = "#625e5a"
border_focused = "#8ba4b0"
header_bg = "#282727"
header_fg = "#c5c9c5"
bar_filled = "#8ea4a2"
bar_empty = "#282727"
error = "#c4746e"
warning = "#c4b28a"
success = "#8a9a7b"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-lotus"
[colors]
bg = "#f2ecbc"
fg = "#545464"
text_correct = "#6f894e"
text_incorrect = "#c84053"
text_incorrect_bg = "#d9a594"
text_pending = "#8a8980"
text_cursor_bg = "#5d57a3"
text_cursor_fg = "#f2ecbc"
focused_key = "#77713f"
accent = "#4d699b"
accent_dim = "#e7dba0"
border = "#a5a37d"
border_focused = "#4d699b"
header_bg = "#e7dba0"
header_fg = "#545464"
bar_filled = "#597b75"
bar_empty = "#d9d0a3"
error = "#c84053"
warning = "#77713f"
success = "#6f894e"

View File

@@ -0,0 +1,23 @@
name = "kanagawa-wave"
[colors]
bg = "#1f1f28"
fg = "#dcd7ba"
text_correct = "#76946a"
text_incorrect = "#c34043"
text_incorrect_bg = "#43242B"
text_pending = "#727169"
text_cursor_bg = "#2d4f67"
text_cursor_fg = "#c8c093"
focused_key = "#c0a36e"
accent = "#7e9cd8"
accent_dim = "#2A2A37"
border = "#54546D"
border_focused = "#7e9cd8"
header_bg = "#2A2A37"
header_fg = "#dcd7ba"
bar_filled = "#7e9cd8"
bar_empty = "#2A2A37"
error = "#c34043"
warning = "#c0a36e"
success = "#76946a"

View File

@@ -0,0 +1,23 @@
name = "terminal-default"
[colors]
bg = "reset"
fg = "reset"
text_correct = "green"
text_incorrect = "red"
text_incorrect_bg = "reset"
text_pending = "darkgray"
text_cursor_bg = "reset"
text_cursor_fg = "reset"
focused_key = "yellow"
accent = "blue"
accent_dim = "darkgray"
border = "darkgray"
border_focused = "blue"
header_bg = "reset"
header_fg = "reset"
bar_filled = "blue"
bar_empty = "darkgray"
error = "red"
warning = "yellow"
success = "green"

View File

@@ -1,14 +1,15 @@
use std::collections::HashSet;
use std::time::Instant;
use rand::rngs::SmallRng;
use rand::SeedableRng;
use rand::rngs::SmallRng;
use crate::config::Config;
use crate::engine::filter::CharFilter;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::scoring;
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree};
use crate::generator::TextGenerator;
use crate::generator::capitalize;
use crate::generator::code_patterns;
use crate::generator::code_syntax::CodeSyntaxGenerator;
@@ -17,14 +18,13 @@ use crate::generator::numbers;
use crate::generator::passage::PassageGenerator;
use crate::generator::phonetic::PhoneticGenerator;
use crate::generator::punctuate;
use crate::generator::TextGenerator;
use crate::generator::transition_table::TransitionTable;
use crate::session::input::{self, KeystrokeEvent};
use crate::session::drill::DrillState;
use crate::session::input::{self, KeystrokeEvent};
use crate::session::result::DrillResult;
use crate::store::json_store::JsonStore;
use crate::store::schema::{KeyStatsData, DrillHistoryData, ProfileData};
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
use crate::ui::components::menu::Menu;
use crate::ui::theme::Theme;
@@ -182,7 +182,8 @@ impl App {
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
// Generate base lowercase text using only lowercase keys from scope
let lowercase_keys: Vec<char> = all_keys.iter()
let lowercase_keys: Vec<char> = all_keys
.iter()
.copied()
.filter(|ch| ch.is_ascii_lowercase() || *ch == ' ')
.collect();
@@ -196,7 +197,8 @@ impl App {
let mut text = generator.generate(&filter, lowercase_focused, word_count);
// Apply capitalization if uppercase keys are in scope
let cap_keys: Vec<char> = all_keys.iter()
let cap_keys: Vec<char> = all_keys
.iter()
.copied()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
@@ -206,9 +208,15 @@ impl App {
}
// Apply punctuation if punctuation keys are in scope
let punct_keys: Vec<char> = all_keys.iter()
let punct_keys: Vec<char> = all_keys
.iter()
.copied()
.filter(|ch| matches!(ch, '.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')'))
.filter(|ch| {
matches!(
ch,
'.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')'
)
})
.collect();
if !punct_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
@@ -216,7 +224,8 @@ impl App {
}
// Apply numbers if digit keys are in scope
let digit_keys: Vec<char> = all_keys.iter()
let digit_keys: Vec<char> = all_keys
.iter()
.copied()
.filter(|ch| ch.is_ascii_digit())
.collect();
@@ -236,16 +245,44 @@ impl App {
),
};
if code_active {
let symbol_keys: Vec<char> = all_keys.iter()
let symbol_keys: Vec<char> = all_keys
.iter()
.copied()
.filter(|ch| matches!(ch,
'=' | '+' | '*' | '/' | '-' | '{' | '}' | '[' | ']' | '<' | '>' |
'&' | '|' | '^' | '~' | '@' | '#' | '$' | '%' | '_' | '\\' | '`'
))
.filter(|ch| {
matches!(
ch,
'=' | '+'
| '*'
| '/'
| '-'
| '{'
| '}'
| '['
| ']'
| '<'
| '>'
| '&'
| '|'
| '^'
| '~'
| '@'
| '#'
| '$'
| '%'
| '_'
| '\\'
| '`'
)
})
.collect();
if !symbol_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = code_patterns::apply_code_symbols(&text, &symbol_keys, focused, &mut rng);
text = code_patterns::apply_code_symbols(
&text,
&symbol_keys,
focused,
&mut rng,
);
}
}
@@ -298,7 +335,12 @@ impl App {
fn finish_drill(&mut self) {
if let Some(ref drill) = self.drill {
let ranked = self.drill_mode.is_ranked();
let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str(), ranked);
let result = DrillResult::from_drill(
drill,
&self.drill_events,
self.drill_mode.as_str(),
ranked,
);
if ranked {
for kt in &result.per_key_times {
@@ -329,8 +371,7 @@ impl App {
} else {
self.profile.streak_days = 1;
}
self.profile.best_streak =
self.profile.best_streak.max(self.profile.streak_days);
self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days);
self.profile.last_practice_date = Some(today);
}
@@ -447,8 +488,7 @@ impl App {
} else {
self.profile.streak_days = 1;
}
self.profile.best_streak =
self.profile.best_streak.max(self.profile.streak_days);
self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days);
self.profile.last_practice_date = Some(day);
}
}

View File

@@ -22,7 +22,7 @@ fn default_target_wpm() -> u32 {
35
}
fn default_theme() -> String {
"catppuccin-mocha".to_string()
"terminal-default".to_string()
}
fn default_keyboard_layout() -> String {
"qwerty".to_string()

View File

@@ -13,8 +13,6 @@ impl CharFilter {
#[allow(dead_code)]
pub fn filter_text(&self, text: &str) -> String {
text.chars()
.filter(|&ch| self.is_allowed(ch))
.collect()
text.chars().filter(|&ch| self.is_allowed(ch)).collect()
}
}

View File

@@ -48,8 +48,7 @@ impl KeyStatsStore {
if stat.sample_count == 1 {
stat.filtered_time_ms = time_ms;
} else {
stat.filtered_time_ms =
EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
stat.filtered_time_ms = EMA_ALPHA * time_ms + (1.0 - EMA_ALPHA) * stat.filtered_time_ms;
}
stat.best_time_ms = stat.best_time_ms.min(stat.filtered_time_ms);
@@ -64,10 +63,7 @@ impl KeyStatsStore {
}
pub fn get_confidence(&self, key: char) -> f64 {
self.stats
.get(&key)
.map(|s| s.confidence)
.unwrap_or(0.0)
self.stats.get(&key).map(|s| s.confidence).unwrap_or(0.0)
}
#[allow(dead_code)]
@@ -104,7 +100,10 @@ mod tests {
let conf = store.get_confidence('t');
// At 175 CPM target, target_time = 60000/175 = 342.8ms
// With 200ms typing time, confidence = 342.8/200 = 1.71
assert!(conf > 1.0, "confidence should be > 1.0 for fast typing, got {conf}");
assert!(
conf > 1.0,
"confidence should be > 1.0 for fast typing, got {conf}"
);
}
#[test]
@@ -115,6 +114,9 @@ mod tests {
}
let conf = store.get_confidence('a');
// target_time = 342.8ms, typing at 1000ms -> conf = 342.8/1000 = 0.34
assert!(conf < 1.0, "confidence should be < 1.0 for slow typing, got {conf}");
assert!(
conf < 1.0,
"confidence should be < 1.0 for slow typing, got {conf}"
);
}
}

View File

@@ -78,8 +78,8 @@ pub struct BranchDefinition {
const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition {
name: "Frequency Order",
keys: &[
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g',
'y', 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y',
'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z',
],
}];
@@ -293,10 +293,7 @@ impl SkillTree {
status: BranchStatus::Locked,
current_level: 0,
};
self.progress
.branches
.get(id.to_key())
.unwrap_or(&DEFAULT)
self.progress.branches.get(id.to_key()).unwrap_or(&DEFAULT)
}
pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress {
@@ -663,8 +660,14 @@ mod tests {
#[test]
fn test_initial_state() {
let tree = SkillTree::default();
assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::InProgress);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Locked);
assert_eq!(
*tree.branch_status(BranchId::Lowercase),
BranchStatus::InProgress
);
assert_eq!(
*tree.branch_status(BranchId::Capitals),
BranchStatus::Locked
);
assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Locked);
}
@@ -711,12 +714,30 @@ mod tests {
tree.update(&stats);
}
assert_eq!(*tree.branch_status(BranchId::Lowercase), BranchStatus::Complete);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::ProsePunctuation), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::Whitespace), BranchStatus::Available);
assert_eq!(*tree.branch_status(BranchId::CodeSymbols), BranchStatus::Available);
assert_eq!(
*tree.branch_status(BranchId::Lowercase),
BranchStatus::Complete
);
assert_eq!(
*tree.branch_status(BranchId::Capitals),
BranchStatus::Available
);
assert_eq!(
*tree.branch_status(BranchId::Numbers),
BranchStatus::Available
);
assert_eq!(
*tree.branch_status(BranchId::ProsePunctuation),
BranchStatus::Available
);
assert_eq!(
*tree.branch_status(BranchId::Whitespace),
BranchStatus::Available
);
assert_eq!(
*tree.branch_status(BranchId::CodeSymbols),
BranchStatus::Available
);
}
#[test]
@@ -726,7 +747,10 @@ mod tests {
tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::Available;
tree.start_branch(BranchId::Capitals);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress);
assert_eq!(
*tree.branch_status(BranchId::Capitals),
BranchStatus::InProgress
);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 0);
}
@@ -745,7 +769,10 @@ mod tests {
tree.update(&stats);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress);
assert_eq!(
*tree.branch_status(BranchId::Capitals),
BranchStatus::InProgress
);
}
#[test]
@@ -766,7 +793,10 @@ mod tests {
tree.update(&stats);
}
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Complete);
assert_eq!(
*tree.branch_status(BranchId::Capitals),
BranchStatus::Complete
);
}
#[test]

View File

@@ -20,23 +20,25 @@ impl EventHandler {
let (tx, rx) = mpsc::channel();
let _tx = tx.clone();
thread::spawn(move || loop {
if event::poll(tick_rate).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) => {
if tx.send(AppEvent::Key(key)).is_err() {
return;
thread::spawn(move || {
loop {
if event::poll(tick_rate).unwrap_or(false) {
match event::read() {
Ok(Event::Key(key)) => {
if tx.send(AppEvent::Key(key)).is_err() {
return;
}
}
}
Ok(Event::Resize(w, h)) => {
if tx.send(AppEvent::Resize(w, h)).is_err() {
return;
Ok(Event::Resize(w, h)) => {
if tx.send(AppEvent::Resize(w, h)).is_err() {
return;
}
}
_ => {}
}
_ => {}
} else if tx.send(AppEvent::Tick).is_err() {
return;
}
} else if tx.send(AppEvent::Tick).is_err() {
return;
}
});

View File

@@ -24,7 +24,13 @@ impl DiskCache {
fn sanitize_key(key: &str) -> String {
key.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect()
}
}

View File

@@ -1,5 +1,5 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
/// Post-processing pass that capitalizes words in generated text.
/// Only capitalizes using letters from `unlocked_capitals`.
@@ -39,11 +39,16 @@ pub fn apply_capitalization(
// Capitalize word starts: boosted for focused key, ~12% for others
if ch.is_ascii_lowercase() && !at_sentence_start {
let is_word_start = i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' ');
let is_word_start =
i == 0 || text.as_bytes().get(i - 1).map(|&b| b as char) == Some(' ');
if is_word_start {
let upper = ch.to_ascii_uppercase();
if unlocked_capitals.contains(&upper) {
let prob = if focused_upper == Some(upper) { 0.40 } else { 0.12 };
let prob = if focused_upper == Some(upper) {
0.40
} else {
0.12
};
if rng.gen_bool(prob) {
result.push(upper);
continue;

View File

@@ -1,5 +1,5 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
/// Post-processing pass that inserts code-like expressions into text.
/// Only uses symbols from `unlocked_symbols`.
@@ -51,35 +51,47 @@ fn generate_code_expr(
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} = val")));
if focused_symbol == Some('=') { focused_patterns.push(idx); }
if focused_symbol == Some('=') {
focused_patterns.push(idx);
}
}
if has('+') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} + num")));
if focused_symbol == Some('+') { focused_patterns.push(idx); }
if focused_symbol == Some('+') {
focused_patterns.push(idx);
}
}
if has('*') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} * cnt")));
if focused_symbol == Some('*') { focused_patterns.push(idx); }
if focused_symbol == Some('*') {
focused_patterns.push(idx);
}
}
if has('/') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} / max")));
if focused_symbol == Some('/') { focused_patterns.push(idx); }
if focused_symbol == Some('/') {
focused_patterns.push(idx);
}
}
if has('-') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} - one")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
if focused_symbol == Some('-') {
focused_patterns.push(idx);
}
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("-{w}")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
if focused_symbol == Some('-') {
focused_patterns.push(idx);
}
}
if has('=') && has('+') {
let w = word.to_string();
@@ -89,7 +101,9 @@ fn generate_code_expr(
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} -= one")));
if focused_symbol == Some('-') { focused_patterns.push(idx); }
if focused_symbol == Some('-') {
focused_patterns.push(idx);
}
}
if has('=') && has('=') {
let w = word.to_string();
@@ -101,19 +115,25 @@ fn generate_code_expr(
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{{ {w} }}")));
if matches!(focused_symbol, Some('{') | Some('}')) { focused_patterns.push(idx); }
if matches!(focused_symbol, Some('{') | Some('}')) {
focused_patterns.push(idx);
}
}
if has('[') && has(']') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w}[idx]")));
if matches!(focused_symbol, Some('[') | Some(']')) { focused_patterns.push(idx); }
if matches!(focused_symbol, Some('[') | Some(']')) {
focused_patterns.push(idx);
}
}
if has('<') && has('>') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("Vec<{w}>")));
if matches!(focused_symbol, Some('<') | Some('>')) { focused_patterns.push(idx); }
if matches!(focused_symbol, Some('<') | Some('>')) {
focused_patterns.push(idx);
}
}
// Logic patterns
@@ -121,19 +141,25 @@ fn generate_code_expr(
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("&{w}")));
if focused_symbol == Some('&') { focused_patterns.push(idx); }
if focused_symbol == Some('&') {
focused_patterns.push(idx);
}
}
if has('|') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w} | nil")));
if focused_symbol == Some('|') { focused_patterns.push(idx); }
if focused_symbol == Some('|') {
focused_patterns.push(idx);
}
}
if has('!') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("!{w}")));
if focused_symbol == Some('!') { focused_patterns.push(idx); }
if focused_symbol == Some('!') {
focused_patterns.push(idx);
}
}
// Special patterns
@@ -141,31 +167,41 @@ fn generate_code_expr(
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("@{w}")));
if focused_symbol == Some('@') { focused_patterns.push(idx); }
if focused_symbol == Some('@') {
focused_patterns.push(idx);
}
}
if has('#') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("#{w}")));
if focused_symbol == Some('#') { focused_patterns.push(idx); }
if focused_symbol == Some('#') {
focused_patterns.push(idx);
}
}
if has('_') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("{w}_val")));
if focused_symbol == Some('_') { focused_patterns.push(idx); }
if focused_symbol == Some('_') {
focused_patterns.push(idx);
}
}
if has('$') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("${w}")));
if focused_symbol == Some('$') { focused_patterns.push(idx); }
if focused_symbol == Some('$') {
focused_patterns.push(idx);
}
}
if has('\\') {
let w = word.to_string();
let idx = patterns.len();
patterns.push(Box::new(move |_| format!("\\{w}")));
if focused_symbol == Some('\\') { focused_patterns.push(idx); }
if focused_symbol == Some('\\') {
focused_patterns.push(idx);
}
}
if patterns.is_empty() {

View File

@@ -1,9 +1,9 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
use crate::engine::filter::CharFilter;
use crate::generator::cache::{DiskCache, fetch_url};
use crate::generator::TextGenerator;
use crate::generator::cache::{DiskCache, fetch_url};
pub struct CodeSyntaxGenerator {
rng: SmallRng,
@@ -49,9 +49,7 @@ impl CodeSyntaxGenerator {
"https://raw.githubusercontent.com/lodash/lodash/main/src/chunk.ts",
"https://raw.githubusercontent.com/expressjs/express/master/lib/router/index.js",
],
"go" => vec![
"https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go",
],
"go" => vec!["https://raw.githubusercontent.com/golang/go/master/src/fmt/print.go"],
_ => vec![],
};

View File

@@ -23,11 +23,7 @@ impl Dictionary {
self.words.clone()
}
pub fn find_matching(
&self,
filter: &CharFilter,
focused: Option<char>,
) -> Vec<&str> {
pub fn find_matching(&self, filter: &CharFilter, focused: Option<char>) -> Vec<&str> {
let mut matching: Vec<&str> = self
.words
.iter()

View File

@@ -14,5 +14,5 @@ use crate::engine::filter::CharFilter;
pub trait TextGenerator {
fn generate(&mut self, filter: &CharFilter, focused: Option<char>, word_count: usize)
-> String;
-> String;
}

View File

@@ -1,5 +1,5 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
/// Post-processing pass that inserts number expressions into text.
/// Only uses digits from `unlocked_digits`.
@@ -127,6 +127,9 @@ mod tests {
let digits = ['1', '2', '3', '4', '5'];
let text = "a b c d e f g h i j k l m n o p q r s t";
let result = apply_numbers(text, &digits, false, None, &mut rng);
assert!(!result.contains('.'), "Should not contain dot when has_dot=false: {result}");
assert!(
!result.contains('.'),
"Should not contain dot when has_dot=false: {result}"
);
}
}

View File

@@ -1,9 +1,9 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
use crate::engine::filter::CharFilter;
use crate::generator::cache::{DiskCache, fetch_url};
use crate::generator::TextGenerator;
use crate::generator::cache::{DiskCache, fetch_url};
const PASSAGES: &[&str] = &[
// Classic literature & speeches
@@ -217,7 +217,9 @@ fn extract_paragraphs(text: &str) -> Vec<String> {
.collect::<Vec<_>>()
.join(" ")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
.filter(|c| {
c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation()
})
.collect::<String>()
.to_lowercase();

View File

@@ -1,10 +1,10 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
use crate::engine::filter::CharFilter;
use crate::generator::TextGenerator;
use crate::generator::dictionary::Dictionary;
use crate::generator::transition_table::TransitionTable;
use crate::generator::TextGenerator;
const MIN_WORD_LEN: usize = 3;
const MAX_WORD_LEN: usize = 10;
@@ -149,7 +149,8 @@ impl PhoneticGenerator {
if space_weight > 0.0 {
let boost = 1.3f64.powi(word.len() as i32 - MIN_WORD_LEN as i32);
let total: f64 = probs.iter().map(|(_, w)| w).sum();
let space_prob = (space_weight * boost) / (total + space_weight * (boost - 1.0));
let space_prob =
(space_weight * boost) / (total + space_weight * (boost - 1.0));
if self.rng.gen_bool(space_prob.min(0.85)) {
break;
}
@@ -164,11 +165,8 @@ impl PhoneticGenerator {
// Get next character from transition table
if let Some(probs) = self.table.segment(&prefix) {
let non_space: Vec<(char, f64)> = probs
.iter()
.filter(|(ch, _)| *ch != ' ')
.copied()
.collect();
let non_space: Vec<(char, f64)> =
probs.iter().filter(|(ch, _)| *ch != ' ').copied().collect();
if let Some(next) = Self::pick_weighted_from(&mut self.rng, &non_space, filter) {
word.push(next);
} else {

View File

@@ -1,5 +1,5 @@
use rand::rngs::SmallRng;
use rand::Rng;
use rand::rngs::SmallRng;
/// Post-processing pass that inserts punctuation into generated text.
/// Only uses punctuation chars from `unlocked_punct`.
@@ -41,25 +41,41 @@ pub fn apply_punctuation(
let mut w = word.to_string();
// Contractions (~8% of words, boosted if apostrophe is focused)
let apostrophe_prob = if focused_punct == Some('\'') { 0.30 } else { 0.08 };
let apostrophe_prob = if focused_punct == Some('\'') {
0.30
} else {
0.08
};
if has_apostrophe && w.len() >= 3 && rng.gen_bool(apostrophe_prob) {
w = make_contraction(&w, rng);
}
// Compound words with dash (~5% of words, boosted if dash is focused)
let dash_prob = if focused_punct == Some('-') { 0.25 } else { 0.05 };
let dash_prob = if focused_punct == Some('-') {
0.25
} else {
0.05
};
if has_dash && i + 1 < words.len() && rng.gen_bool(dash_prob) {
w.push('-');
}
// Sentence ending punctuation
words_since_period += 1;
let end_sentence = words_since_period >= 8 && rng.gen_bool(0.15)
|| words_since_period >= 12;
let end_sentence =
words_since_period >= 8 && rng.gen_bool(0.15) || words_since_period >= 12;
if end_sentence && i < words.len() - 1 {
let q_prob = if focused_punct == Some('?') { 0.40 } else { 0.15 };
let excl_prob = if focused_punct == Some('!') { 0.40 } else { 0.10 };
let q_prob = if focused_punct == Some('?') {
0.40
} else {
0.15
};
let excl_prob = if focused_punct == Some('!') {
0.40
} else {
0.10
};
if has_question && rng.gen_bool(q_prob) {
w.push('?');
} else if has_exclaim && rng.gen_bool(excl_prob) {
@@ -72,34 +88,62 @@ pub fn apply_punctuation(
} else {
// Comma after clause (~every 4-6 words)
words_since_comma += 1;
let comma_prob = if focused_punct == Some(',') { 0.40 } else { 0.20 };
if has_comma && words_since_comma >= 4 && rng.gen_bool(comma_prob) && i < words.len() - 1 {
let comma_prob = if focused_punct == Some(',') {
0.40
} else {
0.20
};
if has_comma
&& words_since_comma >= 4
&& rng.gen_bool(comma_prob)
&& i < words.len() - 1
{
w.push(',');
words_since_comma = 0;
}
// Semicolon between clauses (rare, boosted if focused)
let semi_prob = if focused_punct == Some(';') { 0.25 } else { 0.05 };
if has_semicolon && words_since_comma >= 5 && rng.gen_bool(semi_prob) && i < words.len() - 1 {
let semi_prob = if focused_punct == Some(';') {
0.25
} else {
0.05
};
if has_semicolon
&& words_since_comma >= 5
&& rng.gen_bool(semi_prob)
&& i < words.len() - 1
{
w.push(';');
words_since_comma = 0;
}
// Colon before list-like content (rare, boosted if focused)
let colon_prob = if focused_punct == Some(':') { 0.20 } else { 0.03 };
let colon_prob = if focused_punct == Some(':') {
0.20
} else {
0.03
};
if has_colon && rng.gen_bool(colon_prob) && i < words.len() - 1 {
w.push(':');
}
}
// Quoted phrases (~5% chance to start a quote, boosted if focused)
let quote_prob = if focused_punct == Some('"') { 0.20 } else { 0.04 };
let quote_prob = if focused_punct == Some('"') {
0.20
} else {
0.04
};
if has_quote && rng.gen_bool(quote_prob) && i + 2 < words.len() {
w = format!("\"{w}");
}
// Parenthetical asides (rare, boosted if focused)
let paren_prob = if matches!(focused_punct, Some('(' | ')')) { 0.15 } else { 0.03 };
let paren_prob = if matches!(focused_punct, Some('(' | ')')) {
0.15
} else {
0.03
};
if has_open_paren && has_close_paren && rng.gen_bool(paren_prob) && i + 2 < words.len() {
w = format!("({w}");
}
@@ -122,9 +166,15 @@ pub fn apply_punctuation(
let mut open_parens = 0i32;
for w in &result {
for ch in w.chars() {
if ch == '"' { open_quotes += 1; }
if ch == '(' { open_parens += 1; }
if ch == ')' { open_parens -= 1; }
if ch == '"' {
open_quotes += 1;
}
if ch == '(' {
open_parens += 1;
}
if ch == ')' {
open_parens -= 1;
}
}
}
if let Some(last) = result.last_mut() {

View File

@@ -108,24 +108,94 @@ impl TransitionTable {
let mut table = Self::new(4);
let common_patterns: &[(&str, f64)] = &[
("the", 10.0), ("and", 8.0), ("ing", 7.0), ("tion", 6.0), ("ent", 5.0),
("ion", 5.0), ("her", 4.0), ("for", 4.0), ("are", 4.0), ("his", 4.0),
("hat", 3.0), ("tha", 3.0), ("ere", 3.0), ("ate", 3.0), ("ith", 3.0),
("ver", 3.0), ("all", 3.0), ("not", 3.0), ("ess", 3.0), ("est", 3.0),
("rea", 3.0), ("sta", 3.0), ("ted", 3.0), ("com", 3.0), ("con", 3.0),
("oun", 2.5), ("pro", 2.5), ("oth", 2.5), ("igh", 2.5), ("ore", 2.5),
("our", 2.5), ("ine", 2.5), ("ove", 2.5), ("ome", 2.5), ("use", 2.5),
("ble", 2.0), ("ful", 2.0), ("ous", 2.0), ("str", 2.0), ("tri", 2.0),
("ght", 2.0), ("whi", 2.0), ("who", 2.0), ("hen", 2.0), ("ter", 2.0),
("man", 2.0), ("men", 2.0), ("ner", 2.0), ("per", 2.0), ("pre", 2.0),
("ran", 2.0), ("lin", 2.0), ("kin", 2.0), ("din", 2.0), ("sin", 2.0),
("out", 2.0), ("ind", 2.0), ("ber", 2.0), ("der", 2.0),
("end", 2.0), ("hin", 2.0), ("old", 2.0), ("ear", 2.0), ("ain", 2.0),
("ant", 2.0), ("urn", 2.0), ("ell", 2.0), ("ill", 2.0), ("ade", 2.0),
("ong", 2.0), ("ung", 2.0), ("ast", 2.0), ("ist", 2.0),
("ust", 2.0), ("ost", 2.0), ("ard", 2.0), ("ord", 2.0), ("art", 2.0),
("ort", 2.0), ("ect", 2.0), ("act", 2.0), ("ack", 2.0), ("ick", 2.0),
("ock", 2.0), ("uck", 2.0), ("ash", 2.0), ("ish", 2.0), ("ush", 2.0),
("the", 10.0),
("and", 8.0),
("ing", 7.0),
("tion", 6.0),
("ent", 5.0),
("ion", 5.0),
("her", 4.0),
("for", 4.0),
("are", 4.0),
("his", 4.0),
("hat", 3.0),
("tha", 3.0),
("ere", 3.0),
("ate", 3.0),
("ith", 3.0),
("ver", 3.0),
("all", 3.0),
("not", 3.0),
("ess", 3.0),
("est", 3.0),
("rea", 3.0),
("sta", 3.0),
("ted", 3.0),
("com", 3.0),
("con", 3.0),
("oun", 2.5),
("pro", 2.5),
("oth", 2.5),
("igh", 2.5),
("ore", 2.5),
("our", 2.5),
("ine", 2.5),
("ove", 2.5),
("ome", 2.5),
("use", 2.5),
("ble", 2.0),
("ful", 2.0),
("ous", 2.0),
("str", 2.0),
("tri", 2.0),
("ght", 2.0),
("whi", 2.0),
("who", 2.0),
("hen", 2.0),
("ter", 2.0),
("man", 2.0),
("men", 2.0),
("ner", 2.0),
("per", 2.0),
("pre", 2.0),
("ran", 2.0),
("lin", 2.0),
("kin", 2.0),
("din", 2.0),
("sin", 2.0),
("out", 2.0),
("ind", 2.0),
("ber", 2.0),
("der", 2.0),
("end", 2.0),
("hin", 2.0),
("old", 2.0),
("ear", 2.0),
("ain", 2.0),
("ant", 2.0),
("urn", 2.0),
("ell", 2.0),
("ill", 2.0),
("ade", 2.0),
("ong", 2.0),
("ung", 2.0),
("ast", 2.0),
("ist", 2.0),
("ust", 2.0),
("ost", 2.0),
("ard", 2.0),
("ord", 2.0),
("art", 2.0),
("ort", 2.0),
("ect", 2.0),
("act", 2.0),
("ack", 2.0),
("ick", 2.0),
("ock", 2.0),
("uck", 2.0),
("ash", 2.0),
("ish", 2.0),
("ush", 2.0),
];
for &(pattern, weight) in common_patterns {
@@ -142,8 +212,8 @@ impl TransitionTable {
let vowels = ['a', 'e', 'i', 'o', 'u'];
let consonants = [
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v',
'w', 'x', 'y', 'z',
'b', 'c', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'n', 'p', 'r', 's', 't', 'v', 'w',
'x', 'y', 'z',
];
for &c in &consonants {

View File

@@ -21,28 +21,32 @@ use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Terminal;
use app::{App, AppScreen, DrillMode};
use engine::skill_tree::DrillScope;
use session::result::DrillResult;
use ui::components::skill_tree::{SkillTreeWidget, selectable_branches};
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::stats_dashboard::StatsDashboard;
use ui::components::stats_sidebar::StatsSidebar;
use ui::components::typing_area::TypingArea;
use ui::layout::AppLayout;
#[derive(Parser)]
#[command(name = "keydr", version, about = "Terminal typing tutor with adaptive learning")]
#[command(
name = "keydr",
version,
about = "Terminal typing tutor with adaptive learning"
)]
struct Cli {
#[arg(short, long, help = "Theme name")]
theme: Option<String>,
@@ -237,7 +241,12 @@ fn handle_drill_key(app: &mut App, key: KeyEvent) {
if has_progress && app.drill_mode != DrillMode::Adaptive {
// Non-adaptive: show result screen for partial drill
if let Some(ref drill) = app.drill {
let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str(), app.drill_mode.is_ranked());
let result = DrillResult::from_drill(
drill,
&app.drill_events,
app.drill_mode.as_str(),
app.drill_mode.is_ranked(),
);
app.last_result = Some(result);
}
app.screen = AppScreen::DrillResult;
@@ -283,8 +292,7 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('j') | KeyCode::Down => {
if !app.drill_history.is_empty() {
let max_visible = app.drill_history.len().min(20) - 1;
app.history_selected =
(app.history_selected + 1).min(max_visible);
app.history_selected = (app.history_selected + 1).min(max_visible);
}
}
KeyCode::Char('k') | KeyCode::Up => {
@@ -300,7 +308,11 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
KeyCode::BackTab => {
app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 }
app.stats_tab = if app.stats_tab == 0 {
2
} else {
app.stats_tab - 1
}
}
_ => {}
}
@@ -313,7 +325,13 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
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::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 },
KeyCode::BackTab => {
app.stats_tab = if app.stats_tab == 0 {
2
} else {
app.stats_tab - 1
}
}
_ => {}
}
}
@@ -461,9 +479,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let wpm = drill.wpm();
let accuracy = drill.accuracy();
let errors = drill.typo_count();
let header_text = format!(
" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}"
);
let header_text =
format!(" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}");
let header = Paragraph::new(Line::from(Span::styled(
&*header_text,
Style::default()
@@ -525,11 +542,7 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
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,
);
let progress = ProgressBar::new("Key Progress", progress_val, app.theme);
frame.render_widget(progress, main_layout[idx]);
idx += 1;
}
@@ -550,7 +563,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
}
if let Some(sidebar_area) = app_layout.sidebar {
let sidebar = StatsSidebar::new(drill, app.last_result.as_ref(), &app.drill_history, app.theme);
let sidebar = StatsSidebar::new(
drill,
app.last_result.as_ref(),
&app.drill_history,
app.theme,
);
frame.render_widget(sidebar, sidebar_area);
}
@@ -609,9 +627,15 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
.unwrap_or("rust");
let fields: Vec<(String, String)> = vec![
("Target WPM".to_string(), format!("{}", app.config.target_wpm)),
(
"Target WPM".to_string(),
format!("{}", app.config.target_wpm),
),
("Theme".to_string(), app.config.theme.clone()),
("Word Count".to_string(), format!("{}", app.config.word_count)),
(
"Word Count".to_string(),
format!("{}", app.config.word_count),
),
("Code Language".to_string(), current_lang.to_string()),
];
@@ -633,7 +657,12 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
let field_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(fields.iter().map(|_| Constraint::Length(3)).collect::<Vec<_>>())
.constraints(
fields
.iter()
.map(|_| Constraint::Length(3))
.collect::<Vec<_>>(),
)
.split(layout[1]);
for (i, (label, value)) in fields.iter().enumerate() {
@@ -643,11 +672,17 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
let label_text = format!("{indicator}{label}:");
let value_text = format!(" < {value} >");
let label_style = Style::default().fg(if is_selected {
colors.accent()
} else {
colors.fg()
}).add_modifier(if is_selected { Modifier::BOLD } else { Modifier::empty() });
let label_style = Style::default()
.fg(if is_selected {
colors.accent()
} else {
colors.fg()
})
.add_modifier(if is_selected {
Modifier::BOLD
} else {
Modifier::empty()
});
let value_style = Style::default().fg(if is_selected {
colors.focused_key()

View File

@@ -1,3 +1,3 @@
pub mod input;
pub mod drill;
pub mod input;
pub mod result;

View File

@@ -1,8 +1,8 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::session::input::KeystrokeEvent;
use crate::session::drill::DrillState;
use crate::session::input::KeystrokeEvent;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DrillResult {
@@ -37,7 +37,12 @@ pub struct KeyTime {
}
impl DrillResult {
pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str, ranked: bool) -> Self {
pub fn from_drill(
drill: &DrillState,
events: &[KeystrokeEvent],
drill_mode: &str,
ranked: bool,
) -> Self {
let per_key_times: Vec<KeyTime> = events
.windows(2)
.map(|pair| {

View File

@@ -3,9 +3,9 @@ use std::io::Write;
use std::path::PathBuf;
use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize};
use serde::{Serialize, de::DeserializeOwned};
use crate::store::schema::{KeyStatsData, DrillHistoryData, ProfileData};
use crate::store::schema::{DrillHistoryData, KeyStatsData, ProfileData};
pub struct JsonStore {
base_dir: PathBuf,

View File

@@ -47,8 +47,8 @@ impl Widget for ActivityHeatmap<'_> {
let weeks_to_show = weeks_to_show.min(26);
let start_date = today - 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);
let start_date =
start_date - chrono::Duration::days(start_date.weekday().num_days_from_monday() as i64);
// Day-of-week labels
let day_labels = ["M", " ", "W", " ", "F", " ", "S"];

View File

@@ -54,8 +54,7 @@ impl Widget for Dashboard<'_> {
Style::default().fg(colors.text_pending()),
));
}
let title = Paragraph::new(Line::from(title_spans))
.alignment(Alignment::Center);
let title = Paragraph::new(Line::from(title_spans)).alignment(Alignment::Center);
title.render(layout[0], buf);
let wpm_text = format!("{:.0} WPM", self.result.wpm);

View File

@@ -91,11 +91,7 @@ impl Widget for KeyboardDiagram<'_> {
return;
}
let offsets: &[u16] = if self.compact {
&[0, 1, 3]
} else {
&[1, 3, 5]
};
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;
@@ -128,21 +124,13 @@ impl Widget for KeyboardDiagram<'_> {
.bg(bg)
.add_modifier(Modifier::BOLD)
} else if is_next {
Style::default()
.fg(colors.bg())
.bg(colors.accent())
Style::default().fg(colors.bg()).bg(colors.accent())
} else if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
Style::default().fg(colors.bg()).bg(colors.focused_key())
} else if is_unlocked {
Style::default()
.fg(colors.fg())
.bg(finger_color(key))
Style::default().fg(colors.fg()).bg(finger_color(key))
} else {
Style::default()
.fg(colors.text_pending())
.bg(colors.bg())
Style::default().fg(colors.text_pending()).bg(colors.bg())
};
let display = if self.compact {

View File

@@ -117,13 +117,29 @@ impl Widget for &Menu<'_> {
.collect::<Vec<_>>(),
)
.split(layout[2]);
let key_width = self
.items
.iter()
.map(|item| item.key.len())
.max()
.unwrap_or(1);
for (i, item) in self.items.iter().enumerate() {
let is_selected = i == self.selected;
let indicator = if is_selected { ">" } else { " " };
let label_text = format!(" {indicator} [{key}] {label}", key = item.key, label = item.label);
let desc_text = format!(" {}", item.description);
let label_text = format!(
" {indicator} [{key:<key_width$}] {label}",
key = item.key,
key_width = key_width,
label = item.label
);
let desc_text = format!(
" {:indent$}{}",
"",
item.description,
indent = key_width + 4
);
let lines = vec![
Line::from(Span::styled(

View File

@@ -6,8 +6,7 @@ use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine,
get_branch_definition,
BranchId, BranchStatus, DrillScope, SkillTree as SkillTreeEngine, get_branch_definition,
};
use crate::ui::theme::Theme;
@@ -88,7 +87,8 @@ impl Widget for SkillTreeWidget<'_> {
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 "
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress {
} else if bp.status == BranchStatus::Available || bp.status == BranchStatus::InProgress
{
" [Enter] Start Drill [\u{2191}\u{2193}/jk] Navigate [q] Back "
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
@@ -113,22 +113,29 @@ impl SkillTreeWidget<'_> {
// 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 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),
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()),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
_ => (" ", Style::default().fg(colors.text_pending())),
};
let status_text = match lowercase_bp.status {
@@ -173,43 +180,56 @@ impl SkillTreeWidget<'_> {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let confident_keys = self.skill_tree.branch_confident_keys(branch_id, self.key_stats);
let confident_keys = self
.skill_tree
.branch_confident_keys(branch_id, self.key_stats);
let is_selected = i == self.selected;
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)
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)
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)
Style::default()
.fg(colors.fg())
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.fg())
},
),
BranchStatus::Locked => (
" ",
Style::default().fg(colors.text_pending()),
),
BranchStatus::Locked => (" ", Style::default().fg(colors.text_pending())),
};
let 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::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"),
};
@@ -218,7 +238,10 @@ impl SkillTreeWidget<'_> {
lines.push(Line::from(vec![
Span::styled(format!("{sel_indicator}{prefix}{}", def.name), style),
Span::styled(format!(" {status_text}"), Style::default().fg(colors.text_pending())),
Span::styled(
format!(" {status_text}"),
Style::default().fg(colors.text_pending()),
),
]));
let pct = if total_keys > 0 {
@@ -251,14 +274,18 @@ impl SkillTreeWidget<'_> {
// Branch title with level info
let level_text = match bp.status {
BranchStatus::InProgress => format!("Level {}/{}", bp.current_level + 1, def.levels.len()),
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()),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {}", def.name),
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {level_text}"),
@@ -267,16 +294,19 @@ impl SkillTreeWidget<'_> {
]));
// Per-level key breakdown
let focused = self.skill_tree.focused_key(DrillScope::Branch(branch_id), self.key_stats);
let focused = self
.skill_tree
.focused_key(DrillScope::Branch(branch_id), self.key_stats);
for (level_idx, level) in def.levels.iter().enumerate() {
let level_status = if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
"complete"
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
"in progress"
} else {
"locked"
};
let level_status =
if bp.status == BranchStatus::Complete || level_idx < bp.current_level {
"complete"
} else if bp.status == BranchStatus::InProgress && level_idx == bp.current_level {
"in progress"
} else {
"locked"
};
let mut key_spans: Vec<Span> = Vec::new();
key_spans.push(Span::styled(
@@ -324,7 +354,9 @@ impl SkillTreeWidget<'_> {
// 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()
let sum: f64 = def
.levels
.iter()
.flat_map(|l| l.keys.iter())
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
.sum();
@@ -334,7 +366,11 @@ impl SkillTreeWidget<'_> {
};
lines.push(Line::from(Span::styled(
format!(" Avg Confidence: {} {:.0}%", progress_bar_str(avg_conf, 20), avg_conf * 100.0),
format!(
" Avg Confidence: {} {:.0}%",
progress_bar_str(avg_conf, 20),
avg_conf * 100.0
),
Style::default().fg(colors.text_pending()),
)));
@@ -346,9 +382,5 @@ impl SkillTreeWidget<'_> {
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),
)
format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty),)
}

View File

@@ -83,10 +83,7 @@ impl Widget for StatsDashboard<'_> {
} else {
Style::default().fg(colors.text_pending())
};
vec![
Span::styled(format!(" {label} "), style),
Span::raw(" "),
]
vec![Span::styled(format!(" {label} "), style), Span::raw(" ")]
})
.collect();
Paragraph::new(Line::from(tab_spans)).render(layout[0], buf);
@@ -168,18 +165,13 @@ impl StatsDashboard<'_> {
.constraints([
Constraint::Length(6), // summary stats bordered box
Constraint::Length(3), // progress bars
Constraint::Min(8), // charts
Constraint::Min(8), // charts
])
.split(area);
// Summary stats as bordered table
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self
.history
.iter()
.map(|r| r.wpm)
.fold(0.0f64, f64::max);
let avg_wpm = self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let best_wpm = self.history.iter().map(|r| r.wpm).fold(0.0f64, f64::max);
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
let total_time: f64 = self.history.iter().map(|r| r.elapsed_secs).sum();
@@ -297,12 +289,27 @@ impl StatsDashboard<'_> {
// Y-axis labels (max, mid, 0)
let max_label = format!("{:.0}", max_wpm);
let mid_label = format!("{:.0}", max_wpm / 2.0);
buf.set_string(inner.x, inner.y, &max_label, Style::default().fg(colors.text_pending()));
buf.set_string(
inner.x,
inner.y,
&max_label,
Style::default().fg(colors.text_pending()),
);
if inner.height > 3 {
let mid_y = inner.y + inner.height / 2;
buf.set_string(inner.x, mid_y, &mid_label, Style::default().fg(colors.text_pending()));
buf.set_string(
inner.x,
mid_y,
&mid_label,
Style::default().fg(colors.text_pending()),
);
}
buf.set_string(inner.x, inner.y + inner.height - 1, "0", Style::default().fg(colors.text_pending()));
buf.set_string(
inner.x,
inner.y + inner.height - 1,
"0",
Style::default().fg(colors.text_pending()),
);
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
@@ -342,7 +349,12 @@ impl StatsDashboard<'_> {
// WPM label on top row
if bar_spacing >= 3 {
let label = format!("{wpm:.0}");
buf.set_string(x, inner.y, &label, Style::default().fg(colors.text_pending()));
buf.set_string(
x,
inner.y,
&label,
Style::default().fg(colors.text_pending()),
);
}
}
}
@@ -412,8 +424,7 @@ impl StatsDashboard<'_> {
])
.split(area);
let avg_wpm =
self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let avg_wpm = self.history.iter().map(|r| r.wpm).sum::<f64>() / self.history.len() as f64;
let avg_accuracy =
self.history.iter().map(|r| r.accuracy).sum::<f64>() / self.history.len() as f64;
@@ -454,7 +465,11 @@ impl StatsDashboard<'_> {
);
// Level progress
let total_score: f64 = self.history.iter().map(|r| r.wpm * r.accuracy / 100.0).sum();
let total_score: f64 = self
.history
.iter()
.map(|r| r.wpm * r.accuracy / 100.0)
.sum();
let level = ((total_score / 100.0).sqrt() as u32).max(1);
let next_level_score = ((level + 1) as f64).powi(2) * 100.0;
let current_level_score = (level as f64).powi(2) * 100.0;
@@ -523,11 +538,7 @@ impl StatsDashboard<'_> {
" "
};
let mode_str = if result.ranked {
""
} else {
" (unranked)"
};
let mode_str = if result.ranked { "" } else { " (unranked)" };
let row = format!(
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}",
mode = result.drill_mode,
@@ -656,7 +667,7 @@ impl StatsDashboard<'_> {
.constraints([
Constraint::Length(12), // Activity heatmap
Constraint::Length(7), // Keyboard accuracy heatmap
Constraint::Min(5), // Slowest/Fastest/Stats
Constraint::Min(5), // Slowest/Fastest/Stats
Constraint::Length(5), // Overall stats
])
.split(area);
@@ -738,12 +749,7 @@ impl StatsDashboard<'_> {
} else {
format!("{key} ")
};
buf.set_string(
x,
y,
&display,
Style::default().fg(fg_color).bg(bg_color),
);
buf.set_string(x, y, &display, Style::default().fg(fg_color).bg(bg_color));
}
}
}
@@ -793,12 +799,7 @@ impl StatsDashboard<'_> {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.error()),
);
buf.set_string(inner.x, y, &text, Style::default().fg(colors.error()));
}
}
@@ -826,12 +827,7 @@ impl StatsDashboard<'_> {
break;
}
let text = format!(" '{ch}' {time:.0}ms");
buf.set_string(
inner.x,
y,
&text,
Style::default().fg(colors.success()),
);
buf.set_string(inner.x, y, &text, Style::default().fg(colors.success()));
}
}
@@ -953,12 +949,7 @@ fn render_text_bar(
}
// Label on first line
buf.set_string(
area.x,
area.y,
label,
Style::default().fg(fill_color),
);
buf.set_string(area.x, area.y, label, Style::default().fg(fill_color));
// Bar on second line using ┃ filled / dim ┃ empty
let bar_width = (area.width as usize).saturating_sub(4);

View File

@@ -22,7 +22,12 @@ impl<'a> StatsSidebar<'a> {
history: &'a [DrillResult],
theme: &'a Theme,
) -> Self {
Self { drill, last_result, history, theme }
Self {
drill,
last_result,
history,
theme,
}
}
}
@@ -159,12 +164,10 @@ impl Widget for StatsSidebar<'_> {
colors.text_pending()
};
let mut lines = vec![
Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())),
]),
];
let mut lines = vec![Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())),
])];
if prior_count > 0 {
lines.push(Line::from(vec![

View File

@@ -4,8 +4,8 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::session::input::CharStatus;
use crate::session::drill::DrillState;
use crate::session::input::CharStatus;
use crate::ui::theme::Theme;
pub struct TypingArea<'a> {
@@ -78,6 +78,7 @@ impl Widget for TypingArea<'_> {
for token in &tokens {
let idx = token.target_idx;
let target_ch = self.drill.target[idx];
let style = if idx < self.drill.cursor {
match &self.drill.input[idx] {
@@ -91,6 +92,7 @@ impl Widget for TypingArea<'_> {
Style::default()
.fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg())
.add_modifier(Modifier::REVERSED | Modifier::BOLD)
} else {
Style::default().fg(colors.text_pending())
};
@@ -99,7 +101,6 @@ impl Widget for TypingArea<'_> {
// but always show the token display for whitespace markers
let display = if idx < self.drill.cursor {
if let CharStatus::Incorrect(actual) = &self.drill.input[idx] {
let target_ch = self.drill.target[idx];
if target_ch == '\n' || target_ch == '\t' {
// Show the whitespace marker even when incorrect
token.display.clone()
@@ -109,6 +110,8 @@ impl Widget for TypingArea<'_> {
} else {
token.display.clone()
}
} else if idx == self.drill.cursor && target_ch == ' ' {
"\u{00b7}".to_string()
} else {
token.display.clone()
};
@@ -120,6 +123,16 @@ impl Widget for TypingArea<'_> {
}
}
// Keep cursor visible at end-of-input as an insertion marker.
if self.drill.cursor >= self.drill.target.len() {
lines.last_mut().unwrap().push(Span::styled(
"\u{258f}",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
));
}
let ratatui_lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
let block = Block::bordered()

View File

@@ -8,6 +8,8 @@ use serde::{Deserialize, Serialize};
#[folder = "assets/themes/"]
struct ThemeAssets;
const TERMINAL_DEFAULT_THEME: &str = "terminal-default";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Theme {
pub name: String,
@@ -42,7 +44,10 @@ impl Theme {
pub fn load(name: &str) -> Option<Self> {
// Try user themes dir
if let Some(config_dir) = dirs::config_dir() {
let user_theme_path = config_dir.join("keydr").join("themes").join(format!("{name}.toml"));
let user_theme_path = config_dir
.join("keydr")
.join("themes")
.join(format!("{name}.toml"));
if let Ok(content) = fs::read_to_string(&user_theme_path) {
if let Ok(theme) = toml::from_str::<Theme>(&content) {
return Some(theme);
@@ -64,17 +69,20 @@ impl Theme {
}
pub fn available_themes() -> Vec<String> {
ThemeAssets::iter()
.filter_map(|f| {
f.strip_suffix(".toml").map(|n| n.to_string())
})
.collect()
let mut themes: Vec<String> = ThemeAssets::iter()
.filter_map(|f| f.strip_suffix(".toml").map(|n| n.to_string()))
.collect();
themes.sort_unstable();
if let Some(idx) = themes.iter().position(|t| t == TERMINAL_DEFAULT_THEME) {
themes.swap(0, idx);
}
themes
}
}
impl Default for Theme {
fn default() -> Self {
Self::load("catppuccin-mocha").unwrap_or_else(|| Self {
Self::load(TERMINAL_DEFAULT_THEME).unwrap_or_else(|| Self {
name: "default".to_string(),
colors: ThemeColors::default(),
})
@@ -109,9 +117,10 @@ impl Default for ThemeColors {
}
impl ThemeColors {
pub fn parse_color(hex: &str) -> Color {
let hex = hex.trim_start_matches('#');
if hex.len() == 6 {
pub fn parse_color(value: &str) -> Color {
let value = value.trim();
let hex = value.trim_start_matches('#');
if hex.len() == 6 && value.starts_with('#') {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
@@ -120,27 +129,87 @@ impl ThemeColors {
return Color::Rgb(r, g, b);
}
}
Color::White
match value.to_ascii_lowercase().as_str() {
"reset" | "default" | "none" => Color::Reset,
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" | "brightred" => Color::LightRed,
"lightgreen" | "brightgreen" => Color::LightGreen,
"lightyellow" | "brightyellow" => Color::LightYellow,
"lightblue" | "brightblue" => Color::LightBlue,
"lightmagenta" | "brightmagenta" => Color::LightMagenta,
"lightcyan" | "brightcyan" => Color::LightCyan,
"white" | "lightwhite" | "brightwhite" => Color::White,
_ => Color::White,
}
}
pub fn bg(&self) -> Color { Self::parse_color(&self.bg) }
pub fn fg(&self) -> Color { Self::parse_color(&self.fg) }
pub fn text_correct(&self) -> Color { Self::parse_color(&self.text_correct) }
pub fn text_incorrect(&self) -> Color { Self::parse_color(&self.text_incorrect) }
pub fn text_incorrect_bg(&self) -> Color { Self::parse_color(&self.text_incorrect_bg) }
pub fn text_pending(&self) -> Color { Self::parse_color(&self.text_pending) }
pub fn text_cursor_bg(&self) -> Color { Self::parse_color(&self.text_cursor_bg) }
pub fn text_cursor_fg(&self) -> Color { Self::parse_color(&self.text_cursor_fg) }
pub fn focused_key(&self) -> Color { Self::parse_color(&self.focused_key) }
pub fn accent(&self) -> Color { Self::parse_color(&self.accent) }
pub fn accent_dim(&self) -> Color { Self::parse_color(&self.accent_dim) }
pub fn border(&self) -> Color { Self::parse_color(&self.border) }
pub fn border_focused(&self) -> Color { Self::parse_color(&self.border_focused) }
pub fn header_bg(&self) -> Color { Self::parse_color(&self.header_bg) }
pub fn header_fg(&self) -> Color { Self::parse_color(&self.header_fg) }
pub fn bar_filled(&self) -> Color { Self::parse_color(&self.bar_filled) }
pub fn bar_empty(&self) -> Color { Self::parse_color(&self.bar_empty) }
pub fn error(&self) -> Color { Self::parse_color(&self.error) }
pub fn warning(&self) -> Color { Self::parse_color(&self.warning) }
pub fn success(&self) -> Color { Self::parse_color(&self.success) }
pub fn bg(&self) -> Color {
Self::parse_color(&self.bg)
}
pub fn fg(&self) -> Color {
Self::parse_color(&self.fg)
}
pub fn text_correct(&self) -> Color {
Self::parse_color(&self.text_correct)
}
pub fn text_incorrect(&self) -> Color {
Self::parse_color(&self.text_incorrect)
}
pub fn text_incorrect_bg(&self) -> Color {
Self::parse_color(&self.text_incorrect_bg)
}
pub fn text_pending(&self) -> Color {
Self::parse_color(&self.text_pending)
}
pub fn text_cursor_bg(&self) -> Color {
Self::parse_color(&self.text_cursor_bg)
}
pub fn text_cursor_fg(&self) -> Color {
Self::parse_color(&self.text_cursor_fg)
}
pub fn focused_key(&self) -> Color {
Self::parse_color(&self.focused_key)
}
pub fn accent(&self) -> Color {
Self::parse_color(&self.accent)
}
pub fn accent_dim(&self) -> Color {
Self::parse_color(&self.accent_dim)
}
pub fn border(&self) -> Color {
Self::parse_color(&self.border)
}
pub fn border_focused(&self) -> Color {
Self::parse_color(&self.border_focused)
}
pub fn header_bg(&self) -> Color {
Self::parse_color(&self.header_bg)
}
pub fn header_fg(&self) -> Color {
Self::parse_color(&self.header_fg)
}
pub fn bar_filled(&self) -> Color {
Self::parse_color(&self.bar_filled)
}
pub fn bar_empty(&self) -> Color {
Self::parse_color(&self.bar_empty)
}
pub fn error(&self) -> Color {
Self::parse_color(&self.error)
}
pub fn warning(&self) -> Color {
Self::parse_color(&self.warning)
}
pub fn success(&self) -> Color {
Self::parse_color(&self.success)
}
}