aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/core/src/app.rs96
-rw-r--r--crates/core/src/stats.rs26
-rw-r--r--crates/core/src/ui.rs231
3 files changed, 245 insertions, 108 deletions
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<u64>,
}
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<Self, Box<dyn std::error::Error + Send + Sync>> {
#[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<Line> = 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<char> = app.current_text().content.chars().collect();
let input_chars: Vec<char> = app.input.chars().collect();
let mut colored_text: Vec<Span> = 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);
+}