zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Theme abstraction for the interactive terminal.
//!
//! Phase 0 of the terminal refactor (see `docs/TERMINAL_REFACTOR.md`).
//!
//! Today the colors used by the dashboard live in a private `mod colors`
//! inside `broker::tui` as hardcoded constants. This module replaces that with
//! a first-class [`Theme`] type that:
//!
//! - carries the full palette as `ratatui` [`Color`] values,
//! - ships named built-ins ([`Theme::dark`], [`Theme::light`]),
//! - round-trips to/from TOML via [`ThemeConfig`] (no dependency on ratatui's
//!   optional `serde` feature — colors are stored as strings),
//! - can be loaded from `~/.config/zc/theme.toml` and hot-swapped at runtime.
//!
//! The `dark()` palette intentionally reproduces the exact values previously
//! hardcoded in `broker::tui::colors`, so adopting it is a no-behavior-change
//! refactor.

#![allow(dead_code)]

use std::path::Path;

use ratatui::style::Color;
use serde::{Deserialize, Serialize};

/// A fully-resolved color palette for the terminal UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Theme {
    /// Human-readable name (e.g. "dark", "light", or a custom name).
    pub name: String,
    /// Base background. `Color::Reset` means "use the terminal's own bg".
    pub bg: Color,
    /// Default foreground text.
    pub fg: Color,
    /// Accent / primary highlight (links, selected borders, key figures).
    pub accent: Color,
    /// Success / healthy state.
    pub success: Color,
    /// Warning / degraded state.
    pub warning: Color,
    /// Error / failure state.
    pub error: Color,
    /// Muted / secondary text and dividers.
    pub muted: Color,
    /// Panel borders (unfocused).
    pub border: Color,
    /// Header band background.
    pub header_bg: Color,
    /// Selection / row highlight background.
    pub highlight: Color,
    /// Alternating table-row background (zebra striping).
    pub row_alt: Color,
    /// Modal / overlay background (help, palette).
    pub overlay_bg: Color,
}

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

impl Theme {
    /// The default dark palette. Values match the legacy
    /// `broker::tui::colors` constants exactly, so swapping to this theme is a
    /// pure refactor with no visible change.
    pub fn dark() -> Self {
        Theme {
            name: "dark".to_string(),
            bg: Color::Reset,
            fg: Color::White,
            accent: Color::Cyan,
            success: Color::Green,
            warning: Color::Yellow,
            error: Color::Red,
            muted: Color::DarkGray,
            border: Color::DarkGray,
            header_bg: Color::Rgb(30, 30, 40),
            highlight: Color::Rgb(60, 60, 80),
            row_alt: Color::Rgb(25, 25, 30),
            overlay_bg: Color::Rgb(20, 20, 30),
        }
    }

    /// A light palette suited to light terminal backgrounds.
    pub fn light() -> Self {
        Theme {
            name: "light".to_string(),
            bg: Color::Reset,
            fg: Color::Black,
            accent: Color::Blue,
            success: Color::Green,
            warning: Color::Rgb(176, 124, 0),
            error: Color::Red,
            muted: Color::Gray,
            border: Color::Gray,
            header_bg: Color::Rgb(230, 230, 238),
            highlight: Color::Rgb(210, 215, 235),
            row_alt: Color::Rgb(240, 240, 244),
            overlay_bg: Color::Rgb(245, 245, 250),
        }
    }

    /// Look up a built-in theme by name. Returns `None` for unknown names.
    pub fn named(name: &str) -> Option<Theme> {
        match name.to_ascii_lowercase().as_str() {
            "dark" => Some(Theme::dark()),
            "light" => Some(Theme::light()),
            _ => None,
        }
    }

    /// The names of all built-in themes (for `/theme` completion and help).
    pub fn builtin_names() -> &'static [&'static str] {
        &["dark", "light"]
    }

    /// Load a theme from a TOML file. Any field omitted in the file falls back
    /// to the corresponding [`Theme::dark`] value.
    pub fn load(path: &Path) -> std::io::Result<Theme> {
        let text = std::fs::read_to_string(path)?;
        let cfg: ThemeConfig = toml::from_str(&text)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
        Ok(cfg.into_theme())
    }

    /// Serialize this theme to TOML (for `zc` to write a starter config).
    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
        toml::to_string_pretty(&ThemeConfig::from_theme(self))
    }
}

/// Serializable form of a [`Theme`]. Colors are stored as strings so we don't
/// depend on `ratatui`'s optional `serde` feature. Accepted color syntaxes:
///
/// - `"reset"` — the terminal's own background/foreground
/// - named ANSI: `"black" | "white" | "red" | "green" | "yellow" | "blue" |
///   "magenta" | "cyan" | "gray" | "darkgray"`
/// - hex: `"#1e1e28"` or `"1e1e28"`
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThemeConfig {
    #[serde(default = "default_name")]
    pub name: String,
    #[serde(default)]
    pub bg: Option<String>,
    #[serde(default)]
    pub fg: Option<String>,
    #[serde(default)]
    pub accent: Option<String>,
    #[serde(default)]
    pub success: Option<String>,
    #[serde(default)]
    pub warning: Option<String>,
    #[serde(default)]
    pub error: Option<String>,
    #[serde(default)]
    pub muted: Option<String>,
    #[serde(default)]
    pub border: Option<String>,
    #[serde(default)]
    pub header_bg: Option<String>,
    #[serde(default)]
    pub highlight: Option<String>,
    #[serde(default)]
    pub row_alt: Option<String>,
    #[serde(default)]
    pub overlay_bg: Option<String>,
}

fn default_name() -> String {
    "custom".to_string()
}

