typetui 0.1.0

A terminal-based typing test.
Documentation
use crate::content::{ContentProvider, Mode};
use crate::stats::Stats;

#[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,
}

impl TypingTest {
    pub fn new(mode: Mode, language: String, duration_secs: u64) -> Self {
        let provider = ContentProvider::new(mode, &language);
        let target_text = if mode == Mode::Text {
            provider.generate_text(200)
        } else {
            provider.generate_code_snippet()
        };

        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,
        }
    }

    pub fn start(&mut self) {
        self.is_active = true;
        self.has_started = true;
    }

    pub fn tick(&mut self) {
        if self.is_active && !self.is_finished {
            self.elapsed_secs += 1;
            if self.elapsed_secs >= self.duration_secs {
                self.finish();
            }
        }
    }

    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();
        }

        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() {
            self.finish();
        }
    }

    /// Skip to the next newline by filling remaining characters on current line
    /// with incorrect placeholders. Returns true if a newline was found and skipped to.
    /// This is used when user presses Enter mid-line.
    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();

        // Find the next newline from current cursor position
        for i in self.cursor_position..target_chars.len() {
            if target_chars[i] == '\n' {
                // Fill all characters from current position to (but not including) the newline
                // with a placeholder that will be marked incorrect
                for _ in self.cursor_position..i {
                    // Use a character that's unlikely to match anything in target
                    self.user_input.push('\0');
                    self.stats.record_char(false); // Mark as incorrect
                }
                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();

        // In code mode, if current line only has leading whitespace (indentation),
        // remove the entire line (newline + all indent) to go to previous line's end
        if self.mode == Mode::Code && self.is_at_line_start() {
            // Remove all characters back to and including the previous newline
            let chars_to_remove = self
                .user_input
                .chars()
                .rev()
                .take_while(|c| *c != '\n')
                .count()
                + 1; // +1 for the newline itself

            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;
        }
    }

    /// Check if cursor is at the start of a line (only whitespace typed since last newline).
    /// Returns true if current line consists only of leading whitespace/indentation.
    fn is_at_line_start(&self) -> bool {
        // Find the position after the last newline in user_input
        let last_newline_pos = self.user_input.rfind('\n');
        let current_line_start = match last_newline_pos {
            Some(pos) => pos + 1,
            None => 0,
        };

        // Check if everything from current_line_start to end is whitespace
        self.user_input
            .chars()
            .skip(current_line_start)
            .all(|c| c.is_whitespace())
    }

    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)
    }

    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
    }

    pub fn wpm(&self) -> f64 {
        self.stats.wpm(self.elapsed_secs.max(1))
    }

    pub fn raw_wpm(&self) -> f64 {
        self.stats.raw_wpm(self.elapsed_secs.max(1))
    }

    pub fn accuracy(&self) -> f64 {
        self.stats.accuracy()
    }

    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
    }

    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'); // incorrect
        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);
    }
}