1use owo_colors::{OwoColorize, Rgb};
10
11pub fn parse_color(input: &str) -> Option<Rgb> {
16 let s = input.trim();
17
18 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 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#[derive(Debug, Clone)]
59pub struct Theme {
60 pub name: String,
62 pub label_color: Rgb,
64 pub value_color: Rgb,
66 pub accent_color: Rgb,
68 pub title_color: Rgb,
70 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), value_color: Rgb(255, 255, 255), accent_color: Rgb(0, 255, 0), title_color: Rgb(255, 255, 0), separator_color: Rgb(128, 128, 128), }
84 }
85}
86
87impl Theme {
88 pub fn neutral() -> Self {
90 Self::default()
91 }
92
93 pub fn new_default() -> Self {
95 Self::neutral()
96 }
97
98 pub fn dark() -> Self {
100 Self {
101 name: "dark".to_string(),
102 label_color: Rgb(128, 128, 255), value_color: Rgb(255, 255, 255), accent_color: Rgb(128, 255, 128), title_color: Rgb(255, 255, 128), separator_color: Rgb(128, 128, 128), }
108 }
109
110 pub fn light() -> Self {
112 Self {
113 name: "light".to_string(),
114 label_color: Rgb(0, 0, 255), value_color: Rgb(0, 0, 0), accent_color: Rgb(0, 255, 0), title_color: Rgb(255, 255, 128), separator_color: Rgb(0, 0, 0), }
120 }
121
122 pub fn catppuccin_latte() -> Self {
126 Self {
127 name: "catppuccin-latte".to_string(),
128 label_color: Rgb(30, 102, 245), value_color: Rgb(76, 79, 105), accent_color: Rgb(64, 160, 43), title_color: Rgb(223, 142, 29), separator_color: Rgb(140, 143, 161), }
134 }
135
136 pub fn catppuccin_frappe() -> Self {
138 Self {
139 name: "catppuccin-frappe".to_string(),
140 label_color: Rgb(137, 180, 250), value_color: Rgb(198, 208, 245), accent_color: Rgb(166, 227, 161), title_color: Rgb(249, 226, 175), separator_color: Rgb(98, 104, 128), }
146 }
147
148 pub fn catppuccin_macchiato() -> Self {
150 Self {
151 name: "catppuccin-macchiato".to_string(),
152 label_color: Rgb(138, 173, 244), value_color: Rgb(202, 211, 245), accent_color: Rgb(166, 218, 149), title_color: Rgb(238, 212, 159), separator_color: Rgb(91, 96, 120), }
158 }
159
160 pub fn catppuccin_mocha() -> Self {
162 Self {
163 name: "catppuccin-mocha".to_string(),
164 label_color: Rgb(137, 180, 250), value_color: Rgb(205, 214, 244), accent_color: Rgb(245, 194, 231), title_color: Rgb(249, 226, 175), separator_color: Rgb(108, 112, 134), }
170 }
171
172 pub fn solarized_light() -> Self {
174 Self {
175 name: "solarized-light".to_string(),
176 label_color: Rgb(38, 139, 210), value_color: Rgb(101, 123, 131), accent_color: Rgb(181, 137, 0), title_color: Rgb(203, 75, 22), separator_color: Rgb(147, 161, 161), }
182 }
183
184 pub fn solarized_dark() -> Self {
186 Self {
187 name: "solarized-dark".to_string(),
188 label_color: Rgb(38, 139, 210), value_color: Rgb(131, 148, 150), accent_color: Rgb(181, 137, 0), title_color: Rgb(203, 75, 22), separator_color: Rgb(147, 161, 161), }
194 }
195
196 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(), "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 pub fn detect_system_theme() -> Self {
220 if let Some(config_dir) = dirs::config_dir() {
222 let gtk_settings = config_dir.join("gtk-3.0").join("settings.ini");
223 if gtk_settings.exists() {
224 if let Ok(contents) = std::fs::read_to_string(>k_settings) {
225 for line in contents.lines() {
226 if line.to_lowercase().contains("prefer-dark-theme") {
227 if line.to_lowercase().contains("true") {
228 return Self::dark();
229 } else {
230 return Self::light();
231 }
232 }
233 }
234 }
235 }
236 }
237 Self::dark()
239 }
240
241 pub fn with_custom_overrides(base: Self, custom: &crate::config::CustomTheme) -> Self {
243 let mut theme = base;
244
245 if let Some(color) = &custom.label_color {
246 if let Some(c) = parse_color(color) {
247 theme.label_color = c;
248 }
249 }
250 if let Some(color) = &custom.value_color {
251 if let Some(c) = parse_color(color) {
252 theme.value_color = c;
253 }
254 }
255 if let Some(color) = &custom.accent_color {
256 if let Some(c) = parse_color(color) {
257 theme.accent_color = c;
258 }
259 }
260 if let Some(color) = &custom.title_color {
261 if let Some(c) = parse_color(color) {
262 theme.title_color = c;
263 }
264 }
265 if let Some(color) = &custom.separator_color {
266 if let Some(c) = parse_color(color) {
267 theme.separator_color = c;
268 }
269 }
270
271 theme
272 }
273
274 pub fn color_label(&self, text: &str) -> String {
276 text.color(self.label_color).to_string()
277 }
278
279 pub fn color_value(&self, text: &str) -> String {
281 text.color(self.value_color).to_string()
282 }
283
284 pub fn color_accent(&self, text: &str) -> String {
286 text.color(self.accent_color).to_string()
287 }
288
289 pub fn color_separator(&self, text: &str) -> String {
291 text.color(self.separator_color).to_string()
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_neutral_theme() {
301 let theme = Theme::neutral();
302 assert_eq!(theme.name, "neutral");
303 assert_eq!(theme.label_color, Rgb(0, 255, 255));
304 assert_eq!(theme.value_color, Rgb(255, 255, 255));
305 assert_eq!(theme.accent_color, Rgb(0, 255, 0));
306 assert_eq!(theme.title_color, Rgb(255, 255, 0));
307 }
308
309 #[test]
310 fn test_dark_theme() {
311 let theme = Theme::dark();
312 assert_eq!(theme.name, "dark");
313 assert_eq!(theme.label_color, Rgb(128, 128, 255));
314 assert_eq!(theme.title_color, Rgb(255, 255, 128));
315 }
316
317 #[test]
318 fn test_light_theme() {
319 let theme = Theme::light();
320 assert_eq!(theme.name, "light");
321 assert_eq!(theme.label_color, Rgb(0, 0, 255));
322 assert_eq!(theme.title_color, Rgb(255, 255, 128));
323 }
324
325 #[test]
326 fn test_new_default() {
327 let theme = Theme::new_default();
328 assert_eq!(theme.name, "neutral");
329 }
330
331 #[test]
332 fn test_parse_color() {
333 assert_eq!(parse_color("#ff0000"), Some(Rgb(255, 0, 0)));
334 assert_eq!(parse_color("00ff00"), Some(Rgb(0, 255, 0)));
335 assert_eq!(parse_color("blue"), Some(Rgb(0, 0, 255)));
336 assert_eq!(parse_color("invalid"), None);
337 }
338
339 #[test]
340 fn test_from_name() {
341 assert_eq!(Theme::from_name("dark").name, "dark");
342 assert_eq!(Theme::from_name("light").name, "light");
343 assert_eq!(Theme::from_name("mocha").name, "catppuccin-mocha");
344 assert_eq!(Theme::from_name("unknown").name, "neutral");
345 }
346
347 #[test]
348 fn test_with_custom_overrides_all_fields() {
349 let base = Theme::neutral();
350 let custom = crate::config::CustomTheme {
351 label_color: Some("#123456".to_string()),
352 value_color: Some("blue".to_string()),
353 accent_color: Some("#ff00ff".to_string()),
354 title_color: Some("green".to_string()),
355 separator_color: Some("#00ff00".to_string()),
356 };
357 let theme = Theme::with_custom_overrides(base, &custom);
358 assert_eq!(theme.label_color, Rgb(18, 52, 86));
359 assert_eq!(theme.value_color, Rgb(0, 0, 255));
360 assert_eq!(theme.accent_color, Rgb(255, 0, 255));
361 assert_eq!(theme.title_color, Rgb(0, 255, 0));
362 assert_eq!(theme.separator_color, Rgb(0, 255, 0));
363 }
364
365 #[test]
366 fn test_with_custom_overrides_invalid() {
367 let base = Theme::neutral();
368 let custom = crate::config::CustomTheme {
369 label_color: Some("invalid_color".to_string()),
370 value_color: Some("#123".to_string()), accent_color: Some("".to_string()),
372 title_color: None,
373 separator_color: Some("#ffg000".to_string()), };
375 let theme = Theme::with_custom_overrides(base.clone(), &custom);
376 assert_eq!(theme.label_color, base.label_color);
378 assert_eq!(theme.value_color, base.value_color);
379 assert_eq!(theme.accent_color, base.accent_color);
380 assert_eq!(theme.title_color, base.title_color);
381 assert_eq!(theme.separator_color, base.separator_color);
382 }
383
384 #[test]
385 fn test_parse_color_hex_variants() {
386 assert_eq!(parse_color("#FF6432"), Some(Rgb(255, 100, 50)));
388 assert_eq!(parse_color("#ff6432"), Some(Rgb(255, 100, 50)));
390 assert_eq!(parse_color("#Ff6432"), Some(Rgb(255, 100, 50)));
392 assert_eq!(parse_color("FF6432"), Some(Rgb(255, 100, 50)));
394 assert_eq!(parse_color(" #FF6432 "), Some(Rgb(255, 100, 50)));
396 assert_eq!(parse_color("#fff"), None);
398 assert_eq!(parse_color("fff"), None);
399 assert_eq!(parse_color("#fffffff"), None);
400 assert_eq!(parse_color("#ff643g"), None);
402 }
403}