typetui 0.2.0

A terminal-based typing test.
Documentation
use ratatui::style::{Color, Modifier, Style};

#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeName {
    Pastel,
    Rose,
    Mint,
    Lavender,
    Peach,
    Crimson,
}

impl ThemeName {
    pub fn all() -> &'static [ThemeName] {
        &[
            ThemeName::Pastel,
            ThemeName::Rose,
            ThemeName::Mint,
            ThemeName::Lavender,
            ThemeName::Peach,
            ThemeName::Crimson,
        ]
    }

    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            ThemeName::Pastel => "pastel",
            ThemeName::Rose => "rose",
            ThemeName::Mint => "mint",
            ThemeName::Lavender => "lavender",
            ThemeName::Peach => "peach",
            ThemeName::Crimson => "crimson",
        }
    }

    #[must_use]
    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "pastel" => Some(ThemeName::Pastel),
            "rose" => Some(ThemeName::Rose),
            "mint" => Some(ThemeName::Mint),
            "lavender" => Some(ThemeName::Lavender),
            "peach" => Some(ThemeName::Peach),
            "crimson" => Some(ThemeName::Crimson),
            _ => None,
        }
    }

    pub fn next(&self) -> Self {
        let all = Self::all();
        let pos = all.iter().position(|t| t == self).unwrap_or(0);
        let next_idx = (pos + 1) % all.len();
        all[next_idx]
    }
}

#[derive(Debug, Clone)]
pub struct Theme {
    pub name: ThemeName,
    pub background: Color,
    pub foreground: Color,
    pub accent: Color,
    pub success: Color,
    pub error: Color,
    pub warning: Color,
    pub muted: Color,
    pub border: Color,
    pub highlight: Color,
    pub cursor: Color,
    pub text_untyped: Color,
    pub text_correct: Color,
    pub text_incorrect: Color,
    pub newline: Color,
    pub newline_typed: Color,
}

impl Theme {
    #[must_use]
    pub fn from_name(name: ThemeName) -> Self {
        match name {
            ThemeName::Pastel => Self::pastel(),
            ThemeName::Rose => Self::rose(),
            ThemeName::Mint => Self::mint(),
            ThemeName::Lavender => Self::lavender(),
            ThemeName::Peach => Self::peach(),
            ThemeName::Crimson => Self::crimson(),
        }
    }

    #[must_use]
    pub fn pastel() -> Self {
        Self {
            name: ThemeName::Pastel,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(150, 200, 255),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(200, 180, 255),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn rose() -> Self {
        Self {
            name: ThemeName::Rose,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(255, 160, 180),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(255, 180, 200),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn mint() -> Self {
        Self {
            name: ThemeName::Mint,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(140, 230, 180),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(160, 240, 200),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn lavender() -> Self {
        Self {
            name: ThemeName::Lavender,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(190, 160, 255),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(210, 180, 255),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn peach() -> Self {
        Self {
            name: ThemeName::Peach,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(255, 190, 140),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(255, 200, 160),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn crimson() -> Self {
        Self {
            name: ThemeName::Crimson,
            background: Color::Rgb(30, 30, 35),
            foreground: Color::Rgb(230, 230, 235),
            accent: Color::Rgb(240, 100, 120),
            success: Color::Rgb(150, 240, 180),
            error: Color::Rgb(255, 150, 150),
            warning: Color::Rgb(255, 220, 150),
            muted: Color::Rgb(180, 180, 190),
            border: Color::Rgb(60, 60, 70),
            highlight: Color::Rgb(255, 120, 140),
            cursor: Color::Rgb(255, 230, 150),
            text_untyped: Color::Rgb(140, 140, 150),
            text_correct: Color::Rgb(230, 230, 235),
            text_incorrect: Color::Rgb(255, 150, 150),
            newline: Color::Rgb(100, 100, 110),
            newline_typed: Color::Rgb(160, 160, 170),
        }
    }

    #[must_use]
    pub fn style(&self, fg: Color) -> Style {
        Style::default().fg(fg)
    }

    #[must_use]
    pub fn style_accent(&self) -> Style {
        self.style(self.accent).add_modifier(Modifier::BOLD)
    }

    #[must_use]
    pub fn style_success(&self) -> Style {
        self.style(self.success)
    }

    #[must_use]
    pub fn style_error(&self) -> Style {
        self.style(self.error)
    }

    #[must_use]
    pub fn style_muted(&self) -> Style {
        self.style(self.muted)
    }

    #[must_use]
    pub fn style_highlight(&self) -> Style {
        self.style(self.highlight).add_modifier(Modifier::BOLD)
    }

    /// Dim a color by reducing its brightness (for untyped syntax-highlighted text).
    /// Returns a dimmed version of the color at approximately 75% brightness.
    #[must_use]
    pub fn dim_color(&self, color: Color) -> Color {
        match color {
            Color::Rgb(r, g, b) => {
                let dim = |c: u8| {
                    let faded = (u16::from(c) * 3) / 4; // 75% of original
                    let min_brightness = 80u16; // Ensure minimum visibility
                    faded.max(min_brightness) as u8
                };
                Color::Rgb(dim(r), dim(g), dim(b))
            }
            _ => color, // Non-RGB colors pass through unchanged
        }
    }

    /// Get heatmap color for a given activity level (0-4).
    /// Level 0 is empty (border color), levels 1-4 are accent at increasing intensities.
    #[must_use]
    pub fn heatmap_color(&self, level: u8) -> Color {
        if level == 0 {
            self.border
        } else {
            let intensity = match level {
                1 => 0.25,
                2 => 0.50,
                3 => 0.75,
                _ => 1.00,
            };
            match (self.background, self.accent) {
                (Color::Rgb(br, bg, bb), Color::Rgb(ar, ag, ab)) => {
                    let blend = |base: u8, accent: u8| {
                        let blended =
                            f64::from(base) + (f64::from(accent) - f64::from(base)) * intensity;
                        blended.clamp(0.0, 255.0) as u8
                    };
                    Color::Rgb(blend(br, ar), blend(bg, ag), blend(bb, ab))
                }
                _ => self.accent,
            }
        }
    }
}

impl Default for Theme {
    fn default() -> Self {
        Self::pastel()
    }
}