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 {
330 colorize_nested(text, &rgb_prefix(self.value_color))
331 }
332
333 pub fn color_accent(&self, text: &str) -> String {
335 text.color(self.accent_color).to_string()
336 }
337
338 pub fn color_separator(&self, text: &str) -> String {
340 text.color(self.separator_color).to_string()
341 }
342}
343
344const FG_RESET: &str = "\u{1b}[39m";
350
351fn rgb_prefix(color: Rgb) -> String {
357 let Rgb(r, g, b) = color;
358 format!("\u{1b}[38;2;{r};{g};{b}m")
359}
360
361pub(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
378pub(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()), accent_color: Some("".to_string()),
462 title_color: None,
463 separator_color: Some("#ffg000".to_string()), };
465 let theme = Theme::with_custom_overrides(base.clone(), &custom);
466 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 assert_eq!(parse_color("#FF6432"), Some(Rgb(255, 100, 50)));
478 assert_eq!(parse_color("#ff6432"), Some(Rgb(255, 100, 50)));
480 assert_eq!(parse_color("#Ff6432"), Some(Rgb(255, 100, 50)));
482 assert_eq!(parse_color("FF6432"), Some(Rgb(255, 100, 50)));
484 assert_eq!(parse_color(" #FF6432 "), Some(Rgb(255, 100, 50)));
486 assert_eq!(parse_color("#fff"), None);
488 assert_eq!(parse_color("fff"), None);
489 assert_eq!(parse_color("#fffffff"), None);
490 assert_eq!(parse_color("#ff643g"), None);
492 }
493
494 #[test]
495 fn test_rgb_prefix_matches_owo() {
496 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 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 let prefix = "\u{1b}[94m"; let nested = format!("[{}]tail", "Up".color(Rgb(0, 255, 0)));
524 let out = colorize_nested(&nested, prefix);
525 assert!(out.contains(&format!("\u{1b}[39m{prefix}")));
527 assert!(out.contains(&format!("\u{1b}[39m{prefix}]tail")));
530 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 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 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}