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