ilmari 0.1.5

Minimal tmux popup radar for coding agents
use std::collections::BTreeMap;

use ratatui::style::{Color, Style};

const PALETTE_CSV_LEN: usize = 18;
const PALETTE_SLOT_ORDER: &str =
    "fg,bg,black,red,green,yellow,blue,magenta,cyan,white,bright_black,bright_red,bright_green,bright_yellow,bright_blue,bright_magenta,bright_cyan,bright_white";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SemanticRole {
    StatusRunning,
    StatusWaitingInput,
    StatusFinished,
    StatusTerminated,
    StatusUnknown,
    AppLabel,
    AgentDetailNeutral,
    AgentDetailPositive,
    AgentDetailWarning,
    GitInsertions,
    GitDeletions,
    HeadingAccent,
    MutedText,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Palette {
    fg: Color,
    bg: Color,
    ansi: [Color; 16],
}

impl Default for Palette {
    fn default() -> Self {
        Self {
            fg: Color::Reset,
            bg: Color::Reset,
            ansi: std::array::from_fn(|idx| Color::Indexed(idx as u8)),
        }
    }
}

impl Palette {
    pub fn from_env() -> Self {
        let env = std::env::vars().collect::<BTreeMap<_, _>>();
        Self::from_env_map(&env)
    }

    pub fn base_style(&self) -> Style {
        Style::default().fg(self.fg).bg(self.bg)
    }

    pub fn style_for(&self, role: SemanticRole) -> Style {
        self.base_style().fg(match role {
            SemanticRole::StatusRunning => self.ansi_color(4),
            SemanticRole::StatusWaitingInput => self.ansi_color(3),
            SemanticRole::StatusFinished => self.ansi_color(2),
            SemanticRole::StatusTerminated => self.ansi_color(1),
            SemanticRole::StatusUnknown => self.ansi_color(8),
            SemanticRole::AppLabel => self.ansi_color(14),
            SemanticRole::AgentDetailNeutral => self.ansi_color(8),
            SemanticRole::AgentDetailPositive => self.ansi_color(2),
            SemanticRole::AgentDetailWarning => self.ansi_color(3),
            SemanticRole::GitInsertions => self.ansi_color(2),
            SemanticRole::GitDeletions => self.ansi_color(1),
            SemanticRole::HeadingAccent => self.ansi_color(12),
            SemanticRole::MutedText => self.ansi_color(8),
        })
    }

    fn from_env_map(env: &BTreeMap<String, String>) -> Self {
        let Some(value) = env.get("ILMARI_TUI_PALETTE").or_else(|| env.get("ILMARI_PALETTE"))
        else {
            return Self::default();
        };

        let trimmed = value.trim();
        if trimmed.is_empty() {
            return Self::default();
        }

        Self::parse_csv(trimmed).unwrap_or_default()
    }

    fn parse_csv(value: &str) -> Result<Self, String> {
        let parts: Vec<&str> = value.split(',').map(|part| part.trim()).collect();
        if parts.len() != PALETTE_CSV_LEN {
            return Err(format!(
                "expected {} comma-separated colors ({PALETTE_SLOT_ORDER}), got {}",
                PALETTE_CSV_LEN,
                parts.len()
            ));
        }

        let fg = parse_palette_color(parts[0])?;
        let bg = parse_palette_color(parts[1])?;
        let mut ansi = [Color::Reset; 16];
        for (idx, part) in parts.iter().skip(2).enumerate() {
            ansi[idx] = parse_palette_color(part)?;
        }

        Ok(Self { fg, bg, ansi })
    }

    fn ansi_color(&self, idx: usize) -> Color {
        self.ansi[idx]
    }
}

fn parse_palette_color(value: &str) -> Result<Color, String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return Err("empty color".to_string());
    }

    let lower = trimmed.to_ascii_lowercase();
    if let Some(rest) = lower.strip_prefix("rgb:") {
        let parts: Vec<&str> = rest.split('/').collect();
        if parts.len() != 3 {
            return Err(format!("invalid rgb: value: {trimmed}"));
        }
        let r = parse_hex_channel(parts[0])?;
        let g = parse_hex_channel(parts[1])?;
        let b = parse_hex_channel(parts[2])?;
        return Ok(Color::Rgb(r, g, b));
    }

    let hex = trimmed
        .strip_prefix('#')
        .or_else(|| trimmed.strip_prefix("0x"))
        .or_else(|| trimmed.strip_prefix("0X"))
        .unwrap_or(trimmed);

    if hex.len() != 6 || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
        return Err(format!("invalid hex color: {trimmed} (expected #RRGGBB)"));
    }

    let rgb = u32::from_str_radix(hex, 16).map_err(|_| format!("invalid hex color: {trimmed}"))?;
    Ok(Color::Rgb(((rgb >> 16) & 0xFF) as u8, ((rgb >> 8) & 0xFF) as u8, (rgb & 0xFF) as u8))
}

fn parse_hex_channel(value: &str) -> Result<u8, String> {
    let trimmed = value.trim();
    if trimmed.len() == 2 {
        return u8::from_str_radix(trimmed, 16)
            .map_err(|_| format!("invalid rgb: component {trimmed}"));
    }
    if trimmed.len() == 4 {
        let parsed = u16::from_str_radix(trimmed, 16)
            .map_err(|_| format!("invalid rgb: component {trimmed}"))?;
        return Ok((parsed >> 8) as u8);
    }

    Err(format!("invalid rgb: component {trimmed} (expected 2 or 4 hex digits)"))
}

