Add more themes and rustfmt
This commit is contained in:
23
assets/themes/farout.toml
Normal file
23
assets/themes/farout.toml
Normal 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"
|
||||
23
assets/themes/gruvbox-darkest.toml
Normal file
23
assets/themes/gruvbox-darkest.toml
Normal 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"
|
||||
23
assets/themes/kanagawa-dragon.toml
Normal file
23
assets/themes/kanagawa-dragon.toml
Normal 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"
|
||||
23
assets/themes/kanagawa-lotus.toml
Normal file
23
assets/themes/kanagawa-lotus.toml
Normal 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"
|
||||
23
assets/themes/kanagawa-wave.toml
Normal file
23
assets/themes/kanagawa-wave.toml
Normal 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"
|
||||
23
assets/themes/terminal-default.toml
Normal file
23
assets/themes/terminal-default.toml
Normal 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"
|
||||
80
src/app.rs
80
src/app.rs
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
28
src/event.rs
28
src/event.rs
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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![],
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
src/main.rs
87
src/main.rs
@@ -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()
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod input;
|
||||
pub mod drill;
|
||||
pub mod input;
|
||||
pub mod result;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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()
|
||||
|
||||
131
src/ui/theme.rs
131
src/ui/theme.rs
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user