Skip to main content

retch_cli/
theme.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Color theming and styling.
5//!
6//! Defines color palettes and provides functionality for applying
7//! colors to the output text.
8
9use owo_colors::{OwoColorize, Rgb};
10
11/// Parse a color name or hex string into an RGB value.
12///
13/// Supports named colors (e.g., "blue", "bright_red") and hex values
14/// (e.g., "#ff6432" or "ff6432").
15pub fn parse_color(input: &str) -> Option<Rgb> {
16    let s = input.trim();
17
18    // Hex color support
19    if s.starts_with('#') || s.len() == 6 {
20        let hex = s.strip_prefix('#').unwrap_or(s);
21        if hex.len() == 6 {
22            if let (Ok(r), Ok(g), Ok(b)) = (
23                u8::from_str_radix(&hex[0..2], 16),
24                u8::from_str_radix(&hex[2..4], 16),
25                u8::from_str_radix(&hex[4..6], 16),
26            ) {
27                return Some(Rgb(r, g, b));
28            }
29        }
30    }
31
32    // Named colors
33    match s.to_lowercase().as_str() {
34        "black" => Some(Rgb(0, 0, 0)),
35        "red" => Some(Rgb(255, 0, 0)),
36        "green" => Some(Rgb(0, 255, 0)),
37        "yellow" => Some(Rgb(255, 255, 0)),
38        "blue" => Some(Rgb(0, 0, 255)),
39        "magenta" => Some(Rgb(255, 0, 255)),
40        "cyan" => Some(Rgb(0, 255, 255)),
41        "white" => Some(Rgb(255, 255, 255)),
42        "bright_black" | "grey" | "gray" => Some(Rgb(128, 128, 128)),
43        "bright_red" => Some(Rgb(255, 128, 128)),
44        "bright_green" => Some(Rgb(128, 255, 128)),
45        "bright_yellow" => Some(Rgb(255, 255, 128)),
46        "bright_blue" => Some(Rgb(128, 128, 255)),
47        "bright_magenta" => Some(Rgb(255, 128, 255)),
48        "bright_cyan" => Some(Rgb(128, 255, 255)),
49        "bright_white" => Some(Rgb(255, 255, 255)),
50        _ => None,
51    }
52}
53
54/// A collection of colors used for terminal output.
55///
56/// Themes define how various parts of the system information fetch
57/// (labels, values, titles, etc.) are colored in the terminal.
58#[derive(Debug, Clone)]
59pub struct Theme {
60    /// The unique name of the theme.
61    pub name: String,
62    /// Color for field labels (e.g., "OS", "CPU").
63    pub label_color: Rgb,
64    /// Color for field values (e.g., "Fedora Linux", "Intel i7").
65    pub value_color: Rgb,
66    /// Color for accents and highlights.
67    pub accent_color: Rgb,
68    /// Color for the title/username line.
69    pub title_color: Rgb,
70    /// Color for separators like ":" or "---".
71    pub separator_color: Rgb,
72}
73
74impl Default for Theme {
75    fn default() -> Self {
76        Self {
77            name: "neutral".to_string(),
78            label_color: Rgb(0, 255, 255),       // Cyan
79            value_color: Rgb(255, 255, 255),     // White
80            accent_color: Rgb(0, 255, 0),        // Green
81            title_color: Rgb(255, 255, 0),       // Yellow
82            separator_color: Rgb(128, 128, 128), // BrightBlack / Gray
83        }
84    }
85}
86
87impl Theme {
88    /// Returns the built-in neutral theme.
89    pub fn neutral() -> Self {
90        Self::default()
91    }
92
93    /// Alias for `neutral()` for backward compatibility.
94    pub fn new_default() -> Self {
95        Self::neutral()
96    }
97
98    /// Returns the built-in dark theme.
99    pub fn dark() -> Self {
100        Self {
101            name: "dark".to_string(),
102            label_color: Rgb(128, 128, 255),     // Bright Blue
103            value_color: Rgb(255, 255, 255),     // Bright White
104            accent_color: Rgb(128, 255, 128),    // Bright Green
105            title_color: Rgb(255, 255, 128),     // Bright Yellow
106            separator_color: Rgb(128, 128, 128), // Bright Black / Gray
107        }
108    }
109
110    /// Returns the built-in light theme.
111    pub fn light() -> Self {
112        Self {
113            name: "light".to_string(),
114            label_color: Rgb(0, 0, 255),     // Blue
115            value_color: Rgb(0, 0, 0),       // Black
116            accent_color: Rgb(0, 255, 0),    // Green
117            title_color: Rgb(255, 255, 128), // Bright Yellow
118            separator_color: Rgb(0, 0, 0),   // Black
119        }
120    }
121
122    // === Popular Community Themes ===
123
124    /// Returns the Catppuccin Latte (light) theme.
125    pub fn catppuccin_latte() -> Self {
126        Self {
127            name: "catppuccin-latte".to_string(),
128            label_color: Rgb(30, 102, 245),      // Blue      #1e66f5
129            value_color: Rgb(76, 79, 105),       // Text      #4c4f69
130            accent_color: Rgb(64, 160, 43),      // Green     #40a02b
131            title_color: Rgb(223, 142, 29),      // Yellow    #df8e1d
132            separator_color: Rgb(140, 143, 161), // Overlay0  #8c8fa1
133        }
134    }
135
136    /// Returns the Catppuccin Frappe theme.
137    pub fn catppuccin_frappe() -> Self {
138        Self {
139            name: "catppuccin-frappe".to_string(),
140            label_color: Rgb(137, 180, 250),    // Blue      #89b4fa
141            value_color: Rgb(198, 208, 245),    // Text      #c6d0f5
142            accent_color: Rgb(166, 227, 161),   // Green     #a6e3a1
143            title_color: Rgb(249, 226, 175),    // Yellow    #f9e2af
144            separator_color: Rgb(98, 104, 128), // Overlay0  #626880
145        }
146    }
147
148    /// Returns the Catppuccin Macchiato theme.
149    pub fn catppuccin_macchiato() -> Self {
150        Self {
151            name: "catppuccin-macchiato".to_string(),
152            label_color: Rgb(138, 173, 244),   // Blue      #8aadf4
153            value_color: Rgb(202, 211, 245),   // Text      #cad3f5
154            accent_color: Rgb(166, 218, 149),  // Green     #a6da95
155            title_color: Rgb(238, 212, 159),   // Yellow    #eed6af
156            separator_color: Rgb(91, 96, 120), // Overlay0  #5b6078
157        }
158    }
159
160    /// Returns the Catppuccin Mocha theme.
161    pub fn catppuccin_mocha() -> Self {
162        Self {
163            name: "catppuccin-mocha".to_string(),
164            label_color: Rgb(137, 180, 250),     // Blue      #89b4fa
165            value_color: Rgb(205, 214, 244),     // Text      #cdd6f4
166            accent_color: Rgb(245, 194, 231),    // Pink      #f5c2e7
167            title_color: Rgb(249, 226, 175),     // Yellow    #f9e2af
168            separator_color: Rgb(108, 112, 134), // Overlay0  #6c7086
169        }
170    }
171
172    /// Returns the Solarized Light theme.
173    pub fn solarized_light() -> Self {
174        Self {
175            name: "solarized-light".to_string(),
176            label_color: Rgb(38, 139, 210),      // blue      #268bd2
177            value_color: Rgb(101, 123, 131),     // base00    #657b83
178            accent_color: Rgb(181, 137, 0),      // yellow    #b58900
179            title_color: Rgb(203, 75, 22),       // orange    #cb4b16
180            separator_color: Rgb(147, 161, 161), // base1     #93a1a1
181        }
182    }
183
184    /// Returns the Solarized Dark theme.
185    pub fn solarized_dark() -> Self {
186        Self {
187            name: "solarized-dark".to_string(),
188            label_color: Rgb(38, 139, 210),      // blue      #268bd2
189            value_color: Rgb(131, 148, 150),     // base0     #839496
190            accent_color: Rgb(181, 137, 0),      // yellow    #b58900
191            title_color: Rgb(203, 75, 22),       // orange    #cb4b16
192            separator_color: Rgb(147, 161, 161), // base1     #93a1a1
193        }
194    }
195
196    /// Look up a built-in theme by its name.
197    pub fn from_name(name: &str) -> Self {
198        match name.to_lowercase().replace('_', "-").as_str() {
199            "dark" => Self::dark(),
200            "light" => Self::light(),
201            "neutral" | "default" => Self::neutral(), // "default" kept for backward compat
202            "custom" => Self::default(),
203            "auto" => Self::detect_system_theme(),
204            "catppuccin-latte" | "catppuccin_latte" | "latte" => Self::catppuccin_latte(),
205            "catppuccin-frappe" | "catppuccin_frappe" | "frappe" => Self::catppuccin_frappe(),
206            "catppuccin-macchiato" | "catppuccin_macchiato" | "macchiato" => {
207                Self::catppuccin_macchiato()
208            }
209            "catppuccin-mocha" | "catppuccin_mocha" | "mocha" => Self::catppuccin_mocha(),
210            "solarized-light" | "solarized_light" => Self::solarized_light(),
211            "solarized-dark" | "solarized_dark" => Self::solarized_dark(),
212            _ => Self::default(),
213        }
214    }
215
216    /// Detect system dark/light preference (currently supports GTK settings).
217    ///
218    /// Falls back to `Self::neutral()` when headless, `Self::dark()` when a display is present but no GTK preference is found.
219    pub fn detect_system_theme() -> Self {
220        // Skip GTK detection when running over SSH/mosh or without a display server.
221        // Shell profiles often set $DISPLAY even in SSH sessions, so check SSH vars too.
222        let is_ssh = std::env::var_os("SSH_CLIENT").is_some()
223            || std::env::var_os("SSH_TTY").is_some()
224            || std::env::var_os("SSH_CONNECTION").is_some();
225        let has_display =
226            std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some();
227        if is_ssh || !has_display {
228            return Self::neutral();
229        }
230
231        if let Some(config_dir) = dirs::config_dir() {
232            // GTK / GNOME: gtk-3.0/settings.ini
233            let gtk_settings = config_dir.join("gtk-3.0").join("settings.ini");
234            if gtk_settings.exists() {
235                if let Ok(contents) = std::fs::read_to_string(&gtk_settings) {
236                    for line in contents.lines() {
237                        if line.to_lowercase().contains("prefer-dark-theme") {
238                            if line.to_lowercase().contains("true") {
239                                return Self::dark();
240                            } else {
241                                return Self::light();
242                            }
243                        }
244                    }
245                }
246            }
247
248            // KDE / Plasma: kdeglobals [Colors:Window] BackgroundNormal luminance
249            let kdeglobals = config_dir.join("kdeglobals");
250            if kdeglobals.exists() {
251                if let Ok(contents) = std::fs::read_to_string(&kdeglobals) {
252                    let mut in_colors_window = false;
253                    for line in contents.lines() {
254                        let trimmed = line.trim();
255                        if trimmed.starts_with('[') {
256                            in_colors_window = trimmed == "[Colors:Window]";
257                            continue;
258                        }
259                        if in_colors_window {
260                            if let Some(val) = trimmed.strip_prefix("BackgroundNormal=") {
261                                let rgb: Vec<u8> = val
262                                    .split(',')
263                                    .filter_map(|s| s.trim().parse().ok())
264                                    .collect();
265                                if rgb.len() == 3 {
266                                    let luminance = rgb[0] as f32 * 0.299
267                                        + rgb[1] as f32 * 0.587
268                                        + rgb[2] as f32 * 0.114;
269                                    return if luminance < 128.0 {
270                                        Self::dark()
271                                    } else {
272                                        Self::light()
273                                    };
274                                }
275                            }
276                        }
277                    }
278                }
279            }
280        }
281        // No preference detected
282        Self::neutral()
283    }
284
285    /// Build a new theme by applying custom color overrides to an existing base theme.
286    pub fn with_custom_overrides(base: Self, custom: &crate::config::CustomTheme) -> Self {
287        let mut theme = base;
288
289        if let Some(color) = &custom.label_color {
290            if let Some(c) = parse_color(color) {
291                theme.label_color = c;
292            }
293        }
294        if let Some(color) = &custom.value_color {
295            if let Some(c) = parse_color(color) {
296                theme.value_color = c;
297            }
298        }
299        if let Some(color) = &custom.accent_color {
300            if let Some(c) = parse_color(color) {
301                theme.accent_color = c;
302            }
303        }
304        if let Some(color) = &custom.title_color {
305            if let Some(c) = parse_color(color) {
306                theme.title_color = c;
307            }
308        }
309        if let Some(color) = &custom.separator_color {
310            if let Some(c) = parse_color(color) {
311                theme.separator_color = c;
312            }
313        }
314
315        theme
316    }
317
318    /// Apply the theme's label color to the given text.
319    pub fn color_label(&self, text: &str) -> String {
320        text.color(self.label_color).to_string()
321    }
322
323    /// Apply the theme's value color to the given text.
324    ///
325    /// Uses [`colorize_nested`] rather than a plain `owo_colors` wrap so that a
326    /// value string containing a *nested* colored span (e.g. the green `Up` /
327    /// red `Down` inside a network line) keeps the value color for the text that
328    /// follows the nested span, instead of dropping to the terminal default.
329    pub fn color_value(&self, text: &str) -> String {
330        colorize_nested(text, &rgb_prefix(self.value_color))
331    }
332
333    /// Apply the theme's accent color to the given text.
334    pub fn color_accent(&self, text: &str) -> String {
335        text.color(self.accent_color).to_string()
336    }
337
338    /// Apply the theme's separator color to the given text.
339    pub fn color_separator(&self, text: &str) -> String {
340        text.color(self.separator_color).to_string()
341    }
342}
343
344/// ANSI escape that resets the foreground color to the terminal default.
345///
346/// This is the closer `owo_colors` emits for *every* foreground color
347/// (`\x1b[32m…\x1b[39m` for green, `\x1b[38;2;…m…\x1b[39m` for truecolor, etc.),
348/// which is why it is the hook [`colorize_nested`] re-asserts after.
349const FG_RESET: &str = "\u{1b}[39m";
350
351/// Build the SGR prefix that sets the foreground to the given truecolor RGB.
352///
353/// Matches the sequence `owo_colors` emits for `text.color(Rgb(r, g, b))`, so
354/// swapping a plain `owo_colors` wrap for [`colorize_nested`] is byte-identical
355/// when the wrapped text contains no nested color.
356fn rgb_prefix(color: Rgb) -> String {
357    let Rgb(r, g, b) = color;
358    format!("\u{1b}[38;2;{r};{g};{b}m")
359}
360
361/// Wrap `text` in the foreground color given by `prefix`, re-asserting that
362/// color after any nested foreground-reset already present in `text`.
363///
364/// `owo_colors` closes a colored span with `\x1b[39m` — reset the foreground to
365/// the terminal *default*, not to the enclosing color. So when a colored value
366/// contains a nested colored span (e.g. the green `Up` inside an otherwise
367/// white — or, for the active interface, bright-blue — network line), that
368/// inner reset drops everything after it back to the default color. Inserting a
369/// fresh `prefix` after every interior [`FG_RESET`] keeps the enclosing color
370/// intact across the nested span, so color nesting composes correctly at any
371/// depth. With no interior reset this is exactly `prefix + text + FG_RESET`,
372/// i.e. identical to a plain `owo_colors` wrap.
373pub(crate) fn colorize_nested(text: &str, prefix: &str) -> String {
374    let inner = text.replace(FG_RESET, &format!("{FG_RESET}{prefix}"));
375    format!("{prefix}{inner}{FG_RESET}")
376}
377
378/// SGR prefix used to highlight the active network interface (bright blue).
379///
380/// Kept as the exact sequence `owo_colors`' `.bright_blue()` emits so the
381/// display layer can compose it through [`colorize_nested`] without changing
382/// the rendered color.
383pub(crate) const ACTIVE_IFACE_PREFIX: &str = "\u{1b}[94m";
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_neutral_theme() {
391        let theme = Theme::neutral();
392        assert_eq!(theme.name, "neutral");
393        assert_eq!(theme.label_color, Rgb(0, 255, 255));
394        assert_eq!(theme.value_color, Rgb(255, 255, 255));
395        assert_eq!(theme.accent_color, Rgb(0, 255, 0));
396        assert_eq!(theme.title_color, Rgb(255, 255, 0));
397    }
398
399    #[test]
400    fn test_dark_theme() {
401        let theme = Theme::dark();
402        assert_eq!(theme.name, "dark");
403        assert_eq!(theme.label_color, Rgb(128, 128, 255));
404        assert_eq!(theme.title_color, Rgb(255, 255, 128));
405    }
406
407    #[test]
408    fn test_light_theme() {
409        let theme = Theme::light();
410        assert_eq!(theme.name, "light");
411        assert_eq!(theme.label_color, Rgb(0, 0, 255));
412        assert_eq!(theme.title_color, Rgb(255, 255, 128));
413    }
414
415    #[test]
416    fn test_new_default() {
417        let theme = Theme::new_default();
418        assert_eq!(theme.name, "neutral");
419    }
420
421    #[test]
422    fn test_parse_color() {
423        assert_eq!(parse_color("#ff0000"), Some(Rgb(255, 0, 0)));
424        assert_eq!(parse_color("00ff00"), Some(Rgb(0, 255, 0)));
425        assert_eq!(parse_color("blue"), Some(Rgb(0, 0, 255)));
426        assert_eq!(parse_color("invalid"), None);
427    }
428
429    #[test]
430    fn test_from_name() {
431        assert_eq!(Theme::from_name("dark").name, "dark");
432        assert_eq!(Theme::from_name("light").name, "light");
433        assert_eq!(Theme::from_name("mocha").name, "catppuccin-mocha");
434        assert_eq!(Theme::from_name("unknown").name, "neutral");
435    }
436
437    #[test]
438    fn test_with_custom_overrides_all_fields() {
439        let base = Theme::neutral();
440        let custom = crate::config::CustomTheme {
441            label_color: Some("#123456".to_string()),
442            value_color: Some("blue".to_string()),
443            accent_color: Some("#ff00ff".to_string()),
444            title_color: Some("green".to_string()),
445            separator_color: Some("#00ff00".to_string()),
446        };
447        let theme = Theme::with_custom_overrides(base, &custom);
448        assert_eq!(theme.label_color, Rgb(18, 52, 86));
449        assert_eq!(theme.value_color, Rgb(0, 0, 255));
450        assert_eq!(theme.accent_color, Rgb(255, 0, 255));
451        assert_eq!(theme.title_color, Rgb(0, 255, 0));
452        assert_eq!(theme.separator_color, Rgb(0, 255, 0));
453    }
454
455    #[test]
456    fn test_with_custom_overrides_invalid() {
457        let base = Theme::neutral();
458        let custom = crate::config::CustomTheme {
459            label_color: Some("invalid_color".to_string()),
460            value_color: Some("#123".to_string()), // invalid hex length
461            accent_color: Some("".to_string()),
462            title_color: None,
463            separator_color: Some("#ffg000".to_string()), // invalid hex char
464        };
465        let theme = Theme::with_custom_overrides(base.clone(), &custom);
466        // Should fallback to base theme colors
467        assert_eq!(theme.label_color, base.label_color);
468        assert_eq!(theme.value_color, base.value_color);
469        assert_eq!(theme.accent_color, base.accent_color);
470        assert_eq!(theme.title_color, base.title_color);
471        assert_eq!(theme.separator_color, base.separator_color);
472    }
473
474    #[test]
475    fn test_parse_color_hex_variants() {
476        // Upper case hex
477        assert_eq!(parse_color("#FF6432"), Some(Rgb(255, 100, 50)));
478        // Lower case hex
479        assert_eq!(parse_color("#ff6432"), Some(Rgb(255, 100, 50)));
480        // Mixed case hex
481        assert_eq!(parse_color("#Ff6432"), Some(Rgb(255, 100, 50)));
482        // Hex without # prefix
483        assert_eq!(parse_color("FF6432"), Some(Rgb(255, 100, 50)));
484        // Space padded hex
485        assert_eq!(parse_color("  #FF6432  "), Some(Rgb(255, 100, 50)));
486        // Invalid hex lengths
487        assert_eq!(parse_color("#fff"), None);
488        assert_eq!(parse_color("fff"), None);
489        assert_eq!(parse_color("#fffffff"), None);
490        // Invalid hex chars
491        assert_eq!(parse_color("#ff643g"), None);
492    }
493
494    #[test]
495    fn test_rgb_prefix_matches_owo() {
496        // Must be byte-identical to what owo_colors emits for the same color,
497        // so colorize_nested is a drop-in for a plain `.color(Rgb)` wrap.
498        assert_eq!(rgb_prefix(Rgb(255, 255, 255)), "\u{1b}[38;2;255;255;255m");
499        assert_eq!(
500            rgb_prefix(Rgb(255, 255, 255)),
501            "x".color(Rgb(255, 255, 255))
502                .to_string()
503                .replace("x\u{1b}[39m", "")
504        );
505    }
506
507    #[test]
508    fn test_colorize_nested_plain_text_is_plain_wrap() {
509        // With no interior reset, colorize_nested == prefix + text + reset,
510        // i.e. identical to a plain owo_colors wrap — other fields are unchanged.
511        let prefix = rgb_prefix(Rgb(255, 255, 255));
512        assert_eq!(
513            colorize_nested("hello", &prefix),
514            format!("{prefix}hello\u{1b}[39m"),
515        );
516    }
517
518    #[test]
519    fn test_colorize_nested_reasserts_after_interior_reset() {
520        // A nested green span ("Up") ends with FG_RESET; the enclosing color
521        // must be re-asserted right after it so the trailing text keeps it.
522        let prefix = "\u{1b}[94m"; // bright blue
523        let nested = format!("[{}]tail", "Up".color(Rgb(0, 255, 0)));
524        let out = colorize_nested(&nested, prefix);
525        // Every interior reset is immediately followed by a re-assert of prefix.
526        assert!(out.contains(&format!("\u{1b}[39m{prefix}")));
527        // The trailing "]tail" is not left dangling in the default color: the
528        // last thing before it (after "Up") is a prefix re-assert.
529        assert!(out.contains(&format!("\u{1b}[39m{prefix}]tail")));
530        // Opens and closes with the enclosing color.
531        assert!(out.starts_with(prefix));
532        assert!(out.ends_with("\u{1b}[39m"));
533    }
534
535    #[test]
536    fn test_colorize_nested_no_default_colored_tail() {
537        // Regression for the network "[Up]" bracket-color bug: after wrapping,
538        // there must be no point where a foreground reset is followed by
539        // visible text without the enclosing color being re-asserted first
540        // (other than the single final reset that closes the whole span).
541        let prefix = rgb_prefix(Rgb(255, 255, 255));
542        let net = format!(
543            "eth0 (10.0.0.1) [{}] RX: 1 GB TX: 2 GB",
544            "Up".color(Rgb(0, 255, 0))
545        );
546        let out = colorize_nested(&net, &prefix);
547        // Strip the intended re-assert sequences; the only remaining bare reset
548        // should be the final closer, which must sit at the very end.
549        let stripped = out.replace(&format!("\u{1b}[39m{prefix}"), "");
550        assert!(
551            stripped.ends_with("\u{1b}[39m"),
552            "unexpected mid-string bare reset in {out:?}"
553        );
554        assert_eq!(
555            stripped.matches("\u{1b}[39m").count(),
556            1,
557            "exactly one bare reset (the final closer) should remain: {out:?}"
558        );
559    }
560}