Skill tree progression system & whitespace support

This commit is contained in:
2026-02-15 07:30:34 +00:00
parent 13550505c1
commit 6d6815af02
22 changed files with 2883 additions and 238 deletions

View File

@@ -7,12 +7,16 @@ use rand::SeedableRng;
use crate::config::Config;
use crate::engine::filter::CharFilter;
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::letter_unlock::LetterUnlock;
use crate::engine::scoring;
use crate::engine::skill_tree::{BranchId, BranchStatus, DrillScope, SkillTree};
use crate::generator::capitalize;
use crate::generator::code_patterns;
use crate::generator::code_syntax::CodeSyntaxGenerator;
use crate::generator::dictionary::Dictionary;
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;
@@ -31,6 +35,7 @@ pub enum AppScreen {
DrillResult,
StatsDashboard,
Settings,
SkillTree,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -48,11 +53,16 @@ impl DrillMode {
DrillMode::Passage => "passage",
}
}
pub fn is_ranked(self) -> bool {
matches!(self, DrillMode::Adaptive)
}
}
pub struct App {
pub screen: AppScreen,
pub drill_mode: DrillMode,
pub drill_scope: DrillScope,
pub drill: Option<DrillState>,
pub drill_events: Vec<KeystrokeEvent>,
pub last_result: Option<DrillResult>,
@@ -61,7 +71,7 @@ pub struct App {
pub theme: &'static Theme,
pub config: Config,
pub key_stats: KeyStatsStore,
pub letter_unlock: LetterUnlock,
pub skill_tree: SkillTree,
pub profile: ProfileData,
pub store: Option<JsonStore>,
pub should_quit: bool,
@@ -71,6 +81,7 @@ pub struct App {
pub last_key_time: Option<Instant>,
pub history_selected: usize,
pub history_confirm_delete: bool,
pub skill_tree_selected: usize,
rng: SmallRng,
transition_table: TransitionTable,
#[allow(dead_code)]
@@ -86,22 +97,31 @@ impl App {
let store = JsonStore::new().ok();
let (key_stats, letter_unlock, profile, drill_history) = if let Some(ref s) = store {
let ksd = s.load_key_stats();
let (key_stats, skill_tree, profile, drill_history) = if let Some(ref s) = store {
// load_profile returns None if file exists but can't parse (schema mismatch)
let pd = s.load_profile();
let lhd = s.load_drill_history();
let lu = if pd.unlocked_letters.is_empty() {
LetterUnlock::new()
} else {
LetterUnlock::from_included(pd.unlocked_letters.clone())
};
(ksd.stats, lu, pd, lhd.drills)
match pd {
Some(pd) if !pd.needs_reset() => {
let ksd = s.load_key_stats();
let lhd = s.load_drill_history();
let st = SkillTree::new(pd.skill_tree.clone());
(ksd.stats, st, pd, lhd.drills)
}
_ => {
// Schema mismatch or parse failure: full reset of all stores
(
KeyStatsStore::default(),
SkillTree::default(),
ProfileData::default(),
Vec::new(),
)
}
}
} else {
(
KeyStatsStore::default(),
LetterUnlock::new(),
SkillTree::default(),
ProfileData::default(),
Vec::new(),
)
@@ -116,6 +136,7 @@ impl App {
let mut app = Self {
screen: AppScreen::Menu,
drill_mode: DrillMode::Adaptive,
drill_scope: DrillScope::Global,
drill: None,
drill_events: Vec::new(),
last_result: None,
@@ -124,7 +145,7 @@ impl App {
theme,
config,
key_stats: key_stats_with_target,
letter_unlock,
skill_tree,
profile,
store,
should_quit: false,
@@ -134,6 +155,7 @@ impl App {
last_key_time: None,
history_selected: 0,
history_confirm_delete: false,
skill_tree_selected: 0,
rng: SmallRng::from_entropy(),
transition_table,
dictionary,
@@ -155,13 +177,84 @@ impl App {
match mode {
DrillMode::Adaptive => {
let filter = CharFilter::new(self.letter_unlock.included.clone());
let focused = self.letter_unlock.focused;
let scope = self.drill_scope;
let all_keys = self.skill_tree.unlocked_keys(scope);
let focused = self.skill_tree.focused_key(scope, &self.key_stats);
// Generate base lowercase text using only lowercase keys from scope
let lowercase_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| ch.is_ascii_lowercase() || *ch == ' ')
.collect();
let filter = CharFilter::new(lowercase_keys);
// Only pass focused to phonetic generator if it's a lowercase letter
let lowercase_focused = focused.filter(|ch| ch.is_ascii_lowercase());
let table = self.transition_table.clone();
let dict = Dictionary::load();
let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PhoneticGenerator::new(table, dict, rng);
generator.generate(&filter, focused, word_count)
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()
.copied()
.filter(|ch| ch.is_ascii_uppercase())
.collect();
if !cap_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = capitalize::apply_capitalization(&text, &cap_keys, focused, &mut rng);
}
// Apply punctuation if punctuation keys are in scope
let punct_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| matches!(ch, '.' | ',' | '\'' | ';' | ':' | '"' | '-' | '?' | '!' | '(' | ')'))
.collect();
if !punct_keys.is_empty() {
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = punctuate::apply_punctuation(&text, &punct_keys, focused, &mut rng);
}
// Apply numbers if digit keys are in scope
let digit_keys: Vec<char> = all_keys.iter()
.copied()
.filter(|ch| ch.is_ascii_digit())
.collect();
if !digit_keys.is_empty() {
let has_dot = all_keys.contains(&'.');
let mut rng = SmallRng::from_rng(&mut self.rng).unwrap();
text = numbers::apply_numbers(&text, &digit_keys, has_dot, focused, &mut rng);
}
// Apply code symbols only if this drill is for the CodeSymbols branch,
// or if it's a global drill and CodeSymbols is active
let code_active = match scope {
DrillScope::Branch(id) => id == BranchId::CodeSymbols,
DrillScope::Global => matches!(
self.skill_tree.branch_status(BranchId::CodeSymbols),
BranchStatus::InProgress | BranchStatus::Complete
),
};
if code_active {
let symbol_keys: Vec<char> = all_keys.iter()
.copied()
.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);
}
}
// Apply whitespace line breaks if newline is in scope
if all_keys.contains(&'\n') {
text = insert_line_breaks(&text);
}
text
}
DrillMode::Code => {
let filter = CharFilter::new(('a'..='z').collect());
@@ -204,22 +297,23 @@ impl App {
fn finish_drill(&mut self) {
if let Some(ref drill) = self.drill {
let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str());
let ranked = self.drill_mode.is_ranked();
let result = DrillResult::from_drill(drill, &self.drill_events, self.drill_mode.as_str(), ranked);
if self.drill_mode == DrillMode::Adaptive {
if ranked {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
self.letter_unlock.update(&self.key_stats);
self.skill_tree.update(&self.key_stats);
}
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let complexity = self.skill_tree.complexity();
let score = scoring::compute_score(&result, complexity);
self.profile.total_score += score;
self.profile.total_drills += 1;
self.profile.unlocked_letters = self.letter_unlock.included.clone();
self.profile.skill_tree = self.skill_tree.progress.clone();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
if self.profile.last_practice_date.as_deref() != Some(&today) {
@@ -262,11 +356,11 @@ impl App {
if let Some(ref store) = self.store {
let _ = store.save_profile(&self.profile);
let _ = store.save_key_stats(&KeyStatsData {
schema_version: 1,
schema_version: 2,
stats: self.key_stats.clone(),
});
let _ = store.save_drill_history(&DrillHistoryData {
schema_version: 1,
schema_version: 2,
drills: self.drill_history.clone(),
});
}
@@ -312,27 +406,27 @@ impl App {
// Reset all derived state
self.key_stats = KeyStatsStore::default();
self.key_stats.target_cpm = self.config.target_cpm();
self.letter_unlock = LetterUnlock::new();
self.skill_tree = SkillTree::default();
self.profile.total_score = 0.0;
self.profile.total_drills = 0;
self.profile.streak_days = 0;
self.profile.best_streak = 0;
self.profile.last_practice_date = None;
// Replay each remaining session oldestnewest
// Replay each remaining session oldest->newest
for result in &self.drill_history {
// Only update adaptive progression for adaptive sessions
if result.drill_mode == "adaptive" {
// Only update skill tree for ranked sessions
if result.ranked {
for kt in &result.per_key_times {
if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms);
}
}
self.letter_unlock.update(&self.key_stats);
self.skill_tree.update(&self.key_stats);
}
// Compute score
let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let complexity = self.skill_tree.complexity();
let score = scoring::compute_score(result, complexity);
self.profile.total_score += score;
self.profile.total_drills += 1;
@@ -359,7 +453,24 @@ impl App {
}
}
self.profile.unlocked_letters = self.letter_unlock.included.clone();
self.profile.skill_tree = self.skill_tree.progress.clone();
}
pub fn go_to_skill_tree(&mut self) {
self.skill_tree_selected = 0;
self.screen = AppScreen::SkillTree;
}
pub fn start_branch_drill(&mut self, branch_id: BranchId) {
// Start the branch if it's Available
self.skill_tree.start_branch(branch_id);
self.profile.skill_tree = self.skill_tree.progress.clone();
self.save_data();
// Use adaptive mode with branch-specific scope
self.drill_mode = DrillMode::Adaptive;
self.drill_scope = DrillScope::Branch(branch_id);
self.start_drill();
}
pub fn go_to_settings(&mut self) {
@@ -445,3 +556,33 @@ impl App {
}
}
}
/// Insert newlines at sentence boundaries (~60-80 chars per line).
fn insert_line_breaks(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut col = 0;
for (i, ch) in text.chars().enumerate() {
result.push(ch);
col += 1;
// After sentence-ending punctuation + space, insert newline if past 60 chars
if col >= 60 && (ch == '.' || ch == '?' || ch == '!') {
// Check if next char is a space
let next = text.chars().nth(i + 1);
if next == Some(' ') {
result.push('\n');
col = 0;
// Skip the space (will be consumed by next iteration but we already broke the line)
}
} else if col >= 80 && ch == ' ' {
// Hard wrap at spaces if no sentence boundary found
// Replace the space we just pushed with a newline
result.pop();
result.push('\n');
col = 0;
}
}
result
}

View File

@@ -1,151 +0,0 @@
use crate::engine::key_stats::KeyStatsStore;
pub const FREQUENCY_ORDER: &[char] = &[
'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',
];
const MIN_LETTERS: usize = 6;
#[derive(Clone, Debug)]
pub struct LetterUnlock {
pub included: Vec<char>,
pub focused: Option<char>,
}
impl LetterUnlock {
pub fn new() -> Self {
let included = FREQUENCY_ORDER[..MIN_LETTERS].to_vec();
Self {
included,
focused: None,
}
}
pub fn from_included(included: Vec<char>) -> Self {
let mut lu = Self {
included,
focused: None,
};
lu.focused = None;
lu
}
pub fn update(&mut self, stats: &KeyStatsStore) {
let all_confident = self
.included
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
while self.included.len() < MIN_LETTERS {
for &letter in FREQUENCY_ORDER {
if !self.included.contains(&letter) {
self.included.push(letter);
break;
}
}
}
self.focused = self
.included
.iter()
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
.min_by(|&&a, &&b| {
stats
.get_confidence(a)
.partial_cmp(&stats.get_confidence(b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied();
}
#[allow(dead_code)]
pub fn is_unlocked(&self, ch: char) -> bool {
self.included.contains(&ch)
}
pub fn unlocked_count(&self) -> usize {
self.included.len()
}
pub fn total_letters(&self) -> usize {
FREQUENCY_ORDER.len()
}
pub fn progress(&self) -> f64 {
self.unlocked_count() as f64 / self.total_letters() as f64
}
}
impl Default for LetterUnlock {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::key_stats::KeyStatsStore;
#[test]
fn test_initial_unlock_has_min_letters() {
let lu = LetterUnlock::new();
assert_eq!(lu.unlocked_count(), 6);
assert_eq!(&lu.included, &['e', 't', 'a', 'o', 'i', 'n']);
}
#[test]
fn test_no_unlock_without_confidence() {
let mut lu = LetterUnlock::new();
let stats = KeyStatsStore::default();
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 6);
}
#[test]
fn test_unlock_when_all_confident() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make all included keys confident by typing fast
for &ch in &['e', 't', 'a', 'o', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
lu.update(&stats);
assert_eq!(lu.unlocked_count(), 7);
assert!(lu.included.contains(&'s'));
}
#[test]
fn test_focused_key_is_weakest() {
let mut lu = LetterUnlock::new();
let mut stats = KeyStatsStore::default();
// Make most keys confident except 'o'
for &ch in &['e', 't', 'a', 'i', 'n'] {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
stats.update_key('o', 1000.0); // slow on 'o'
lu.update(&stats);
assert_eq!(lu.focused, Some('o'));
}
#[test]
fn test_progress_ratio() {
let lu = LetterUnlock::new();
let expected = 6.0 / 26.0;
assert!((lu.progress() - expected).abs() < 0.001);
}
}

View File

@@ -1,5 +1,5 @@
pub mod filter;
pub mod key_stats;
pub mod learning_rate;
pub mod letter_unlock;
pub mod scoring;
pub mod skill_tree;

View File

@@ -7,8 +7,8 @@ pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 {
(speed * complexity) / (errors + 1.0) * (length / 50.0)
}
pub fn compute_complexity(unlocked_count: usize) -> f64 {
(unlocked_count as f64 / 26.0).max(0.1)
pub fn compute_complexity(unlocked_count: usize, total_keys: usize) -> f64 {
(unlocked_count as f64 / total_keys as f64).max(0.1)
}
pub fn level_from_score(total_score: f64) -> u32 {
@@ -38,8 +38,8 @@ mod tests {
}
#[test]
fn test_complexity_scales_with_letters() {
assert!(compute_complexity(26) > compute_complexity(6));
assert!((compute_complexity(26) - 1.0).abs() < 0.001);
fn test_complexity_scales_with_keys() {
assert!(compute_complexity(96, 96) > compute_complexity(6, 96));
assert!((compute_complexity(96, 96) - 1.0).abs() < 0.001);
}
}

854
src/engine/skill_tree.rs Normal file
View File

@@ -0,0 +1,854 @@
use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
// --- Branch ID ---
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum BranchId {
Lowercase,
Capitals,
Numbers,
ProsePunctuation,
Whitespace,
CodeSymbols,
}
impl BranchId {
pub fn to_key(self) -> &'static str {
match self {
BranchId::Lowercase => "lowercase",
BranchId::Capitals => "capitals",
BranchId::Numbers => "numbers",
BranchId::ProsePunctuation => "prose_punctuation",
BranchId::Whitespace => "whitespace",
BranchId::CodeSymbols => "code_symbols",
}
}
pub fn from_key(key: &str) -> Option<Self> {
match key {
"lowercase" => Some(BranchId::Lowercase),
"capitals" => Some(BranchId::Capitals),
"numbers" => Some(BranchId::Numbers),
"prose_punctuation" => Some(BranchId::ProsePunctuation),
"whitespace" => Some(BranchId::Whitespace),
"code_symbols" => Some(BranchId::CodeSymbols),
_ => None,
}
}
pub fn all() -> &'static [BranchId] {
&[
BranchId::Lowercase,
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
]
}
}
// --- Branch Status ---
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum BranchStatus {
Locked,
Available,
InProgress,
Complete,
}
// --- Static Definitions ---
pub struct LevelDefinition {
pub name: &'static str,
pub keys: &'static [char],
}
pub struct BranchDefinition {
pub id: BranchId,
pub name: &'static str,
pub levels: &'static [LevelDefinition],
}
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',
],
}];
const CAPITALS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Common Sentence Capitals",
keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'],
},
LevelDefinition {
name: "Name Capitals",
keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'],
},
LevelDefinition {
name: "Remaining Capitals",
keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'],
},
];
const NUMBERS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Common Digits",
keys: &['1', '2', '3', '4', '5'],
},
LevelDefinition {
name: "All Digits",
keys: &['0', '6', '7', '8', '9'],
},
];
const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Essential",
keys: &['.', ',', '\''],
},
LevelDefinition {
name: "Common",
keys: &[';', ':', '"', '-'],
},
LevelDefinition {
name: "Expressive",
keys: &['?', '!', '(', ')'],
},
];
const WHITESPACE_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Enter/Return",
keys: &['\n'],
},
LevelDefinition {
name: "Tab/Indent",
keys: &['\t'],
},
];
const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[
LevelDefinition {
name: "Arithmetic & Assignment",
keys: &['=', '+', '*', '/', '-'],
},
LevelDefinition {
name: "Grouping",
keys: &['{', '}', '[', ']', '<', '>'],
},
LevelDefinition {
name: "Logic & Reference",
keys: &['&', '|', '^', '~', '!'],
},
LevelDefinition {
name: "Special",
keys: &['@', '#', '$', '%', '_', '\\', '`'],
},
];
pub const ALL_BRANCHES: &[BranchDefinition] = &[
BranchDefinition {
id: BranchId::Lowercase,
name: "Lowercase a-z",
levels: LOWERCASE_LEVELS,
},
BranchDefinition {
id: BranchId::Capitals,
name: "Capitals A-Z",
levels: CAPITALS_LEVELS,
},
BranchDefinition {
id: BranchId::Numbers,
name: "Numbers 0-9",
levels: NUMBERS_LEVELS,
},
BranchDefinition {
id: BranchId::ProsePunctuation,
name: "Prose Punctuation",
levels: PROSE_PUNCTUATION_LEVELS,
},
BranchDefinition {
id: BranchId::Whitespace,
name: "Whitespace",
levels: WHITESPACE_LEVELS,
},
BranchDefinition {
id: BranchId::CodeSymbols,
name: "Code Symbols",
levels: CODE_SYMBOLS_LEVELS,
},
];
pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition {
ALL_BRANCHES
.iter()
.find(|b| b.id == id)
.expect("branch definition not found")
}
// --- Persisted Progress ---
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BranchProgress {
pub status: BranchStatus,
pub current_level: usize,
}
impl Default for BranchProgress {
fn default() -> Self {
Self {
status: BranchStatus::Locked,
current_level: 0,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SkillTreeProgress {
pub branches: HashMap<String, BranchProgress>,
}
impl Default for SkillTreeProgress {
fn default() -> Self {
let mut branches = HashMap::new();
// Lowercase starts as InProgress; everything else Locked
branches.insert(
BranchId::Lowercase.to_key().to_string(),
BranchProgress {
status: BranchStatus::InProgress,
current_level: 0,
},
);
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
branches.insert(id.to_key().to_string(), BranchProgress::default());
}
Self { branches }
}
}
// --- Skill Tree Engine ---
/// The scope for key collection and focus selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DrillScope {
/// Global adaptive: all InProgress + Complete branches
Global,
/// Branch-specific drill: specific branch + a-z background
Branch(BranchId),
}
pub struct SkillTree {
pub progress: SkillTreeProgress,
pub total_unique_keys: usize,
}
/// Number of lowercase letters to start with before unlocking one-at-a-time
const LOWERCASE_MIN_KEYS: usize = 6;
impl SkillTree {
pub fn new(progress: SkillTreeProgress) -> Self {
let total_unique_keys = Self::compute_total_unique_keys();
Self {
progress,
total_unique_keys,
}
}
fn compute_total_unique_keys() -> usize {
let mut all_keys: HashSet<char> = HashSet::new();
for branch in ALL_BRANCHES {
for level in branch.levels {
for &key in level.keys {
all_keys.insert(key);
}
}
}
all_keys.len()
}
pub fn branch_status(&self, id: BranchId) -> &BranchStatus {
self.progress
.branches
.get(id.to_key())
.map(|bp| &bp.status)
.unwrap_or(&BranchStatus::Locked)
}
pub fn branch_progress(&self, id: BranchId) -> &BranchProgress {
static DEFAULT: BranchProgress = BranchProgress {
status: BranchStatus::Locked,
current_level: 0,
};
self.progress
.branches
.get(id.to_key())
.unwrap_or(&DEFAULT)
}
pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress {
self.progress
.branches
.entry(id.to_key().to_string())
.or_default()
}
/// Start a branch (transition Available -> InProgress).
pub fn start_branch(&mut self, id: BranchId) {
let bp = self.branch_progress_mut(id);
if bp.status == BranchStatus::Available {
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
}
}
/// Collect all unlocked keys for the given scope.
pub fn unlocked_keys(&self, scope: DrillScope) -> Vec<char> {
match scope {
DrillScope::Global => self.global_unlocked_keys(),
DrillScope::Branch(id) => self.branch_unlocked_keys(id),
}
}
fn global_unlocked_keys(&self) -> Vec<char> {
let mut keys = Vec::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
// For lowercase, use the progressive unlock system
if branch_def.id == BranchId::Lowercase {
keys.extend(self.lowercase_unlocked_keys());
} else {
// Include current level's keys + all prior levels
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
keys.extend_from_slice(level.keys);
}
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
keys
}
fn branch_unlocked_keys(&self, id: BranchId) -> Vec<char> {
let mut keys = Vec::new();
// Always include a-z background keys
if id != BranchId::Lowercase {
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_bp = self.branch_progress(BranchId::Lowercase);
match lowercase_bp.status {
BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()),
BranchStatus::Complete => {
for level in lowercase_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
// Include keys from the target branch
let branch_def = get_branch_definition(id);
let bp = self.branch_progress(id);
if id == BranchId::Lowercase {
keys.extend(self.lowercase_unlocked_keys());
} else {
match bp.status {
BranchStatus::InProgress => {
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
keys.extend_from_slice(level.keys);
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
keys.extend_from_slice(level.keys);
}
}
_ => {}
}
}
keys
}
/// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic).
fn lowercase_unlocked_keys(&self) -> Vec<char> {
let def = get_branch_definition(BranchId::Lowercase);
let bp = self.branch_progress(BranchId::Lowercase);
let all_keys = def.levels[0].keys;
match bp.status {
BranchStatus::Complete => all_keys.to_vec(),
BranchStatus::InProgress => {
// current_level represents number of keys unlocked beyond LOWERCASE_MIN_KEYS
let count = (LOWERCASE_MIN_KEYS + bp.current_level).min(all_keys.len());
all_keys[..count].to_vec()
}
_ => Vec::new(),
}
}
/// Number of unlocked lowercase letters (for display).
pub fn lowercase_unlocked_count(&self) -> usize {
self.lowercase_unlocked_keys().len()
}
/// Find the focused (weakest) key for the given scope.
pub fn focused_key(&self, scope: DrillScope, stats: &KeyStatsStore) -> Option<char> {
match scope {
DrillScope::Global => self.global_focused_key(stats),
DrillScope::Branch(id) => self.branch_focused_key(id, stats),
}
}
fn global_focused_key(&self, stats: &KeyStatsStore) -> Option<char> {
// Collect keys from all InProgress branches (current level only) + complete branches
let mut focus_candidates = Vec::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
if branch_def.id == BranchId::Lowercase {
focus_candidates.extend(self.lowercase_unlocked_keys());
} else if bp.current_level < branch_def.levels.len() {
// Only current level keys are focus candidates
focus_candidates
.extend_from_slice(branch_def.levels[bp.current_level].keys);
// Plus prior level keys for reinforcement
for i in 0..bp.current_level {
focus_candidates.extend_from_slice(branch_def.levels[i].keys);
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
focus_candidates.extend_from_slice(level.keys);
}
}
_ => {}
}
}
Self::weakest_key(&focus_candidates, stats)
}
fn branch_focused_key(&self, id: BranchId, stats: &KeyStatsStore) -> Option<char> {
let branch_def = get_branch_definition(id);
let bp = self.branch_progress(id);
if id == BranchId::Lowercase {
return Self::weakest_key(&self.lowercase_unlocked_keys(), stats);
}
match bp.status {
BranchStatus::InProgress if bp.current_level < branch_def.levels.len() => {
// Focus only within current level's keys
let current_keys = branch_def.levels[bp.current_level].keys;
Self::weakest_key(&current_keys.to_vec(), stats)
}
_ => None,
}
}
fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option<char> {
keys.iter()
.filter(|&&ch| stats.get_confidence(ch) < 1.0)
.min_by(|&&a, &&b| {
stats
.get_confidence(a)
.partial_cmp(&stats.get_confidence(b))
.unwrap_or(std::cmp::Ordering::Equal)
})
.copied()
}
/// Update skill tree progress based on current key stats.
/// Call after updating KeyStatsStore.
pub fn update(&mut self, stats: &KeyStatsStore) {
// Update lowercase branch (progressive unlock)
self.update_lowercase(stats);
// Check if lowercase is complete -> unlock other branches
if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete {
for &id in &[
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
] {
let bp = self.branch_progress_mut(id);
if bp.status == BranchStatus::Locked {
bp.status = BranchStatus::Available;
}
}
}
// Update InProgress branches (non-lowercase)
for branch_def in ALL_BRANCHES {
if branch_def.id == BranchId::Lowercase {
continue;
}
let bp = self.branch_progress(branch_def.id).clone();
if bp.status != BranchStatus::InProgress {
continue;
}
self.update_branch_level(branch_def, stats);
}
}
fn update_lowercase(&mut self, stats: &KeyStatsStore) {
let bp = self.branch_progress(BranchId::Lowercase).clone();
if bp.status != BranchStatus::InProgress {
return;
}
let all_keys = get_branch_definition(BranchId::Lowercase).levels[0].keys;
let current_count = LOWERCASE_MIN_KEYS + bp.current_level;
if current_count >= all_keys.len() {
// All 26 keys unlocked, check if all confident
let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.status = BranchStatus::Complete;
bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS;
}
return;
}
// Check if all current keys are confident -> unlock next
let current_keys = &all_keys[..current_count];
let all_confident = current_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(BranchId::Lowercase);
bp_mut.current_level += 1;
}
}
fn update_branch_level(&mut self, branch_def: &BranchDefinition, stats: &KeyStatsStore) {
let bp = self.branch_progress(branch_def.id).clone();
if bp.current_level >= branch_def.levels.len() {
// Already past last level, mark complete
let bp_mut = self.branch_progress_mut(branch_def.id);
bp_mut.status = BranchStatus::Complete;
return;
}
// Check if all keys in current level are confident
let current_level_keys = branch_def.levels[bp.current_level].keys;
let all_confident = current_level_keys
.iter()
.all(|&ch| stats.get_confidence(ch) >= 1.0);
if all_confident {
let bp_mut = self.branch_progress_mut(branch_def.id);
bp_mut.current_level += 1;
if bp_mut.current_level >= branch_def.levels.len() {
bp_mut.status = BranchStatus::Complete;
}
}
}
/// Total number of unlocked unique keys across all branches.
pub fn total_unlocked_count(&self) -> usize {
let mut keys: HashSet<char> = HashSet::new();
for branch_def in ALL_BRANCHES {
let bp = self.branch_progress(branch_def.id);
match bp.status {
BranchStatus::InProgress => {
if branch_def.id == BranchId::Lowercase {
for key in self.lowercase_unlocked_keys() {
keys.insert(key);
}
} else {
for (i, level) in branch_def.levels.iter().enumerate() {
if i <= bp.current_level {
for &key in level.keys {
keys.insert(key);
}
}
}
}
}
BranchStatus::Complete => {
for level in branch_def.levels {
for &key in level.keys {
keys.insert(key);
}
}
}
_ => {}
}
}
keys.len()
}
/// Complexity for scoring: total_unlocked / total_unique
pub fn complexity(&self) -> f64 {
(self.total_unlocked_count() as f64 / self.total_unique_keys as f64).max(0.1)
}
/// Get all branch definitions with their current progress (for UI).
pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> {
ALL_BRANCHES
.iter()
.map(|def| (def, self.branch_progress(def.id)))
.collect()
}
/// Total keys defined in a branch (across all levels).
pub fn branch_total_keys(id: BranchId) -> usize {
let def = get_branch_definition(id);
def.levels.iter().map(|l| l.keys.len()).sum()
}
/// Count of confident keys in a branch.
pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize {
let def = get_branch_definition(id);
def.levels
.iter()
.flat_map(|l| l.keys.iter())
.filter(|&&ch| stats.get_confidence(ch) >= 1.0)
.count()
}
}
impl Default for SkillTree {
fn default() -> Self {
Self::new(SkillTreeProgress::default())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) {
for &ch in keys {
for _ in 0..50 {
stats.update_key(ch, 200.0);
}
}
}
#[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::Numbers), BranchStatus::Locked);
}
#[test]
fn test_total_unique_keys() {
let tree = SkillTree::default();
assert_eq!(tree.total_unique_keys, 96);
}
#[test]
fn test_initial_lowercase_unlocked() {
let tree = SkillTree::default();
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), LOWERCASE_MIN_KEYS);
assert_eq!(&keys[..6], &['e', 't', 'a', 'o', 'i', 'n']);
}
#[test]
fn test_lowercase_progressive_unlock() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make initial 6 keys confident
make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']);
tree.update(&stats);
// Should unlock 7th key ('s')
let keys = tree.unlocked_keys(DrillScope::Global);
assert_eq!(keys.len(), 7);
assert!(keys.contains(&'s'));
}
#[test]
fn test_lowercase_completion_unlocks_branches() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Make all 26 lowercase keys confident
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
make_stats_confident(&mut stats, all_lowercase);
// Need to repeatedly update as each unlock requires all current keys confident
for _ in 0..30 {
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);
}
#[test]
fn test_start_branch() {
let mut tree = SkillTree::default();
// Force capitals to Available
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_progress(BranchId::Capitals).current_level, 0);
}
#[test]
fn test_branch_level_advancement() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// Set capitals to InProgress at level 0
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make level 1 capitals confident: T I A S W H B M
make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']);
tree.update(&stats);
assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1);
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::InProgress);
}
#[test]
fn test_branch_completion() {
let mut tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
// Make all capital letter levels confident
let all_caps: Vec<char> = ('A'..='Z').collect();
make_stats_confident(&mut stats, &all_caps);
// Update multiple times for level advancement
for _ in 0..5 {
tree.update(&stats);
}
assert_eq!(*tree.branch_status(BranchId::Capitals), BranchStatus::Complete);
}
#[test]
fn test_shared_key_confidence() {
let _tree = SkillTree::default();
let mut stats = KeyStatsStore::default();
// '-' is shared between ProsePunctuation L2 and CodeSymbols L1
// Master it once
make_stats_confident(&mut stats, &['-']);
// Both branches should see it as confident
assert!(stats.get_confidence('-') >= 1.0);
}
#[test]
fn test_focused_key_global() {
let tree = SkillTree::default();
let stats = KeyStatsStore::default();
// All keys at 0 confidence, focused should be first in order
let focused = tree.focused_key(DrillScope::Global, &stats);
assert!(focused.is_some());
// Should be one of the initial 6 lowercase keys
assert!(
['e', 't', 'a', 'o', 'i', 'n'].contains(&focused.unwrap()),
"focused: {:?}",
focused
);
}
#[test]
fn test_focused_key_branch() {
let mut tree = SkillTree::default();
let stats = KeyStatsStore::default();
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 0;
let focused = tree.focused_key(DrillScope::Branch(BranchId::Capitals), &stats);
assert!(focused.is_some());
// Should be one of level 1 capitals
assert!(
['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'].contains(&focused.unwrap()),
"focused: {:?}",
focused
);
}
#[test]
fn test_complexity_scales() {
let tree = SkillTree::default();
let initial_complexity = tree.complexity();
assert!(initial_complexity > 0.0);
assert!(initial_complexity < 1.0);
// Full unlock should give complexity ~1.0
let mut full_tree = SkillTree::default();
for id in BranchId::all() {
let bp = full_tree.branch_progress_mut(*id);
bp.status = BranchStatus::Complete;
}
let full_complexity = full_tree.complexity();
assert!((full_complexity - 1.0).abs() < 0.01);
}
#[test]
fn test_branch_keys_for_drill() {
let mut tree = SkillTree::default();
// Set lowercase complete, capitals in progress at level 1
tree.branch_progress_mut(BranchId::Lowercase).status = BranchStatus::Complete;
let bp = tree.branch_progress_mut(BranchId::Capitals);
bp.status = BranchStatus::InProgress;
bp.current_level = 1;
let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals));
// Should include all 26 lowercase + Capitals L1 (8) + Capitals L2 (10)
assert!(keys.contains(&'e')); // lowercase background
assert!(keys.contains(&'T')); // Capitals L1
assert!(keys.contains(&'J')); // Capitals L2 (current level)
assert!(!keys.contains(&'O')); // Capitals L3 (locked)
}
}

