typetui 0.2.1

A terminal-based typing test.
Documentation
use rand::prelude::{IndexedRandom, IteratorRandom};
use rand::{RngExt, rng};
use std::collections::HashSet;

use syntect::easy::HighlightLines;
use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;

include!(concat!(env!("OUT_DIR"), "/content.rs"));
pub use generated_content::*;

/// Syntax highlighter for code snippets.
pub struct SyntaxHighlighter {
    syntax_set: SyntaxSet,
    theme_set: ThemeSet,
}

impl Default for SyntaxHighlighter {
    fn default() -> Self {
        Self::new()
    }
}

impl SyntaxHighlighter {
    /// Create a new syntax highlighter with default syntaxes and themes.
    #[must_use]
    pub fn new() -> Self {
        Self {
            syntax_set: SyntaxSet::load_defaults_newlines(),
            theme_set: ThemeSet::load_defaults(),
        }
    }

    /// Get the syntect syntax name for our internal language identifier.
    fn language_to_syntect_scope(&self, lang: &str) -> Option<&syntect::parsing::SyntaxReference> {
        // Map our language names to syntect's syntax names
        let syntect_name = match lang {
            "cpp" => "C++",
            "go" => "Go",
            "python" => "Python",
            "rust" => "Rust",
            "typescript" => "TypeScript",
            _ => return None, // No built-in support for jai, zig, etc.
        };
        self.syntax_set.find_syntax_by_name(syntect_name)
    }

    /// Check if a language has syntax highlighting support.
    #[must_use]
    pub fn has_support(&self, lang: &str) -> bool {
        self.language_to_syntect_scope(lang).is_some()
    }

    /// Get syntax-highlighted colors for each character in the code.
    /// Returns a vector of Option<Color> where None means no specific syntax color.
    /// The colors are from the default theme and need to be converted to `ratatui::style::Color`.
    pub fn highlight_code(&self, code: &str, lang: &str) -> Vec<Option<ratatui::style::Color>> {
        let Some(syntax) = self.language_to_syntect_scope(lang) else {
            return vec![None; code.chars().count()];
        };

        // Use base16-ocean.dark theme - it's good for terminals
        let theme = self
            .theme_set
            .themes
            .get("base16-ocean.dark")
            .cloned()
            .unwrap_or_else(|| {
                self.theme_set
                    .themes
                    .values()
                    .next()
                    .cloned()
                    .expect("No themes available")
            });

        // Get the theme's default foreground color to skip it
        let default_fg = theme
            .settings
            .foreground
            .unwrap_or(syntect::highlighting::Color::WHITE);

        let mut highlighter = HighlightLines::new(syntax, &theme);
        let code_char_count = code.chars().count();
        let mut result: Vec<Option<ratatui::style::Color>> = vec![None; code_char_count];
        let mut char_idx = 0;

        for line in LinesWithEndings::from(code) {
            let line_len = line.chars().count();
            let highlight_result = highlighter.highlight_line(line, &self.syntax_set);

            if let Ok(ranges) = highlight_result {
                let mut line_offset = 0;
                for (syntect_style, text) in &ranges {
                    let color = syntect_style_to_ratatui(syntect_style, default_fg);
                    let text_len = text.chars().count();
                    // Map each character in this range to its color by position
                    for i in 0..text_len {
                        let global_idx = char_idx + line_offset + i;
                        if global_idx < code_char_count {
                            result[global_idx] = color;
                        }
                    }
                    line_offset += text_len;
                }
            } else {
                // On error, leave this line as None (default colors)
            }
            char_idx += line_len;
        }

        result
    }
}

/// Convert a syntect highlighting style to ratatui Color.
/// Returns None if the color matches the default foreground (meaning no special syntax highlighting).
fn syntect_style_to_ratatui(
    style: &SyntectStyle,
    default_fg: syntect::highlighting::Color,
) -> Option<ratatui::style::Color> {
    let fg = style.foreground;

    // Skip if it matches the theme's default foreground color
    // (this is assigned to unstyled text like whitespace, punctuation, etc.)
    if fg.r == default_fg.r && fg.g == default_fg.g && fg.b == default_fg.b {
        return None;
    }

    Some(ratatui::style::Color::Rgb(fg.r, fg.g, fg.b))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Text,
    Code,
}

impl Mode {
    #[must_use]
    pub fn all() -> &'static [Mode] {
        &[Mode::Text, Mode::Code]
    }

    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Mode::Text => "text",
            Mode::Code => "code",
        }
    }

    #[must_use]
    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "text" => Some(Mode::Text),
            "code" => Some(Mode::Code),
            _ => None,
        }
    }

    #[must_use]
    pub fn languages(&self) -> &'static [&'static str] {
        match self {
            Mode::Text => TEXT_LANGUAGES,
            Mode::Code => CODE_LANGUAGES,
        }
    }

    #[must_use]
    pub fn default_language(&self) -> &'static str {
        match self {
            Mode::Text => TEXT_LANGUAGES.first().copied().unwrap_or("english"),
            Mode::Code => CODE_LANGUAGES.first().copied().unwrap_or("python"),
        }
    }
}

#[derive(Debug, Clone)]
pub struct ContentProvider {
    mode: Mode,
    language: String,
}

impl ContentProvider {
    pub fn new(mode: Mode, language: impl Into<String>) -> Self {
        Self {
            mode,
            language: language.into(),
        }
    }

