Files
keydr/src/app.rs

448 lines
16 KiB
Rust

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<LessonState>,
pub lesson_events: Vec<KeystrokeEvent>,
pub last_result: Option<LessonResult>,
pub lesson_history: Vec<LessonResult>,
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<JsonStore>,
pub should_quit: bool,
pub settings_selected: usize,
pub stats_tab: usize,
pub depressed_keys: HashSet<char>,
pub last_key_time: Option<Instant>,
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()];
}
_ => {}
}
}
}