123
src/generator/capitalize.rs Normal file
View File

@@ -0,0 +1,123 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that capitalizes words in generated text.
/// Only capitalizes using letters from `unlocked_capitals`.
pub fn apply_capitalization(
text: &str,
unlocked_capitals: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_capitals.is_empty() {
return text.to_string();
}
// If focused key is an uppercase letter, boost its probability
let focused_upper = focused.filter(|ch| ch.is_ascii_uppercase());
let mut result = String::with_capacity(text.len());
let mut at_sentence_start = true;
for (i, ch) in text.chars().enumerate() {
if at_sentence_start && ch.is_ascii_lowercase() {
let upper = ch.to_ascii_uppercase();
if unlocked_capitals.contains(&upper) {
result.push(upper);
at_sentence_start = false;
continue;
}
}
// After period/question/exclamation + space, next word starts a sentence
if ch == ' ' && i > 0 {
let prev = text.as_bytes().get(i - 1).map(|&b| b as char);
if matches!(prev, Some('.' | '?' | '!')) {
at_sentence_start = true;
}
}
// 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(' ');
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 };
if rng.gen_bool(prob) {
result.push(upper);
continue;
}
}
}
}
if ch != '.' && ch != '?' && ch != '!' {
at_sentence_start = false;
}
result.push(ch);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_caps_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_capitalizes_first_word() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("hello world", &['H', 'W'], None, &mut rng);
assert!(result.starts_with('H'));
}
#[test]
fn test_only_capitalizes_unlocked() {
let mut rng = SmallRng::seed_from_u64(42);
// Only 'W' is unlocked, not 'H'
let result = apply_capitalization("hello world", &['W'], None, &mut rng);
assert!(result.starts_with('h')); // 'H' not unlocked
}
#[test]
fn test_after_period() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_capitalization("one. two", &['O', 'T'], None, &mut rng);
assert!(result.starts_with('O'));
assert!(result.contains("Two") || result.contains("two"));
// At minimum, first word should be capitalized
}
#[test]
fn test_focused_capital_boosted() {
// With focused 'W', W capitalization should happen more often
let caps = &['H', 'W'];
let mut focused_count = 0;
let mut unfocused_count = 0;
// Run many trials to check statistical boosting
for seed in 0..200 {
let mut rng = SmallRng::seed_from_u64(seed);
let text = "hello world wide web wonder what where who will work";
let result = apply_capitalization(text, caps, Some('W'), &mut rng);
// Count W capitalizations (skip first word which is always capitalized if 'H' is available)
focused_count += result.matches('W').count();
let mut rng2 = SmallRng::seed_from_u64(seed);
let result2 = apply_capitalization(text, caps, None, &mut rng2);
unfocused_count += result2.matches('W').count();
}
assert!(
focused_count > unfocused_count,
"Focused W count ({focused_count}) should exceed unfocused ({unfocused_count})"
);
}
}

