From 13550505c165b8471f0709fd3ad8aeba10887d31 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sun, 15 Feb 2026 04:44:49 +0000 Subject: [PATCH] Consistently refer to drills as drills now, rename from lesson/practice Also fix some issues in the stats screen. --- src/app.rs | 130 +++++++++++----------- src/engine/scoring.rs | 4 +- src/main.rs | 100 ++++++++--------- src/session/{lesson.rs => drill.rs} | 82 +++++++------- src/session/input.rs | 32 +++--- src/session/mod.rs | 2 +- src/session/result.rs | 26 ++--- src/store/json_store.rs | 6 +- src/store/schema.rs | 16 +-- src/ui/components/activity_heatmap.rs | 8 +- src/ui/components/chart.rs | 2 +- src/ui/components/dashboard.rs | 8 +- src/ui/components/menu.rs | 6 +- src/ui/components/stats_dashboard.rs | 138 ++++++++++++++--------- src/ui/components/stats_sidebar.rs | 154 ++++++++++++++++++-------- src/ui/components/typing_area.rs | 18 +-- 16 files changed, 413 insertions(+), 319 deletions(-) rename src/session/{lesson.rs => drill.rs} (59%) diff --git a/src/app.rs b/src/app.rs index a2105a4..4db6e6d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,46 +17,46 @@ 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::session::drill::DrillState; +use crate::session::result::DrillResult; use crate::store::json_store::JsonStore; -use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; +use crate::store::schema::{KeyStatsData, DrillHistoryData, ProfileData}; use crate::ui::components::menu::Menu; use crate::ui::theme::Theme; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum AppScreen { Menu, - Lesson, - LessonResult, + Drill, + DrillResult, StatsDashboard, Settings, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LessonMode { +pub enum DrillMode { Adaptive, Code, Passage, } -impl LessonMode { +impl DrillMode { pub fn as_str(self) -> &'static str { match self { - LessonMode::Adaptive => "adaptive", - LessonMode::Code => "code", - LessonMode::Passage => "passage", + DrillMode::Adaptive => "adaptive", + DrillMode::Code => "code", + DrillMode::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 drill_mode: DrillMode, + pub drill: Option, + pub drill_events: Vec, + pub last_result: Option, + pub drill_history: Vec, pub menu: Menu<'static>, pub theme: &'static Theme, pub config: Config, @@ -86,10 +86,10 @@ impl App { let store = JsonStore::new().ok(); - let (key_stats, letter_unlock, profile, lesson_history) = if let Some(ref s) = store { + let (key_stats, letter_unlock, profile, drill_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 lhd = s.load_drill_history(); let lu = if pd.unlocked_letters.is_empty() { LetterUnlock::new() @@ -97,7 +97,7 @@ impl App { LetterUnlock::from_included(pd.unlocked_letters.clone()) }; - (ksd.stats, lu, pd, lhd.lessons) + (ksd.stats, lu, pd, lhd.drills) } else { ( KeyStatsStore::default(), @@ -115,11 +115,11 @@ impl App { let mut app = Self { screen: AppScreen::Menu, - lesson_mode: LessonMode::Adaptive, - lesson: None, - lesson_events: Vec::new(), + drill_mode: DrillMode::Adaptive, + drill: None, + drill_events: Vec::new(), last_result: None, - lesson_history, + drill_history, menu, theme, config, @@ -138,23 +138,23 @@ impl App { transition_table, dictionary, }; - app.start_lesson(); + app.start_drill(); app } - pub fn start_lesson(&mut self) { + pub fn start_drill(&mut self) { let text = self.generate_text(); - self.lesson = Some(LessonState::new(&text)); - self.lesson_events.clear(); - self.screen = AppScreen::Lesson; + self.drill = Some(DrillState::new(&text)); + self.drill_events.clear(); + self.screen = AppScreen::Drill; } fn generate_text(&mut self) -> String { let word_count = self.config.word_count; - let mode = self.lesson_mode; + let mode = self.drill_mode; match mode { - LessonMode::Adaptive => { + DrillMode::Adaptive => { let filter = CharFilter::new(self.letter_unlock.included.clone()); let focused = self.letter_unlock.focused; let table = self.transition_table.clone(); @@ -163,7 +163,7 @@ impl App { let mut generator = PhoneticGenerator::new(table, dict, rng); generator.generate(&filter, focused, word_count) } - LessonMode::Code => { + DrillMode::Code => { let filter = CharFilter::new(('a'..='z').collect()); let lang = self .config @@ -175,7 +175,7 @@ impl App { let mut generator = CodeSyntaxGenerator::new(rng, &lang); generator.generate(&filter, None, word_count) } - LessonMode::Passage => { + DrillMode::Passage => { let filter = CharFilter::new(('a'..='z').collect()); let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let mut generator = PassageGenerator::new(rng); @@ -185,28 +185,28 @@ impl App { } 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 let Some(ref mut drill) = self.drill { + if let Some(event) = input::process_char(drill, ch) { + self.drill_events.push(event); } - if lesson.is_complete() { - self.finish_lesson(); + if drill.is_complete() { + self.finish_drill(); } } } pub fn backspace(&mut self) { - if let Some(ref mut lesson) = self.lesson { - input::process_backspace(lesson); + if let Some(ref mut drill) = self.drill { + input::process_backspace(drill); } } - 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()); + 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()); - if self.lesson_mode == LessonMode::Adaptive { + if self.drill_mode == DrillMode::Adaptive { for kt in &result.per_key_times { if kt.correct { self.key_stats.update_key(kt.key, kt.time_ms); @@ -218,7 +218,7 @@ impl App { 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.total_drills += 1; self.profile.unlocked_letters = self.letter_unlock.included.clone(); let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); @@ -240,18 +240,18 @@ impl App { 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.drill_history.push(result.clone()); + if self.drill_history.len() > 500 { + self.drill_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(); + // Adaptive mode auto-continues to next drill (like keybr.com) + if self.drill_mode == DrillMode::Adaptive { + self.start_drill(); } else { - self.screen = AppScreen::LessonResult; + self.screen = AppScreen::DrillResult; } self.save_data(); @@ -265,21 +265,21 @@ impl App { schema_version: 1, stats: self.key_stats.clone(), }); - let _ = store.save_lesson_history(&LessonHistoryData { + let _ = store.save_drill_history(&DrillHistoryData { schema_version: 1, - lessons: self.lesson_history.clone(), + drills: self.drill_history.clone(), }); } } - pub fn retry_lesson(&mut self) { - self.start_lesson(); + pub fn retry_drill(&mut self) { + self.start_drill(); } pub fn go_to_menu(&mut self) { self.screen = AppScreen::Menu; - self.lesson = None; - self.lesson_events.clear(); + self.drill = None; + self.drill_events.clear(); } pub fn go_to_stats(&mut self) { @@ -290,18 +290,18 @@ impl App { } pub fn delete_session(&mut self) { - if self.lesson_history.is_empty() { + if self.drill_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); + let actual_idx = self.drill_history.len() - 1 - self.history_selected; + self.drill_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; + if !self.drill_history.is_empty() { + let max_visible = self.drill_history.len().min(20) - 1; self.history_selected = self.history_selected.min(max_visible); } else { self.history_selected = 0; @@ -314,15 +314,15 @@ impl App { 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.total_drills = 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 { + for result in &self.drill_history { // Only update adaptive progression for adaptive sessions - if result.lesson_mode == "adaptive" { + if result.drill_mode == "adaptive" { for kt in &result.per_key_times { if kt.correct { self.key_stats.update_key(kt.key, kt.time_ms); @@ -335,7 +335,7 @@ impl App { 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.total_drills += 1; // Rebuild streak tracking let day = result.timestamp.format("%Y-%m-%d").to_string(); diff --git a/src/engine/scoring.rs b/src/engine/scoring.rs index 243f578..7e47280 100644 --- a/src/engine/scoring.rs +++ b/src/engine/scoring.rs @@ -1,6 +1,6 @@ -use crate::session::result::LessonResult; +use crate::session::result::DrillResult; -pub fn compute_score(result: &LessonResult, complexity: f64) -> f64 { +pub fn compute_score(result: &DrillResult, complexity: f64) -> f64 { let speed = result.cpm; let errors = result.incorrect as f64; let length = result.total_chars as f64; diff --git a/src/main.rs b/src/main.rs index 4c1f5e6..0041988 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,8 +28,8 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::Terminal; -use app::{App, AppScreen, LessonMode}; -use session::result::LessonResult; +use app::{App, AppScreen, DrillMode}; +use session::result::DrillResult; use event::{AppEvent, EventHandler}; use ui::components::dashboard::Dashboard; use ui::components::keyboard_diagram::KeyboardDiagram; @@ -48,7 +48,7 @@ struct Cli { #[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")] layout: Option, - #[arg(short, long, help = "Number of words per lesson")] + #[arg(short, long, help = "Number of words per drill")] words: Option, } @@ -156,8 +156,8 @@ fn handle_key(app: &mut App, key: KeyEvent) { match app.screen { AppScreen::Menu => handle_menu_key(app, key), - AppScreen::Lesson => handle_lesson_key(app, key), - AppScreen::LessonResult => handle_result_key(app, key), + AppScreen::Drill => handle_drill_key(app, key), + AppScreen::DrillResult => handle_result_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::Settings => handle_settings_key(app, key), } @@ -167,16 +167,16 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('1') => { - app.lesson_mode = LessonMode::Adaptive; - app.start_lesson(); + app.drill_mode = DrillMode::Adaptive; + app.start_drill(); } KeyCode::Char('2') => { - app.lesson_mode = LessonMode::Code; - app.start_lesson(); + app.drill_mode = DrillMode::Code; + app.start_drill(); } KeyCode::Char('3') => { - app.lesson_mode = LessonMode::Passage; - app.start_lesson(); + app.drill_mode = DrillMode::Passage; + app.start_drill(); } KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('c') => app.go_to_settings(), @@ -184,16 +184,16 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { KeyCode::Down | KeyCode::Char('j') => app.menu.next(), KeyCode::Enter => match app.menu.selected { 0 => { - app.lesson_mode = LessonMode::Adaptive; - app.start_lesson(); + app.drill_mode = DrillMode::Adaptive; + app.start_drill(); } 1 => { - app.lesson_mode = LessonMode::Code; - app.start_lesson(); + app.drill_mode = DrillMode::Code; + app.start_drill(); } 2 => { - app.lesson_mode = LessonMode::Passage; - app.start_lesson(); + app.drill_mode = DrillMode::Passage; + app.start_drill(); } 3 => app.go_to_stats(), 4 => app.go_to_settings(), @@ -203,17 +203,17 @@ fn handle_menu_key(app: &mut App, key: KeyEvent) { } } -fn handle_lesson_key(app: &mut App, key: KeyEvent) { +fn handle_drill_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc => { - let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0); - if has_progress && app.lesson_mode != LessonMode::Adaptive { - // Non-adaptive: show result screen for partial lesson - if let Some(ref lesson) = app.lesson { - let result = LessonResult::from_lesson(lesson, &app.lesson_events, app.lesson_mode.as_str()); + 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()); app.last_result = Some(result); } - app.screen = AppScreen::LessonResult; + app.screen = AppScreen::DrillResult; } else { app.go_to_menu(); } @@ -226,7 +226,7 @@ fn handle_lesson_key(app: &mut App, key: KeyEvent) { fn handle_result_key(app: &mut App, key: KeyEvent) { match key.code { - KeyCode::Char('r') => app.retry_lesson(), + KeyCode::Char('r') => app.retry_drill(), KeyCode::Char('q') | KeyCode::Esc => app.go_to_menu(), KeyCode::Char('s') => app.go_to_stats(), _ => {} @@ -254,8 +254,8 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Char('j') | KeyCode::Down => { - if !app.lesson_history.is_empty() { - let max_visible = app.lesson_history.len().min(20) - 1; + if !app.drill_history.is_empty() { + let max_visible = app.drill_history.len().min(20) - 1; app.history_selected = (app.history_selected + 1).min(max_visible); } @@ -264,12 +264,12 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { app.history_selected = app.history_selected.saturating_sub(1); } KeyCode::Char('x') | KeyCode::Delete => { - if !app.lesson_history.is_empty() { + if !app.drill_history.is_empty() { app.history_confirm_delete = true; } } - KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0, - KeyCode::Char('h') | KeyCode::Char('2') => {} // already on history + KeyCode::Char('1') => app.stats_tab = 0, + KeyCode::Char('2') => {} // already on history KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::BackTab => { @@ -282,9 +282,9 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), - KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0, - KeyCode::Char('h') | KeyCode::Char('2') => app.stats_tab = 1, - KeyCode::Char('k') | KeyCode::Char('3') => app.stats_tab = 2, + KeyCode::Char('1') => app.stats_tab = 0, + KeyCode::Char('2') => app.stats_tab = 1, + KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 }, _ => {} @@ -326,8 +326,8 @@ fn render(frame: &mut ratatui::Frame, app: &App) { match app.screen { AppScreen::Menu => render_menu(frame, app), - AppScreen::Lesson => render_lesson(frame, app), - AppScreen::LessonResult => render_result(frame, app), + AppScreen::Drill => render_drill(frame, app), + AppScreen::DrillResult => render_result(frame, app), AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::Settings => render_settings(frame, app), } @@ -387,25 +387,25 @@ fn render_menu(frame: &mut ratatui::Frame, app: &App) { frame.render_widget(footer, layout[2]); } -fn render_lesson(frame: &mut ratatui::Frame, app: &App) { +fn render_drill(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let colors = &app.theme.colors; - if let Some(ref lesson) = app.lesson { + if let Some(ref drill) = app.drill { let app_layout = AppLayout::new(area); let tier = app_layout.tier; - let mode_name = match app.lesson_mode { - LessonMode::Adaptive => "Adaptive", - LessonMode::Code => "Code", - LessonMode::Passage => "Passage", + let mode_name = match app.drill_mode { + DrillMode::Adaptive => "Adaptive", + DrillMode::Code => "Code", + DrillMode::Passage => "Passage", }; // For medium/narrow: show compact stats in header if !tier.show_sidebar() { - let wpm = lesson.wpm(); - let accuracy = lesson.accuracy(); - let errors = lesson.typo_count(); + let wpm = drill.wpm(); + let accuracy = drill.accuracy(); + let errors = drill.typo_count(); let header_text = format!( " {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}" ); @@ -419,7 +419,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) { .style(Style::default().bg(colors.header_bg())); frame.render_widget(header, app_layout.header); } else { - let header_title = format!(" {mode_name} Practice "); + let header_title = format!(" {mode_name} Drill "); let focus_text = if let Some(focused) = app.letter_unlock.focused { format!(" | Focus: '{focused}'") } else { @@ -461,7 +461,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) { .constraints(constraints) .split(app_layout.main); - let typing = TypingArea::new(lesson, app.theme); + let typing = TypingArea::new(drill, app.theme); frame.render_widget(typing, main_layout[0]); let mut idx = 1; @@ -476,7 +476,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) { } if show_kbd { - let next_char = lesson.target.get(lesson.cursor).copied(); + let next_char = drill.target.get(drill.cursor).copied(); let kbd = KeyboardDiagram::new( app.letter_unlock.focused, next_char, @@ -489,12 +489,12 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) { } if let Some(sidebar_area) = app_layout.sidebar { - let sidebar = StatsSidebar::new(lesson, app.last_result.as_ref(), app.theme); + let sidebar = StatsSidebar::new(drill, app.last_result.as_ref(), &app.drill_history, app.theme); frame.render_widget(sidebar, sidebar_area); } let footer = Paragraph::new(Line::from(Span::styled( - " [ESC] End lesson [Backspace] Delete ", + " [ESC] End drill [Backspace] Delete ", Style::default().fg(colors.text_pending()), ))); frame.render_widget(footer, app_layout.footer); @@ -514,7 +514,7 @@ fn render_result(frame: &mut ratatui::Frame, app: &App) { fn render_stats(frame: &mut ratatui::Frame, app: &App) { let area = frame.area(); let dashboard = StatsDashboard::new( - &app.lesson_history, + &app.drill_history, &app.key_stats, app.stats_tab, app.config.target_wpm, diff --git a/src/session/lesson.rs b/src/session/drill.rs similarity index 59% rename from src/session/lesson.rs rename to src/session/drill.rs index 0f2ae0c..1601c68 100644 --- a/src/session/lesson.rs +++ b/src/session/drill.rs @@ -3,7 +3,7 @@ use std::time::Instant; use crate::session::input::CharStatus; -pub struct LessonState { +pub struct DrillState { pub target: Vec, pub input: Vec, pub cursor: usize, @@ -12,7 +12,7 @@ pub struct LessonState { pub typo_flags: HashSet, } -impl LessonState { +impl DrillState { pub fn new(text: &str) -> Self { Self { target: text.chars().collect(), @@ -90,72 +90,72 @@ mod tests { use crate::session::input; #[test] - fn test_new_lesson() { - let lesson = LessonState::new("hello"); - assert_eq!(lesson.target.len(), 5); - assert_eq!(lesson.cursor, 0); - assert!(!lesson.is_complete()); - assert_eq!(lesson.progress(), 0.0); + fn test_new_drill() { + let drill = DrillState::new("hello"); + assert_eq!(drill.target.len(), 5); + assert_eq!(drill.cursor, 0); + assert!(!drill.is_complete()); + assert_eq!(drill.progress(), 0.0); } #[test] fn test_accuracy_starts_at_100() { - let lesson = LessonState::new("test"); - assert_eq!(lesson.accuracy(), 100.0); + let drill = DrillState::new("test"); + assert_eq!(drill.accuracy(), 100.0); } #[test] - fn test_empty_lesson_progress() { - let lesson = LessonState::new(""); - assert!(lesson.is_complete()); - assert_eq!(lesson.progress(), 0.0); + fn test_empty_drill_progress() { + let drill = DrillState::new(""); + assert!(drill.is_complete()); + assert_eq!(drill.progress(), 0.0); } #[test] fn test_correct_typing_no_typos() { - let mut lesson = LessonState::new("abc"); - input::process_char(&mut lesson, 'a'); - input::process_char(&mut lesson, 'b'); - input::process_char(&mut lesson, 'c'); - assert!(lesson.typo_flags.is_empty()); - assert_eq!(lesson.accuracy(), 100.0); + let mut drill = DrillState::new("abc"); + input::process_char(&mut drill, 'a'); + input::process_char(&mut drill, 'b'); + input::process_char(&mut drill, 'c'); + assert!(drill.typo_flags.is_empty()); + assert_eq!(drill.accuracy(), 100.0); } #[test] fn test_wrong_then_backspace_then_correct_counts_as_error() { - let mut lesson = LessonState::new("abc"); + let mut drill = DrillState::new("abc"); // Type wrong at pos 0 - input::process_char(&mut lesson, 'x'); - assert!(lesson.typo_flags.contains(&0)); + input::process_char(&mut drill, 'x'); + assert!(drill.typo_flags.contains(&0)); // Backspace - input::process_backspace(&mut lesson); + input::process_backspace(&mut drill); // Typo flag persists - assert!(lesson.typo_flags.contains(&0)); + assert!(drill.typo_flags.contains(&0)); // Type correct - input::process_char(&mut lesson, 'a'); - assert!(lesson.typo_flags.contains(&0)); - assert_eq!(lesson.typo_count(), 1); - assert!(lesson.accuracy() < 100.0); + input::process_char(&mut drill, 'a'); + assert!(drill.typo_flags.contains(&0)); + assert_eq!(drill.typo_count(), 1); + assert!(drill.accuracy() < 100.0); } #[test] fn test_multiple_errors_same_position_counts_as_one() { - let mut lesson = LessonState::new("abc"); + let mut drill = DrillState::new("abc"); // Wrong, backspace, wrong again, backspace, correct - input::process_char(&mut lesson, 'x'); - input::process_backspace(&mut lesson); - input::process_char(&mut lesson, 'y'); - input::process_backspace(&mut lesson); - input::process_char(&mut lesson, 'a'); - assert_eq!(lesson.typo_count(), 1); + input::process_char(&mut drill, 'x'); + input::process_backspace(&mut drill); + input::process_char(&mut drill, 'y'); + input::process_backspace(&mut drill); + input::process_char(&mut drill, 'a'); + assert_eq!(drill.typo_count(), 1); } #[test] fn test_wrong_char_without_backspace() { - let mut lesson = LessonState::new("abc"); - input::process_char(&mut lesson, 'x'); // wrong at pos 0 - input::process_char(&mut lesson, 'b'); // correct at pos 1 - assert_eq!(lesson.typo_count(), 1); - assert!(lesson.typo_flags.contains(&0)); + let mut drill = DrillState::new("abc"); + input::process_char(&mut drill, 'x'); // wrong at pos 0 + input::process_char(&mut drill, 'b'); // correct at pos 1 + assert_eq!(drill.typo_count(), 1); + assert!(drill.typo_flags.contains(&0)); } } diff --git a/src/session/input.rs b/src/session/input.rs index 089cd4e..54f98e5 100644 --- a/src/session/input.rs +++ b/src/session/input.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use crate::session::lesson::LessonState; +use crate::session::drill::DrillState; #[derive(Clone, Debug)] pub enum CharStatus { @@ -17,16 +17,16 @@ pub struct KeystrokeEvent { pub correct: bool, } -pub fn process_char(lesson: &mut LessonState, ch: char) -> Option { - if lesson.is_complete() { +pub fn process_char(drill: &mut DrillState, ch: char) -> Option { + if drill.is_complete() { return None; } - if lesson.started_at.is_none() { - lesson.started_at = Some(Instant::now()); + if drill.started_at.is_none() { + drill.started_at = Some(Instant::now()); } - let expected = lesson.target[lesson.cursor]; + let expected = drill.target[drill.cursor]; let correct = ch == expected; let event = KeystrokeEvent { @@ -37,23 +37,23 @@ pub fn process_char(lesson: &mut LessonState, ch: char) -> Option 0 { - lesson.cursor -= 1; - lesson.input.pop(); +pub fn process_backspace(drill: &mut DrillState) { + if drill.cursor > 0 { + drill.cursor -= 1; + drill.input.pop(); } } diff --git a/src/session/mod.rs b/src/session/mod.rs index 2e4a99d..2ea5241 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -1,3 +1,3 @@ pub mod input; -pub mod lesson; +pub mod drill; pub mod result; diff --git a/src/session/result.rs b/src/session/result.rs index 988ed0e..3f058d6 100644 --- a/src/session/result.rs +++ b/src/session/result.rs @@ -2,10 +2,10 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::session::input::KeystrokeEvent; -use crate::session::lesson::LessonState; +use crate::session::drill::DrillState; #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct LessonResult { +pub struct DrillResult { pub wpm: f64, pub cpm: f64, pub accuracy: f64, @@ -15,11 +15,11 @@ pub struct LessonResult { pub elapsed_secs: f64, pub timestamp: DateTime, pub per_key_times: Vec, - #[serde(default = "default_lesson_mode")] - pub lesson_mode: String, + #[serde(default = "default_drill_mode", alias = "lesson_mode")] + pub drill_mode: String, } -fn default_lesson_mode() -> String { +fn default_drill_mode() -> String { "adaptive".to_string() } @@ -30,8 +30,8 @@ pub struct KeyTime { pub correct: bool, } -impl LessonResult { - pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent], lesson_mode: &str) -> Self { +impl DrillResult { + pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str) -> Self { let per_key_times: Vec = events .windows(2) .map(|pair| { @@ -44,8 +44,8 @@ impl LessonResult { }) .collect(); - let total_chars = lesson.target.len(); - let typo_count = lesson.typo_flags.len(); + let total_chars = drill.target.len(); + let typo_count = drill.typo_flags.len(); let accuracy = if total_chars > 0 { ((total_chars - typo_count) as f64 / total_chars as f64 * 100.0).clamp(0.0, 100.0) } else { @@ -53,16 +53,16 @@ impl LessonResult { }; Self { - wpm: lesson.wpm(), - cpm: lesson.cpm(), + wpm: drill.wpm(), + cpm: drill.cpm(), accuracy, correct: total_chars - typo_count, incorrect: typo_count, total_chars, - elapsed_secs: lesson.elapsed_secs(), + elapsed_secs: drill.elapsed_secs(), timestamp: Utc::now(), per_key_times, - lesson_mode: lesson_mode.to_string(), + drill_mode: drill_mode.to_string(), } } } diff --git a/src/store/json_store.rs b/src/store/json_store.rs index f6e662c..c3f4f1f 100644 --- a/src/store/json_store.rs +++ b/src/store/json_store.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use anyhow::Result; use serde::{de::DeserializeOwned, Serialize}; -use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; +use crate::store::schema::{KeyStatsData, DrillHistoryData, ProfileData}; pub struct JsonStore { base_dir: PathBuf, @@ -65,11 +65,11 @@ impl JsonStore { self.save("key_stats.json", data) } - pub fn load_lesson_history(&self) -> LessonHistoryData { + pub fn load_drill_history(&self) -> DrillHistoryData { self.load("lesson_history.json") } - pub fn save_lesson_history(&self, data: &LessonHistoryData) -> Result<()> { + pub fn save_drill_history(&self, data: &DrillHistoryData) -> Result<()> { self.save("lesson_history.json", data) } } diff --git a/src/store/schema.rs b/src/store/schema.rs index b8d0f0b..c656c83 100644 --- a/src/store/schema.rs +++ b/src/store/schema.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use crate::engine::key_stats::KeyStatsStore; -use crate::session::result::LessonResult; +use crate::session::result::DrillResult; const SCHEMA_VERSION: u32 = 1; @@ -10,7 +10,8 @@ pub struct ProfileData { pub schema_version: u32, pub unlocked_letters: Vec, pub total_score: f64, - pub total_lessons: u32, + #[serde(alias = "total_lessons")] + pub total_drills: u32, pub streak_days: u32, pub best_streak: u32, pub last_practice_date: Option, @@ -22,7 +23,7 @@ impl Default for ProfileData { schema_version: SCHEMA_VERSION, unlocked_letters: Vec::new(), total_score: 0.0, - total_lessons: 0, + total_drills: 0, streak_days: 0, best_streak: 0, last_practice_date: None, @@ -46,16 +47,17 @@ impl Default for KeyStatsData { } #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct LessonHistoryData { +pub struct DrillHistoryData { pub schema_version: u32, - pub lessons: Vec, + #[serde(alias = "lessons")] + pub drills: Vec, } -impl Default for LessonHistoryData { +impl Default for DrillHistoryData { fn default() -> Self { Self { schema_version: SCHEMA_VERSION, - lessons: Vec::new(), + drills: Vec::new(), } } } diff --git a/src/ui/components/activity_heatmap.rs b/src/ui/components/activity_heatmap.rs index 5e84612..ecc87cb 100644 --- a/src/ui/components/activity_heatmap.rs +++ b/src/ui/components/activity_heatmap.rs @@ -6,16 +6,16 @@ use ratatui::layout::Rect; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Widget}; -use crate::session::result::LessonResult; +use crate::session::result::DrillResult; use crate::ui::theme::Theme; pub struct ActivityHeatmap<'a> { - history: &'a [LessonResult], + history: &'a [DrillResult], theme: &'a Theme, } impl<'a> ActivityHeatmap<'a> { - pub fn new(history: &'a [LessonResult], theme: &'a Theme) -> Self { + pub fn new(history: &'a [DrillResult], theme: &'a Theme) -> Self { Self { history, theme } } } @@ -25,7 +25,7 @@ impl Widget for ActivityHeatmap<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Activity ") + .title(" Daily Activity (Sessions per Day) ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); diff --git a/src/ui/components/chart.rs b/src/ui/components/chart.rs index 8ddcaa6..95281ce 100644 --- a/src/ui/components/chart.rs +++ b/src/ui/components/chart.rs @@ -53,7 +53,7 @@ impl Widget for WpmChart<'_> { ) .x_axis( Axis::default() - .title("Lesson") + .title("Drill #") .style(Style::default().fg(colors.text_pending())) .bounds([0.0, max_x]), ) diff --git a/src/ui/components/dashboard.rs b/src/ui/components/dashboard.rs index 8beb136..b81c1bc 100644 --- a/src/ui/components/dashboard.rs +++ b/src/ui/components/dashboard.rs @@ -4,16 +4,16 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; -use crate::session::result::LessonResult; +use crate::session::result::DrillResult; use crate::ui::theme::Theme; pub struct Dashboard<'a> { - pub result: &'a LessonResult, + pub result: &'a DrillResult, pub theme: &'a Theme, } impl<'a> Dashboard<'a> { - pub fn new(result: &'a LessonResult, theme: &'a Theme) -> Self { + pub fn new(result: &'a DrillResult, theme: &'a Theme) -> Self { Self { result, theme } } } @@ -23,7 +23,7 @@ impl Widget for Dashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Lesson Complete ") + .title(" Drill Complete ") .border_style(Style::default().fg(colors.accent())) .style(Style::default().bg(colors.bg())); let inner = block.inner(area); diff --git a/src/ui/components/menu.rs b/src/ui/components/menu.rs index bb32d5b..8accd69 100644 --- a/src/ui/components/menu.rs +++ b/src/ui/components/menu.rs @@ -24,17 +24,17 @@ impl<'a> Menu<'a> { items: vec![ MenuItem { key: "1".to_string(), - label: "Adaptive Practice".to_string(), + label: "Adaptive Drill".to_string(), description: "Phonetic words with adaptive letter unlocking".to_string(), }, MenuItem { key: "2".to_string(), - label: "Code Practice".to_string(), + label: "Code Drill".to_string(), description: "Practice typing code syntax".to_string(), }, MenuItem { key: "3".to_string(), - label: "Passage Mode".to_string(), + label: "Passage Drill".to_string(), description: "Type passages from books".to_string(), }, MenuItem { diff --git a/src/ui/components/stats_dashboard.rs b/src/ui/components/stats_dashboard.rs index 9e5a876..5ecaf74 100644 --- a/src/ui/components/stats_dashboard.rs +++ b/src/ui/components/stats_dashboard.rs @@ -5,12 +5,12 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; use crate::engine::key_stats::KeyStatsStore; -use crate::session::result::LessonResult; +use crate::session::result::DrillResult; use crate::ui::components::activity_heatmap::ActivityHeatmap; use crate::ui::theme::Theme; pub struct StatsDashboard<'a> { - pub history: &'a [LessonResult], + pub history: &'a [DrillResult], pub key_stats: &'a KeyStatsStore, pub active_tab: usize, pub target_wpm: u32, @@ -21,7 +21,7 @@ pub struct StatsDashboard<'a> { impl<'a> StatsDashboard<'a> { pub fn new( - history: &'a [LessonResult], + history: &'a [DrillResult], key_stats: &'a KeyStatsStore, active_tab: usize, target_wpm: u32, @@ -54,7 +54,7 @@ impl Widget for StatsDashboard<'_> { if self.history.is_empty() { let msg = Paragraph::new(Line::from(Span::styled( - "No lessons completed yet. Start typing!", + "No drills completed yet. Start typing!", Style::default().fg(colors.text_pending()), ))); msg.render(inner, buf); @@ -71,7 +71,7 @@ impl Widget for StatsDashboard<'_> { .split(inner); // Tab header - let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"]; + let tabs = ["[1] Dashboard", "[2] History", "[3] Keystrokes"]; let tab_spans: Vec = tabs .iter() .enumerate() @@ -113,7 +113,7 @@ impl Widget for StatsDashboard<'_> { let footer_text = if self.active_tab == 1 { " [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete" } else { - " [ESC] Back [Tab] Next tab [D/H/K] Switch tab" + " [ESC] Back [Tab] Next tab [1/2/3] Switch tab" }; let footer = Paragraph::new(Line::from(Span::styled( footer_text, @@ -198,7 +198,7 @@ impl StatsDashboard<'_> { let summary = vec![ Line::from(vec![ - Span::styled(" Lessons: ", Style::default().fg(colors.fg())), + Span::styled(" Drills: ", Style::default().fg(colors.fg())), Span::styled( &*total_str, Style::default() @@ -249,8 +249,9 @@ impl StatsDashboard<'_> { fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) { let colors = &self.theme.colors; + let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm); let block = Block::bordered() - .title(" WPM (Last 20) ") + .title(target_label) .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); @@ -276,20 +277,40 @@ impl StatsDashboard<'_> { let max_wpm = recent.iter().fold(0.0f64, |a, &b| a.max(b)).max(10.0); let target = self.target_wpm as f64; - let bar_count = (inner.width as usize).min(recent.len()); + + // Reserve left margin for Y-axis labels + let y_label_width: u16 = 4; + let chart_x = inner.x + y_label_width; + let chart_width = inner.width.saturating_sub(y_label_width); + + if chart_width < 5 { + return; + } + + let bar_count = (chart_width as usize).min(recent.len()); let bar_spacing = if bar_count > 0 { - inner.width / bar_count as u16 + chart_width / bar_count as u16 } else { return; }; + // Y-axis labels (max, mid, 0) + let max_label = format!("{:.0}", max_wpm); + let mid_label = format!("{:.0}", max_wpm / 2.0); + buf.set_string(inner.x, inner.y, &max_label, Style::default().fg(colors.text_pending())); + if inner.height > 3 { + let mid_y = inner.y + inner.height / 2; + buf.set_string(inner.x, mid_y, &mid_label, Style::default().fg(colors.text_pending())); + } + buf.set_string(inner.x, inner.y + inner.height - 1, "0", Style::default().fg(colors.text_pending())); + let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; // Render each bar as a column let start_idx = recent.len().saturating_sub(bar_count); for (i, &wpm) in recent[start_idx..].iter().enumerate() { - let x = inner.x + i as u16 * bar_spacing; - if x >= inner.x + inner.width { + let x = chart_x + i as u16 * bar_spacing; + if x >= chart_x + chart_width { break; } @@ -343,7 +364,7 @@ impl StatsDashboard<'_> { if data.is_empty() { let block = Block::bordered() - .title(" Accuracy Trend ") + .title(" Accuracy % (Last 50 Drills) ") .border_style(Style::default().fg(colors.border())); block.render(area, buf); return; @@ -360,18 +381,18 @@ impl StatsDashboard<'_> { let chart = Chart::new(vec![dataset]) .block( Block::bordered() - .title(" Accuracy Trend ") + .title(" Accuracy % (Last 50 Drills) ") .border_style(Style::default().fg(colors.border())), ) .x_axis( Axis::default() - .title("Lesson") + .title("Drill #") .style(Style::default().fg(colors.text_pending())) .bounds([0.0, max_x]), ) .y_axis( Axis::default() - .title("%") + .title("Accuracy %") .style(Style::default().fg(colors.text_pending())) .bounds([80.0, 100.0]), ); @@ -481,7 +502,7 @@ impl StatsDashboard<'_> { )), ]; - let recent: Vec<&LessonResult> = self.history.iter().rev().take(20).collect(); + let recent: Vec<&DrillResult> = self.history.iter().rev().take(20).collect(); let total = self.history.len(); for (i, result) in recent.iter().enumerate() { @@ -534,16 +555,21 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Character Speed Distribution ") + .title(" Avg Key Time by Character ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); - if inner.width < 52 || inner.height < 2 { + let columns_per_row: usize = 13; + let col_width: u16 = 4; + let row_height: u16 = 3; + + if inner.width < columns_per_row as u16 * col_width || inner.height < row_height { return; } let letters: Vec = ('a'..='z').collect(); + let row_count = if inner.height >= row_height * 2 { 2 } else { 1 }; let max_time = letters .iter() .filter_map(|&ch| self.key_stats.stats.get(&ch)) @@ -553,9 +579,13 @@ impl StatsDashboard<'_> { let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; - for (i, &ch) in letters.iter().enumerate() { - let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1)); - if x >= inner.x + inner.width { + for (i, &ch) in letters.iter().take(columns_per_row * row_count).enumerate() { + let row = i / columns_per_row; + let col = i % columns_per_row; + let x = inner.x + (col as u16 * col_width); + let y = inner.y + row as u16 * row_height; + + if x + col_width > inner.x + inner.width || y + 2 >= inner.y + inner.height { break; } @@ -576,35 +606,37 @@ impl StatsDashboard<'_> { }; // Letter label - buf.set_string(x, inner.y, &ch.to_string(), Style::default().fg(color)); + buf.set_string(x, y, &ch.to_string(), Style::default().fg(color)); // Bar indicator - if inner.height >= 2 { - let bar_char = if time > 0.0 { - let idx = ((ratio * 7.0).round() as usize).min(7); - bar_chars[idx] - } else { - ' ' - }; - buf.set_string( - x, - inner.y + 1, - &bar_char.to_string(), - Style::default().fg(color), - ); - } + let bar_char = if time > 0.0 { + let idx = ((ratio * 7.0).round() as usize).min(7); + bar_chars[idx] + } else { + ' ' + }; + buf.set_string(x, y + 1, &bar_char.to_string(), Style::default().fg(color)); - // Time label on row 3 - if inner.height >= 3 && time > 0.0 { - let time_label = format!("{time:.0}"); - if x + time_label.len() as u16 <= inner.x + inner.width { - buf.set_string( - x, - inner.y + 2, - &time_label, - Style::default().fg(colors.text_pending()), - ); - } + // Time label on row 3, render seconds when value exceeds 999ms. + if time > 0.0 { + let time_label = if time > 999.0 { + format!("({:.0}s)", time / 1000.0) + } else { + format!("{time:.0}") + }; + let label = if time_label.len() > col_width as usize { + let start = time_label.len() - col_width as usize; + &time_label[start..] + } else { + &time_label + }; + let label_x = x + col_width.saturating_sub(label.len() as u16); + buf.set_string( + label_x, + y + 2, + label, + Style::default().fg(colors.text_pending()), + ); } } } @@ -649,7 +681,7 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Keyboard Accuracy ") + .title(" Keyboard Accuracy % ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); @@ -732,7 +764,7 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Slowest ") + .title(" Slowest Keys (ms) ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); @@ -765,7 +797,7 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Fastest ") + .title(" Fastest Keys (ms) ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); @@ -798,7 +830,7 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Worst Accuracy ") + .title(" Worst Accuracy Keys (%) ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); @@ -858,7 +890,7 @@ impl StatsDashboard<'_> { let colors = &self.theme.colors; let block = Block::bordered() - .title(" Overall ") + .title(" Overall Totals ") .border_style(Style::default().fg(colors.border())); let inner = block.inner(area); block.render(area, buf); diff --git a/src/ui/components/stats_sidebar.rs b/src/ui/components/stats_sidebar.rs index beda39c..569227a 100644 --- a/src/ui/components/stats_sidebar.rs +++ b/src/ui/components/stats_sidebar.rs @@ -4,19 +4,36 @@ use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget}; -use crate::session::lesson::LessonState; -use crate::session::result::LessonResult; +use crate::session::drill::DrillState; +use crate::session::result::DrillResult; use crate::ui::theme::Theme; pub struct StatsSidebar<'a> { - lesson: &'a LessonState, - last_result: Option<&'a LessonResult>, + drill: &'a DrillState, + last_result: Option<&'a DrillResult>, + history: &'a [DrillResult], theme: &'a Theme, } impl<'a> StatsSidebar<'a> { - pub fn new(lesson: &'a LessonState, last_result: Option<&'a LessonResult>, theme: &'a Theme) -> Self { - Self { lesson, last_result, theme } + pub fn new( + drill: &'a DrillState, + last_result: Option<&'a DrillResult>, + history: &'a [DrillResult], + theme: &'a Theme, + ) -> Self { + Self { drill, last_result, history, theme } + } +} + +/// Format a delta value with arrow indicator +fn format_delta(delta: f64, suffix: &str) -> String { + if delta > 0.0 { + format!("\u{2191}+{:.1}{suffix}", delta) + } else if delta < 0.0 { + format!("\u{2193}{:.1}{suffix}", delta) + } else { + format!("={suffix}") } } @@ -26,11 +43,11 @@ impl Widget for StatsSidebar<'_> { let has_last = self.last_result.is_some(); - // Split sidebar into current stats and last lesson sections + // Split sidebar into current stats and last drill sections let sections = if has_last { Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Min(10), Constraint::Min(10)]) + .constraints([Constraint::Min(10), Constraint::Min(12)]) .split(area) } else { Layout::default() @@ -39,14 +56,14 @@ impl Widget for StatsSidebar<'_> { .split(area) }; - // Current lesson stats + // Current drill stats { - let wpm = self.lesson.wpm(); - let accuracy = self.lesson.accuracy(); - let progress = self.lesson.progress() * 100.0; - let correct = self.lesson.correct_count(); - let incorrect = self.lesson.typo_count(); - let elapsed = self.lesson.elapsed_secs(); + let wpm = self.drill.wpm(); + let accuracy = self.drill.accuracy(); + let progress = self.drill.progress() * 100.0; + let correct = self.drill.correct_count(); + let incorrect = self.drill.typo_count(); + let elapsed = self.drill.elapsed_secs(); let wpm_str = format!("{wpm:.0}"); let acc_str = format!("{accuracy:.1}%"); @@ -104,51 +121,94 @@ impl Widget for StatsSidebar<'_> { paragraph.render(sections[0], buf); } - // Last lesson stats + // Last drill stats with session impact deltas if let Some(last) = self.last_result { let wpm_str = format!("{:.0}", last.wpm); let acc_str = format!("{:.1}%", last.accuracy); - let chars_str = format!("{}", last.total_chars); let time_str = format!("{:.1}s", last.elapsed_secs); let errors_str = format!("{}", last.incorrect); - let lines = vec![ + // Compute deltas: compare last drill to the average of all prior drills + // (excluding the last one which is the current result) + let prior_count = self.history.len().saturating_sub(1); + let (wpm_delta, acc_delta) = if prior_count > 0 { + let prior = &self.history[..prior_count]; + let avg_wpm = prior.iter().map(|r| r.wpm).sum::() / prior.len() as f64; + let avg_acc = prior.iter().map(|r| r.accuracy).sum::() / prior.len() as f64; + (last.wpm - avg_wpm, last.accuracy - avg_acc) + } else { + (0.0, 0.0) + }; + + let wpm_delta_str = format_delta(wpm_delta, ""); + let acc_delta_str = format_delta(acc_delta, "%"); + + let wpm_delta_color = if wpm_delta > 0.0 { + colors.success() + } else if wpm_delta < 0.0 { + colors.error() + } else { + colors.text_pending() + }; + + let acc_delta_color = if acc_delta > 0.0 { + colors.success() + } else if acc_delta < 0.0 { + colors.error() + } else { + colors.text_pending() + }; + + let mut lines = vec![ Line::from(vec![ Span::styled("WPM: ", Style::default().fg(colors.fg())), Span::styled(wpm_str, Style::default().fg(colors.accent())), ]), - Line::from(""), - Line::from(vec![ - Span::styled("Accuracy: ", Style::default().fg(colors.fg())), - Span::styled( - acc_str, - Style::default().fg(if last.accuracy >= 95.0 { - colors.success() - } else if last.accuracy >= 85.0 { - colors.warning() - } else { - colors.error() - }), - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Chars: ", Style::default().fg(colors.fg())), - Span::styled(chars_str, Style::default().fg(colors.fg())), - ]), - Line::from(vec![ - Span::styled("Errors: ", Style::default().fg(colors.fg())), - Span::styled(errors_str, Style::default().fg(colors.error())), - ]), - Line::from(""), - Line::from(vec![ - Span::styled("Time: ", Style::default().fg(colors.fg())), - Span::styled(time_str, Style::default().fg(colors.fg())), - ]), ]; + if prior_count > 0 { + lines.push(Line::from(vec![ + Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())), + Span::styled(wpm_delta_str, Style::default().fg(wpm_delta_color)), + ])); + } + + lines.push(Line::from("")); + + lines.push(Line::from(vec![ + Span::styled("Accuracy: ", Style::default().fg(colors.fg())), + Span::styled( + acc_str, + Style::default().fg(if last.accuracy >= 95.0 { + colors.success() + } else if last.accuracy >= 85.0 { + colors.warning() + } else { + colors.error() + }), + ), + ])); + + if prior_count > 0 { + lines.push(Line::from(vec![ + Span::styled(" vs avg: ", Style::default().fg(colors.text_pending())), + Span::styled(acc_delta_str, Style::default().fg(acc_delta_color)), + ])); + } + + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Errors: ", Style::default().fg(colors.fg())), + Span::styled(errors_str, Style::default().fg(colors.error())), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Time: ", Style::default().fg(colors.fg())), + Span::styled(time_str, Style::default().fg(colors.fg())), + ])); + let block = Block::bordered() - .title(" Last Lesson ") + .title(" Last Drill ") .border_style(Style::default().fg(colors.border())) .style(Style::default().bg(colors.bg())); diff --git a/src/ui/components/typing_area.rs b/src/ui/components/typing_area.rs index b0c5179..1873e8c 100644 --- a/src/ui/components/typing_area.rs +++ b/src/ui/components/typing_area.rs @@ -5,17 +5,17 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use crate::session::input::CharStatus; -use crate::session::lesson::LessonState; +use crate::session::drill::DrillState; use crate::ui::theme::Theme; pub struct TypingArea<'a> { - lesson: &'a LessonState, + drill: &'a DrillState, theme: &'a Theme, } impl<'a> TypingArea<'a> { - pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self { - Self { lesson, theme } + pub fn new(drill: &'a DrillState, theme: &'a Theme) -> Self { + Self { drill, theme } } } @@ -24,21 +24,21 @@ impl Widget for TypingArea<'_> { let colors = &self.theme.colors; let mut spans: Vec = Vec::new(); - for (i, &target_ch) in self.lesson.target.iter().enumerate() { - if i < self.lesson.cursor { - let style = match &self.lesson.input[i] { + for (i, &target_ch) in self.drill.target.iter().enumerate() { + if i < self.drill.cursor { + let style = match &self.drill.input[i] { 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.lesson.input[i] { + 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.lesson.cursor { + } else if i == self.drill.cursor { let style = Style::default() .fg(colors.text_cursor_fg()) .bg(colors.text_cursor_bg());