From 18a1b361a9d6567b87c49e8bbbf0bba9ba51687f Mon Sep 17 00:00:00 2001 From: srdusr Date: Tue, 30 Sep 2025 13:15:59 +0200 Subject: TUI: fixed wpm history/made UI more identical to web/ctrl-backspace behaviour/improved accuracy Web: improved category selection/fixed endscreen responsiveness inconsistency/mistake tracking/clean game memory after use/improve general UI --- crates/core/src/app.rs | 96 +++++++++++++++++++- crates/core/src/stats.rs | 26 +++++- crates/core/src/ui.rs | 231 ++++++++++++++++++++++++++--------------------- 3 files changed, 245 insertions(+), 108 deletions(-) (limited to 'crates') diff --git a/crates/core/src/app.rs b/crates/core/src/app.rs index b230bc3..5e2da72 100644 --- a/crates/core/src/app.rs +++ b/crates/core/src/app.rs @@ -24,6 +24,7 @@ pub struct App { pub current_text_index: usize, pub should_exit: bool, pub state: State, + pub wpm_history: Vec, } impl App { @@ -64,9 +65,59 @@ impl App { current_text_index, should_exit, state, + wpm_history: Vec::new(), }) } + fn handle_backspace_with_rules(&mut self, ctrl: bool) { + if self.input.is_empty() { return; } + let current_text = &self.texts[self.current_text_index].content; + if ctrl { + // Delete to start of current word + let word_start = self.get_current_word_start(); + if word_start < self.input.len() { + self.input.truncate(word_start); + self.update_stats(); + } + return; + } + + // Deleting one character. Only allow crossing into previous word if there are errors before. + let target_pos = self.input.len().saturating_sub(1); + let current_word_start = self.get_current_word_start(); + if target_pos < current_word_start { + if !self.has_errors_before_position(current_text, current_word_start) { + // No errors before; do not allow moving back into previous words + return; + } + } + self.input.pop(); + self.update_stats(); + } + + fn get_current_word_start(&self) -> usize { + let mut word_start = 0; + let mut in_word = false; + for (i, c) in self.input.chars().enumerate() { + if c.is_whitespace() { + if in_word { word_start = i + 1; } + in_word = false; + } else { + in_word = true; + } + } + word_start + } + + fn has_errors_before_position(&self, text: &str, position: usize) -> bool { + let compare_len = self.input.len().min(text.len()); + for (i, (ic, tc)) in self.input.chars().zip(text.chars()).take(compare_len).enumerate() { + if i >= position { break; } + if ic != tc { return true; } + } + false + } + pub fn new_with_config(config: Config) -> Result> { #[derive(Deserialize)] struct RawText { category: String, content: String, attribution: String } @@ -97,6 +148,7 @@ impl App { current_text_index, stats: Stats::new(), config, + wpm_history: Vec::new(), }) } @@ -104,6 +156,7 @@ impl App { self.input.clear(); self.stats.reset(); self.current_text_index = self.pick_random_index(); + self.wpm_history.clear(); } fn pick_random_index(&self) -> usize { @@ -162,15 +215,45 @@ impl App { State::TypingGame => { match key.code { crossterm::event::KeyCode::Char(c) => { - if !self.stats.is_running() { - self.stats.start(); + // Handle control-word delete (Ctrl+W) + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) + && (c == 'w' || c == 'W') + { + self.handle_backspace_with_rules(true); + return; + } + // Don't insert invisible control chars; only insert when no CTRL/ALT (SHIFT ok) + if key.modifiers.intersects(crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT) { + return; } + if !self.stats.is_running() { self.stats.start(); } + // Record keystroke correctness before mutating input + let was_correct = { + let pos = self.input.len(); + let current_text = &self.texts[self.current_text_index].content; + if pos < current_text.len() { + // Compare with target at this position + current_text.chars().nth(pos).map(|tc| tc == c).unwrap_or(false) + } else { + false // extra chars are considered incorrect + } + }; + self.stats.note_keypress(was_correct); self.input.push(c); self.update_stats(); } crossterm::event::KeyCode::Backspace => { - self.input.pop(); - self.update_stats(); + // Treat Ctrl or Alt modified Backspace as word delete for tmux/screen/terms + let ctrl_or_alt = key.modifiers.intersects( + crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT, + ); + self.handle_backspace_with_rules(ctrl_or_alt); + } + // Some terminals send Ctrl+H instead of Ctrl+Backspace + crossterm::event::KeyCode::Char('h') | crossterm::event::KeyCode::Char('H') + if key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) => + { + self.handle_backspace_with_rules(true); } crossterm::event::KeyCode::Esc => { self.state = State::MainMenu; @@ -270,6 +353,11 @@ impl App { pub fn update(&mut self) { if self.state == State::TypingGame { self.update_stats(); + // Sample WPM once per elapsed second to build a compact sparkline + let secs = self.stats.elapsed_time().as_secs() as usize; + while self.wpm_history.len() < secs { + self.wpm_history.push(self.stats.wpm().round() as u64); + } } } } \ No newline at end of file diff --git a/crates/core/src/stats.rs b/crates/core/src/stats.rs index 59d56c9..264000b 100644 --- a/crates/core/src/stats.rs +++ b/crates/core/src/stats.rs @@ -14,6 +14,10 @@ pub struct Stats { total_words: usize, correct_words: usize, errors: usize, + // Persistent keystroke-level tracking (CLI): + // counts every typed character (excluding control sequences) and how many were incorrect at time of keypress + keystrokes_total: usize, + keystrokes_incorrect: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -81,6 +85,8 @@ impl Stats { total_words: 0, correct_words: 0, errors: 0, + keystrokes_total: 0, + keystrokes_incorrect: 0, } } @@ -96,6 +102,8 @@ impl Stats { self.total_words = 0; self.correct_words = 0; self.errors = 0; + self.keystrokes_total = 0; + self.keystrokes_incorrect = 0; } pub fn start(&mut self) { @@ -147,6 +155,15 @@ impl Stats { self.best_streak = self.best_streak.max(best_streak_local); } + // Record a single keypress for persistent accuracy tracking (CLI only). + // If the typed char at the time of keypress was incorrect, mark it as incorrect permanently. + pub fn note_keypress(&mut self, was_correct: bool) { + self.keystrokes_total = self.keystrokes_total.saturating_add(1); + if !was_correct { + self.keystrokes_incorrect = self.keystrokes_incorrect.saturating_add(1); + } + } + pub fn finish(&mut self) { self.end_time = Some(Instant::now()); } @@ -178,11 +195,14 @@ impl Stats { } pub fn get_accuracy(&self) -> f64 { + // Prefer persistent keystroke accuracy for CLI to avoid resetting to 100% after fixes. + if self.keystrokes_total > 0 { + let correct = (self.keystrokes_total - self.keystrokes_incorrect) as f64; + return (correct / self.keystrokes_total as f64) * 100.0; + } if self.total_chars > 0 { (self.correct_chars as f64 / self.total_chars as f64) * 100.0 - } else { - 0.0 - } + } else { 0.0 } } pub fn get_time_elapsed(&self) -> Duration { diff --git a/crates/core/src/ui.rs b/crates/core/src/ui.rs index f503474..ac18e41 100644 --- a/crates/core/src/ui.rs +++ b/crates/core/src/ui.rs @@ -1,22 +1,13 @@ -#[cfg(feature = "tui")] -use std::io; - -#[cfg(feature = "tui")] use ratatui::{ - backend::Backend, layout::{Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::{Span}, - widgets::{Block, Borders, Paragraph, Wrap}, + text::Span, + widgets::{Block, Paragraph, Wrap}, Frame, }; +use ratatui::prelude::{Alignment, Line}; -#[cfg(feature = "tui")] -use crate::app::App; - -#[cfg(feature = "tui")] -use ratatui::prelude::{Line, Alignment}; -use crate::app::State; +use crate::app::{App, State}; pub fn draw(f: &mut Frame, app: &App) { match app.state { @@ -26,61 +17,58 @@ pub fn draw(f: &mut Frame, app: &App) { } } -pub fn draw_main_menu(f: &mut Frame, _app: &App) { +pub fn draw_main_menu(f: &mut Frame, app: &App) { let chunks = Layout::default() .direction(Direction::Vertical) - .margin(2) + .margin(1) .constraints([Constraint::Min(0)]) .split(f.size()); let category_label = { - let cat = _app.selected_category.as_ref().map(String::as_str).unwrap_or("Random"); - format!("Category: {} (←/→ to change)", cat) + let cat = app + .selected_category + .as_ref() + .map(String::as_str) + .unwrap_or("Random"); + format!("Category: {} (\u{2190}/\u{2192} to change)", cat) }; - let main_menu = vec![ - Line::from(Span::styled("Welcome to Typerpunk!", Style::default().add_modifier(Modifier::BOLD))), - Line::from(Span::styled(category_label, Style::default().fg(Color::Cyan))), - Line::from(Span::styled("Press Enter to Start", Style::default())), - Line::from(Span::styled("Press Esc to Quit", Style::default())), + + let mut lines: Vec = vec![ + Line::from(Span::styled( + "TYPERPUNK", + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + Line::from(Span::from("")), + Line::from(Span::styled( + category_label, + Style::default().fg(Color::Cyan), + )), + Line::from(Span::from("")), + Line::from(Span::styled("Start: Enter", Style::default())), + Line::from(Span::styled( + "Change Category: \u{2190} / \u{2192}", + Style::default(), + )), + Line::from(Span::styled("Quit: Esc", Style::default())), ]; f.render_widget( - Paragraph::new(main_menu) + Paragraph::new(lines) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), + .block(Block::default()), chunks[0], ); } pub fn draw_typing_game(f: &mut Frame, app: &App) { + let area = f.size(); let chunks = Layout::default() .direction(Direction::Vertical) - .margin(2) - .constraints([ - Constraint::Length(3), // Stats - Constraint::Min(0), // Text - ]) - .split(f.size()); - - // Draw stats - let stats = vec![ - Line::from(vec![ - Span::styled(format!("WPM: {:.1}", app.stats.wpm()), Style::default().fg(Color::Yellow)), - Span::styled(" | ", Style::default()), - Span::styled(format!("Time: {:.1}s", app.stats.elapsed_time().as_secs_f64()), Style::default().fg(Color::Cyan)), - Span::styled(" | ", Style::default()), - Span::styled(format!("Accuracy: {:.1}%", app.stats.accuracy()), Style::default().fg(Color::Green)), - ]), - ]; - - f.render_widget( - Paragraph::new(stats) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), - chunks[0], - ); + .margin(1) + .constraints([Constraint::Min(0)]) + .split(area); - // Draw text + // Build colored text let text_chars: Vec = app.current_text().content.chars().collect(); let input_chars: Vec = app.input.chars().collect(); let mut colored_text: Vec = Vec::new(); @@ -96,7 +84,6 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) { } else { Style::default().fg(Color::Gray) }; - let span = if i == cursor_pos { Span::styled(c.to_string(), style.add_modifier(Modifier::REVERSED)) } else { @@ -105,83 +92,125 @@ pub fn draw_typing_game(f: &mut Frame, app: &App) { colored_text.push(span); } - // Add any remaining incorrect characters if input_chars.len() > text_chars.len() { for &c in &input_chars[text_chars.len()..] { - colored_text.push(Span::styled( - c.to_string(), - Style::default().fg(Color::Red), - )); + colored_text.push(Span::styled(c.to_string(), Style::default().fg(Color::Red))); } } - let text = vec![ - Line::from(Span::styled( - "Type the following text:", - Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(colored_text), - ]; + let lines = vec![Line::from(Span::from("")), Line::from(colored_text)]; f.render_widget( - Paragraph::new(text) - .alignment(Alignment::Left) - .block(Block::default().borders(Borders::ALL)) + Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default()) .wrap(Wrap { trim: true }), - chunks[1], + chunks[0], ); - // Render attribution beneath the text box - let attribution = app.current_text().source.clone(); - if !attribution.is_empty() { - let area = ratatui::layout::Rect { - x: chunks[1].x, - y: chunks[1].y.saturating_add(chunks[1].height.saturating_sub(2)), - width: chunks[1].width, + // Attribution under text + if !app.current_text().source.is_empty() { + let att_area = ratatui::layout::Rect { + x: chunks[0].x, + y: chunks[0].y.saturating_add(chunks[0].height.saturating_sub(5)), + width: chunks[0].width, height: 2, }; let attribution_line = Line::from(Span::styled( - format!("— {}", attribution), + format!("— {}", app.current_text().source), Style::default().fg(Color::Gray), )); f.render_widget( Paragraph::new(vec![attribution_line]) - .alignment(Alignment::Right) + .alignment(Alignment::Center) .wrap(Wrap { trim: true }), - area, + att_area, ); } -} -pub fn draw_end_screen(f: &mut Frame, app: &App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(2) - .constraints([Constraint::Min(0)]) - .split(f.size()); + // Anchored stats: WPM (left), ACC (right), TIME (bottom center) + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(3), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(2), width: 20, height: 2 }; - let end_screen = vec![ - Line::from(Span::styled("Game Over!", Style::default().add_modifier(Modifier::BOLD))), + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Words Per Minute: {:.1}", app.stats.wpm()), - Style::default(), + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Time Taken: {:.1} seconds", app.stats.elapsed_time().as_secs_f64()), - Style::default(), + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), Line::from(Span::styled( - format!("Accuracy: {:.1}%", app.stats.accuracy()), - Style::default(), + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), )), - Line::from(Span::styled("Press Enter to Play Again", Style::default())), - Line::from(Span::styled("Press Esc to Quit", Style::default())), - ]; + ]) + .alignment(Alignment::Center); - f.render_widget( - Paragraph::new(end_screen) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)), - chunks[0], - ); -} \ No newline at end of file + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); +} + +pub fn draw_end_screen(f: &mut Frame, app: &App) { + let area = f.size(); + // We don't render a central RESULTS section to avoid duplication. + // We only render anchored stats and bottom buttons. + + // Anchored stats at the edges + let wpm_rect = ratatui::layout::Rect { x: area.x + 1, y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let acc_rect = ratatui::layout::Rect { x: area.x + area.width.saturating_sub(21), y: area.y + area.height.saturating_sub(6), width: 20, height: 3 }; + let time_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 10, y: area.y + area.height.saturating_sub(5), width: 20, height: 2 }; + let buttons_rect = ratatui::layout::Rect { x: area.x + area.width / 2 - 20, y: area.y + area.height.saturating_sub(2), width: 40, height: 2 }; + + let wpm_widget = Paragraph::new(vec![ + Line::from(Span::styled("WPM", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}", app.stats.wpm()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Left); + + let acc_widget = Paragraph::new(vec![ + Line::from(Span::styled("ACC", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.0}%", app.stats.accuracy()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Right); + + let time_widget = Paragraph::new(vec![ + Line::from(Span::styled("TIME", Style::default().fg(Color::Gray))), + Line::from(Span::styled( + format!("{:.1}", app.stats.elapsed_time().as_secs_f64()), + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + )), + ]) + .alignment(Alignment::Center); + + let buttons = Paragraph::new(vec![ + Line::from(Span::styled("Enter: Play Again", Style::default())), + Line::from(Span::styled("Esc: Main Menu", Style::default())), + ]) + .alignment(Alignment::Center); + + f.render_widget(wpm_widget, wpm_rect); + f.render_widget(acc_widget, acc_rect); + f.render_widget(time_widget, time_rect); + f.render_widget(buttons, buttons_rect); +} -- cgit v1.2.3