1#[cfg(target_os = "linux")]
21pub(crate) mod kde;
22pub mod watch;
23
24pub use watch::{OsThemeChanged, OsThemeWatch};
25
26use teksilo_kde_scheme::KdeDesktop;
27#[cfg(target_os = "linux")]
28use teksilo_tokens::Color;
29use teksilo_tokens::{ColorSchemePreference, OsThemeColors};
30
31#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub struct NativeDesktop {
36 pub os: OsThemeColors,
38 pub kde: Option<KdeDesktop>,
44}
45
46impl NativeDesktop {
47 pub fn new(os: OsThemeColors) -> Self {
49 NativeDesktop { os, kde: None }
50 }
51}
52
53pub fn query_color_scheme() -> ColorSchemePreference {
61 platform::query_color_scheme()
62}
63
64pub fn query_native_desktop() -> NativeDesktop {
74 platform::query_native_desktop()
75}
76
77pub fn query_os_theme_colors() -> OsThemeColors {
82 query_native_desktop().os
83}
84
85#[cfg(target_os = "linux")]
87mod platform {
88 use super::*;
89 use crate::linux_helpers::{Desktop, detect_desktop, read_gsettings};
90 use crate::linux_portal::{read_portal_rgb, read_portal_u32};
91 use teksilo_kde_scheme::KdeConfigLocations;
92
93 pub(super) fn query_color_scheme() -> ColorSchemePreference {
94 if let Some(v) = read_portal_u32("org.freedesktop.appearance", "color-scheme") {
100 return match v {
101 1 => ColorSchemePreference::Dark,
102 2 => ColorSchemePreference::Light,
103 _ => ColorSchemePreference::NoPreference,
104 };
105 }
106
107 match detect_desktop() {
113 Desktop::Kde => {
114 let locations = KdeConfigLocations::from_env(kde::process_env);
117 if let Some(dark) =
118 kde::kde_prefers_dark(&locations, &kde::process_env, kde::read_file)
119 {
120 return if dark {
121 ColorSchemePreference::Dark
122 } else {
123 ColorSchemePreference::Light
124 };
125 }
126 }
127 Desktop::Cinnamon => {
128 if let Some(name) = read_gsettings("org.cinnamon.desktop.interface", "gtk-theme") {
129 if name.to_lowercase().contains("dark") {
130 return ColorSchemePreference::Dark;
131 }
132 return ColorSchemePreference::Light;
133 }
134 }
135 Desktop::Gnome | Desktop::Other(_) => {
136 if let Some(name) = read_gsettings("org.gnome.desktop.interface", "gtk-theme") {
137 if name.to_lowercase().contains("dark") {
138 return ColorSchemePreference::Dark;
139 }
140 return ColorSchemePreference::Light;
141 }
142 }
143 }
144
145 ColorSchemePreference::NoPreference
146 }
147
148 pub(super) fn query_native_desktop() -> NativeDesktop {
149 match detect_desktop() {
150 Desktop::Kde => {
151 let locations = KdeConfigLocations::from_env(kde::process_env);
152 let desktop = kde::discover_kde(&locations, &kde::process_env, kde::read_file);
153 kde::native_desktop(desktop, || {
154 let mut colors = OsThemeColors::new(query_color_scheme());
155 colors.accent = portal_accent();
156 colors
157 })
158 }
159 Desktop::Cinnamon => {
160 let mut colors = OsThemeColors::new(query_color_scheme());
161 query_cinnamon(&mut colors);
162 NativeDesktop::new(colors)
163 }
164 Desktop::Gnome | Desktop::Other(_) => {
165 let mut colors = OsThemeColors::new(query_color_scheme());
166 query_gnome(&mut colors);
167 NativeDesktop::new(colors)
168 }
169 }
170 }
171
172 fn portal_accent() -> Option<Color> {
174 read_portal_rgb("org.freedesktop.appearance", "accent-color")
175 .map(|(r, g, b)| Color::from_rgb(r as f32, g as f32, b as f32))
176 }
177
178 fn query_gnome(colors: &mut OsThemeColors) {
181 colors.accent = portal_accent();
183
184 if colors.accent.is_none()
186 && let Some(name) = read_gsettings("org.gnome.desktop.interface", "accent-color")
187 {
188 colors.accent = gnome_named_accent(&name);
189 }
190
191 if let Some(theme_name) = read_gsettings("org.gnome.desktop.interface", "gtk-theme") {
193 apply_gtk_css_colors(colors, &theme_name);
194 }
195 }
196
197 fn gnome_named_accent(name: &str) -> Option<Color> {
199 let hex = match name.to_lowercase().as_str() {
200 "blue" => "#3584e4",
201 "teal" => "#2190a4",
202 "green" => "#3a944a",
203 "yellow" => "#c88800",
204 "orange" => "#ed5b00",
205 "red" => "#e62d42",
206 "pink" => "#d56199",
207 "purple" => "#9141ac",
208 "slate" => "#6f8396",
209 _ => return None,
210 };
211 Some(Color::from_hex(hex))
212 }
213
214 fn query_cinnamon(colors: &mut OsThemeColors) {
217 let theme_name = read_gsettings("org.cinnamon.desktop.interface", "gtk-theme")
218 .or_else(|| read_gsettings("org.gnome.desktop.interface", "gtk-theme"));
219
220 if let Some(ref name) = theme_name {
221 colors.accent = mint_y_accent(name);
223
224 apply_gtk_css_colors(colors, name);
226 }
227 }
228
229 fn mint_y_accent(theme_name: &str) -> Option<Color> {
231 let suffix = theme_name.rsplit('-').next()?;
233 let hex = match suffix.to_lowercase().as_str() {
234 "aqua" => "#1a9e87",
235 "blue" => "#0c75de",
236 "grey" => "#70737a",
237 "orange" => "#dd6516",
238 "pink" => "#e54980",
239 "purple" => "#7e57c2",
240 "red" => "#c0392b",
241 "sand" => "#c5a07c",
242 "teal" => "#009688",
243 _ if theme_name.starts_with("Mint-Y") => "#92b372",
245 _ => return None,
246 };
247 Some(Color::from_hex(hex))
248 }
249
250 fn apply_gtk_css_colors(colors: &mut OsThemeColors, theme_name: &str) {
255 let css = load_gtk_css(theme_name);
256 if css.is_empty() {
257 return;
258 }
259
260 let defined = parse_define_colors(&css);
261
262 let resolve = |names: &[&str]| -> Option<Color> {
265 for name in names {
266 if let Some(c) = resolve_color(&defined, name) {
267 return Some(c);
268 }
269 }
270 None
271 };
272
273 if colors.window_bg.is_none() {
274 colors.window_bg = resolve(&["theme_bg_color"]);
275 }
276 if colors.window_fg.is_none() {
277 colors.window_fg = resolve(&["theme_fg_color"]);
278 }
279 if colors.selection_bg.is_none() {
280 colors.selection_bg = resolve(&["theme_selected_bg_color"]);
281 }
282 if colors.selection_fg.is_none() {
283 colors.selection_fg = resolve(&["theme_selected_fg_color"]);
284 }
285 if colors.button_bg.is_none() {
286 colors.button_bg =
287 resolve(&["theme_button_background_normal", "theme_unfocused_bg_color"]);
288 }
289 if colors.tooltip_bg.is_none() {
290 colors.tooltip_bg = resolve(&["tooltip_bg_color"]);
291 }
292 if colors.tooltip_fg.is_none() {
293 colors.tooltip_fg = resolve(&["tooltip_fg_color"]);
294 }
295 fill_gtk_view_colors(colors, resolve);
296
297 if colors.accent.is_none()
299 && let Some(sel) = resolve(&["theme_selected_bg_color"])
300 {
301 colors.accent = Some(sel);
302 }
303 }
304
305 fn fill_gtk_view_colors(
313 colors: &mut OsThemeColors,
314 resolve: impl Fn(&[&str]) -> Option<Color>,
315 ) {
316 let fill = |slot: &mut Option<Color>, names: &[&str]| {
317 if slot.is_none() {
318 *slot = resolve(names);
319 }
320 };
321 fill(&mut colors.view_bg, &["theme_base_color", "view_bg_color"]);
322 fill(&mut colors.view_fg, &["theme_text_color", "view_fg_color"]);
323 fill(&mut colors.link, &["link_color", "link_color_breeze"]);
324 fill(
325 &mut colors.link_visited,
326 &["link_visited_color", "link_visited_color_breeze"],
327 );
328 fill(&mut colors.error, &["error_color"]);
329 fill(&mut colors.warning, &["warning_color"]);
330 fill(&mut colors.success, &["success_color"]);
331 fill(&mut colors.focus, &["theme_button_decoration_focus_breeze"]);
332 }
333
334 fn load_gtk_css(theme_name: &str) -> String {
337 let candidates = [
338 format!(
340 "{}/.themes/{}/gtk-4.0/gtk.css",
341 std::env::var("HOME").unwrap_or_default(),
342 theme_name
343 ),
344 format!(
345 "{}/.themes/{}/gtk-3.0/gtk.css",
346 std::env::var("HOME").unwrap_or_default(),
347 theme_name
348 ),
349 format!("/usr/share/themes/{}/gtk-4.0/gtk.css", theme_name),
351 format!("/usr/share/themes/{}/gtk-3.0/gtk.css", theme_name),
352 format!("/usr/local/share/themes/{}/gtk-4.0/gtk.css", theme_name),
354 ];
355
356 for path in &candidates {
357 if let Ok(content) = std::fs::read_to_string(path) {
358 if content.contains("@define-color") {
360 return content;
361 }
362 }
363 }
364
365 String::new()
366 }
367
368 fn parse_define_colors(css: &str) -> std::collections::HashMap<String, String> {
371 let mut map = std::collections::HashMap::new();
372 for line in css.lines() {
373 let trimmed = line.trim();
374 if let Some(rest) = trimmed.strip_prefix("@define-color") {
375 let rest = rest.trim();
376 if let Some((name, value)) = rest.split_once(char::is_whitespace) {
377 let value = value.trim().trim_end_matches(';').trim();
378 map.insert(name.to_string(), value.to_string());
379 }
380 }
381 }
382 map
383 }
384
385 fn resolve_color(
387 defined: &std::collections::HashMap<String, String>,
388 name: &str,
389 ) -> Option<Color> {
390 resolve_color_depth(defined, name, 0)
391 }
392
393 fn resolve_color_depth(
394 defined: &std::collections::HashMap<String, String>,
395 name: &str,
396 depth: u32,
397 ) -> Option<Color> {
398 if depth > 10 {
399 return None; }
401
402 let value = defined.get(name)?;
403
404 if let Some(ref_name) = value.strip_prefix('@') {
406 return resolve_color_depth(defined, ref_name.trim(), depth + 1);
407 }
408
409 parse_css_color(value)
410 }
411
412 fn parse_css_color(value: &str) -> Option<Color> {
414 let value = value.trim();
415
416 if value.starts_with('#') {
418 return Some(Color::from_hex(value));
419 }
420
421 if let Some(inner) = value
423 .strip_prefix("rgba(")
424 .and_then(|s| s.strip_suffix(')'))
425 {
426 let parts: Vec<&str> = inner.split(',').collect();
427 if parts.len() == 4 {
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 let a = parts[3].trim().parse::<f32>().ok()?;
432 return Some(Color::from_rgba(r, g, b, a));
433 }
434 }
435
436 if let Some(inner) = value.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
438 let parts: Vec<&str> = inner.split(',').collect();
439 if parts.len() == 3 {
440 let r = parse_css_component(parts[0])?;
441 let g = parse_css_component(parts[1])?;
442 let b = parse_css_component(parts[2])?;
443 return Some(Color::from_rgb(r, g, b));
444 }
445 }
446
447 match value.to_lowercase().as_str() {
449 "white" => Some(Color::WHITE),
450 "black" => Some(Color::BLACK),
451 "transparent" => Some(Color::TRANSPARENT),
452 _ => None,
453 }
454 }
455
456 fn parse_css_component(s: &str) -> Option<f32> {
459 let s = s.trim();
460 if s.contains('.') {
461 s.parse::<f32>().ok()
463 } else if let Ok(i) = s.parse::<u16>() {
464 Some(i as f32 / 255.0)
466 } else {
467 None
468 }
469 }
470
471 #[cfg(test)]
472 mod tests {
473 use super::*;
474
475 #[test]
476 fn parse_define_colors_basic() {
477 let css = r#"
478@define-color theme_bg_color #eff0f1;
479@define-color theme_fg_color #232629;
480@define-color theme_selected_bg_color @accent_color;
481@define-color accent_color #3584e4;
482"#;
483 let defined = parse_define_colors(css);
484 assert_eq!(defined.get("theme_bg_color").unwrap(), "#eff0f1");
485 assert_eq!(defined.get("theme_fg_color").unwrap(), "#232629");
486
487 let c = resolve_color(&defined, "theme_selected_bg_color").unwrap();
488 assert!((c.r() - 0x35 as f32 / 255.0).abs() < 0.01);
489 }
490
491 #[test]
492 fn gtk_view_colors_come_from_their_css_names() {
493 let css = r#"
496@define-color theme_base_color_breeze #ffffff;
497@define-color theme_text_color_breeze #232629;
498@define-color link_color_breeze #2980b9;
499@define-color link_visited_color_breeze #9b59b6;
500@define-color error_color_breeze #da4453;
501@define-color warning_color_breeze #f67400;
502@define-color success_color_breeze #27ae60;
503@define-color theme_button_decoration_focus_breeze #3daee9;
504@define-color theme_base_color @theme_base_color_breeze;
505@define-color theme_text_color @theme_text_color_breeze;
506@define-color error_color @error_color_breeze;
507@define-color warning_color @warning_color_breeze;
508@define-color success_color @success_color_breeze;
509"#;
510 let defined = parse_define_colors(css);
511 let resolve = |names: &[&str]| names.iter().find_map(|n| resolve_color(&defined, n));
512 let mut colors = OsThemeColors::new(ColorSchemePreference::Light);
513 colors.success = Some(Color::BLACK);
515 fill_gtk_view_colors(&mut colors, resolve);
516 assert_eq!(colors.view_bg, Some(Color::from_hex("#ffffff")));
517 assert_eq!(colors.view_fg, Some(Color::from_hex("#232629")));
518 assert_eq!(colors.link, Some(Color::from_hex("#2980b9")));
519 assert_eq!(colors.link_visited, Some(Color::from_hex("#9b59b6")));
520 assert_eq!(colors.error, Some(Color::from_hex("#da4453")));
521 assert_eq!(colors.warning, Some(Color::from_hex("#f67400")));
522 assert_eq!(colors.success, Some(Color::BLACK));
523 assert_eq!(colors.focus, Some(Color::from_hex("#3daee9")));
524 assert_eq!(colors.alternate_bg, None);
526 assert_eq!(colors.inactive_fg, None);
527
528 let empty = std::collections::HashMap::new();
530 let mut colors = OsThemeColors::new(ColorSchemePreference::Dark);
531 fill_gtk_view_colors(&mut colors, |names: &[&str]| {
532 names.iter().find_map(|n| resolve_color(&empty, n))
533 });
534 assert_eq!(colors, OsThemeColors::new(ColorSchemePreference::Dark));
535 }
536
537 #[test]
538 fn parse_css_color_hex() {
539 let c = parse_css_color("#3584e4").unwrap();
540 assert!((c.r() - 0x35 as f32 / 255.0).abs() < 0.01);
541 assert!((c.g() - 0x84 as f32 / 255.0).abs() < 0.01);
542 }
543
544 #[test]
545 fn parse_css_color_rgba() {
546 let c = parse_css_color("rgba(61, 174, 233, 0.5)").unwrap();
547 assert!((c.r() - 61.0 / 255.0).abs() < 0.01);
548 assert!((c.a() - 0.5).abs() < 0.01);
549 }
550
551 #[test]
552 fn parse_css_color_named() {
553 assert_eq!(parse_css_color("white").unwrap(), Color::WHITE);
554 assert_eq!(parse_css_color("black").unwrap(), Color::BLACK);
555 }
556
557 #[test]
558 fn gnome_named_accent_mapping() {
559 assert!(gnome_named_accent("blue").is_some());
560 assert!(gnome_named_accent("teal").is_some());
561 assert!(gnome_named_accent("nonexistent").is_none());
562 }
563
564 #[test]
565 fn mint_y_accent_mapping() {
566 assert!(mint_y_accent("Mint-Y-Dark-Aqua").is_some());
567 assert!(mint_y_accent("Mint-Y-Blue").is_some());
568 assert!(mint_y_accent("Mint-Y").is_some());
569 assert!(mint_y_accent("Adwaita").is_none());
570 }
571 }
572}
573
574#[cfg(target_os = "macos")]
576mod platform {
577 use super::*;
578
579 pub(super) fn query_color_scheme() -> ColorSchemePreference {
580 #[cfg(debug_assertions)]
586 {
587 use std::sync::Once;
588 static WARNED: Once = Once::new();
589 WARNED.call_once(|| {
590 eprintln!(
591 "teksilo-platform: ThemeMode::Native windowless color-scheme \
592 detection is unimplemented on macOS — falling back to \
593 NoPreference; drive it from winit's window.theme() instead."
594 );
595 });
596 }
597 ColorSchemePreference::NoPreference
598 }
599
600 pub(super) fn query_native_desktop() -> NativeDesktop {
601 NativeDesktop::new(OsThemeColors::new(query_color_scheme()))
602 }
603}
604
605#[cfg(target_os = "windows")]
607mod platform {
608 use super::*;
609
610 pub(super) fn query_color_scheme() -> ColorSchemePreference {
611 #[cfg(debug_assertions)]
618 {
619 use std::sync::Once;
620 static WARNED: Once = Once::new();
621 WARNED.call_once(|| {
622 eprintln!(
623 "teksilo-platform: ThemeMode::Native windowless color-scheme \
624 detection is unimplemented on Windows — falling back to \
625 NoPreference; drive it from winit's window.theme() instead."
626 );
627 });
628 }
629 ColorSchemePreference::NoPreference
630 }
631
632 pub(super) fn query_native_desktop() -> NativeDesktop {
633 NativeDesktop::new(OsThemeColors::new(query_color_scheme()))
634 }
635}
636
637#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
639mod platform {
640 use super::*;
641
642 pub(super) fn query_color_scheme() -> ColorSchemePreference {
643 ColorSchemePreference::NoPreference
644 }
645
646 pub(super) fn query_native_desktop() -> NativeDesktop {
647 NativeDesktop::new(OsThemeColors::default())
648 }
649}