    #[must_use]
    pub fn generate_text(&self, word_count: usize) -> String {
        match self.mode {
            Mode::Text => self.generate_words(word_count),
            Mode::Code => self.generate_code_snippet(),
        }
    }

    fn generate_words(&self, count: usize) -> String {
        let words = get_word_list(&self.language);
        if words.is_empty() {
            return "the quick brown fox jumps over the lazy dog".to_string();
        }

        let mut rng = rng();
        let selected: Vec<&str> = (0..count)
            .map(|_| *words.choose(&mut rng).unwrap_or(&"the"))
            .collect();

        selected.join(" ")
    }

    #[must_use]
    pub fn generate_code_snippet(&self) -> String {
        let snippets = get_snippets(&self.language);
        if snippets.is_empty() {
            return "print('hello world')".to_string();
        }

        let mut rng = rng();
        snippets
            .choose(&mut rng)
            .unwrap_or(&"print('hello world')")
            .to_string()
    }

    /// Generate a 15-line random slice from a code snippet.
    /// Takes a set of already shown (`snippet_index`, `start_line`) tuples to avoid repeats.
    /// Returns the slice and the new shown slice identifier.
    /// Skips empty/whitespace-only lines at start and end of the slice.
    #[must_use]
    pub fn generate_code_snippet_slice(
        &self,
        shown_slices: &HashSet<(usize, usize)>,
    ) -> Option<(String, (usize, usize))> {
        const SNIPPET_LINES: usize = 15;
        let snippets = get_snippets(&self.language);
        if snippets.is_empty() {
            return None;
        }

        let mut rng = rand::rng();

        // Helper to check if a line is meaningful (not empty/whitespace-only)
        let is_meaningful = |line: &str| !line.trim().is_empty();

        // Collect available snippet indices and their valid slice starts
        // Only consider starts where the first line is meaningful
        let mut available: Vec<(usize, usize)> = Vec::new();
        for (idx, snippet) in snippets.iter().enumerate() {
            let lines: Vec<&str> = snippet.lines().collect();
            if lines.is_empty() {
                continue;
            }
            // Find valid start positions where first line is meaningful
            let max_start = lines.len().saturating_sub(SNIPPET_LINES);
            for start in 0..=max_start {
                if !shown_slices.contains(&(idx, start)) && is_meaningful(lines[start]) {
                    // Also ensure we can get 15 meaningful lines (or at least not all empty)
                    let end = (start + SNIPPET_LINES).min(lines.len());
                    if lines[start..end].iter().any(|l| is_meaningful(l)) {
                        available.push((idx, start));
                    }
                }
            }
        }

        // Try to find a valid slice
        let max_attempts = 100;
        for _ in 0..max_attempts {
            let selected = if available.is_empty() {
                // Pick completely random, will validate below
                let snippet_idx = (0..snippets.len()).choose(&mut rng)?;
                let lines: Vec<&str> = snippets[snippet_idx].lines().collect();
                if lines.is_empty() {
                    continue;
                }
                let max_start = lines.len().saturating_sub(SNIPPET_LINES);
                let start = if max_start == 0 {
                    0
                } else {
                    rng.random_range(0..=max_start)
                };
                (snippet_idx, start)
            } else {
                *available.choose(&mut rng)?
            };

            let (snippet_idx, start_line) = selected;
            let snippet: &str = snippets[snippet_idx];
            let lines: Vec<&str> = snippet.lines().collect();
            let end_line: usize = (start_line + SNIPPET_LINES).min(lines.len());

            // Skip if first line is empty/whitespace-only
            if !is_meaningful(lines[start_line]) {
                continue;
            }

            // Extract slice and trim trailing empty lines
            let mut slice_lines: Vec<&str> = lines[start_line..end_line].to_vec();

            // Remove trailing empty/whitespace-only lines
            while let Some(last) = slice_lines.last() {
                if is_meaningful(last) {
                    break;
                }
                slice_lines.pop();
            }

            // Skip if we removed everything (all lines were empty)
            if slice_lines.is_empty() {
                continue;
            }

            // Skip if last remaining line is empty (shouldn't happen due to trim, but safety check)
            if !is_meaningful(slice_lines.last().unwrap()) {
                continue;
            }

            let slice = slice_lines.join("\n");
            return Some((slice, selected));
        }

        // Fallback: return a basic snippet if all attempts failed
        None
    }

    #[must_use]
    pub fn available_languages(&self) -> &'static [&'static str] {
        self.mode.languages()
    }

    #[must_use]
    pub fn validate_language(&self, lang: &str) -> bool {
        self.available_languages().contains(&lang)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_mode_from_str() {
        assert_eq!(Mode::from_str("text"), Some(Mode::Text));
        assert_eq!(Mode::from_str("code"), Some(Mode::Code));
        assert_eq!(Mode::from_str("unknown"), None);
    }

    #[test]
    fn test_content_provider_text() {
        let provider = ContentProvider::new(Mode::Text, "english");
        let text = provider.generate_text(10);
        assert!(!text.is_empty());
        // Word count may vary since some entries in the word list are multi-word phrases
        let words: Vec<&str> = text.split_whitespace().collect();
        assert!(
            words.len() >= 10,
            "expected at least 10 words, got {}",
            words.len()
        );
    }

    #[test]
    fn test_content_provider_code() {
        let provider = ContentProvider::new(Mode::Code, "python");
        let text = provider.generate_text(10);
        assert!(!text.is_empty());
    }
}