Skip to main content

retch_cli/
theme.rs

1use owo_colors::{OwoColorize, Rgb};
2
3/// Parse a color name or hex string into an RGB value.
4///
5/// Supports named colors (e.g., "blue", "bright_red") and hex values
6/// (e.g., "#ff6432" or "ff6432").
7pub fn parse_color(input: &str) -> Option<Rgb> {
8    let s = input.trim();
9
10    // Hex color support
11    if s.starts_with('#') || s.len() == 6 {
12        let hex = s.strip_prefix('#').unwrap_or(s);
13        if hex.len() == 6 {
14            if let (Ok(r), Ok(g), Ok(b)) = (
15                u8::from_str_radix(&hex[0..2], 16),
16                u8::from_str_radix(&hex[2..4], 16),
17                u8::from_str_radix(&hex[4..6], 16),
18            ) {
19                return Some(Rgb(r, g, b));
20            }
21        }
22    }
23
24    // Named colors
25    match s.to_lowercase().as_str() {
26        "black" => Some(Rgb(0, 0, 0)),
27        "red" => Some(Rgb(255, 0, 0)),
28        "green" => Some(Rgb(0, 255, 0)),
29        "yellow" => Some(Rgb(255, 255, 0)),
30        "blue" => Some(Rgb(0, 0, 255)),
31        "magenta" => Some(Rgb(255, 0, 255)),
32        "cyan" => Some(Rgb(0, 255, 255)),
33        "white" => Some(Rgb(255, 255, 255)),
34        "bright_black" | "grey" | "gray" => Some(Rgb(128, 128, 128)),
35        "bright_red" => Some(Rgb(255, 128, 128)),
36        "bright_green" => Some(Rgb(128, 255, 128)),
37        "bright_yellow" => Some(Rgb(255, 255, 128)),
38        "bright_blue" => Some(Rgb(128, 128, 255)),
39        "bright_magenta" => Some(Rgb(255, 128, 255)),
40        "bright_cyan" => Some(Rgb(128, 255, 255)),
41        "bright_white" => Some(Rgb(255, 255, 255)),
42        _ => None,
43    }
44}
45
46/// A collection of colors used for terminal output.
47///
48/// Themes define how various parts of the system information fetch
49/// (labels, values, titles, etc.) are colored in the terminal.
50#[derive(Debug, Clone)]
51pub struct Theme {
52    /// The unique name of the theme.
53    pub name: String,
54    /// Color for field labels (e.g., "OS", "CPU").
55    pub label_color: Rgb,
56    /// Color for field values (e.g., "Fedora Linux", "Intel i7").
57    pub value_color: Rgb,
58    /// Color for accents and highlights.
59    pub accent_color: Rgb,
60    /// Color for the title/username line.
61    pub title_color: Rgb,
62    /// Color for separators like ":" or "---".
63    pub separator_color: Rgb,
64}
65
66impl Default for Theme {
67    fn default() -> Self {
68        Self {
69            name: "neutral".to_string(),
70            label_color: Rgb(0, 255, 255),       // Cyan
71            value_color: Rgb(255, 255, 255),     // White
72            accent_color: Rgb(0, 255, 0),        // Green
73            title_color: Rgb(255, 255, 0),       // Yellow
74            separator_color: Rgb(128, 128, 128), // BrightBlack / Gray
75        }
76    }
77}
78
79impl Theme {
80    /// Returns the built-in neutral theme.
81    pub fn neutral() -> Self {
82        Self::default()
83    }
84
85    /// Alias for `neutral()` for backward compatibility.
86    pub fn new_default() -> Self {
87        Self::neutral()
88    }
89
90    /// Returns the built-in dark theme.
91    pub fn dark() -> Self {
92        Self {
93            name: "dark".to_string(),
94            label_color: Rgb(128, 128, 255),     // Bright Blue
95            value_color: Rgb(255, 255, 255),     // Bright White
96            accent_color: Rgb(128, 255, 128),    // Bright Green
97            title_color: Rgb(255, 255, 128),     // Bright Yellow
98            separator_color: Rgb(128, 128, 128), // Bright Black / Gray
99        }
100    }
101
102    /// Returns the built-in light theme.
103    pub fn light() -> Self {
104        Self {
105            name: "light".to_string(),
106            label_color: Rgb(0, 0, 255),     // Blue
107            value_color: Rgb(0, 0, 0),       // Black
108            accent_color: Rgb(0, 255, 0),    // Green
109            title_color: Rgb(255, 255, 128), // Bright Yellow
110            separator_color: Rgb(0, 0, 0),   // Black
111        }
112    }
113
114    // === Popular Community Themes ===
115
116    /// Returns the Catppuccin Latte (light) theme.
117    pub fn catppuccin_latte() -> Self {
118        Self {
119            name: "catppuccin-latte".to_string(),
120            label_color: Rgb(30, 102, 245),      // Blue      #1e66f5
121            value_color: Rgb(76, 79, 105),       // Text      #4c4f69
122            accent_color: Rgb(64, 160, 43),      // Green     #40a02b
123            title_color: Rgb(223, 142, 29),      // Yellow    #df8e1d
124            separator_color: Rgb(140, 143, 161), // Overlay0  #8c8fa1
125        }
126    }
127
128    /// Returns the Catppuccin Frappe theme.
129    pub fn catppuccin_frappe() -> Self {
130        Self {
131            name: "catppuccin-frappe".to_string(),
132            label_color: Rgb(137, 180, 250),    // Blue      #89b4fa
133            value_color: Rgb(198, 208, 245),    // Text      #c6d0f5
134            accent_color: Rgb(166, 227, 161),   // Green     #a6e3a1
135            title_color: Rgb(249, 226, 175),    // Yellow    #f9e2af
136            separator_color: Rgb(98, 104, 128), // Overlay0  #626880
137        }
138    }
139
140    /// Returns the Catppuccin Macchiato theme.
141    pub fn catppuccin_macchiato() -> Self {
142        Self {
143            name: "catppuccin-macchiato".to_string(),
144            label_color: Rgb(138, 173, 244),   // Blue      #8aadf4
145            value_color: Rgb(202, 211, 245),   // Text      #cad3f5
146            accent_color: Rgb(166, 218, 149),  // Green     #a6da95
147            title_color: Rgb(238, 212, 159),   // Yellow    #eed6af
148            separator_color: Rgb(91, 96, 120), // Overlay0  #5b6078
149        }
150    }
151
152    /// Returns the Catppuccin Mocha theme.
153    pub fn catppuccin_mocha() -> Self {
154        Self {
155            name: "catppuccin-mocha".to_string(),
156            label_color: Rgb(137, 180, 250),     // Blue      #89b4fa
157            value_color: Rgb(205, 214, 244),     // Text      #cdd6f4
158            accent_color: Rgb(245, 194, 231),    // Pink      #f5c2e7
159            title_color: Rgb(249, 226, 175),     // Yellow    #f9e2af
160            separator_color: Rgb(108, 112, 134), // Overlay0  #6c7086
161        }
162    }
163
164    /// Returns the Solarized Light theme.
165    pub fn solarized_light() -> Self {
166        Self {
167            name: "solarized-light".to_string(),
168            label_color: Rgb(38, 139, 210),      // blue      #268bd2
169            value_color: Rgb(101, 123, 131),     // base00    #657b83
170            accent_color: Rgb(181, 137, 0),      // yellow    #b58900
171            title_color: Rgb(203, 75, 22),       // orange    #cb4b16
172            separator_color: Rgb(147, 161, 161), // base1     #93a1a1
173        }
174    }
175
176    /// Returns the Solarized Dark theme.
177    pub fn solarized_dark() -> Self {
178        Self {
179            name: "solarized-dark".to_string(),
180            label_color: Rgb(38, 139, 210),      // blue      #268bd2
181            value_color: Rgb(131, 148, 150),     // base0     #839496
182            accent_color: Rgb(181, 137, 0),      // yellow    #b58900
183            title_color: Rgb(203, 75, 22),       // orange    #cb4b16
184            separator_color: Rgb(147, 161, 161), // base1     #93a1a1
185        }
186    }
187
188    /// Look up a built-in theme by its name.
189    pub fn from_name(name: &str) -> Self {
190        match name.to_lowercase().replace('_', "-").as_str() {
191            "dark" => Self::dark(),
192            "light" => Self::light(),
193            "neutral" | "default" => Self::neutral(), // "default" kept for backward compat
194            "custom" => Self::default(),
195            "auto" => Self::detect_system_theme(),
196            "catppuccin-latte" | "catppuccin_latte" | "latte" => Self::catppuccin_latte(),
197            "catppuccin-frappe" | "catppuccin_frappe" | "frappe" => Self::catppuccin_frappe(),
198            "catppuccin-macchiato" | "catppuccin_macchiato" | "macchiato" => {
199                Self::catppuccin_macchiato()
200            }
201            "catppuccin-mocha" | "catppuccin_mocha" | "mocha" => Self::catppuccin_mocha(),
202            "solarized-light" | "solarized_light" => Self::solarized_light(),
203            "solarized-dark" | "solarized_dark" => Self::solarized_dark(),
204            _ => Self::default(),
205        }
206    }
207
208    /// Detect system dark/light preference (currently supports GTK settings).
209    ///
210    /// Falls back to `Self::dark()` if detection fails.
211    pub fn detect_system_theme() -> Self {
212        // Try to read GTK settings
213        if let Some(config_dir) = dirs::config_dir() {
214            let gtk_settings = config_dir.join("gtk-3.0").join("settings.ini");
215            if gtk_settings.exists() {
216                if let Ok(contents) = std::fs::read_to_string(&gtk_settings) {
217                    for line in contents.lines() {
218                        if line.to_lowercase().contains("prefer-dark-theme") {
219                            if line.to_lowercase().contains("true") {
220                                return Self::dark();
221                            } else {
222                                return Self::light();
223                            }
224                        }
225                    }
226                }
227            }
228        }
229        // Default fallback
230        Self::dark()
231    }
232
233    /// Build a new theme by applying custom color overrides to an existing base theme.
234    pub fn with_custom_overrides(base: Self, custom: &crate::config::CustomTheme) -> Self {
235        let mut theme = base;
236
237        if let Some(color) = &custom.label_color {
238            if let Some(c) = parse_color(color) {
239                theme.label_color = c;
240            }
241        }
242        if let Some(color) = &custom.value_color {
243            if let Some(c) = parse_color(color) {
244                theme.value_color = c;
245            }
246        }
247        if let Some(color) = &custom.accent_color {
248            if let Some(c) = parse_color(color) {
249                theme.accent_color = c;
250            }
251        }
252        if let Some(color) = &custom.title_color {
253            if let Some(c) = parse_color(color) {
254                theme.title_color = c;
255            }
256        }
257        if let Some(color) = &custom.separator_color {
258            if let Some(c) = parse_color(color) {
259                theme.separator_color = c;
260            }
261        }
262
263        theme
264    }
265
266    /// Apply the theme's label color to the given text.
267    pub fn color_label(&self, text: &str) -> String {
268        text.color(self.label_color).to_string()
269    }
270
271    /// Apply the theme's value color to the given text.
272    pub fn color_value(&self, text: &str) -> String {
273        text.color(self.value_color).to_string()
274    }
275
276    /// Apply the theme's accent color to the given text.
277    pub fn color_accent(&self, text: &str) -> String {
278        text.color(self.accent_color).to_string()
279    }
280
281    /// Apply the theme's separator color to the given text.
282    pub fn color_separator(&self, text: &str) -> String {
283        text.color(self.separator_color).to_string()
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_neutral_theme() {
293        let theme = Theme::neutral();
294        assert_eq!(theme.name, "neutral");
295        assert_eq!(theme.label_color, Rgb(0, 255, 255));
296        assert_eq!(theme.value_color, Rgb(255, 255, 255));
297        assert_eq!(theme.accent_color, Rgb(0, 255, 0));
298        assert_eq!(theme.title_color, Rgb(255, 255, 0));
299    }
300
301    #[test]
302    fn test_dark_theme() {
303        let theme = Theme::dark();
304        assert_eq!(theme.name, "dark");
305        assert_eq!(theme.label_color, Rgb(128, 128, 255));
306        assert_eq!(theme.title_color, Rgb(255, 255, 128));
307    }
308
309    #[test]
310    fn test_light_theme() {
311        let theme = Theme::light();
312        assert_eq!(theme.name, "light");
313        assert_eq!(theme.label_color, Rgb(0, 0, 255));
314        assert_eq!(theme.title_color, Rgb(255, 255, 128));
315    }
316
317    #[test]
318    fn test_new_default() {
319        let theme = Theme::new_default();
320        assert_eq!(theme.name, "neutral");
321    }
322}