View File

@@ -0,0 +1,220 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts code-like expressions into text.
/// Only uses symbols from `unlocked_symbols`.
pub fn apply_code_symbols(
text: &str,
unlocked_symbols: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_symbols.is_empty() {
return text.to_string();
}
// If focused key is a code symbol, boost insertion probability
let focused_symbol = focused.filter(|ch| unlocked_symbols.contains(ch));
let base_prob = if focused_symbol.is_some() { 0.35 } else { 0.20 };
let words: Vec<&str> = text.split(' ').collect();
let mut result = Vec::new();
for word in &words {
if rng.gen_bool(base_prob) {
let expr = generate_code_expr(word, unlocked_symbols, focused_symbol, rng);
result.push(expr);
} else {
result.push(word.to_string());
}
}
result.join(" ")
}
fn generate_code_expr(
word: &str,
symbols: &[char],
focused_symbol: Option<char>,
rng: &mut SmallRng,
) -> String {
// Categorize available symbols
let has = |ch: char| symbols.contains(&ch);
// Try various patterns based on available symbols
let mut patterns: Vec<Box<dyn Fn(&mut SmallRng) -> String>> = Vec::new();
// Track which patterns use the focused symbol for priority selection
let mut focused_patterns: Vec<usize> = Vec::new();
// Arithmetic & Assignment patterns
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 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 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 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 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); }
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 has('=') && has('+') {
let w = word.to_string();
patterns.push(Box::new(move |_| format!("{w} += one")));
}
if has('=') && 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 has('=') && has('=') {
let w = word.to_string();
patterns.push(Box::new(move |_| format!("{w} == nil")));
}
// Grouping patterns
if has('{') && has('}') {
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 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 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); }
}
// Logic patterns
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 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 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); }
}
// Special patterns
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 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 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 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 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 patterns.is_empty() {
return word.to_string();
}
// 50% chance to prefer a pattern that uses the focused symbol
let idx = if !focused_patterns.is_empty() && rng.gen_bool(0.50) {
focused_patterns[rng.gen_range(0..focused_patterns.len())]
} else {
rng.gen_range(0..patterns.len())
};
patterns[idx](rng)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_symbols_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_code_symbols("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_uses_only_unlocked_symbols() {
let mut rng = SmallRng::seed_from_u64(42);
let symbols = ['=', '+'];
let text = "a b c d e f g h i j";
let result = apply_code_symbols(text, &symbols, None, &mut rng);
for ch in result.chars() {
if !ch.is_alphanumeric() && ch != ' ' {
assert!(
symbols.contains(&ch),
"Unexpected symbol '{ch}' in: {result}"
);
}
}
}
#[test]
fn test_dash_patterns_generated() {
let mut rng = SmallRng::seed_from_u64(42);
let symbols = ['-', '='];
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_code_symbols(text, &symbols, None, &mut rng);
assert!(result.contains('-'), "Expected dash in: {result}");
}
}

