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 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 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(>k_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 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 Self::neutral()
283 }
284
285 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 pub fn color_label(&self, text: &str) -> String {
320 text.color(self.label_color).to_string()
321 }
322
323 pub fn color_value(&self, text: &str) -> String {
325 text.color(self.value_color).to_string()
326 }
327
328 pub fn color_accent(&self, text: &str) -> String {
330 text.color(self.accent_color).to_string()
331 }
332
333 pub fn color_separator(&self, text: &str) -> String {
335 text.color(self.separator_color).to_string()
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_neutral_theme() {
345 let theme = Theme::neutral();
346 assert_eq!(theme.name, "neutral");
347 assert_eq!(theme.label_color, Rgb(0, 255, 255));
348 assert_eq!(theme.value_color, Rgb(255, 255, 255));
349 assert_eq!(theme.accent_color, Rgb(0, 255, 0));
350 assert_eq!(theme.title_color, Rgb(255, 255, 0));
351 }
352
353 #[test]
354 fn test_dark_theme() {
355 let theme = Theme::dark();
356 assert_eq!(theme.name, "dark");
357 assert_eq!(theme.label_color, Rgb(128, 128, 255));
358 assert_eq!(theme.title_color, Rgb(255, 255, 128));
359 }
360
361 #[test]
362 fn test_light_theme() {
363 let theme = Theme::light();
364 assert_eq!(theme.name, "light");
365 assert_eq!(theme.label_color, Rgb(0, 0, 255));
366 assert_eq!(theme.title_color, Rgb(255, 255, 128));
367 }
368
369 #[test]
370 fn test_new_default() {
371 let theme = Theme::new_default();
372 assert_eq!(theme.name, "neutral");
373 }
374
375 #[test]
376 fn test_parse_color() {
377 assert_eq!(parse_color("#ff0000"), Some(Rgb(255, 0, 0)));
378 assert_eq!(parse_color("00ff00"), Some(Rgb(0, 255, 0)));
379 assert_eq!(parse_color("blue"), Some(Rgb(0, 0, 255)));
380 assert_eq!(parse_color("invalid"), None);
381 }
382
383 #[test]
384 fn test_from_name() {
385 assert_eq!(Theme::from_name("dark").name, "dark");
386 assert_eq!(Theme::from_name("light").name, "light");
387 assert_eq!(Theme::from_name("mocha").name, "catppuccin-mocha");
388 assert_eq!(Theme::from_name("unknown").name, "neutral");
389 }
390
391 #[test]
392 fn test_with_custom_overrides_all_fields() {
393 let base = Theme::neutral();
394 let custom = crate::config::CustomTheme {
395 label_color: Some("#123456".to_string()),
396 value_color: Some("blue".to_string()),
397 accent_color: Some("#ff00ff".to_string()),
398 title_color: Some("green".to_string()),
399 separator_color: Some("#00ff00".to_string()),
400 };
401 let theme = Theme::with_custom_overrides(base, &custom);
402 assert_eq!(theme.label_color, Rgb(18, 52, 86));
403 assert_eq!(theme.value_color, Rgb(0, 0, 255));
404 assert_eq!(theme.accent_color, Rgb(255, 0, 255));
405 assert_eq!(theme.title_color, Rgb(0, 255, 0));
406 assert_eq!(theme.separator_color, Rgb(0, 255, 0));
407 }
408
409 #[test]
410 fn test_with_custom_overrides_invalid() {
411 let base = Theme::neutral();
412 let custom = crate::config::CustomTheme {
413 label_color: Some("invalid_color".to_string()),
414 value_color: Some("#123".to_string()), accent_color: Some("".to_string()),
416 title_color: None,
417 separator_color: Some("#ffg000".to_string()), };
419 let theme = Theme::with_custom_overrides(base.clone(), &custom);
420 assert_eq!(theme.label_color, base.label_color);
422 assert_eq!(theme.value_color, base.value_color);
423 assert_eq!(theme.accent_color, base.accent_color);
424 assert_eq!(theme.title_color, base.title_color);
425 assert_eq!(theme.separator_color, base.separator_color);
426 }
427
428 #[test]
429 fn test_parse_color_hex_variants() {
430 assert_eq!(parse_color("#FF6432"), Some(Rgb(255, 100, 50)));
432 assert_eq!(parse_color("#ff6432"), Some(Rgb(255, 100, 50)));
434 assert_eq!(parse_color("#Ff6432"), Some(Rgb(255, 100, 50)));
436 assert_eq!(parse_color("FF6432"), Some(Rgb(255, 100, 50)));
438 assert_eq!(parse_color(" #FF6432 "), Some(Rgb(255, 100, 50)));
440 assert_eq!(parse_color("#fff"), None);
442 assert_eq!(parse_color("fff"), None);
443 assert_eq!(parse_color("#fffffff"), None);
444 assert_eq!(parse_color("#ff643g"), None);
446 }
447}