use crate::content::{ContentProvider, Mode, SyntaxHighlighter};
use crate::stats::{Stats, StatsSnapshot};
use ratatui::style::Color;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct TypingTest {
pub mode: Mode,
pub language: String,
pub target_text: String,
pub user_input: String,
pub cursor_position: usize,
pub stats: Stats,
pub duration_secs: u64,
pub elapsed_secs: u64,
pub is_active: bool,
pub is_finished: bool,
pub has_started: bool,
pub shown_code_slices: HashSet<(usize, usize)>,
pub syntax_colors: Vec<Option<Color>>,
pub time_series: Vec<StatsSnapshot>,
}
impl TypingTest {
#[must_use]
pub fn new(mode: Mode, language: String, duration_secs: u64) -> Self {
let provider = ContentProvider::new(mode, &language);
let shown_code_slices = HashSet::new();
let target_text = if mode == Mode::Text {
provider.generate_text(200)
} else {
provider
.generate_code_snippet_slice(&shown_code_slices)
.map(|(_slice, id)| {
let mut set = shown_code_slices.clone();
set.insert(id);
set
});
match provider.generate_code_snippet_slice(&shown_code_slices) {
Some((slice, id)) => {
let mut new_shown = shown_code_slices.clone();
new_shown.insert(id);
slice
}
None => "print('hello world')".to_string(),
}
};
let shown_code_slices = if mode == Mode::Code {
let provider = ContentProvider::new(mode, &language);
match provider.generate_code_snippet_slice(&HashSet::new()) {
Some((_, id)) => {
let mut set = HashSet::new();
set.insert(id);
set
}
None => HashSet::new(),
}
} else {
HashSet::new()
};
let syntax_colors = if mode == Mode::Code {
let highlighter = SyntaxHighlighter::new();
highlighter.highlight_code(&target_text, &language)
} else {
Vec::new()
};
Self {
mode,
language,
target_text,
user_input: String::new(),
cursor_position: 0,
stats: Stats::default(),
duration_secs,
elapsed_secs: 0,
is_active: false,
is_finished: false,
has_started: false,
shown_code_slices,
syntax_colors,
time_series: Vec::new(),
}
}
pub fn start(&mut self) {
self.is_active = true;
self.has_started = true;
self.record_snapshot();
}
pub fn tick(&mut self) {
if self.is_active && !self.is_finished {
self.elapsed_secs += 1;
self.record_snapshot();
if self.elapsed_secs >= self.duration_secs {
self.finish();
}
}
}
fn record_snapshot(&mut self) {
self.time_series.push(StatsSnapshot {
elapsed_secs: self.elapsed_secs,
wpm: self.stats.wpm(self.elapsed_secs),
raw_wpm: self.stats.raw_wpm(self.elapsed_secs),
accuracy: self.stats.accuracy(),
});
}
pub fn finish(&mut self) {
self.is_active = false;
self.is_finished = true;
}
pub fn handle_input(&mut self, c: char) {
if self.is_finished {
return;
}
if !self.has_started {
self.start();
}
if self.mode == Mode::Code && self.cursor_position == 0 {
self.skip_leading_indentation();
}
let expected = self.target_text.chars().nth(self.cursor_position);
let correct = expected == Some(c);
self.user_input.push(c);
self.stats.record_char(correct);
self.cursor_position += 1;
if self.cursor_position >= self.target_text.len() {
if self.mode == Mode::Code {
self.load_next_code_snippet();
} else {
self.finish();
}
}
}
fn skip_leading_indentation(&mut self) {
let leading_whitespace: String = self
.target_text
.chars()
.take_while(|c| c.is_whitespace() && *c != '\n')
.collect();
for ws_char in leading_whitespace.chars() {
self.user_input.push(ws_char);
self.stats.record_char(true); self.cursor_position += 1;
}
}
fn load_next_code_snippet(&mut self) {
let provider = ContentProvider::new(self.mode, &self.language);
match provider.generate_code_snippet_slice(&self.shown_code_slices) {
Some((slice, id)) => {
self.target_text = slice;
self.user_input.clear();
self.cursor_position = 0;
self.shown_code_slices.insert(id);
let highlighter = SyntaxHighlighter::new();
self.syntax_colors = highlighter.highlight_code(&self.target_text, &self.language);
}
None => {
self.finish();
}
}
}
pub fn skip_to_end_of_line(&mut self) -> bool {
if self.is_finished {
return false;
}
if !self.has_started {
self.start();
}
let target_chars: Vec<char> = self.target_text.chars().collect();
#[allow(clippy::needless_range_loop)]
for i in self.cursor_position..target_chars.len() {
if target_chars[i] == '\n' {
for _ in self.cursor_position..i {
self.user_input.push('\0');
self.stats.record_char(false); }
self.cursor_position = i;
return true;
}
}
false
}
pub fn handle_backspace(&mut self) {
if !self.is_active || self.is_finished || self.cursor_position == 0 {
return;
}
self.stats.record_backspace();
if self.mode == Mode::Code && self.is_at_line_start() {
let chars_to_remove = self
.user_input
.chars()
.rev()
.take_while(|c| *c != '\n')
.count()
+ 1;
let new_len = self.user_input.len().saturating_sub(chars_to_remove);
self.user_input.truncate(new_len);
self.cursor_position = self.user_input.len();
} else {
self.user_input.pop();
self.cursor_position -= 1;
}
}
fn is_at_line_start(&self) -> bool {
let last_newline_pos = self.user_input.rfind('\n');
let current_line_start = match last_newline_pos {
Some(pos) => pos + 1,
None => 0,
};
self.user_input
.chars()
.skip(current_line_start)
.all(char::is_whitespace)
}
#[must_use]
pub fn get_display_text(&self, max_chars: usize) -> (String, String, String) {
let typed = self.user_input.chars().take(max_chars).collect::<String>();
let remaining = self
.target_text
.chars()
.skip(self.cursor_position)
.take(max_chars.saturating_sub(typed.chars().count()))
.collect::<String>();
let overflow = if self.cursor_position + remaining.chars().count() < self.target_text.len()
{
"...".to_string()
} else {
String::new()
};
(typed, remaining, overflow)
}
#[must_use]
pub fn current_char_status(&self) -> Vec<(char, CharStatus)> {
let mut result = Vec::new();
for (i, target_char) in self.target_text.chars().enumerate() {
let status = if i >= self.user_input.len() {
CharStatus::Untyped
} else {
let typed_char = self.user_input.chars().nth(i).unwrap();
if typed_char == target_char {
CharStatus::Correct
} else {
CharStatus::Incorrect
}
};
result.push((target_char, status));
}
result
}
#[must_use]
pub fn wpm(&self) -> f64 {
self.stats.wpm(self.elapsed_secs.max(1))
}
#[must_use]
pub fn raw_wpm(&self) -> f64 {
self.stats.raw_wpm(self.elapsed_secs.max(1))
}
#[must_use]
pub fn accuracy(&self) -> f64 {
self.stats.accuracy()
}
#[must_use]
pub fn progress_pct(&self) -> f64 {
if self.target_text.is_empty() {
return 0.0;
}
(self.cursor_position as f64 / self.target_text.len() as f64) * 100.0
}
#[must_use]
pub fn remaining_secs(&self) -> u64 {
self.duration_secs.saturating_sub(self.elapsed_secs)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CharStatus {
Untyped,
Correct,
Incorrect,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_typing_flow() {
let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
test.start();
assert!(test.is_active);
assert!(test.has_started);
}
#[test]
fn test_handle_input() {
let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
test.target_text = "hello world".to_string();
test.start();
for c in "hello".chars() {
test.handle_input(c);
}
assert_eq!(test.cursor_position, 5);
assert_eq!(test.user_input, "hello");
}
#[test]
fn test_backspace() {
let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
test.target_text = "hello".to_string();
test.start();
test.handle_input('h');
test.handle_input('e');
assert_eq!(test.cursor_position, 2);
test.handle_backspace();
assert_eq!(test.cursor_position, 1);
assert_eq!(test.user_input, "h");
}
#[test]
fn test_char_status() {
let mut test = TypingTest::new(Mode::Text, "english".to_string(), 60);
test.target_text = "abc".to_string();
test.start();
test.handle_input('a');
test.handle_input('x'); test.handle_input('c');
let status = test.current_char_status();
assert_eq!(status[0].1, CharStatus::Correct);
assert_eq!(status[1].1, CharStatus::Incorrect);
assert_eq!(status[2].1, CharStatus::Correct);
}
}