View File

@@ -245,11 +245,11 @@ impl TextGenerator for CodeSyntaxGenerator {
result.push(snippet.to_string());
}
result.join(" ")
result.join("\n\n")
}
}
/// Extract function-length snippets from raw source code
/// Extract function-length snippets from raw source code, preserving whitespace.
fn extract_code_snippets(source: &str) -> Vec<String> {
let mut snippets = Vec::new();
let lines: Vec<&str> = source.lines().collect();
@@ -285,11 +285,11 @@ fn extract_code_snippets(source: &str) -> Vec<String> {
}
if snippet_lines.len() >= 3 && snippet_lines.len() <= 30 {
let snippet = snippet_lines.join(" ");
// Normalize whitespace
let normalized: String = snippet.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.len() >= 20 && normalized.len() <= 500 {
snippets.push(normalized);
// Preserve original newlines and indentation
let snippet = snippet_lines.join("\n");
let char_count = snippet.chars().filter(|c| !c.is_whitespace()).count();
if char_count >= 20 && snippet.len() <= 800 {
snippets.push(snippet);
}
}

View File

@@ -1,9 +1,13 @@
pub mod cache;
pub mod capitalize;
pub mod code_patterns;
pub mod code_syntax;
pub mod dictionary;
pub mod github_code;
pub mod numbers;
pub mod passage;
pub mod phonetic;
pub mod punctuate;
pub mod transition_table;
use crate::engine::filter::CharFilter;

132
src/generator/numbers.rs Normal file
View File

@@ -0,0 +1,132 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts number expressions into text.
/// Only uses digits from `unlocked_digits`.
pub fn apply_numbers(
text: &str,
unlocked_digits: &[char],
has_dot: bool,
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_digits.is_empty() {
return text.to_string();
}
// If focused key is a digit, boost number insertion probability
let focused_digit = focused.filter(|ch| ch.is_ascii_digit());
let base_prob = if focused_digit.is_some() { 0.30 } else { 0.15 };
let words: Vec<&str> = text.split(' ').collect();
let mut result = Vec::new();
for word in &words {
if rng.gen_bool(base_prob) {
let expr = generate_number_expr(unlocked_digits, has_dot, focused_digit, rng);
result.push(expr);
} else {
result.push(word.to_string());
}
}
result.join(" ")
}
fn generate_number_expr(
digits: &[char],
has_dot: bool,
focused_digit: Option<char>,
rng: &mut SmallRng,
) -> String {
// Determine how many patterns are available (version pattern needs dot)
let max_pattern = if has_dot { 5 } else { 4 };
let pattern = rng.gen_range(0..max_pattern);
let num = match pattern {
0 => {
// Simple count: "3" or "42"
random_number(digits, 1, 3, focused_digit, rng)
}
1 => {
// Measurement: "7 miles" or "42 items"
let num = random_number(digits, 1, 2, focused_digit, rng);
let units = ["items", "miles", "days", "lines", "times", "parts"];
let unit = units[rng.gen_range(0..units.len())];
return format!("{num} {unit}");
}
2 => {
// Year-like: "2024"
random_number(digits, 4, 4, focused_digit, rng)
}
3 => {
// ID: "room 42" or "page 7"
let prefixes = ["room", "page", "step", "item", "line", "port"];
let prefix = prefixes[rng.gen_range(0..prefixes.len())];
let num = random_number(digits, 1, 3, focused_digit, rng);
return format!("{prefix} {num}");
}
_ => {
// Version-like: "3.14" or "2.0" (only when dot is available)
let major = random_number(digits, 1, 1, focused_digit, rng);
let minor = random_number(digits, 1, 2, focused_digit, rng);
return format!("{major}.{minor}");
}
};
num
}
fn random_number(
digits: &[char],
min_len: usize,
max_len: usize,
focused_digit: Option<char>,
rng: &mut SmallRng,
) -> String {
let len = rng.gen_range(min_len..=max_len);
(0..len)
.map(|_| {
// 40% chance to use the focused digit if it's a digit
if let Some(fd) = focused_digit {
if rng.gen_bool(0.40) {
return fd;
}
}
digits[rng.gen_range(0..digits.len())]
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_numbers_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_numbers("hello world", &[], false, None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_numbers_use_only_unlocked_digits() {
let mut rng = SmallRng::seed_from_u64(42);
let digits = ['1', '2', '3'];
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);
for ch in result.chars() {
if ch.is_ascii_digit() {
assert!(digits.contains(&ch), "Unexpected digit {ch} in: {result}");
}
}
}
#[test]
fn test_no_dot_without_punctuation() {
let mut rng = SmallRng::seed_from_u64(42);
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}");
}
}

213
src/generator/punctuate.rs Normal file
View File

@@ -0,0 +1,213 @@
use rand::rngs::SmallRng;
use rand::Rng;
/// Post-processing pass that inserts punctuation into generated text.
/// Only uses punctuation chars from `unlocked_punct`.
pub fn apply_punctuation(
text: &str,
unlocked_punct: &[char],
focused: Option<char>,
rng: &mut SmallRng,
) -> String {
if unlocked_punct.is_empty() {
return text.to_string();
}
// If focused key is a punctuation char in our set, boost its insertion probability
let focused_punct = focused.filter(|ch| unlocked_punct.contains(ch));
let words: Vec<&str> = text.split(' ').collect();
if words.is_empty() {
return text.to_string();
}
let has_period = unlocked_punct.contains(&'.');
let has_comma = unlocked_punct.contains(&',');
let has_apostrophe = unlocked_punct.contains(&'\'');
let has_semicolon = unlocked_punct.contains(&';');
let has_colon = unlocked_punct.contains(&':');
let has_quote = unlocked_punct.contains(&'"');
let has_dash = unlocked_punct.contains(&'-');
let has_question = unlocked_punct.contains(&'?');
let has_exclaim = unlocked_punct.contains(&'!');
let has_open_paren = unlocked_punct.contains(&'(');
let has_close_paren = unlocked_punct.contains(&')');
let mut result = Vec::new();
let mut words_since_period = 0;
let mut words_since_comma = 0;
for (i, word) in words.iter().enumerate() {
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 };
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 };
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;
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 };
if has_question && rng.gen_bool(q_prob) {
w.push('?');
} else if has_exclaim && rng.gen_bool(excl_prob) {
w.push('!');
} else if has_period {
w.push('.');
}
words_since_period = 0;
words_since_comma = 0;
} 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 {
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 {
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 };
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 };
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 };
if has_open_paren && has_close_paren && rng.gen_bool(paren_prob) && i + 2 < words.len() {
w = format!("({w}");
}
result.push(w);
}
// End with period if we have it
if has_period {
if let Some(last) = result.last_mut() {
let last_char = last.chars().last();
if !matches!(last_char, Some('.' | '?' | '!' | '"' | ')')) {
last.push('.');
}
}
}
// Close any open quotes/parens
let mut open_quotes = 0i32;
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 let Some(last) = result.last_mut() {
if open_quotes % 2 != 0 && has_quote {
// Remove trailing period to put quote after
let had_period = last.ends_with('.');
if had_period {
last.pop();
}
last.push('"');
if had_period {
last.push('.');
}
}
if open_parens > 0 && has_close_paren {
let had_period = last.ends_with('.');
if had_period {
last.pop();
}
last.push(')');
if had_period {
last.push('.');
}
}
}
result.join(" ")
}
fn make_contraction(word: &str, rng: &mut SmallRng) -> String {
// Simple contractions based on common patterns
let contractions: &[(&str, &str)] = &[
("not", "n't"),
("will", "'ll"),
("would", "'d"),
("have", "'ve"),
("are", "'re"),
("is", "'s"),
];
for &(base, suffix) in contractions {
if word == base {
// For "not" -> "don't", "can't", etc. - just return the contraction form
return format!("{word}{suffix}");
}
}
// Generic: ~chance to add 's
if rng.gen_bool(0.5) {
format!("{word}'s")
} else {
word.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
#[test]
fn test_no_punct_when_empty() {
let mut rng = SmallRng::seed_from_u64(42);
let result = apply_punctuation("hello world", &[], None, &mut rng);
assert_eq!(result, "hello world");
}
#[test]
fn test_adds_period_at_end() {
let mut rng = SmallRng::seed_from_u64(42);
let text = "one two three four five six seven eight nine ten";
let result = apply_punctuation(text, &['.'], None, &mut rng);
assert!(result.ends_with('.'));
}
#[test]
fn test_period_appears_mid_text() {
let mut rng = SmallRng::seed_from_u64(42);
let words: Vec<&str> = (0..20).map(|_| "word").collect();
let text = words.join(" ");
let result = apply_punctuation(&text, &['.', ','], None, &mut rng);
// Should have at least one period somewhere in the middle
let period_count = result.chars().filter(|&c| c == '.').count();
assert!(period_count >= 1, "Expected periods in: {result}");
}
}

View File

@@ -29,7 +29,9 @@ 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 ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram;
@@ -160,6 +162,7 @@ fn handle_key(app: &mut App, key: KeyEvent) {
AppScreen::DrillResult => handle_result_key(app, key),
AppScreen::StatsDashboard => handle_stats_key(app, key),
AppScreen::Settings => handle_settings_key(app, key),
AppScreen::SkillTree => handle_skill_tree_key(app, key),
}
}
@@ -168,16 +171,20 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('1') => {
app.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
KeyCode::Char('2') => {
app.drill_mode = DrillMode::Code;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
KeyCode::Char('3') => {
app.drill_mode = DrillMode::Passage;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
KeyCode::Char('t') => app.go_to_skill_tree(),
KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(),
KeyCode::Up | KeyCode::Char('k') => app.menu.prev(),
@@ -185,18 +192,22 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
KeyCode::Enter => match app.menu.selected {
0 => {
app.drill_mode = DrillMode::Adaptive;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
1 => {
app.drill_mode = DrillMode::Code;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
2 => {
app.drill_mode = DrillMode::Passage;
app.drill_scope = DrillScope::Global;
app.start_drill();
}
3 => app.go_to_stats(),
4 => app.go_to_settings(),
3 => app.go_to_skill_tree(),
4 => app.go_to_stats(),
5 => app.go_to_settings(),
_ => {}
},
_ => {}
@@ -204,13 +215,29 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) {
}
fn handle_drill_key(app: &mut App, key: KeyEvent) {
// Route Enter/Tab as typed characters during active drills
if app.drill.is_some() {
match key.code {
KeyCode::Enter => {
app.type_char('\n');
return;
}
KeyCode::Tab => {
app.type_char('\t');
return;
}
KeyCode::BackTab => return, // Ignore Shift+Tab
_ => {}
}
}
match key.code {
KeyCode::Esc => {
let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
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());
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;
@@ -317,6 +344,33 @@ fn handle_settings_key(app: &mut App, key: KeyEvent) {
}
}
fn handle_skill_tree_key(app: &mut App, key: KeyEvent) {
let branches = selectable_branches();
match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Up | KeyCode::Char('k') => {
app.skill_tree_selected = app.skill_tree_selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') => {
if app.skill_tree_selected + 1 < branches.len() {
app.skill_tree_selected += 1;
}
}
KeyCode::Enter => {
if app.skill_tree_selected < branches.len() {
let branch_id = branches[app.skill_tree_selected];
let status = app.skill_tree.branch_status(branch_id).clone();
if status == engine::skill_tree::BranchStatus::Available
|| status == engine::skill_tree::BranchStatus::InProgress
{
app.start_branch_drill(branch_id);
}
}
}
_ => {}
}
}
fn render(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let colors = &app.theme.colors;
@@ -330,6 +384,7 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
AppScreen::DrillResult => render_result(frame, app),
AppScreen::StatsDashboard => render_stats(frame, app),
AppScreen::Settings => render_settings(frame, app),
AppScreen::SkillTree => render_skill_tree(frame, app),
}
}
@@ -352,11 +407,11 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
String::new()
};
let header_info = format!(
" Level {} | Score {:.0} | {}/{} letters{}",
" Level {} | Score {:.0} | {}/{} keys{}",
crate::engine::scoring::level_from_score(app.profile.total_score),
app.profile.total_score,
app.letter_unlock.unlocked_count(),
app.letter_unlock.total_letters(),
app.skill_tree.total_unlocked_count(),
app.skill_tree.total_unique_keys,
streak_text,
);
let header = Paragraph::new(Line::from(vec![
@@ -381,7 +436,7 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(&app.menu, menu_area);
let footer = Paragraph::new(Line::from(vec![Span::styled(
" [1-3] Start [s] Stats [q] Quit ",
" [1-3] Start [t] Skill Tree [s] Stats [q] Quit ",
Style::default().fg(colors.text_pending()),
)]));
frame.render_widget(footer, layout[2]);
@@ -397,8 +452,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let mode_name = match app.drill_mode {
DrillMode::Adaptive => "Adaptive",
DrillMode::Code => "Code",
DrillMode::Passage => "Passage",
DrillMode::Code => "Code (Unranked)",
DrillMode::Passage => "Passage (Unranked)",
};
// For medium/narrow: show compact stats in header
@@ -420,7 +475,8 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
frame.render_widget(header, app_layout.header);
} else {
let header_title = format!(" {mode_name} Drill ");
let focus_text = if let Some(focused) = app.letter_unlock.focused {
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let focus_text = if let Some(focused) = focused {
format!(" | Focus: '{focused}'")
} else {
String::new()
@@ -466,9 +522,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
let mut idx = 1;
if show_progress {
let unlocked = app.skill_tree.total_unlocked_count() as f64;
let total = app.skill_tree.total_unique_keys as f64;
let progress_val = (unlocked / total).min(1.0);
let progress = ProgressBar::new(
"Letter Progress",
app.letter_unlock.progress(),
"Key Progress",
progress_val,
app.theme,
);
frame.render_widget(progress, main_layout[idx]);
@@ -477,10 +536,12 @@ fn render_drill(frame: &mut ratatui::Frame, app: &App) {
if show_kbd {
let next_char = drill.target.get(drill.cursor).copied();
let unlocked_keys = app.skill_tree.unlocked_keys(app.drill_scope);
let focused = app.skill_tree.focused_key(app.drill_scope, &app.key_stats);
let kbd = KeyboardDiagram::new(
app.letter_unlock.focused,
focused,
next_char,
&app.letter_unlock.included,
&unlocked_keys,
&app.depressed_keys,
app.theme,
)
@@ -609,3 +670,15 @@ fn render_settings(frame: &mut ratatui::Frame, app: &App) {
)));
footer.render(layout[3], frame.buffer_mut());
}
fn render_skill_tree(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area();
let centered = ui::layout::centered_rect(70, 90, area);
let widget = SkillTreeWidget::new(
&app.skill_tree,
&app.key_stats,
app.skill_tree_selected,
app.theme,
);
frame.render_widget(widget, centered);
}

View File

@@ -17,12 +17,18 @@ pub struct DrillResult {
pub per_key_times: Vec<KeyTime>,
#[serde(default = "default_drill_mode", alias = "lesson_mode")]
pub drill_mode: String,
#[serde(default = "default_true")]
pub ranked: bool,
}
fn default_drill_mode() -> String {
"adaptive".to_string()
}
fn default_true() -> bool {
true
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyTime {
pub key: char,
@@ -31,7 +37,7 @@ pub struct KeyTime {
}
impl DrillResult {
pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str) -> 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| {
@@ -63,6 +69,7 @@ impl DrillResult {
timestamp: Utc::now(),
per_key_times,
drill_mode: drill_mode.to_string(),
ranked,
}
}
}

View File

@@ -49,8 +49,17 @@ impl JsonStore {
Ok(())
}
pub fn load_profile(&self) -> ProfileData {
self.load("profile.json")
/// Load and deserialize profile. Returns None if file exists but
/// cannot be parsed (schema mismatch / corruption).
pub fn load_profile(&self) -> Option<ProfileData> {
let path = self.file_path("profile.json");
if path.exists() {
let content = fs::read_to_string(&path).ok()?;
serde_json::from_str(&content).ok()
} else {
// No file yet — return fresh default (not a schema mismatch)
Some(ProfileData::default())
}
}
pub fn save_profile(&self, data: &ProfileData) -> Result<()> {

View File

@@ -1,14 +1,15 @@
use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore;
use crate::engine::skill_tree::SkillTreeProgress;
use crate::session::result::DrillResult;
const SCHEMA_VERSION: u32 = 1;
const SCHEMA_VERSION: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProfileData {
pub schema_version: u32,
pub unlocked_letters: Vec<char>,
pub skill_tree: SkillTreeProgress,
pub total_score: f64,
#[serde(alias = "total_lessons")]
pub total_drills: u32,
@@ -21,7 +22,7 @@ impl Default for ProfileData {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
unlocked_letters: Vec::new(),
skill_tree: SkillTreeProgress::default(),
total_score: 0.0,
total_drills: 0,
streak_days: 0,
@@ -31,6 +32,13 @@ impl Default for ProfileData {
}
}
impl ProfileData {
/// Check if loaded data has a stale schema version and needs reset.
pub fn needs_reset(&self) -> bool {
self.schema_version != SCHEMA_VERSION
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct KeyStatsData {
pub schema_version: u32,

View File

@@ -42,13 +42,20 @@ impl Widget for Dashboard<'_> {
])
.split(inner);
let title = Paragraph::new(Line::from(Span::styled(
let mut title_spans = vec![Span::styled(
"Results",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
)))
.alignment(Alignment::Center);
)];
if !self.result.ranked {
title_spans.push(Span::styled(
" (Unranked \u{2014} does not count toward skill tree)",
Style::default().fg(colors.text_pending()),
));
}
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

@@ -37,6 +37,11 @@ impl<'a> Menu<'a> {
label: "Passage Drill".to_string(),
description: "Type passages from books".to_string(),
},
MenuItem {
key: "t".to_string(),
label: "Skill Tree".to_string(),
description: "View progression branches and launch drills".to_string(),
},
MenuItem {
key: "s".to_string(),
label: "Statistics".to_string(),

View File

@@ -4,6 +4,7 @@ pub mod dashboard;
pub mod keyboard_diagram;
pub mod menu;
pub mod progress_bar;
pub mod skill_tree;
pub mod stats_dashboard;
pub mod stats_sidebar;
pub mod typing_area;

View File

@@ -0,0 +1,354 @@
use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
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,
};
use crate::ui::theme::Theme;
pub struct SkillTreeWidget<'a> {
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
theme: &'a Theme,
}
impl<'a> SkillTreeWidget<'a> {
pub fn new(
skill_tree: &'a SkillTreeEngine,
key_stats: &'a KeyStatsStore,
selected: usize,
theme: &'a Theme,
) -> Self {
Self {
skill_tree,
key_stats,
selected,
theme,
}
}
}
/// Get the list of selectable branch IDs (all non-Lowercase branches).
pub fn selectable_branches() -> Vec<BranchId> {
vec![
BranchId::Capitals,
BranchId::Numbers,
BranchId::ProsePunctuation,
BranchId::Whitespace,
BranchId::CodeSymbols,
]
}
impl Widget for SkillTreeWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let block = Block::bordered()
.title(" Skill Tree ")
.border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg()));
let inner = block.inner(area);
block.render(area, buf);
// Layout: header (2), branch list (dynamic), separator (1), detail panel (dynamic), footer (2)
let branches = selectable_branches();
let branch_list_height = 3 + branches.len() as u16 * 2 + 1; // root + separator + 5 branches
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(branch_list_height.min(inner.height.saturating_sub(6))),
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(2),
])
.split(inner);
// --- Branch list ---
self.render_branch_list(layout[0], buf, &branches);
// --- Separator ---
let sep = Paragraph::new(Line::from(Span::styled(
"\u{2500}".repeat(layout[1].width as usize),
Style::default().fg(colors.border()),
)));
sep.render(layout[1], buf);
// --- Detail panel for selected branch ---
self.render_detail_panel(layout[2], buf, &branches);
// --- Footer ---
let footer_text = if self.selected < branches.len() {
let bp = self.skill_tree.branch_progress(branches[self.selected]);
if *self.skill_tree.branch_status(branches[self.selected]) == BranchStatus::Locked {
" Complete a-z to unlock branches "
} 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 "
}
} else {
" [\u{2191}\u{2193}/jk] Navigate [q] Back "
};
let footer = Paragraph::new(Line::from(Span::styled(
footer_text,
Style::default().fg(colors.text_pending()),
)));
footer.render(layout[3], buf);
}
}
impl SkillTreeWidget<'_> {
fn render_branch_list(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
let colors = &self.theme.colors;
let mut lines: Vec<Line> = Vec::new();
// Root: Lowercase a-z
let lowercase_bp = self.skill_tree.branch_progress(BranchId::Lowercase);
let lowercase_def = get_branch_definition(BranchId::Lowercase);
let lowercase_total = lowercase_def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let lowercase_confident = self.skill_tree.branch_confident_keys(BranchId::Lowercase, self.key_stats);
let (prefix, style) = match lowercase_bp.status {
BranchStatus::Complete => (
"\u{2605} ",
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD),
),
BranchStatus::InProgress => (
"\u{25b6} ",
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD),
),
_ => (
" ",
Style::default().fg(colors.text_pending()),
),
};
let status_text = match lowercase_bp.status {
BranchStatus::Complete => "COMPLETE".to_string(),
BranchStatus::InProgress => {
let unlocked = self.skill_tree.lowercase_unlocked_count();
format!("{unlocked}/{lowercase_total}")
}
_ => "LOCKED".to_string(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {prefix}{name}", name = lowercase_def.name),
style,
),
Span::styled(
format!(" {status_text} {lowercase_confident}/{lowercase_total} keys"),
Style::default().fg(colors.text_pending()),
),
]));
// Progress bar for lowercase
let pct = if lowercase_total > 0 {
lowercase_confident as f64 / lowercase_total as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
// Separator
lines.push(Line::from(Span::styled(
" \u{2500}\u{2500} Branches (unlocked after a-z) \u{2500}\u{2500}",
Style::default().fg(colors.border()),
)));
// Branches
for (i, &branch_id) in branches.iter().enumerate() {
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
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 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)
} else {
Style::default().fg(colors.text_correct()).add_modifier(Modifier::BOLD)
},
),
BranchStatus::InProgress => (
"\u{25b6} ",
if is_selected {
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
Style::default().fg(colors.accent()).add_modifier(Modifier::BOLD)
},
),
BranchStatus::Available => (
" ",
if is_selected {
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()),
),
};
let status_text = match bp.status {
BranchStatus::Complete => format!("COMPLETE {confident_keys}/{total_keys} keys"),
BranchStatus::InProgress => format!("Lvl {}/{} {confident_keys}/{total_keys} keys", bp.current_level + 1, def.levels.len()),
BranchStatus::Available => format!("Available 0/{total_keys} keys"),
BranchStatus::Locked => format!("Locked 0/{total_keys} keys"),
};
let sel_indicator = if is_selected { "> " } else { " " };
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())),
]));
let pct = if total_keys > 0 {
confident_keys as f64 / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" {}", progress_bar_str(pct, 30)),
style,
)));
}
let paragraph = Paragraph::new(lines);
paragraph.render(area, buf);
}
fn render_detail_panel(&self, area: Rect, buf: &mut Buffer, branches: &[BranchId]) {
let colors = &self.theme.colors;
if self.selected >= branches.len() {
return;
}
let branch_id = branches[self.selected];
let bp = self.skill_tree.branch_progress(branch_id);
let def = get_branch_definition(branch_id);
let mut lines: Vec<Line> = Vec::new();
// Branch title with level info
let level_text = match bp.status {
BranchStatus::InProgress => format!("Level {}/{}", bp.current_level + 1, def.levels.len()),
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),
),
Span::styled(
format!(" {level_text}"),
Style::default().fg(colors.text_pending()),
),
]));
// Per-level key breakdown
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 mut key_spans: Vec<Span> = Vec::new();
key_spans.push(Span::styled(
format!(" L{}: ", level_idx + 1),
Style::default().fg(colors.fg()),
));
for &key in level.keys {
let is_confident = self.key_stats.get_confidence(key) >= 1.0;
let is_focused = focused == Some(key);
let display = if key == '\n' {
"\\n".to_string()
} else if key == '\t' {
"\\t".to_string()
} else {
key.to_string()
};
let style = if is_focused {
Style::default()
.fg(colors.bg())
.bg(colors.focused_key())
.add_modifier(Modifier::BOLD)
} else if is_confident {
Style::default().fg(colors.text_correct())
} else if level_status == "locked" {
Style::default().fg(colors.text_pending())
} else {
Style::default().fg(colors.fg())
};
key_spans.push(Span::styled(display, style));
key_spans.push(Span::raw(" "));
}
key_spans.push(Span::styled(
format!(" ({level_status})"),
Style::default().fg(colors.text_pending()),
));
lines.push(Line::from(key_spans));
}
// Average confidence
let total_keys = def.levels.iter().map(|l| l.keys.len()).sum::<usize>();
let avg_conf = if total_keys > 0 {
let sum: f64 = def.levels.iter()
.flat_map(|l| l.keys.iter())
.map(|&ch| self.key_stats.get_confidence(ch).min(1.0))
.sum();
sum / total_keys as f64
} else {
0.0
};
lines.push(Line::from(Span::styled(
format!(" Avg Confidence: {} {:.0}%", progress_bar_str(avg_conf, 20), avg_conf * 100.0),
Style::default().fg(colors.text_pending()),
)));
let paragraph = Paragraph::new(lines);
paragraph.render(area, buf);
}
}
fn progress_bar_str(pct: f64, width: usize) -> String {
let filled = (pct * width as f64).round() as usize;
let empty = width.saturating_sub(filled);
format!(
"{}{}",
"\u{2588}".repeat(filled),
"\u{2591}".repeat(empty),
)
}

