Consistently refer to drills as drills now, rename from lesson/practice

Also fix some issues in the stats screen.
This commit is contained in:
2026-02-15 04:44:49 +00:00
parent a51adafeb0
commit 13550505c1
16 changed files with 413 additions and 319 deletions

View File

@@ -17,46 +17,46 @@ use crate::generator::TextGenerator;
use crate::generator::transition_table::TransitionTable; use crate::generator::transition_table::TransitionTable;
use crate::session::input::{self, KeystrokeEvent}; use crate::session::input::{self, KeystrokeEvent};
use crate::session::lesson::LessonState; use crate::session::drill::DrillState;
use crate::session::result::LessonResult; use crate::session::result::DrillResult;
use crate::store::json_store::JsonStore; 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::components::menu::Menu;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AppScreen { pub enum AppScreen {
Menu, Menu,
Lesson, Drill,
LessonResult, DrillResult,
StatsDashboard, StatsDashboard,
Settings, Settings,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LessonMode { pub enum DrillMode {
Adaptive, Adaptive,
Code, Code,
Passage, Passage,
} }
impl LessonMode { impl DrillMode {
pub fn as_str(self) -> &'static str { pub fn as_str(self) -> &'static str {
match self { match self {
LessonMode::Adaptive => "adaptive", DrillMode::Adaptive => "adaptive",
LessonMode::Code => "code", DrillMode::Code => "code",
LessonMode::Passage => "passage", DrillMode::Passage => "passage",
} }
} }
} }
pub struct App { pub struct App {
pub screen: AppScreen, pub screen: AppScreen,
pub lesson_mode: LessonMode, pub drill_mode: DrillMode,
pub lesson: Option<LessonState>, pub drill: Option<DrillState>,
pub lesson_events: Vec<KeystrokeEvent>, pub drill_events: Vec<KeystrokeEvent>,
pub last_result: Option<LessonResult>, pub last_result: Option<DrillResult>,
pub lesson_history: Vec<LessonResult>, pub drill_history: Vec<DrillResult>,
pub menu: Menu<'static>, pub menu: Menu<'static>,
pub theme: &'static Theme, pub theme: &'static Theme,
pub config: Config, pub config: Config,
@@ -86,10 +86,10 @@ impl App {
let store = JsonStore::new().ok(); 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 ksd = s.load_key_stats();
let pd = s.load_profile(); 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() { let lu = if pd.unlocked_letters.is_empty() {
LetterUnlock::new() LetterUnlock::new()
@@ -97,7 +97,7 @@ impl App {
LetterUnlock::from_included(pd.unlocked_letters.clone()) LetterUnlock::from_included(pd.unlocked_letters.clone())
}; };
(ksd.stats, lu, pd, lhd.lessons) (ksd.stats, lu, pd, lhd.drills)
} else { } else {
( (
KeyStatsStore::default(), KeyStatsStore::default(),
@@ -115,11 +115,11 @@ impl App {
let mut app = Self { let mut app = Self {
screen: AppScreen::Menu, screen: AppScreen::Menu,
lesson_mode: LessonMode::Adaptive, drill_mode: DrillMode::Adaptive,
lesson: None, drill: None,
lesson_events: Vec::new(), drill_events: Vec::new(),
last_result: None, last_result: None,
lesson_history, drill_history,
menu, menu,
theme, theme,
config, config,
@@ -138,23 +138,23 @@ impl App {
transition_table, transition_table,
dictionary, dictionary,
}; };
app.start_lesson(); app.start_drill();
app app
} }
pub fn start_lesson(&mut self) { pub fn start_drill(&mut self) {
let text = self.generate_text(); let text = self.generate_text();
self.lesson = Some(LessonState::new(&text)); self.drill = Some(DrillState::new(&text));
self.lesson_events.clear(); self.drill_events.clear();
self.screen = AppScreen::Lesson; self.screen = AppScreen::Drill;
} }
fn generate_text(&mut self) -> String { fn generate_text(&mut self) -> String {
let word_count = self.config.word_count; let word_count = self.config.word_count;
let mode = self.lesson_mode; let mode = self.drill_mode;
match mode { match mode {
LessonMode::Adaptive => { DrillMode::Adaptive => {
let filter = CharFilter::new(self.letter_unlock.included.clone()); let filter = CharFilter::new(self.letter_unlock.included.clone());
let focused = self.letter_unlock.focused; let focused = self.letter_unlock.focused;
let table = self.transition_table.clone(); let table = self.transition_table.clone();
@@ -163,7 +163,7 @@ impl App {
let mut generator = PhoneticGenerator::new(table, dict, rng); let mut generator = PhoneticGenerator::new(table, dict, rng);
generator.generate(&filter, focused, word_count) generator.generate(&filter, focused, word_count)
} }
LessonMode::Code => { DrillMode::Code => {
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
let lang = self let lang = self
.config .config
@@ -175,7 +175,7 @@ impl App {
let mut generator = CodeSyntaxGenerator::new(rng, &lang); let mut generator = CodeSyntaxGenerator::new(rng, &lang);
generator.generate(&filter, None, word_count) generator.generate(&filter, None, word_count)
} }
LessonMode::Passage => { DrillMode::Passage => {
let filter = CharFilter::new(('a'..='z').collect()); let filter = CharFilter::new(('a'..='z').collect());
let rng = SmallRng::from_rng(&mut self.rng).unwrap(); let rng = SmallRng::from_rng(&mut self.rng).unwrap();
let mut generator = PassageGenerator::new(rng); let mut generator = PassageGenerator::new(rng);
@@ -185,28 +185,28 @@ impl App {
} }
pub fn type_char(&mut self, ch: char) { pub fn type_char(&mut self, ch: char) {
if let Some(ref mut lesson) = self.lesson { if let Some(ref mut drill) = self.drill {
if let Some(event) = input::process_char(lesson, ch) { if let Some(event) = input::process_char(drill, ch) {
self.lesson_events.push(event); self.drill_events.push(event);
} }
if lesson.is_complete() { if drill.is_complete() {
self.finish_lesson(); self.finish_drill();
} }
} }
} }
pub fn backspace(&mut self) { pub fn backspace(&mut self) {
if let Some(ref mut lesson) = self.lesson { if let Some(ref mut drill) = self.drill {
input::process_backspace(lesson); input::process_backspace(drill);
} }
} }
fn finish_lesson(&mut self) { fn finish_drill(&mut self) {
if let Some(ref lesson) = self.lesson { if let Some(ref drill) = self.drill {
let result = LessonResult::from_lesson(lesson, &self.lesson_events, self.lesson_mode.as_str()); 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 { for kt in &result.per_key_times {
if kt.correct { if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms); 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 complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let score = scoring::compute_score(&result, complexity); let score = scoring::compute_score(&result, complexity);
self.profile.total_score += score; self.profile.total_score += score;
self.profile.total_lessons += 1; self.profile.total_drills += 1;
self.profile.unlocked_letters = self.letter_unlock.included.clone(); self.profile.unlocked_letters = self.letter_unlock.included.clone();
let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); 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.profile.last_practice_date = Some(today);
} }
self.lesson_history.push(result.clone()); self.drill_history.push(result.clone());
if self.lesson_history.len() > 500 { if self.drill_history.len() > 500 {
self.lesson_history.remove(0); self.drill_history.remove(0);
} }
self.last_result = Some(result); self.last_result = Some(result);
// Adaptive mode auto-continues to next lesson (like keybr.com) // Adaptive mode auto-continues to next drill (like keybr.com)
if self.lesson_mode == LessonMode::Adaptive { if self.drill_mode == DrillMode::Adaptive {
self.start_lesson(); self.start_drill();
} else { } else {
self.screen = AppScreen::LessonResult; self.screen = AppScreen::DrillResult;
} }
self.save_data(); self.save_data();
@@ -265,21 +265,21 @@ impl App {
schema_version: 1, schema_version: 1,
stats: self.key_stats.clone(), stats: self.key_stats.clone(),
}); });
let _ = store.save_lesson_history(&LessonHistoryData { let _ = store.save_drill_history(&DrillHistoryData {
schema_version: 1, schema_version: 1,
lessons: self.lesson_history.clone(), drills: self.drill_history.clone(),
}); });
} }
} }
pub fn retry_lesson(&mut self) { pub fn retry_drill(&mut self) {
self.start_lesson(); self.start_drill();
} }
pub fn go_to_menu(&mut self) { pub fn go_to_menu(&mut self) {
self.screen = AppScreen::Menu; self.screen = AppScreen::Menu;
self.lesson = None; self.drill = None;
self.lesson_events.clear(); self.drill_events.clear();
} }
pub fn go_to_stats(&mut self) { pub fn go_to_stats(&mut self) {
@@ -290,18 +290,18 @@ impl App {
} }
pub fn delete_session(&mut self) { pub fn delete_session(&mut self) {
if self.lesson_history.is_empty() { if self.drill_history.is_empty() {
return; return;
} }
// History tab shows reverse order, so convert display index to actual index // History tab shows reverse order, so convert display index to actual index
let actual_idx = self.lesson_history.len() - 1 - self.history_selected; let actual_idx = self.drill_history.len() - 1 - self.history_selected;
self.lesson_history.remove(actual_idx); self.drill_history.remove(actual_idx);
self.rebuild_from_history(); self.rebuild_from_history();
self.save_data(); self.save_data();
// Clamp selection to visible range (max 20 visible rows) // Clamp selection to visible range (max 20 visible rows)
if !self.lesson_history.is_empty() { if !self.drill_history.is_empty() {
let max_visible = self.lesson_history.len().min(20) - 1; let max_visible = self.drill_history.len().min(20) - 1;
self.history_selected = self.history_selected.min(max_visible); self.history_selected = self.history_selected.min(max_visible);
} else { } else {
self.history_selected = 0; self.history_selected = 0;
@@ -314,15 +314,15 @@ impl App {
self.key_stats.target_cpm = self.config.target_cpm(); self.key_stats.target_cpm = self.config.target_cpm();
self.letter_unlock = LetterUnlock::new(); self.letter_unlock = LetterUnlock::new();
self.profile.total_score = 0.0; self.profile.total_score = 0.0;
self.profile.total_lessons = 0; self.profile.total_drills = 0;
self.profile.streak_days = 0; self.profile.streak_days = 0;
self.profile.best_streak = 0; self.profile.best_streak = 0;
self.profile.last_practice_date = None; self.profile.last_practice_date = None;
// Replay each remaining session oldest→newest // 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 // Only update adaptive progression for adaptive sessions
if result.lesson_mode == "adaptive" { if result.drill_mode == "adaptive" {
for kt in &result.per_key_times { for kt in &result.per_key_times {
if kt.correct { if kt.correct {
self.key_stats.update_key(kt.key, kt.time_ms); 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 complexity = scoring::compute_complexity(self.letter_unlock.unlocked_count());
let score = scoring::compute_score(result, complexity); let score = scoring::compute_score(result, complexity);
self.profile.total_score += score; self.profile.total_score += score;
self.profile.total_lessons += 1; self.profile.total_drills += 1;
// Rebuild streak tracking // Rebuild streak tracking
let day = result.timestamp.format("%Y-%m-%d").to_string(); let day = result.timestamp.format("%Y-%m-%d").to_string();

View File

@@ -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 speed = result.cpm;
let errors = result.incorrect as f64; let errors = result.incorrect as f64;
let length = result.total_chars as f64; let length = result.total_chars as f64;

View File

@@ -28,8 +28,8 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Terminal; use ratatui::Terminal;
use app::{App, AppScreen, LessonMode}; use app::{App, AppScreen, DrillMode};
use session::result::LessonResult; use session::result::DrillResult;
use event::{AppEvent, EventHandler}; use event::{AppEvent, EventHandler};
use ui::components::dashboard::Dashboard; use ui::components::dashboard::Dashboard;
use ui::components::keyboard_diagram::KeyboardDiagram; use ui::components::keyboard_diagram::KeyboardDiagram;
@@ -48,7 +48,7 @@ struct Cli {
#[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")] #[arg(short, long, help = "Keyboard layout (qwerty, dvorak, colemak)")]
layout: Option<String>, layout: Option<String>,
#[arg(short, long, help = "Number of words per lesson")] #[arg(short, long, help = "Number of words per drill")]
words: Option<usize>, words: Option<usize>,
} }
@@ -156,8 +156,8 @@ fn handle_key(app: &mut App, key: KeyEvent) {
match app.screen { match app.screen {
AppScreen::Menu => handle_menu_key(app, key), AppScreen::Menu => handle_menu_key(app, key),
AppScreen::Lesson => handle_lesson_key(app, key), AppScreen::Drill => handle_drill_key(app, key),
AppScreen::LessonResult => handle_result_key(app, key), AppScreen::DrillResult => handle_result_key(app, key),
AppScreen::StatsDashboard => handle_stats_key(app, key), AppScreen::StatsDashboard => handle_stats_key(app, key),
AppScreen::Settings => handle_settings_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 { match key.code {
KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
KeyCode::Char('1') => { KeyCode::Char('1') => {
app.lesson_mode = LessonMode::Adaptive; app.drill_mode = DrillMode::Adaptive;
app.start_lesson(); app.start_drill();
} }
KeyCode::Char('2') => { KeyCode::Char('2') => {
app.lesson_mode = LessonMode::Code; app.drill_mode = DrillMode::Code;
app.start_lesson(); app.start_drill();
} }
KeyCode::Char('3') => { KeyCode::Char('3') => {
app.lesson_mode = LessonMode::Passage; app.drill_mode = DrillMode::Passage;
app.start_lesson(); app.start_drill();
} }
KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('s') => app.go_to_stats(),
KeyCode::Char('c') => app.go_to_settings(), 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::Down | KeyCode::Char('j') => app.menu.next(),
KeyCode::Enter => match app.menu.selected { KeyCode::Enter => match app.menu.selected {
0 => { 0 => {
app.lesson_mode = LessonMode::Adaptive; app.drill_mode = DrillMode::Adaptive;
app.start_lesson(); app.start_drill();
} }
1 => { 1 => {
app.lesson_mode = LessonMode::Code; app.drill_mode = DrillMode::Code;
app.start_lesson(); app.start_drill();
} }
2 => { 2 => {
app.lesson_mode = LessonMode::Passage; app.drill_mode = DrillMode::Passage;
app.start_lesson(); app.start_drill();
} }
3 => app.go_to_stats(), 3 => app.go_to_stats(),
4 => app.go_to_settings(), 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 { match key.code {
KeyCode::Esc => { KeyCode::Esc => {
let has_progress = app.lesson.as_ref().is_some_and(|l| l.cursor > 0); let has_progress = app.drill.as_ref().is_some_and(|d| d.cursor > 0);
if has_progress && app.lesson_mode != LessonMode::Adaptive { if has_progress && app.drill_mode != DrillMode::Adaptive {
// Non-adaptive: show result screen for partial lesson // Non-adaptive: show result screen for partial drill
if let Some(ref lesson) = app.lesson { if let Some(ref drill) = app.drill {
let result = LessonResult::from_lesson(lesson, &app.lesson_events, app.lesson_mode.as_str()); let result = DrillResult::from_drill(drill, &app.drill_events, app.drill_mode.as_str());
app.last_result = Some(result); app.last_result = Some(result);
} }
app.screen = AppScreen::LessonResult; app.screen = AppScreen::DrillResult;
} else { } else {
app.go_to_menu(); 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) { fn handle_result_key(app: &mut App, key: KeyEvent) {
match key.code { 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('q') | KeyCode::Esc => app.go_to_menu(),
KeyCode::Char('s') => app.go_to_stats(), KeyCode::Char('s') => app.go_to_stats(),
_ => {} _ => {}
@@ -254,8 +254,8 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('j') | KeyCode::Down => { KeyCode::Char('j') | KeyCode::Down => {
if !app.lesson_history.is_empty() { if !app.drill_history.is_empty() {
let max_visible = app.lesson_history.len().min(20) - 1; let max_visible = app.drill_history.len().min(20) - 1;
app.history_selected = app.history_selected =
(app.history_selected + 1).min(max_visible); (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); app.history_selected = app.history_selected.saturating_sub(1);
} }
KeyCode::Char('x') | KeyCode::Delete => { KeyCode::Char('x') | KeyCode::Delete => {
if !app.lesson_history.is_empty() { if !app.drill_history.is_empty() {
app.history_confirm_delete = true; app.history_confirm_delete = true;
} }
} }
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('h') | KeyCode::Char('2') => {} // already on history KeyCode::Char('2') => {} // already on history
KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
KeyCode::BackTab => { KeyCode::BackTab => {
@@ -282,9 +282,9 @@ fn handle_stats_key(app: &mut App, key: KeyEvent) {
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(), KeyCode::Esc | KeyCode::Char('q') => app.go_to_menu(),
KeyCode::Char('d') | KeyCode::Char('1') => app.stats_tab = 0, KeyCode::Char('1') => app.stats_tab = 0,
KeyCode::Char('h') | KeyCode::Char('2') => app.stats_tab = 1, KeyCode::Char('2') => app.stats_tab = 1,
KeyCode::Char('k') | KeyCode::Char('3') => app.stats_tab = 2, KeyCode::Char('3') => app.stats_tab = 2,
KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3, KeyCode::Tab => app.stats_tab = (app.stats_tab + 1) % 3,
KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 }, KeyCode::BackTab => app.stats_tab = if app.stats_tab == 0 { 2 } else { app.stats_tab - 1 },
_ => {} _ => {}
@@ -326,8 +326,8 @@ fn render(frame: &mut ratatui::Frame, app: &App) {
match app.screen { match app.screen {
AppScreen::Menu => render_menu(frame, app), AppScreen::Menu => render_menu(frame, app),
AppScreen::Lesson => render_lesson(frame, app), AppScreen::Drill => render_drill(frame, app),
AppScreen::LessonResult => render_result(frame, app), AppScreen::DrillResult => render_result(frame, app),
AppScreen::StatsDashboard => render_stats(frame, app), AppScreen::StatsDashboard => render_stats(frame, app),
AppScreen::Settings => render_settings(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]); 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 area = frame.area();
let colors = &app.theme.colors; 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 app_layout = AppLayout::new(area);
let tier = app_layout.tier; let tier = app_layout.tier;
let mode_name = match app.lesson_mode { let mode_name = match app.drill_mode {
LessonMode::Adaptive => "Adaptive", DrillMode::Adaptive => "Adaptive",
LessonMode::Code => "Code", DrillMode::Code => "Code",
LessonMode::Passage => "Passage", DrillMode::Passage => "Passage",
}; };
// For medium/narrow: show compact stats in header // For medium/narrow: show compact stats in header
if !tier.show_sidebar() { if !tier.show_sidebar() {
let wpm = lesson.wpm(); let wpm = drill.wpm();
let accuracy = lesson.accuracy(); let accuracy = drill.accuracy();
let errors = lesson.typo_count(); let errors = drill.typo_count();
let header_text = format!( let header_text = format!(
" {mode_name} | WPM: {wpm:.0} | Acc: {accuracy:.1}% | Errors: {errors}" " {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())); .style(Style::default().bg(colors.header_bg()));
frame.render_widget(header, app_layout.header); frame.render_widget(header, app_layout.header);
} else { } 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 { let focus_text = if let Some(focused) = app.letter_unlock.focused {
format!(" | Focus: '{focused}'") format!(" | Focus: '{focused}'")
} else { } else {
@@ -461,7 +461,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
.constraints(constraints) .constraints(constraints)
.split(app_layout.main); .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]); frame.render_widget(typing, main_layout[0]);
let mut idx = 1; let mut idx = 1;
@@ -476,7 +476,7 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
} }
if show_kbd { 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( let kbd = KeyboardDiagram::new(
app.letter_unlock.focused, app.letter_unlock.focused,
next_char, next_char,
@@ -489,12 +489,12 @@ fn render_lesson(frame: &mut ratatui::Frame, app: &App) {
} }
if let Some(sidebar_area) = app_layout.sidebar { 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); frame.render_widget(sidebar, sidebar_area);
} }
let footer = Paragraph::new(Line::from(Span::styled( let footer = Paragraph::new(Line::from(Span::styled(
" [ESC] End lesson [Backspace] Delete ", " [ESC] End drill [Backspace] Delete ",
Style::default().fg(colors.text_pending()), Style::default().fg(colors.text_pending()),
))); )));
frame.render_widget(footer, app_layout.footer); 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) { fn render_stats(frame: &mut ratatui::Frame, app: &App) {
let area = frame.area(); let area = frame.area();
let dashboard = StatsDashboard::new( let dashboard = StatsDashboard::new(
&app.lesson_history, &app.drill_history,
&app.key_stats, &app.key_stats,
app.stats_tab, app.stats_tab,
app.config.target_wpm, app.config.target_wpm,

View File

@@ -3,7 +3,7 @@ use std::time::Instant;
use crate::session::input::CharStatus; use crate::session::input::CharStatus;
pub struct LessonState { pub struct DrillState {
pub target: Vec<char>, pub target: Vec<char>,
pub input: Vec<CharStatus>, pub input: Vec<CharStatus>,
pub cursor: usize, pub cursor: usize,
@@ -12,7 +12,7 @@ pub struct LessonState {
pub typo_flags: HashSet<usize>, pub typo_flags: HashSet<usize>,
} }
impl LessonState { impl DrillState {
pub fn new(text: &str) -> Self { pub fn new(text: &str) -> Self {
Self { Self {
target: text.chars().collect(), target: text.chars().collect(),
@@ -90,72 +90,72 @@ mod tests {
use crate::session::input; use crate::session::input;
#[test] #[test]
fn test_new_lesson() { fn test_new_drill() {
let lesson = LessonState::new("hello"); let drill = DrillState::new("hello");
assert_eq!(lesson.target.len(), 5); assert_eq!(drill.target.len(), 5);
assert_eq!(lesson.cursor, 0); assert_eq!(drill.cursor, 0);
assert!(!lesson.is_complete()); assert!(!drill.is_complete());
assert_eq!(lesson.progress(), 0.0); assert_eq!(drill.progress(), 0.0);
} }
#[test] #[test]
fn test_accuracy_starts_at_100() { fn test_accuracy_starts_at_100() {
let lesson = LessonState::new("test"); let drill = DrillState::new("test");
assert_eq!(lesson.accuracy(), 100.0); assert_eq!(drill.accuracy(), 100.0);
} }
#[test] #[test]
fn test_empty_lesson_progress() { fn test_empty_drill_progress() {
let lesson = LessonState::new(""); let drill = DrillState::new("");
assert!(lesson.is_complete()); assert!(drill.is_complete());
assert_eq!(lesson.progress(), 0.0); assert_eq!(drill.progress(), 0.0);
} }
#[test] #[test]
fn test_correct_typing_no_typos() { fn test_correct_typing_no_typos() {
let mut lesson = LessonState::new("abc"); let mut drill = DrillState::new("abc");
input::process_char(&mut lesson, 'a'); input::process_char(&mut drill, 'a');
input::process_char(&mut lesson, 'b'); input::process_char(&mut drill, 'b');
input::process_char(&mut lesson, 'c'); input::process_char(&mut drill, 'c');
assert!(lesson.typo_flags.is_empty()); assert!(drill.typo_flags.is_empty());
assert_eq!(lesson.accuracy(), 100.0); assert_eq!(drill.accuracy(), 100.0);
} }
#[test] #[test]
fn test_wrong_then_backspace_then_correct_counts_as_error() { 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 // Type wrong at pos 0
input::process_char(&mut lesson, 'x'); input::process_char(&mut drill, 'x');
assert!(lesson.typo_flags.contains(&0)); assert!(drill.typo_flags.contains(&0));
// Backspace // Backspace
input::process_backspace(&mut lesson); input::process_backspace(&mut drill);
// Typo flag persists // Typo flag persists
assert!(lesson.typo_flags.contains(&0)); assert!(drill.typo_flags.contains(&0));
// Type correct // Type correct
input::process_char(&mut lesson, 'a'); input::process_char(&mut drill, 'a');
assert!(lesson.typo_flags.contains(&0)); assert!(drill.typo_flags.contains(&0));
assert_eq!(lesson.typo_count(), 1); assert_eq!(drill.typo_count(), 1);
assert!(lesson.accuracy() < 100.0); assert!(drill.accuracy() < 100.0);
} }
#[test] #[test]
fn test_multiple_errors_same_position_counts_as_one() { 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 // Wrong, backspace, wrong again, backspace, correct
input::process_char(&mut lesson, 'x'); input::process_char(&mut drill, 'x');
input::process_backspace(&mut lesson); input::process_backspace(&mut drill);
input::process_char(&mut lesson, 'y'); input::process_char(&mut drill, 'y');
input::process_backspace(&mut lesson); input::process_backspace(&mut drill);
input::process_char(&mut lesson, 'a'); input::process_char(&mut drill, 'a');
assert_eq!(lesson.typo_count(), 1); assert_eq!(drill.typo_count(), 1);
} }
#[test] #[test]
fn test_wrong_char_without_backspace() { fn test_wrong_char_without_backspace() {
let mut lesson = LessonState::new("abc"); let mut drill = DrillState::new("abc");
input::process_char(&mut lesson, 'x'); // wrong at pos 0 input::process_char(&mut drill, 'x'); // wrong at pos 0
input::process_char(&mut lesson, 'b'); // correct at pos 1 input::process_char(&mut drill, 'b'); // correct at pos 1
assert_eq!(lesson.typo_count(), 1); assert_eq!(drill.typo_count(), 1);
assert!(lesson.typo_flags.contains(&0)); assert!(drill.typo_flags.contains(&0));
} }
} }

View File

@@ -1,6 +1,6 @@
use std::time::Instant; use std::time::Instant;
use crate::session::lesson::LessonState; use crate::session::drill::DrillState;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum CharStatus { pub enum CharStatus {
@@ -17,16 +17,16 @@ pub struct KeystrokeEvent {
pub correct: bool, pub correct: bool,
} }
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> { pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent> {
if lesson.is_complete() { if drill.is_complete() {
return None; return None;
} }
if lesson.started_at.is_none() { if drill.started_at.is_none() {
lesson.started_at = Some(Instant::now()); drill.started_at = Some(Instant::now());
} }
let expected = lesson.target[lesson.cursor]; let expected = drill.target[drill.cursor];
let correct = ch == expected; let correct = ch == expected;
let event = KeystrokeEvent { let event = KeystrokeEvent {
@@ -37,23 +37,23 @@ pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent
}; };
if correct { if correct {
lesson.input.push(CharStatus::Correct); drill.input.push(CharStatus::Correct);
} else { } else {
lesson.input.push(CharStatus::Incorrect(ch)); drill.input.push(CharStatus::Incorrect(ch));
lesson.typo_flags.insert(lesson.cursor); drill.typo_flags.insert(drill.cursor);
} }
lesson.cursor += 1; drill.cursor += 1;
if lesson.is_complete() { if drill.is_complete() {
lesson.finished_at = Some(Instant::now()); drill.finished_at = Some(Instant::now());
} }
Some(event) Some(event)
} }
pub fn process_backspace(lesson: &mut LessonState) { pub fn process_backspace(drill: &mut DrillState) {
if lesson.cursor > 0 { if drill.cursor > 0 {
lesson.cursor -= 1; drill.cursor -= 1;
lesson.input.pop(); drill.input.pop();
} }
} }

View File

@@ -1,3 +1,3 @@
pub mod input; pub mod input;
pub mod lesson; pub mod drill;
pub mod result; pub mod result;

View File

@@ -2,10 +2,10 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::session::input::KeystrokeEvent; use crate::session::input::KeystrokeEvent;
use crate::session::lesson::LessonState; use crate::session::drill::DrillState;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonResult { pub struct DrillResult {
pub wpm: f64, pub wpm: f64,
pub cpm: f64, pub cpm: f64,
pub accuracy: f64, pub accuracy: f64,
@@ -15,11 +15,11 @@ pub struct LessonResult {
pub elapsed_secs: f64, pub elapsed_secs: f64,
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
pub per_key_times: Vec<KeyTime>, pub per_key_times: Vec<KeyTime>,
#[serde(default = "default_lesson_mode")] #[serde(default = "default_drill_mode", alias = "lesson_mode")]
pub lesson_mode: String, pub drill_mode: String,
} }
fn default_lesson_mode() -> String { fn default_drill_mode() -> String {
"adaptive".to_string() "adaptive".to_string()
} }
@@ -30,8 +30,8 @@ pub struct KeyTime {
pub correct: bool, pub correct: bool,
} }
impl LessonResult { impl DrillResult {
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent], lesson_mode: &str) -> Self { pub fn from_drill(drill: &DrillState, events: &[KeystrokeEvent], drill_mode: &str) -> Self {
let per_key_times: Vec<KeyTime> = events let per_key_times: Vec<KeyTime> = events
.windows(2) .windows(2)
.map(|pair| { .map(|pair| {
@@ -44,8 +44,8 @@ impl LessonResult {
}) })
.collect(); .collect();
let total_chars = lesson.target.len(); let total_chars = drill.target.len();
let typo_count = lesson.typo_flags.len(); let typo_count = drill.typo_flags.len();
let accuracy = if total_chars > 0 { let accuracy = if total_chars > 0 {
((total_chars - typo_count) as f64 / total_chars as f64 * 100.0).clamp(0.0, 100.0) ((total_chars - typo_count) as f64 / total_chars as f64 * 100.0).clamp(0.0, 100.0)
} else { } else {
@@ -53,16 +53,16 @@ impl LessonResult {
}; };
Self { Self {
wpm: lesson.wpm(), wpm: drill.wpm(),
cpm: lesson.cpm(), cpm: drill.cpm(),
accuracy, accuracy,
correct: total_chars - typo_count, correct: total_chars - typo_count,
incorrect: typo_count, incorrect: typo_count,
total_chars, total_chars,
elapsed_secs: lesson.elapsed_secs(), elapsed_secs: drill.elapsed_secs(),
timestamp: Utc::now(), timestamp: Utc::now(),
per_key_times, per_key_times,
lesson_mode: lesson_mode.to_string(), drill_mode: drill_mode.to_string(),
} }
} }
} }

View File

@@ -5,7 +5,7 @@ use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use crate::store::schema::{KeyStatsData, LessonHistoryData, ProfileData}; use crate::store::schema::{KeyStatsData, DrillHistoryData, ProfileData};
pub struct JsonStore { pub struct JsonStore {
base_dir: PathBuf, base_dir: PathBuf,
@@ -65,11 +65,11 @@ impl JsonStore {
self.save("key_stats.json", data) 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") 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) self.save("lesson_history.json", data)
} }
} }

View File

@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::engine::key_stats::KeyStatsStore; use crate::engine::key_stats::KeyStatsStore;
use crate::session::result::LessonResult; use crate::session::result::DrillResult;
const SCHEMA_VERSION: u32 = 1; const SCHEMA_VERSION: u32 = 1;
@@ -10,7 +10,8 @@ pub struct ProfileData {
pub schema_version: u32, pub schema_version: u32,
pub unlocked_letters: Vec<char>, pub unlocked_letters: Vec<char>,
pub total_score: f64, pub total_score: f64,
pub total_lessons: u32, #[serde(alias = "total_lessons")]
pub total_drills: u32,
pub streak_days: u32, pub streak_days: u32,
pub best_streak: u32, pub best_streak: u32,
pub last_practice_date: Option<String>, pub last_practice_date: Option<String>,
@@ -22,7 +23,7 @@ impl Default for ProfileData {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
unlocked_letters: Vec::new(), unlocked_letters: Vec::new(),
total_score: 0.0, total_score: 0.0,
total_lessons: 0, total_drills: 0,
streak_days: 0, streak_days: 0,
best_streak: 0, best_streak: 0,
last_practice_date: None, last_practice_date: None,
@@ -46,16 +47,17 @@ impl Default for KeyStatsData {
} }
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LessonHistoryData { pub struct DrillHistoryData {
pub schema_version: u32, pub schema_version: u32,
pub lessons: Vec<LessonResult>, #[serde(alias = "lessons")]
pub drills: Vec<DrillResult>,
} }
impl Default for LessonHistoryData { impl Default for DrillHistoryData {
fn default() -> Self { fn default() -> Self {
Self { Self {
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
lessons: Vec::new(), drills: Vec::new(),
} }
} }
} }

View File

@@ -6,16 +6,16 @@ use ratatui::layout::Rect;
use ratatui::style::{Color, Style}; use ratatui::style::{Color, Style};
use ratatui::widgets::{Block, Widget}; use ratatui::widgets::{Block, Widget};
use crate::session::result::LessonResult; use crate::session::result::DrillResult;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct ActivityHeatmap<'a> { pub struct ActivityHeatmap<'a> {
history: &'a [LessonResult], history: &'a [DrillResult],
theme: &'a Theme, theme: &'a Theme,
} }
impl<'a> ActivityHeatmap<'a> { 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 } Self { history, theme }
} }
} }
@@ -25,7 +25,7 @@ impl Widget for ActivityHeatmap<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Activity ") .title(" Daily Activity (Sessions per Day) ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);

View File

@@ -53,7 +53,7 @@ impl Widget for WpmChart<'_> {
) )
.x_axis( .x_axis(
Axis::default() Axis::default()
.title("Lesson") .title("Drill #")
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]), .bounds([0.0, max_x]),
) )

View File

@@ -4,16 +4,16 @@ use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::result::LessonResult; use crate::session::result::DrillResult;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct Dashboard<'a> { pub struct Dashboard<'a> {
pub result: &'a LessonResult, pub result: &'a DrillResult,
pub theme: &'a Theme, pub theme: &'a Theme,
} }
impl<'a> Dashboard<'a> { 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 } Self { result, theme }
} }
} }
@@ -23,7 +23,7 @@ impl Widget for Dashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Lesson Complete ") .title(" Drill Complete ")
.border_style(Style::default().fg(colors.accent())) .border_style(Style::default().fg(colors.accent()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));
let inner = block.inner(area); let inner = block.inner(area);

View File

@@ -24,17 +24,17 @@ impl<'a> Menu<'a> {
items: vec![ items: vec![
MenuItem { MenuItem {
key: "1".to_string(), key: "1".to_string(),
label: "Adaptive Practice".to_string(), label: "Adaptive Drill".to_string(),
description: "Phonetic words with adaptive letter unlocking".to_string(), description: "Phonetic words with adaptive letter unlocking".to_string(),
}, },
MenuItem { MenuItem {
key: "2".to_string(), key: "2".to_string(),
label: "Code Practice".to_string(), label: "Code Drill".to_string(),
description: "Practice typing code syntax".to_string(), description: "Practice typing code syntax".to_string(),
}, },
MenuItem { MenuItem {
key: "3".to_string(), key: "3".to_string(),
label: "Passage Mode".to_string(), label: "Passage Drill".to_string(),
description: "Type passages from books".to_string(), description: "Type passages from books".to_string(),
}, },
MenuItem { MenuItem {

View File

@@ -5,12 +5,12 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use crate::engine::key_stats::KeyStatsStore; 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::components::activity_heatmap::ActivityHeatmap;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct StatsDashboard<'a> { pub struct StatsDashboard<'a> {
pub history: &'a [LessonResult], pub history: &'a [DrillResult],
pub key_stats: &'a KeyStatsStore, pub key_stats: &'a KeyStatsStore,
pub active_tab: usize, pub active_tab: usize,
pub target_wpm: u32, pub target_wpm: u32,
@@ -21,7 +21,7 @@ pub struct StatsDashboard<'a> {
impl<'a> StatsDashboard<'a> { impl<'a> StatsDashboard<'a> {
pub fn new( pub fn new(
history: &'a [LessonResult], history: &'a [DrillResult],
key_stats: &'a KeyStatsStore, key_stats: &'a KeyStatsStore,
active_tab: usize, active_tab: usize,
target_wpm: u32, target_wpm: u32,
@@ -54,7 +54,7 @@ impl Widget for StatsDashboard<'_> {
if self.history.is_empty() { if self.history.is_empty() {
let msg = Paragraph::new(Line::from(Span::styled( 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()), Style::default().fg(colors.text_pending()),
))); )));
msg.render(inner, buf); msg.render(inner, buf);
@@ -71,7 +71,7 @@ impl Widget for StatsDashboard<'_> {
.split(inner); .split(inner);
// Tab header // Tab header
let tabs = ["[D] Dashboard", "[H] History", "[K] Keystrokes"]; let tabs = ["[1] Dashboard", "[2] History", "[3] Keystrokes"];
let tab_spans: Vec<Span> = tabs let tab_spans: Vec<Span> = tabs
.iter() .iter()
.enumerate() .enumerate()
@@ -113,7 +113,7 @@ impl Widget for StatsDashboard<'_> {
let footer_text = if self.active_tab == 1 { let footer_text = if self.active_tab == 1 {
" [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete" " [ESC] Back [Tab] Next tab [j/k] Navigate [x] Delete"
} else { } 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( let footer = Paragraph::new(Line::from(Span::styled(
footer_text, footer_text,
@@ -198,7 +198,7 @@ impl StatsDashboard<'_> {
let summary = vec![ let summary = vec![
Line::from(vec![ Line::from(vec![
Span::styled(" Lessons: ", Style::default().fg(colors.fg())), Span::styled(" Drills: ", Style::default().fg(colors.fg())),
Span::styled( Span::styled(
&*total_str, &*total_str,
Style::default() Style::default()
@@ -249,8 +249,9 @@ impl StatsDashboard<'_> {
fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) { fn render_wpm_bar_graph(&self, area: Rect, buf: &mut Buffer) {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let target_label = format!(" WPM per Drill (Last 20, Target: {}) ", self.target_wpm);
let block = Block::bordered() let block = Block::bordered()
.title(" WPM (Last 20) ") .title(target_label)
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); 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 max_wpm = recent.iter().fold(0.0f64, |a, &b| a.max(b)).max(10.0);
let target = self.target_wpm as f64; 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 { let bar_spacing = if bar_count > 0 {
inner.width / bar_count as u16 chart_width / bar_count as u16
} else { } else {
return; 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 = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
// Render each bar as a column // Render each bar as a column
let start_idx = recent.len().saturating_sub(bar_count); let start_idx = recent.len().saturating_sub(bar_count);
for (i, &wpm) in recent[start_idx..].iter().enumerate() { for (i, &wpm) in recent[start_idx..].iter().enumerate() {
let x = inner.x + i as u16 * bar_spacing; let x = chart_x + i as u16 * bar_spacing;
if x >= inner.x + inner.width { if x >= chart_x + chart_width {
break; break;
} }
@@ -343,7 +364,7 @@ impl StatsDashboard<'_> {
if data.is_empty() { if data.is_empty() {
let block = Block::bordered() let block = Block::bordered()
.title(" Accuracy Trend ") .title(" Accuracy % (Last 50 Drills) ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
block.render(area, buf); block.render(area, buf);
return; return;
@@ -360,18 +381,18 @@ impl StatsDashboard<'_> {
let chart = Chart::new(vec![dataset]) let chart = Chart::new(vec![dataset])
.block( .block(
Block::bordered() Block::bordered()
.title(" Accuracy Trend ") .title(" Accuracy % (Last 50 Drills) ")
.border_style(Style::default().fg(colors.border())), .border_style(Style::default().fg(colors.border())),
) )
.x_axis( .x_axis(
Axis::default() Axis::default()
.title("Lesson") .title("Drill #")
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.bounds([0.0, max_x]), .bounds([0.0, max_x]),
) )
.y_axis( .y_axis(
Axis::default() Axis::default()
.title("%") .title("Accuracy %")
.style(Style::default().fg(colors.text_pending())) .style(Style::default().fg(colors.text_pending()))
.bounds([80.0, 100.0]), .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(); let total = self.history.len();
for (i, result) in recent.iter().enumerate() { for (i, result) in recent.iter().enumerate() {
@@ -534,16 +555,21 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Character Speed Distribution ") .title(" Avg Key Time by Character ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); 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; return;
} }
let letters: Vec<char> = ('a'..='z').collect(); let letters: Vec<char> = ('a'..='z').collect();
let row_count = if inner.height >= row_height * 2 { 2 } else { 1 };
let max_time = letters let max_time = letters
.iter() .iter()
.filter_map(|&ch| self.key_stats.stats.get(&ch)) .filter_map(|&ch| self.key_stats.stats.get(&ch))
@@ -553,9 +579,13 @@ impl StatsDashboard<'_> {
let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; let bar_chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
for (i, &ch) in letters.iter().enumerate() { for (i, &ch) in letters.iter().take(columns_per_row * row_count).enumerate() {
let x = inner.x + (i as u16 * 2).min(inner.width.saturating_sub(1)); let row = i / columns_per_row;
if x >= inner.x + inner.width { 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; break;
} }
@@ -576,35 +606,37 @@ impl StatsDashboard<'_> {
}; };
// Letter label // 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 // Bar indicator
if inner.height >= 2 { let bar_char = if time > 0.0 {
let bar_char = if time > 0.0 { let idx = ((ratio * 7.0).round() as usize).min(7);
let idx = ((ratio * 7.0).round() as usize).min(7); bar_chars[idx]
bar_chars[idx] } else {
} else { ' '
' ' };
}; buf.set_string(x, y + 1, &bar_char.to_string(), Style::default().fg(color));
buf.set_string(
x,
inner.y + 1,
&bar_char.to_string(),
Style::default().fg(color),
);
}
// Time label on row 3 // Time label on row 3, render seconds when value exceeds 999ms.
if inner.height >= 3 && time > 0.0 { if time > 0.0 {
let time_label = format!("{time:.0}"); let time_label = if time > 999.0 {
if x + time_label.len() as u16 <= inner.x + inner.width { format!("({:.0}s)", time / 1000.0)
buf.set_string( } else {
x, format!("{time:.0}")
inner.y + 2, };
&time_label, let label = if time_label.len() > col_width as usize {
Style::default().fg(colors.text_pending()), 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 colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Keyboard Accuracy ") .title(" Keyboard Accuracy % ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -732,7 +764,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Slowest ") .title(" Slowest Keys (ms) ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -765,7 +797,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Fastest ") .title(" Fastest Keys (ms) ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -798,7 +830,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Worst Accuracy ") .title(" Worst Accuracy Keys (%) ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);
@@ -858,7 +890,7 @@ impl StatsDashboard<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let block = Block::bordered() let block = Block::bordered()
.title(" Overall ") .title(" Overall Totals ")
.border_style(Style::default().fg(colors.border())); .border_style(Style::default().fg(colors.border()));
let inner = block.inner(area); let inner = block.inner(area);
block.render(area, buf); block.render(area, buf);

View File

@@ -4,19 +4,36 @@ use ratatui::style::Style;
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget}; use ratatui::widgets::{Block, Paragraph, Widget};
use crate::session::lesson::LessonState; use crate::session::drill::DrillState;
use crate::session::result::LessonResult; use crate::session::result::DrillResult;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct StatsSidebar<'a> { pub struct StatsSidebar<'a> {
lesson: &'a LessonState, drill: &'a DrillState,
last_result: Option<&'a LessonResult>, last_result: Option<&'a DrillResult>,
history: &'a [DrillResult],
theme: &'a Theme, theme: &'a Theme,
} }
impl<'a> StatsSidebar<'a> { impl<'a> StatsSidebar<'a> {
pub fn new(lesson: &'a LessonState, last_result: Option<&'a LessonResult>, theme: &'a Theme) -> Self { pub fn new(
Self { lesson, last_result, theme } 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(); 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 { let sections = if has_last {
Layout::default() Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Min(10)]) .constraints([Constraint::Min(10), Constraint::Min(12)])
.split(area) .split(area)
} else { } else {
Layout::default() Layout::default()
@@ -39,14 +56,14 @@ impl Widget for StatsSidebar<'_> {
.split(area) .split(area)
}; };
// Current lesson stats // Current drill stats
{ {
let wpm = self.lesson.wpm(); let wpm = self.drill.wpm();
let accuracy = self.lesson.accuracy(); let accuracy = self.drill.accuracy();
let progress = self.lesson.progress() * 100.0; let progress = self.drill.progress() * 100.0;
let correct = self.lesson.correct_count(); let correct = self.drill.correct_count();
let incorrect = self.lesson.typo_count(); let incorrect = self.drill.typo_count();
let elapsed = self.lesson.elapsed_secs(); let elapsed = self.drill.elapsed_secs();
let wpm_str = format!("{wpm:.0}"); let wpm_str = format!("{wpm:.0}");
let acc_str = format!("{accuracy:.1}%"); let acc_str = format!("{accuracy:.1}%");
@@ -104,51 +121,94 @@ impl Widget for StatsSidebar<'_> {
paragraph.render(sections[0], buf); paragraph.render(sections[0], buf);
} }
// Last lesson stats // Last drill stats with session impact deltas
if let Some(last) = self.last_result { if let Some(last) = self.last_result {
let wpm_str = format!("{:.0}", last.wpm); let wpm_str = format!("{:.0}", last.wpm);
let acc_str = format!("{:.1}%", last.accuracy); let acc_str = format!("{:.1}%", last.accuracy);
let chars_str = format!("{}", last.total_chars);
let time_str = format!("{:.1}s", last.elapsed_secs); let time_str = format!("{:.1}s", last.elapsed_secs);
let errors_str = format!("{}", last.incorrect); 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::<f64>() / prior.len() as f64;
let avg_acc = prior.iter().map(|r| r.accuracy).sum::<f64>() / 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![ Line::from(vec![
Span::styled("WPM: ", Style::default().fg(colors.fg())), Span::styled("WPM: ", Style::default().fg(colors.fg())),
Span::styled(wpm_str, Style::default().fg(colors.accent())), 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() let block = Block::bordered()
.title(" Last Lesson ") .title(" Last Drill ")
.border_style(Style::default().fg(colors.border())) .border_style(Style::default().fg(colors.border()))
.style(Style::default().bg(colors.bg())); .style(Style::default().bg(colors.bg()));

View File

@@ -5,17 +5,17 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Paragraph, Widget, Wrap};
use crate::session::input::CharStatus; use crate::session::input::CharStatus;
use crate::session::lesson::LessonState; use crate::session::drill::DrillState;
use crate::ui::theme::Theme; use crate::ui::theme::Theme;
pub struct TypingArea<'a> { pub struct TypingArea<'a> {
lesson: &'a LessonState, drill: &'a DrillState,
theme: &'a Theme, theme: &'a Theme,
} }
impl<'a> TypingArea<'a> { impl<'a> TypingArea<'a> {
pub fn new(lesson: &'a LessonState, theme: &'a Theme) -> Self { pub fn new(drill: &'a DrillState, theme: &'a Theme) -> Self {
Self { lesson, theme } Self { drill, theme }
} }
} }
@@ -24,21 +24,21 @@ impl Widget for TypingArea<'_> {
let colors = &self.theme.colors; let colors = &self.theme.colors;
let mut spans: Vec<Span> = Vec::new(); let mut spans: Vec<Span> = Vec::new();
for (i, &target_ch) in self.lesson.target.iter().enumerate() { for (i, &target_ch) in self.drill.target.iter().enumerate() {
if i < self.lesson.cursor { if i < self.drill.cursor {
let style = match &self.lesson.input[i] { let style = match &self.drill.input[i] {
CharStatus::Correct => Style::default().fg(colors.text_correct()), CharStatus::Correct => Style::default().fg(colors.text_correct()),
CharStatus::Incorrect(_) => Style::default() CharStatus::Incorrect(_) => Style::default()
.fg(colors.text_incorrect()) .fg(colors.text_incorrect())
.bg(colors.text_incorrect_bg()) .bg(colors.text_incorrect_bg())
.add_modifier(Modifier::UNDERLINED), .add_modifier(Modifier::UNDERLINED),
}; };
let display = match &self.lesson.input[i] { let display = match &self.drill.input[i] {
CharStatus::Incorrect(actual) => *actual, CharStatus::Incorrect(actual) => *actual,
_ => target_ch, _ => target_ch,
}; };
spans.push(Span::styled(display.to_string(), style)); spans.push(Span::styled(display.to_string(), style));
} else if i == self.lesson.cursor { } else if i == self.drill.cursor {
let style = Style::default() let style = Style::default()
.fg(colors.text_cursor_fg()) .fg(colors.text_cursor_fg())
.bg(colors.text_cursor_bg()); .bg(colors.text_cursor_bg());