1#[cfg(target_os = "linux")]
11use teksilo_tokens::Color;
12use teksilo_tokens::{ColorSchemePreference, OsThemeColors};
13
14pub fn query_color_scheme() -> ColorSchemePreference {
17 platform::query_color_scheme()
18}
19
20pub fn query_os_theme_colors() -> OsThemeColors {
23 platform::query_os_theme_colors()
24}
25
26#[cfg(target_os = "linux")]
28mod platform {
29 use super::*;
30 use crate::linux_helpers::{
31 Desktop, detect_desktop, read_gsettings, read_portal_rgb, read_portal_u32,
32 };
33
34 pub(super) fn query_color_scheme() -> ColorSchemePreference {
35 if let Some(v) = read_portal_u32("org.freedesktop.appearance", "color-scheme") {
41 return match v {
42 1 => ColorSchemePreference::Dark,
43 2 => ColorSchemePreference::Light,
44 _ => ColorSchemePreference::NoPreference,
45 };
46 }
47
48 match detect_desktop() {
54 Desktop::Kde => {
55 let path = dirs_kdeglobals();
62 if let Ok(content) = std::fs::read_to_string(&path)
63 && let Some(scheme) = ini_value(&content, "General", "ColorScheme")
64 {
65 if scheme.to_lowercase().contains("dark") {
66 return ColorSchemePreference::Dark;
67 }
68 return ColorSchemePreference::Light;
69 }
70 }
71 Desktop::Cinnamon => {
72 if let Some(name) = read_gsettings("org.cinnamon.desktop.interface", "gtk-theme") {
73 if name.to_lowercase().contains("dark") {
74 return ColorSchemePreference::Dark;
75 }
76 return ColorSchemePreference::Light;
77 }
78 }
79 Desktop::Gnome | Desktop::Other(_) => {
80 if let Some(name) = read_gsettings("org.gnome.desktop.interface", "gtk-theme") {
81 if name.to_lowercase().contains("dark") {
82 return ColorSchemePreference::Dark;
83 }
84 return ColorSchemePreference::Light;
85 }
86 }
87 }
88
89 ColorSchemePreference::NoPreference
90 }
91
92 pub(super) fn query_os_theme_colors() -> OsThemeColors {
93 let mut colors = OsThemeColors {
94 color_scheme: query_color_scheme(),
95 ..Default::default()
96 };
97
98 match detect_desktop() {
99 Desktop::Kde => query_kde(&mut colors),
100 Desktop::Cinnamon => query_cinnamon(&mut colors),
101 Desktop::Gnome | Desktop::Other(_) => query_gnome(&mut colors),
102 }
103
104 colors
105 }
106
107 fn query_gnome(colors: &mut OsThemeColors) {
110 if let Some((r, g, b)) = read_portal_rgb("org.freedesktop.appearance", "accent-color") {
112 colors.accent = Some(Color::from_rgb(r as f32, g as f32, b as f32));
113 }
114
115 if colors.accent.is_none()
117 && let Some(name) = read_gsettings("org.gnome.desktop.interface", "accent-color")
118 {
119 colors.accent = gnome_named_accent(&name);
120 }
121
122 if let Some(theme_name) = read_gsettings("org.gnome.desktop.interface", "gtk-theme") {
124 apply_gtk_css_colors(colors, &theme_name);
125 }
126 }
127
128 fn gnome_named_accent(name: &str) -> Option<Color> {
130 let hex = match name.to_lowercase().as_str() {
131 "blue" => "#3584e4",
132 "teal" => "#2190a4",
133 "green" => "#3a944a",
134 "yellow" => "#c88800",
135 "orange" => "#ed5b00",
136 "red" => "#e62d42",
137 "pink" => "#d56199",
138 "purple" => "#9141ac",
139 "slate" => "#6f8396",
140 _ => return None,
141 };
142 Some(Color::from_hex(hex))
143 }
144
145 fn query_kde(colors: &mut OsThemeColors) {
148 let path = dirs_kdeglobals();
149 let content = match std::fs::read_to_string(&path) {
150 Ok(c) => c,
151 Err(_) => return,
152 };
153
154 if colors.color_scheme == ColorSchemePreference::NoPreference
156 && let Some(scheme) = ini_value(&content, "General", "ColorScheme")
157 {
158 if scheme.to_lowercase().contains("dark") {
159 colors.color_scheme = ColorSchemePreference::Dark;
160 } else {
161 colors.color_scheme = ColorSchemePreference::Light;
162 }
163 }
164
165 colors.window_bg = ini_color(&content, "Colors:Window", "BackgroundNormal");
167 colors.window_fg = ini_color(&content, "Colors:Window", "ForegroundNormal");
168 colors.accent = ini_color(&content, "Colors:Window", "DecorationFocus");
169
170 colors.button_bg = ini_color(&content, "Colors:Button", "BackgroundNormal");
172 colors.button_fg = ini_color(&content, "Colors:Button", "ForegroundNormal");
173
174 colors.selection_bg = ini_color(&content, "Colors:Selection", "BackgroundNormal");
176 colors.selection_fg = ini_color(&content, "Colors:Selection", "ForegroundNormal");
177
178 colors.tooltip_bg = ini_color(&content, "Colors:Tooltip", "BackgroundNormal");
180 colors.tooltip_fg = ini_color(&content, "Colors:Tooltip", "ForegroundNormal");
181 }
182
183 fn dirs_kdeglobals() -> std::path::PathBuf {
184 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
185 std::path::PathBuf::from(config_home).join("kdeglobals")
186 } else if let Ok(home) = std::env::var("HOME") {
187 std::path::PathBuf::from(home).join(".config/kdeglobals")
188 } else {
189 std::path::PathBuf::from("/dev/null")
190 }
191 }
192
193 fn ini_value<'a>(content: &'a str, section: &str, key: &str) -> Option<&'a str> {
195 let section_header = format!("[{}]", section);
196 let mut in_section = false;
197
198 for line in content.lines() {
199 let trimmed = line.trim();
200 if trimmed.starts_with('[') {
201 in_section = trimmed == section_header;
202 continue;
203 }
204 if in_section
205 && let Some((k, v)) = trimmed.split_once('=')
206 && k.trim() == key
207 {
208 return Some(v.trim());
209 }
210 }
211 None
212 }
213
214 fn ini_color(content: &str, section: &str, key: &str) -> Option<Color> {
216 let value = ini_value(content, section, key)?;
217 let parts: Vec<&str> = value.split(',').collect();
218 if parts.len() >= 3 {
219 let r = parts[0].trim().parse::<u8>().ok()?;
220 let g = parts[1].trim().parse::<u8>().ok()?;
221 let b = parts[2].trim().parse::<u8>().ok()?;
222 Some(Color::from_rgb(
223 r as f32 / 255.0,
224 g as f32 / 255.0,
225 b as f32 / 255.0,
226 ))
227 } else {
228 None
229 }
230 }
231
232 fn query_cinnamon(colors: &mut OsThemeColors) {
235 let theme_name = read_gsettings("org.cinnamon.desktop.interface", "gtk-theme")
236 .or_else(|| read_gsettings("org.gnome.desktop.interface", "gtk-theme"));
237
238 if let Some(ref name) = theme_name {
239 colors.accent = mint_y_accent(name);
241
242 apply_gtk_css_colors(colors, name);
244 }
245 }
246
247 fn mint_y_accent(theme_name: &str) -> Option<Color> {
249 let suffix = theme_name.rsplit('-').next()?;
251 let hex = match suffix.to_lowercase().as_str() {
252 "aqua" => "#1a9e87",
253 "blue" => "#0c75de",
254 "grey" => "#70737a",
255 "orange" => "#dd6516",
256 "pink" => "#e54980",
257 "purple" => "#7e57c2",
258 "red" => "#c0392b",
259 "sand" => "#c5a07c",
260 "teal" => "#009688",
261 _ if theme_name.starts_with("Mint-Y") => "#92b372",
263 _ => return None,
264 };
265 Some(Color::from_hex(hex))
266 }
267
268 fn apply_gtk_css_colors(colors: &mut OsThemeColors, theme_name: &str) {
273 let css = load_gtk_css(theme_name);
274 if css.is_empty() {
275 return;
276 }
277
278 let defined = parse_define_colors(&css);
279
280 let resolve = |names: &[&str]| -> Option<Color> {
283 for name in names {
284 if let Some(c) = resolve_color(&defined, name) {
285 return Some(c);
286 }
287 }
288 None
289 };
290
291 if colors.window_bg.is_none() {
292 colors.window_bg = resolve(&["theme_bg_color"]);
293 }
294 if colors.window_fg.is_none() {
295 colors.window_fg = resolve(&["theme_fg_color"]);
296 }
297 if colors.selection_bg.is_none() {
298 colors.selection_bg = resolve(&["theme_selected_bg_color"]);
299 }
300 if colors.selection_fg.is_none() {
301 colors.selection_fg = resolve(&["theme_selected_fg_color"]);
302 }
303 if colors.button_bg.is_none() {
304 colors.button_bg =
305 resolve(&["theme_button_background_normal", "theme_unfocused_bg_color"]);
306 }
307 if colors.tooltip_bg.is_none() {
308 colors.tooltip_bg = resolve(&["tooltip_bg_color"]);
309 }
310 if colors.tooltip_fg.is_none() {
311 colors.tooltip_fg = resolve(&["tooltip_fg_color"]);
312 }
313
314 if colors.accent.is_none()
316 && let Some(sel) = resolve(&["theme_selected_bg_color"])
317 {
318 colors.accent = Some(sel);
319 }
320 }
321
322 fn load_gtk_css(theme_name: &str) -> String {
325 let candidates = [
326 format!(
328 "{}/.themes/{}/gtk-4.0/gtk.css",
329 std::env::var("HOME").unwrap_or_default(),
330 theme_name
331 ),
332 format!(
333 "{}/.themes/{}/gtk-3.0/gtk.css",
334 std::env::var("HOME").unwrap_or_default(),
335 theme_name
336 ),
337 format!("/usr/share/themes/{}/gtk-4.0/gtk.css", theme_name),
339 format!("/usr/share/themes/{}/gtk-3.0/gtk.css", theme_name),
340 format!("/usr/local/share/themes/{}/gtk-4.0/gtk.css", theme_name),
342 ];
343
344 for path in &candidates {
345 if let Ok(content) = std::fs::read_to_string(path) {
346 if content.contains("@define-color") {
348 return content;
349 }
350 }
351 }
352
353 String::new()
354 }
355
356 fn parse_define_colors(css: &str) -> std::collections::HashMap<String, String> {
359 let mut map = std::collections::HashMap::new();
360 for line in css.lines() {
361 let trimmed = line.trim();
362 if let Some(rest) = trimmed.strip_prefix("@define-color") {
363 let rest = rest.trim();
364 if let Some((name, value)) = rest.split_once(char::is_whitespace) {
365 let value = value.trim().trim_end_matches(';').trim();
366 map.insert(name.to_string(), value.to_string());
367 }
368 }
369 }
370 map
371 }
372
373 fn resolve_color(
375 defined: &std::collections::HashMap<String, String>,
376 name: &str,
377 ) -> Option<Color> {
378 resolve_color_depth(defined, name, 0)
379 }
380
381 fn resolve_color_depth(
382 defined: &std::collections::HashMap<String, String>,
383 name: &str,
384 depth: u32,
385 ) -> Option<Color> {
386 if depth > 10 {
387 return None; }
389
390 let value = defined.get(name)?;
391
392 if let Some(ref_name) = value.strip_prefix('@') {
394 return resolve_color_depth(defined, ref_name.trim(), depth + 1);
395 }
396
397 parse_css_color(value)
398 }
399
400 fn parse_css_color(value: &str) -> Option<Color> {
402 let value = value.trim();
403
404 if value.starts_with('#') {
406 return Some(Color::from_hex(value));
407 }
408
409 if let Some(inner) = value
411 .strip_prefix("rgba(")
412 .and_then(|s| s.strip_suffix(')'))
413 {
414 let parts: Vec<&str> = inner.split(',').collect();
415 if parts.len() == 4 {
416 let r = parse_css_component(parts[0])?;
417 let g = parse_css_component(parts[1])?;
418 let b = parse_css_component(parts[2])?;
419 let a = parts[3].trim().parse::<f32>().ok()?;
420 return Some(Color::from_rgba(r, g, b, a));
421 }
422 }
423
424 if let Some(inner) = value.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
426 let parts: Vec<&str> = inner.split(',').collect();
427 if parts.len() == 3 {
428 let r = parse_css_component(parts[0])?;
429 let g = parse_css_component(parts[1])?;
430 let b = parse_css_component(parts[2])?;
431 return Some(Color::from_rgb(r, g, b));
432 }
433 }
434
435 match value.to_lowercase().as_str() {
437 "white" => Some(Color::WHITE),
438 "black" => Some(Color::BLACK),
439 "transparent" => Some(Color::TRANSPARENT),
440 _ => None,
441 }
442 }
443
444 fn parse_css_component(s: &str) -> Option<f32> {
447 let s = s.trim();
448 if s.contains('.') {
449 s.parse::<f32>().ok()
451 } else if let Ok(i) = s.parse::<u16>() {
452 Some(i as f32 / 255.0)
454 } else {
455 None
456 }
457 }
458
459 #[cfg(test)]
460 mod tests {
461 use super::*;
462
463 #[test]
464 fn parse_define_colors_basic() {
465 let css = r#"
466@define-color theme_bg_color #eff0f1;
467@define-color theme_fg_color #232629;
468@define-color theme_selected_bg_color @accent_color;
469@define-color accent_color #3584e4;
470"#;
471 let defined = parse_define_colors(css);
472 assert_eq!(defined.get("theme_bg_color").unwrap(), "#eff0f1");
473 assert_eq!(defined.get("theme_fg_color").unwrap(), "#232629");
474
475 let c = resolve_color(&defined, "theme_selected_bg_color").unwrap();
476 assert!((c.r() - 0x35 as f32 / 255.0).abs() < 0.01);
477 }
478
479 #[test]
480 fn parse_css_color_hex() {
481 let c = parse_css_color("#3584e4").unwrap();
482 assert!((c.r() - 0x35 as f32 / 255.0).abs() < 0.01);
483 assert!((c.g() - 0x84 as f32 / 255.0).abs() < 0.01);
484 }
485
486 #[test]
487 fn parse_css_color_rgba() {
488 let c = parse_css_color("rgba(61, 174, 233, 0.5)").unwrap();
489 assert!((c.r() - 61.0 / 255.0).abs() < 0.01);
490 assert!((c.a() - 0.5).abs() < 0.01);
491 }
492
493 #[test]
494 fn parse_css_color_named() {
495 assert_eq!(parse_css_color("white").unwrap(), Color::WHITE);
496 assert_eq!(parse_css_color("black").unwrap(), Color::BLACK);
497 }
498
499 #[test]
500 fn kde_ini_parsing() {
501 let content = r#"
502[General]
503ColorScheme=Breeze-Dark
504
505[Colors:Window]
506BackgroundNormal=49,54,59
507ForegroundNormal=239,240,241
508DecorationFocus=61,174,233
509"#;
510 let bg = ini_color(content, "Colors:Window", "BackgroundNormal").unwrap();
511 assert!((bg.r() - 49.0 / 255.0).abs() < 0.01);
512
513 let fg = ini_color(content, "Colors:Window", "ForegroundNormal").unwrap();
514 assert!((fg.r() - 239.0 / 255.0).abs() < 0.01);
515
516 let scheme = ini_value(content, "General", "ColorScheme").unwrap();
517 assert!(scheme.contains("Dark"));
518 }
519
520 #[test]
521 fn gnome_named_accent_mapping() {
522 assert!(gnome_named_accent("blue").is_some());
523 assert!(gnome_named_accent("teal").is_some());
524 assert!(gnome_named_accent("nonexistent").is_none());
525 }
526
527 #[test]
528 fn mint_y_accent_mapping() {
529 assert!(mint_y_accent("Mint-Y-Dark-Aqua").is_some());
530 assert!(mint_y_accent("Mint-Y-Blue").is_some());
531 assert!(mint_y_accent("Mint-Y").is_some());
532 assert!(mint_y_accent("Adwaita").is_none());
533 }
534 }
535}
536
537#[cfg(target_os = "macos")]
539mod platform {
540 use super::*;
541
542 pub(super) fn query_color_scheme() -> ColorSchemePreference {
543 #[cfg(debug_assertions)]
549 {
550 use std::sync::Once;
551 static WARNED: Once = Once::new();
552 WARNED.call_once(|| {
553 eprintln!(
554 "teksilo-platform: ThemeMode::Native windowless color-scheme \
555 detection is unimplemented on macOS — falling back to \
556 NoPreference; drive it from winit's window.theme() instead."
557 );
558 });
559 }
560 ColorSchemePreference::NoPreference
561 }
562
563 pub(super) fn query_os_theme_colors() -> OsThemeColors {
564 OsThemeColors {
565 color_scheme: query_color_scheme(),
566 ..Default::default()
567 }
568 }
569}
570
571#[cfg(target_os = "windows")]
573mod platform {
574 use super::*;
575
576 pub(super) fn query_color_scheme() -> ColorSchemePreference {
577 #[cfg(debug_assertions)]
584 {
585 use std::sync::Once;
586 static WARNED: Once = Once::new();
587 WARNED.call_once(|| {
588 eprintln!(
589 "teksilo-platform: ThemeMode::Native windowless color-scheme \
590 detection is unimplemented on Windows — falling back to \
591 NoPreference; drive it from winit's window.theme() instead."
592 );
593 });
594 }
595 ColorSchemePreference::NoPreference
596 }
597
598 pub(super) fn query_os_theme_colors() -> OsThemeColors {
599 OsThemeColors {
600 color_scheme: query_color_scheme(),
601 ..Default::default()
602 }
603 }
604}
605
606#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
608mod platform {
609 use super::*;
610
611 pub(super) fn query_color_scheme() -> ColorSchemePreference {
612 ColorSchemePreference::NoPreference
613 }
614
615 pub(super) fn query_os_theme_colors() -> OsThemeColors {
616 OsThemeColors::default()
617 }
618}