View File

@@ -488,7 +488,7 @@ impl StatsDashboard<'_> {
table_block.render(layout[0], buf);
let header = Line::from(vec![Span::styled(
" # WPM Raw Acc% Time Date",
" # WPM Raw Acc% Time Date Mode",
Style::default()
.fg(colors.accent())
.add_modifier(Modifier::BOLD),
@@ -523,8 +523,14 @@ impl StatsDashboard<'_> {
" "
};
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}"
" {wpm_indicator}{idx_str} {wpm_str} {raw_str} {acc_str} {time_str:>6} {date_str} {mode}{mode_str}",
mode = result.drill_mode,
);
let acc_color = if result.accuracy >= 95.0 {
@@ -538,6 +544,9 @@ impl StatsDashboard<'_> {
let is_selected = i == self.history_selected;
let style = if is_selected {
Style::default().fg(acc_color).bg(colors.accent_dim())
} else if !result.ranked {
// Muted styling for unranked drills
Style::default().fg(colors.text_pending())
} else {
Style::default().fg(acc_color)
};

View File

@@ -19,43 +19,172 @@ impl<'a> TypingArea<'a> {
}
}
/// A render token maps a single target character to its display representation.
struct RenderToken {
target_idx: usize,
display: String,
is_line_break: bool,
}
/// Expand target chars into render tokens, handling whitespace display.
fn build_render_tokens(target: &[char]) -> Vec<RenderToken> {
let mut tokens = Vec::new();
let mut col = 0usize;
for (i, &ch) in target.iter().enumerate() {
match ch {
'\n' => {
tokens.push(RenderToken {
target_idx: i,
display: "\u{21b5}".to_string(), // ↵
is_line_break: true,
});
col = 0;
}
'\t' => {
let tab_width = 4 - (col % 4);
let mut display = String::from("\u{2192}"); // →
for _ in 1..tab_width {
display.push('\u{00b7}'); // ·
}
tokens.push(RenderToken {
target_idx: i,
display,
is_line_break: false,
});
col += tab_width;
}
_ => {
tokens.push(RenderToken {
target_idx: i,
display: ch.to_string(),
is_line_break: false,
});
col += 1;
}
}
}
tokens
}
impl Widget for TypingArea<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors;
let mut spans: Vec<Span> = Vec::new();
let tokens = build_render_tokens(&self.drill.target);
for (i, &target_ch) in self.drill.target.iter().enumerate() {
if i < self.drill.cursor {
let style = match &self.drill.input[i] {
// Group tokens into lines, splitting on line_break tokens
let mut lines: Vec<Vec<Span>> = vec![Vec::new()];
for token in &tokens {
let idx = token.target_idx;
let style = if idx < self.drill.cursor {
match &self.drill.input[idx] {
CharStatus::Correct => Style::default().fg(colors.text_correct()),
CharStatus::Incorrect(_) => Style::default()
.fg(colors.text_incorrect())
.bg(colors.text_incorrect_bg())
.add_modifier(Modifier::UNDERLINED),
};
let display = match &self.drill.input[i] {
CharStatus::Incorrect(actual) => *actual,
_ => target_ch,
};
spans.push(Span::styled(display.to_string(), style));
} else if i == self.drill.cursor {
let style = Style::default()
}
} else if idx == self.drill.cursor {
Style::default()
.fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg());
spans.push(Span::styled(target_ch.to_string(), style));
.bg(colors.text_cursor_bg())
} else {
let style = Style::default().fg(colors.text_pending());
spans.push(Span::styled(target_ch.to_string(), style));
Style::default().fg(colors.text_pending())
};
// For incorrect chars, show the actual typed char for regular chars,
// 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()
} else {
actual.to_string()
}
} else {
token.display.clone()
}
} else {
token.display.clone()
};
lines.last_mut().unwrap().push(Span::styled(display, style));
if token.is_line_break {
lines.push(Vec::new());
}
}
let line = Line::from(spans);
let ratatui_lines: Vec<Line> = lines.into_iter().map(Line::from).collect();
let block = Block::bordered()
.border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg()));
let paragraph = Paragraph::new(line).block(block).wrap(Wrap { trim: false });
let paragraph = Paragraph::new(ratatui_lines)
.block(block)
.wrap(Wrap { trim: false });
paragraph.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_tokens_basic() {
let target: Vec<char> = "abc".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert_eq!(tokens[0].display, "a");
assert_eq!(tokens[1].display, "b");
assert_eq!(tokens[2].display, "c");
assert!(!tokens[0].is_line_break);
}
#[test]
fn test_render_tokens_newline() {
let target: Vec<char> = "a\nb".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert_eq!(tokens[1].display, "\u{21b5}"); // ↵
assert!(tokens[1].is_line_break);
assert_eq!(tokens[1].target_idx, 1);
}
#[test]
fn test_render_tokens_tab() {
let target: Vec<char> = "\tx".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 2);
// Tab at col 0: width = 4 - (0 % 4) = 4 => "→···"
assert_eq!(tokens[0].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
assert!(!tokens[0].is_line_break);
assert_eq!(tokens[0].target_idx, 0);
}
#[test]
fn test_render_tokens_tab_alignment() {
// "ab\t" -> col 2, tab_width = 4 - (2 % 4) = 2 => "→·"
let target: Vec<char> = "ab\t".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens[2].display, "\u{2192}\u{00b7}");
}
#[test]
fn test_render_tokens_newline_resets_column() {
// "\n\tx" -> after newline, col resets to 0, tab_width = 4
let target: Vec<char> = "\n\tx".chars().collect();
let tokens = build_render_tokens(&target);
assert_eq!(tokens.len(), 3);
assert!(tokens[0].is_line_break);
assert_eq!(tokens[1].display, "\u{2192}\u{00b7}\u{00b7}\u{00b7}");
}
}