use std::collections::HashSet; use std::time::Instant; use rand::rngs::SmallRng; 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::generator::code_syntax::CodeSyntaxGenerator; use crate::generator::dictionary::Dictionary; use crate::generator::passage::PassageGenerator; use crate::generator::phonetic::PhoneticGenerator; use crate::generator::TextGenerator; use crate::generator::transition_table::TransitionTable; use crate::session::input::{self, KeystrokeEvent}; use crate::session::lesson::LessonState; use crate::session::result::LessonResult; use crate::store::json_store::JsonStore; use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; use crate::ui::components::menu::Menu; use crate::ui::theme::Theme; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AppScreen { Menu, Lesson, LessonResult, StatsDashboard, Settings, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LessonMode { Adaptive, Code, Passage, } impl LessonMode { pub fn as_str(self) -> &'static str { match self { LessonMode::Adaptive => "adaptive", LessonMode::Code => "code", LessonMode::Passage => "passage", } } } pub struct App { pub screen: AppScreen, pub lesson_mode: LessonMode, pub lesson: Option, pub lesson_events: Vec, pub last_result: Option, pub lesson_history: Vec, pub menu: Menu<'static>, pub theme: &'static Theme, pub config: Config, pub key_stats: KeyStatsStore, pub letter_unlock: LetterUnlock, pub profile: ProfileData, pub store: Option, pub should_quit: bool, pub settings_selected: usize, pub stats_tab: usize, pub depressed_keys: HashSet, pub last_key_time: Option, pub history_selected: usize, pub history_confirm_delete: bool, rng: SmallRng, transition_table: TransitionTable, #[allow(dead_code)] dictionary: Dictionary, } impl App { pub fn new() -> Self { let config = Config::load().unwrap_or_default(); let loaded_theme = Theme::load(&config.theme).unwrap_or_default(); let theme: &'static Theme = Box::leak(Box::new(loaded_theme)); let menu = Menu::new(theme); let store = JsonStore::new().ok(); let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store { let ksd = s.load_key_stats(); let pd = s.load_profile(); let lhd = s.load_lesson_history(); let lu = if pd.unlocked_letters.is_empty() { LetterUnlock::new() } else { LetterUnlock::from_included(pd.unlocked_letters.clone()) }; (ksd.stats, lu, pd, lhd.lessons) } else { ( KeyStatsStore::default(), LetterUnlock::new(), ProfileData::default(), Vec::new(), ) }; let mut key_stats_with_target = key_stats; key_stats_with_target.target_cpm = config.target_cpm(); let dictionary = Dictionary::load(); let transition_table = TransitionTable::build_from_words(&dictionary.words_list()); let mut app = Self { screen: AppScreen::Menu, lesson_mode: LessonMode::Adaptive, lesson: None, lesson_events: Vec::new(), last_result: None, lesson_history, menu, theme, config, key_stats: key_stats_with_target, letter_unlock, profile, store, should_quit: false, settings_selected: 0, stats_tab: 0, depressed_keys: HashSet::new(), last_key_time: None, history_selected: 0, history_confirm_delete: false, rng: SmallRng::from_entropy(), transition_table, dictionary, }; app.start_lesson(); app } pub fn start_lesson(&mut self) { let text = self.generate_text(); self.lesson = Some(LessonState::new(&text)); self.lesson_events.clear(); self.screen = AppScreen::Lesson; } fn generate_text(&mut self) -> String { let word_count = self.config.word_count; let mode = self.lesson_mode; match mode { LessonMode::Adaptive => { let filter = CharFilter::new(self.letter_unlock.included.clone()); let focused = self.letter_unlock.focused; 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) } LessonMode::Code => { let filter = CharFilter::new(('a'..='z').collect()); let lang = self .config .code_languages .first() .cloned() .unwrap_or_else(|| "rust".to_string()); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let mut generator = CodeSyntaxGenerator::new(rng, &lang); generator.generate(&filter, None, word_count) } LessonMode::Passage => { let filter = CharFilter::new(('a'..='z').collect()); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let mut generator = PassageGenerator::new(rng); generator.generate(&filter, None, word_count) } } } pub fn type_char(&mut self, ch: char) { if let Some(ref mut lesson) = self.lesson { if let Some(event) = input::process_char(lesson, ch) { self.lesson_events.push(event); } if lesson.is_complete() { self.finish_lesson(); } } } pub fn backspace(&mut self) { if let Some(ref mut lesson) = self.lesson { input::process_backspace(lesson); } } fn finish_lesson(&mut self) { if let Some(ref lesson) = self.lesson { let result = LessonResult::from_lesson(lesson, &self.lesson_events, self.lesson_mode.as_str()); if self.lesson_mode == LessonMode::Adaptive { 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); } let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); let score = scoring::compute_score(&result, complexity); self.profile.total_score += score; self.profile.total_lessons += 1; self.profile.unlocked_letters = self.letter_unlock.included.clone(); let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); if self.profile.last_practice_date.as_deref() != Some(&today) { if let Some(ref last) = self.profile.last_practice_date { let yesterday = (chrono::Utc::now() - chrono::Duration::days(1)) .format("%Y-%m-%d") .to_string(); if last == &yesterday { self.profile.streak_days += 1; } else { self.profile.streak_days = 1; } } else { self.profile.streak_days = 1; } self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days); self.profile.last_practice_date = Some(today); } self.lesson_history.push(result.clone()); if self.lesson_history.len() > 500 { self.lesson_history.remove(0); } self.last_result = Some(result); // Adaptive mode auto-continues to next lesson (like keybr.com) if self.lesson_mode == LessonMode::Adaptive { self.start_lesson(); } else { self.screen = AppScreen::LessonResult; } self.save_data(); } } fn save_data(&self) { if let Some(ref store) = self.store { let _ = store.save_profile(&self.profile); let _ = store.save_key_stats(&KeyStatsData { schema_version: 1, stats: self.key_stats.clone(), }); let _ = store.save_lesson_history(&LessonHistoryData { schema_version: 1, lessons: self.lesson_history.clone(), }); } } pub fn retry_lesson(&mut self) { self.start_lesson(); } pub fn go_to_menu(&mut self) { self.screen = AppScreen::Menu; self.lesson = None; self.lesson_events.clear(); } pub fn go_to_stats(&mut self) { self.stats_tab = 0; self.history_selected = 0; self.history_confirm_delete = false; self.screen = AppScreen::StatsDashboard; } pub fn delete_session(&mut self) { if self.lesson_history.is_empty() { return; } // History tab shows reverse order, so convert display index to actual index let actual_idx = self.lesson_history.len() - 1 - self.history_selected; self.lesson_history.remove(actual_idx); self.rebuild_from_history(); self.save_data(); // Clamp selection to visible range (max 20 visible rows) if !self.lesson_history.is_empty() { let max_visible = self.lesson_history.len().min(20) - 1; self.history_selected = self.history_selected.min(max_visible); } else { self.history_selected = 0; } } pub fn rebuild_from_history(&mut self) { // Reset all derived state self.key_stats = KeyStatsStore::default(); self.key_stats.target_cpm = self.config.target_cpm(); self.letter_unlock = LetterUnlock::new(); self.profile.total_score = 0.0; self.profile.total_lessons = 0; self.profile.streak_days = 0; self.profile.best_streak = 0; self.profile.last_practice_date = None; // Replay each remaining session oldest→newest for result in &self.lesson_history { // Only update adaptive progression for adaptive sessions if result.lesson_mode == "adaptive" { 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); } // Compute score let complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count()); let score = scoring::compute_score(result, complexity); self.profile.total_score += score; self.profile.total_lessons += 1; // Rebuild streak tracking let day = result.timestamp.format("%Y-%m-%d").to_string(); if self.profile.last_practice_date.as_deref() != Some(&day) { if let Some(ref last) = self.profile.last_practice_date { let result_date = result.timestamp.date_naive(); let last_date = chrono::NaiveDate::parse_from_str(last, "%Y-%m-%d").unwrap_or(result_date); let diff = result_date.signed_duration_since(last_date).num_days(); if diff == 1 { self.profile.streak_days += 1; } else { self.profile.streak_days = 1; } } else { self.profile.streak_days = 1; } self.profile.best_streak = self.profile.best_streak.max(self.profile.streak_days); self.profile.last_practice_date = Some(day); } } self.profile.unlocked_letters = self.letter_unlock.included.clone(); } pub fn go_to_settings(&mut self) { self.settings_selected = 0; self.screen = AppScreen::Settings; } pub fn settings_cycle_forward(&mut self) { match self.settings_selected { 0 => { self.config.target_wpm = (self.config.target_wpm + 5).min(200); self.key_stats.target_cpm = self.config.target_cpm(); } 1 => { let themes = Theme::available_themes(); if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) { let next = (idx + 1) % themes.len(); self.config.theme = themes[next].clone(); } else if let Some(first) = themes.first() { self.config.theme = first.clone(); } if let Some(new_theme) = Theme::load(&self.config.theme) { let theme: &'static Theme = Box::leak(Box::new(new_theme)); self.theme = theme; self.menu.theme = theme; } } 2 => { self.config.word_count = (self.config.word_count + 5).min(100); } 3 => { let langs = ["rust", "python", "javascript", "go"]; let current = self .config .code_languages .first() .map(|s| s.as_str()) .unwrap_or("rust"); let idx = langs.iter().position(|&l| l == current).unwrap_or(0); let next = (idx + 1) % langs.len(); self.config.code_languages = vec![langs[next].to_string()]; } _ => {} } } pub fn settings_cycle_backward(&mut self) { match self.settings_selected { 0 => { self.config.target_wpm = self.config.target_wpm.saturating_sub(5).max(10); self.key_stats.target_cpm = self.config.target_cpm(); } 1 => { let themes = Theme::available_themes(); if let Some(idx) = themes.iter().position(|t| *t == self.config.theme) { let next = if idx == 0 { themes.len() - 1 } else { idx - 1 }; self.config.theme = themes[next].clone(); } else if let Some(first) = themes.first() { self.config.theme = first.clone(); } if let Some(new_theme) = Theme::load(&self.config.theme) { let theme: &'static Theme = Box::leak(Box::new(new_theme)); self.theme = theme; self.menu.theme = theme; } } 2 => { self.config.word_count = self.config.word_count.saturating_sub(5).max(5); } 3 => { let langs = ["rust", "python", "javascript", "go"]; let current = self .config .code_languages .first() .map(|s| s.as_str()) .unwrap_or("rust"); let idx = langs.iter().position(|&l| l == current).unwrap_or(0); let next = if idx == 0 { langs.len() - 1 } else { idx - 1 }; self.config.code_languages = vec![langs[next].to_string()]; } _ => {} } } }