impl ThemeConfig {
    /// Resolve into a [`Theme`], falling back to dark defaults for any field
    /// that is missing or fails to parse.
    pub fn into_theme(self) -> Theme {
        let base = Theme::dark();
        let pick = |opt: Option<String>, fallback: Color| -> Color {
            opt.as_deref().and_then(parse_color).unwrap_or(fallback)
        };
        Theme {
            name: self.name,
            bg: pick(self.bg, base.bg),
            fg: pick(self.fg, base.fg),
            accent: pick(self.accent, base.accent),
            success: pick(self.success, base.success),
            warning: pick(self.warning, base.warning),
            error: pick(self.error, base.error),
            muted: pick(self.muted, base.muted),
            border: pick(self.border, base.border),
            header_bg: pick(self.header_bg, base.header_bg),
            highlight: pick(self.highlight, base.highlight),
            row_alt: pick(self.row_alt, base.row_alt),
            overlay_bg: pick(self.overlay_bg, base.overlay_bg),
        }
    }

    /// Build a config from a resolved theme (for serialization).
    pub fn from_theme(t: &Theme) -> ThemeConfig {
        ThemeConfig {
            name: t.name.clone(),
            bg: Some(color_to_string(t.bg)),
            fg: Some(color_to_string(t.fg)),
            accent: Some(color_to_string(t.accent)),
            success: Some(color_to_string(t.success)),
            warning: Some(color_to_string(t.warning)),
            error: Some(color_to_string(t.error)),
            muted: Some(color_to_string(t.muted)),
            border: Some(color_to_string(t.border)),
            header_bg: Some(color_to_string(t.header_bg)),
            highlight: Some(color_to_string(t.highlight)),
            row_alt: Some(color_to_string(t.row_alt)),
            overlay_bg: Some(color_to_string(t.overlay_bg)),
        }
    }
}

/// Parse a color string (named, `reset`, or hex) into a ratatui [`Color`].
pub fn parse_color(s: &str) -> Option<Color> {
    let s = s.trim();
    match s.to_ascii_lowercase().as_str() {
        "reset" => return Some(Color::Reset),
        "black" => return Some(Color::Black),
        "red" => return Some(Color::Red),
        "green" => return Some(Color::Green),
        "yellow" => return Some(Color::Yellow),
        "blue" => return Some(Color::Blue),
        "magenta" => return Some(Color::Magenta),
        "cyan" => return Some(Color::Cyan),
        "gray" | "grey" => return Some(Color::Gray),
        "darkgray" | "darkgrey" => return Some(Color::DarkGray),
        "white" => return Some(Color::White),
        _ => {}
    }
    let hex = s.strip_prefix('#').unwrap_or(s);
    if hex.len() == 6 {
        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
        return Some(Color::Rgb(r, g, b));
    }
    None
}

/// Render a [`Color`] back to a string understood by [`parse_color`].
pub fn color_to_string(c: Color) -> String {
    match c {
        Color::Reset => "reset".to_string(),
        Color::Black => "black".to_string(),
        Color::Red => "red".to_string(),
        Color::Green => "green".to_string(),
        Color::Yellow => "yellow".to_string(),
        Color::Blue => "blue".to_string(),
        Color::Magenta => "magenta".to_string(),
        Color::Cyan => "cyan".to_string(),
        Color::Gray => "gray".to_string(),
        Color::DarkGray => "darkgray".to_string(),
        Color::White => "white".to_string(),
        Color::Rgb(r, g, b) => format!("#{:02x}{:02x}{:02x}", r, g, b),
        Color::Indexed(i) => format!("indexed:{}", i),
        other => format!("{:?}", other),
    }
}

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

    #[test]
    fn dark_matches_legacy_constants() {
        let t = Theme::dark();
        assert_eq!(t.accent, Color::Cyan);
        assert_eq!(t.muted, Color::DarkGray);
        assert_eq!(t.header_bg, Color::Rgb(30, 30, 40));
        assert_eq!(t.highlight, Color::Rgb(60, 60, 80));
        assert_eq!(t.row_alt, Color::Rgb(25, 25, 30));
        assert_eq!(t.overlay_bg, Color::Rgb(20, 20, 30));
    }

    #[test]
    fn parse_color_forms() {
        assert_eq!(parse_color("reset"), Some(Color::Reset));
        assert_eq!(parse_color("cyan"), Some(Color::Cyan));
        assert_eq!(parse_color("#1e1e28"), Some(Color::Rgb(0x1e, 0x1e, 0x28)));
        assert_eq!(parse_color("1e1e28"), Some(Color::Rgb(0x1e, 0x1e, 0x28)));
        assert_eq!(parse_color("nope"), None);
    }

    #[test]
    fn toml_round_trip() {
        let t = Theme::dark();
        let toml_str = t.to_toml().expect("serialize");
        let cfg: ThemeConfig = toml::from_str(&toml_str).expect("deserialize");
        assert_eq!(cfg.into_theme(), t);
    }

    #[test]
    fn missing_fields_fall_back_to_dark() {
        let cfg: ThemeConfig =
            toml::from_str("name = \"partial\"\naccent = \"#ff0000\"\n").expect("deserialize");
        let t = cfg.into_theme();
        assert_eq!(t.name, "partial");
        assert_eq!(t.accent, Color::Rgb(0xff, 0, 0));
        // Untouched fields keep dark defaults.
        assert_eq!(t.muted, Theme::dark().muted);
    }

    #[test]
    fn named_lookup() {
        assert!(Theme::named("dark").is_some());
        assert!(Theme::named("LIGHT").is_some());
        assert!(Theme::named("solarized").is_none());
    }
}