Skip to main content

remendo/config/
theme.rs

1//! Theme and colorscheme configuration.
2//!
3//! Defines semantic color roles for the TUI, with support for
4//! hex (`#rrggbb`) and named color strings.
5
6use ratatui::style::Color;
7use serde::{Deserialize, Deserializer};
8use std::fmt;
9
10/// A color value that deserializes from hex or named color strings.
11///
12/// Converts to [`ratatui::style::Color`] for rendering.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ColorValue(pub Color);
15
16impl ColorValue {
17    /// Get the underlying ratatui color.
18    #[must_use]
19    pub fn color(&self) -> Color {
20        self.0
21    }
22}
23
24impl Default for ColorValue {
25    fn default() -> Self {
26        Self(Color::Reset)
27    }
28}
29
30impl fmt::Display for ColorValue {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "{:?}", self.0)
33    }
34}
35
36/// Parse a color string into a [`Color`].
37///
38/// Supports hex (`#rrggbb`) and named colors (`red`, `blue`, etc.).
39fn parse_color(s: &str) -> Color {
40    let s = s.trim();
41
42    // Hex color: #rrggbb
43    if let Some(hex) = s.strip_prefix('#') {
44        if hex.len() == 6
45            && let (Ok(r), Ok(g), Ok(b)) = (
46                u8::from_str_radix(&hex[0..2], 16),
47                u8::from_str_radix(&hex[2..4], 16),
48                u8::from_str_radix(&hex[4..6], 16),
49            )
50        {
51            return Color::Rgb(r, g, b);
52        }
53        tracing::warn!(color = s, "invalid hex color, using Reset");
54        return Color::Reset;
55    }
56
57    // Named colors
58    match s.to_lowercase().as_str() {
59        "black" => Color::Black,
60        "red" => Color::Red,
61        "green" => Color::Green,
62        "yellow" => Color::Yellow,
63        "blue" => Color::Blue,
64        "magenta" => Color::Magenta,
65        "cyan" => Color::Cyan,
66        "white" => Color::White,
67        "gray" | "grey" => Color::Gray,
68        "dark_gray" | "dark_grey" | "darkgray" | "darkgrey" => Color::DarkGray,
69        "light_red" | "lightred" => Color::LightRed,
70        "light_green" | "lightgreen" => Color::LightGreen,
71        "light_yellow" | "lightyellow" => Color::LightYellow,
72        "light_blue" | "lightblue" => Color::LightBlue,
73        "light_magenta" | "lightmagenta" => Color::LightMagenta,
74        "light_cyan" | "lightcyan" => Color::LightCyan,
75        "reset" => Color::Reset,
76        _ => {
77            tracing::warn!(color = s, "unknown color name, using Reset");
78            Color::Reset
79        }
80    }
81}
82
83impl<'de> Deserialize<'de> for ColorValue {
84    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85    where
86        D: Deserializer<'de>,
87    {
88        let s = String::deserialize(deserializer)?;
89        Ok(Self(parse_color(&s)))
90    }
91}
92
93/// Semantic color palette for the TUI.
94///
95/// Each field represents a semantic role, not a raw ANSI code.
96#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
97#[serde(default)]
98pub struct ColorPalette {
99    /// Default text color.
100    pub foreground: ColorValue,
101    /// Default background color.
102    pub background: ColorValue,
103    /// Accent color for highlights and active elements.
104    pub accent: ColorValue,
105    /// Color for error messages and critical findings.
106    pub error: ColorValue,
107    /// Color for warnings and medium-severity findings.
108    pub warning: ColorValue,
109    /// Color for success indicators.
110    pub success: ColorValue,
111    /// Color for informational text.
112    pub info: ColorValue,
113    /// Background color for selected items.
114    pub selected_bg: ColorValue,
115    /// Text color for selected items.
116    pub selected_fg: ColorValue,
117    /// Border and separator color.
118    pub border: ColorValue,
119    /// Muted/dimmed text color.
120    pub muted: ColorValue,
121}
122
123impl Default for ColorPalette {
124    fn default() -> Self {
125        Self {
126            foreground: ColorValue(Color::Rgb(0xd8, 0xd8, 0xd8)),
127            background: ColorValue(Color::Rgb(0x18, 0x18, 0x18)),
128            accent: ColorValue(Color::Rgb(0x6a, 0x9f, 0xb5)),
129            error: ColorValue(Color::Rgb(0xac, 0x42, 0x42)),
130            warning: ColorValue(Color::Rgb(0xf4, 0xbf, 0x75)),
131            success: ColorValue(Color::Rgb(0x90, 0xa9, 0x59)),
132            info: ColorValue(Color::Rgb(0x75, 0xb5, 0xaa)),
133            selected_bg: ColorValue(Color::Rgb(0x38, 0x38, 0x38)),
134            selected_fg: ColorValue(Color::Rgb(0xf8, 0xf8, 0xf8)),
135            border: ColorValue(Color::Rgb(0x58, 0x58, 0x58)),
136            muted: ColorValue(Color::Rgb(0x6b, 0x6b, 0x6b)),
137        }
138    }
139}
140
141/// Color theme configuration for the TUI.
142#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
143#[serde(default)]
144pub struct ThemeConfig {
145    /// The semantic color palette.
146    pub colors: ColorPalette,
147}
148
149#[cfg(test)]
150#[allow(clippy::expect_used)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parse_hex_color() {
156        let color = parse_color("#6a9fb5");
157        assert_eq!(color, Color::Rgb(106, 159, 181));
158    }
159
160    #[test]
161    fn parse_named_color() {
162        assert_eq!(parse_color("red"), Color::Red);
163        assert_eq!(parse_color("blue"), Color::Blue);
164        assert_eq!(parse_color("green"), Color::Green);
165        assert_eq!(parse_color("dark_gray"), Color::DarkGray);
166    }
167
168    #[test]
169    fn parse_invalid_color_falls_back() {
170        assert_eq!(parse_color("not_a_color"), Color::Reset);
171        assert_eq!(parse_color("#xyz"), Color::Reset);
172    }
173
174    #[test]
175    fn default_palette_has_dark_theme() {
176        let palette = ColorPalette::default();
177        assert_eq!(palette.background, ColorValue(Color::Rgb(0x18, 0x18, 0x18)));
178        assert_eq!(palette.accent, ColorValue(Color::Rgb(0x6a, 0x9f, 0xb5)));
179    }
180
181    #[test]
182    fn deserialize_theme_from_toml() {
183        let toml_str = r##"
184            [colors]
185            accent = "#ff0000"
186            error = "red"
187        "##;
188        let theme: ThemeConfig = toml::from_str(toml_str).expect("parse theme");
189        assert_eq!(theme.colors.accent, ColorValue(Color::Rgb(255, 0, 0)));
190        assert_eq!(theme.colors.error, ColorValue(Color::Red));
191        // Non-overridden fields keep defaults
192        assert_eq!(
193            theme.colors.background,
194            ColorValue(Color::Rgb(0x18, 0x18, 0x18))
195        );
196    }
197
198    #[test]
199    fn color_value_display() {
200        let cv = ColorValue(Color::Red);
201        let display = cv.to_string();
202        assert!(!display.is_empty());
203    }
204}