#[cfg(test)]
mod tests {
    use super::{Palette, SemanticRole};
    use ratatui::style::Color;
    use std::collections::BTreeMap;

    #[test]
    fn default_palette_uses_reset_base_and_ansi_indexed_roles() {
        let palette = Palette::default();

        assert_eq!(palette.base_style().fg, Some(Color::Reset));
        assert_eq!(palette.base_style().bg, Some(Color::Reset));
        assert_eq!(palette.style_for(SemanticRole::StatusRunning).fg, Some(Color::Indexed(4)));
        assert_eq!(
            palette.style_for(SemanticRole::AgentDetailPositive).fg,
            Some(Color::Indexed(2))
        );
        assert_eq!(palette.style_for(SemanticRole::AppLabel).fg, Some(Color::Indexed(14)));
        assert_eq!(palette.style_for(SemanticRole::HeadingAccent).fg, Some(Color::Indexed(12)));
    }

    #[test]
    fn env_palette_prefers_ilmari_tui_palette() {
        let mut env = BTreeMap::new();
        env.insert(
            "ILMARI_PALETTE".to_string(),
            "#010101,#020202,#030303,#040404,#050505,#060606,#070707,#080808,#090909,#0a0a0a,#0b0b0b,#0c0c0c,#0d0d0d,#0e0e0e,#0f0f0f,#101010,#111111,#121212".to_string(),
        );
        env.insert(
            "ILMARI_TUI_PALETTE".to_string(),
            "#111111,#222222,#000000,#ff0000,#00ff00,#ffff00,#0000ff,#ff00ff,#00ffff,#cccccc,#555555,#ff5555,#55ff55,#ffff55,#5555ff,#ff55ff,#55ffff,#ffffff".to_string(),
        );

        let palette = Palette::from_env_map(&env);

        assert_eq!(
            palette.style_for(SemanticRole::StatusRunning).fg,
            Some(Color::Rgb(0x00, 0x00, 0xff))
        );
        assert_eq!(palette.base_style().fg, Some(Color::Rgb(0x11, 0x11, 0x11)));
        assert_eq!(palette.base_style().bg, Some(Color::Rgb(0x22, 0x22, 0x22)));
    }

    #[test]
    fn env_palette_uses_compatibility_alias_when_primary_is_missing() {
        let mut env = BTreeMap::new();
        env.insert(
            "ILMARI_PALETTE".to_string(),
            "010101,020202,030303,040404,050505,060606,070707,080808,090909,0a0a0a,0b0b0b,0c0c0c,0d0d0d,0e0e0e,0f0f0f,101010,111111,121212".to_string(),
        );

        let palette = Palette::from_env_map(&env);

        assert_eq!(palette.base_style().fg, Some(Color::Rgb(0x01, 0x01, 0x01)));
        assert_eq!(palette.base_style().bg, Some(Color::Rgb(0x02, 0x02, 0x02)));
        assert_eq!(
            palette.style_for(SemanticRole::StatusTerminated).fg,
            Some(Color::Rgb(0x04, 0x04, 0x04))
        );
    }

    #[test]
    fn palette_parser_accepts_plain_hex_and_rgb_variants() {
        let palette = Palette::parse_csv(
            "112233,445566,778899,0xAA0000,BBCCDD,rgb:789A/9ABC/BCDE,rgb:12/34/56,#010203,#111213,#212223,#313233,#414243,#515253,#616263,#717273,#818283,#919293,#A1A2A3",
        )
        .expect("palette should parse");

        assert_eq!(palette.base_style().fg, Some(Color::Rgb(0x11, 0x22, 0x33)));
        assert_eq!(palette.base_style().bg, Some(Color::Rgb(0x44, 0x55, 0x66)));
        assert_eq!(
            palette.style_for(SemanticRole::StatusRunning).fg,
            Some(Color::Rgb(0x12, 0x34, 0x56))
        );
        assert_eq!(
            palette.style_for(SemanticRole::HeadingAccent).fg,
            Some(Color::Rgb(0x71, 0x72, 0x73))
        );
    }

    #[test]
    fn empty_palette_override_is_treated_as_absent() {
        let mut env = BTreeMap::new();
        env.insert("ILMARI_TUI_PALETTE".to_string(), "   ".to_string());

        let palette = Palette::from_env_map(&env);

        assert_eq!(palette.base_style().fg, Some(Color::Reset));
        assert_eq!(palette.style_for(SemanticRole::MutedText).fg, Some(Color::Indexed(8)));
    }

    #[test]
    fn malformed_palette_falls_back_cleanly() {
        let mut env = BTreeMap::new();
        env.insert("ILMARI_TUI_PALETTE".to_string(), "#000000,#111111".to_string());

        let palette = Palette::from_env_map(&env);

        assert_eq!(palette.style_for(SemanticRole::StatusFinished).fg, Some(Color::Indexed(2)));
        assert_eq!(palette.base_style().fg, Some(Color::Reset));
    }
}