tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Terminal color parsing and contrast helpers (tix #1530).
//!
//! A configuration value names a color (`red`, `dark_gray`, `bright_blue`, …).
//! The TUI resolves it here so a tag painted green means the same green in the
//! board, the detail pane and `tix list`. [`contrast_fg`] picks a readable
//! foreground for a filled chip, so a custom background color can never leave
//! its text unreadable.

use std::collections::HashMap;

use ratatui::style::Color;

/// Resolved color lookups for the TUI (tix #1530).
///
/// Built once per board rebuild from the tag store and the config, then read by
/// the card and detail renderers. Keys are lowercased, so a label's case never
/// matters. A missing entry means "no color" — an unconfigured tag, type or
/// milestone renders in the default style rather than a guessed one.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Theme {
    tags: HashMap<String, Color>,
    issue_types: HashMap<String, Color>,
    milestones: HashMap<String, Color>,
}

impl Theme {
    /// Build a theme from the three lookups.
    pub fn new(
        tags: HashMap<String, Color>,
        issue_types: HashMap<String, Color>,
        milestones: HashMap<String, Color>,
    ) -> Self {
        Self {
            tags,
            issue_types,
            milestones,
        }
    }

    /// The color for a tag (issue label), matched case-insensitively.
    pub fn tag(&self, label: &str) -> Option<Color> {
        self.tags.get(&label.trim().to_ascii_lowercase()).copied()
    }

    /// The color for an issue type.
    pub fn issue_type(&self, type_name: &str) -> Option<Color> {
        self.issue_types
            .get(&type_name.trim().to_ascii_lowercase())
            .copied()
    }

    /// The color for a milestone.
    pub fn milestone(&self, name: &str) -> Option<Color> {
        self.milestones
            .get(&name.trim().to_ascii_lowercase())
            .copied()
    }

    /// Whether the theme carries no colors at all.
    pub fn is_empty(&self) -> bool {
        self.tags.is_empty() && self.issue_types.is_empty() && self.milestones.is_empty()
    }
}

/// Parse a color name.
///
/// Names are case-insensitive and ignore `_`/`-`/spaces, so `DarkGray`,
/// `dark_gray` and `dark gray` all parse. `bright_*` is accepted as a synonym of
/// the `light_*` palette. An unknown name returns `None` rather than a guess, so
/// a typo degrades to "no color" instead of the wrong one.
pub fn parse(name: &str) -> Option<Color> {
    let key: String = name
        .trim()
        .to_ascii_lowercase()
        .chars()
        .filter(|c| !matches!(c, '_' | '-' | ' '))
        .collect();
    Some(match key.as_str() {
        "black" => Color::Black,
        "red" => Color::Red,
        "green" => Color::Green,
        "yellow" => Color::Yellow,
        "blue" => Color::Blue,
        "magenta" | "purple" => Color::Magenta,
        "cyan" => Color::Cyan,
        "gray" | "grey" => Color::Gray,
        "white" => Color::White,
        "darkgray" | "darkgrey" => Color::DarkGray,
        "lightred" | "brightred" => Color::LightRed,
        "lightgreen" | "brightgreen" => Color::LightGreen,
        "lightyellow" | "brightyellow" => Color::LightYellow,
        "lightblue" | "brightblue" => Color::LightBlue,
        "lightmagenta" | "brightmagenta" => Color::LightMagenta,
        "lightcyan" | "brightcyan" => Color::LightCyan,
        _ => return None,
    })
}

/// Whether `color` is a **light** background, by the coarse rule terminals use:
/// the bright palette and white are light; the base colors and grays are dark.
///
/// Only used to pick a foreground, so a wrong guess is cosmetic and never
/// changes what is drawn.
fn is_light(color: Color) -> bool {
    matches!(
        color,
        Color::White
            // Yellow and Cyan are bright on the standard palette even in their
            // base forms, so black text on them is the readable choice.
            | Color::Yellow
            | Color::Cyan
            | Color::LightRed
            | Color::LightGreen
            | Color::LightYellow
            | Color::LightBlue
            | Color::LightMagenta
            | Color::LightCyan
    )
}

/// A readable foreground for text drawn on `bg`: black on a light background,
/// white on a dark one.
///
/// This is what keeps a filled tag chip legible whatever color it names — the
/// "front and back always stay readable" requirement of tix #1530, made a rule
/// rather than a per-chip judgement.
pub fn contrast_fg(bg: Color) -> Color {
    if is_light(bg) {
        Color::Black
    } else {
        Color::White
    }
}

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

    #[test]
    fn parses_names_case_and_separator_insensitively() {
        assert_eq!(parse("Red"), Some(Color::Red));
        assert_eq!(parse("dark_gray"), Some(Color::DarkGray));
        assert_eq!(parse("Dark Gray"), Some(Color::DarkGray));
        assert_eq!(parse("brightblue"), Some(Color::LightBlue));
        assert_eq!(parse("purple"), Some(Color::Magenta));
        assert_eq!(parse("nope"), None);
    }

    #[test]
    fn contrast_is_black_on_light_and_white_on_dark() {
        assert_eq!(contrast_fg(Color::Yellow), Color::Black);
        assert_eq!(contrast_fg(Color::White), Color::Black);
        assert_eq!(contrast_fg(Color::Blue), Color::White);
        assert_eq!(contrast_fg(Color::DarkGray), Color::White);
    }
}