Skip to main content

teksilo_platform/
os_theme.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! OS theme detection — reads colors directly from desktop environment
5//! configuration files and settings.
6//!
7//! Supports GNOME, KDE, and Cinnamon on Linux. macOS and Windows return
8//! only the light/dark preference (via winit), with no color reading.
9
10#[cfg(target_os = "linux")]
11use teksilo_tokens::Color;
12use teksilo_tokens::{ColorSchemePreference, OsThemeColors};
13
14/// Query only the OS light/dark preference (lightweight).
15/// Used by `ThemeMode::FollowSystem`.
16pub fn query_color_scheme() -> ColorSchemePreference {
17    platform::query_color_scheme()
18}
19
20/// Query full OS theme colors from desktop config files.
21/// Used by `ThemeMode::Native`.
22pub fn query_os_theme_colors() -> OsThemeColors {
23    platform::query_os_theme_colors()
24}
25
26// ── Linux ───────────────────────────────────────────────────────────────────
27#[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        // XDG portal: 0 = no preference, 1 = dark, 2 = light.
36        // Works on GNOME and on KDE Plasma 5.27+ (with the
37        // `xdg-desktop-portal-kde` package installed). On older or
38        // headless KDE setups the portal call returns None and we
39        // fall through.
40        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        // Desktop-specific fallback. Picks the right config source
49        // for the current DE rather than blindly trying GNOME's
50        // gsettings (which is empty on a clean KDE install and
51        // would otherwise leave us at `NoPreference`, mapping
52        // dark-mode KDE users to a light theme).
53        match detect_desktop() {
54            Desktop::Kde => {
55                // KDE stores the active scheme in `kdeglobals` —
56                // `[General] ColorScheme=BreezeDark` for dark,
57                // `BreezeLight` (or any name without "dark") for
58                // light. Reading the file is cheap; we already
59                // do it under `ThemeMode::Native` for the full
60                // colour palette.
61                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    // ── GNOME ────────────────────────────────────────────────────────────
108
109    fn query_gnome(colors: &mut OsThemeColors) {
110        // Accent color from XDG portal (RGB doubles)
111        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        // Fallback: GNOME 47 named accent
116        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        // Read surface/selection colors from GTK CSS
123        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    /// Map GNOME 47 named accent colors to RGB.
129    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    // ── KDE ──────────────────────────────────────────────────────────────
146
147    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        // Infer dark/light from color scheme name if portal didn't provide it
155        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        // Window colors
166        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        // Button colors
171        colors.button_bg = ini_color(&content, "Colors:Button", "BackgroundNormal");
172        colors.button_fg = ini_color(&content, "Colors:Button", "ForegroundNormal");
173
174        // Selection colors
175        colors.selection_bg = ini_color(&content, "Colors:Selection", "BackgroundNormal");
176        colors.selection_fg = ini_color(&content, "Colors:Selection", "ForegroundNormal");
177
178        // Tooltip colors
179        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    /// Read a value from a simple INI file (section + key).
194    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    /// Parse a KDE "R,G,B" color value (0-255 integers).
215    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    // ── Cinnamon ─────────────────────────────────────────────────────────
233
234    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            // Infer accent from Mint-Y theme name suffix
240            colors.accent = mint_y_accent(name);
241
242            // Read surface/selection colors from GTK CSS
243            apply_gtk_css_colors(colors, name);
244        }
245    }
246
247    /// Map Mint-Y theme name suffixes to accent colors.
248    fn mint_y_accent(theme_name: &str) -> Option<Color> {
249        // Theme names like "Mint-Y-Dark-Aqua" → extract the last segment
250        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            // Default Mint-Y (no accent suffix) uses green
262            _ if theme_name.starts_with("Mint-Y") => "#92b372",
263            _ => return None,
264        };
265        Some(Color::from_hex(hex))
266    }
267
268    // ── Shared GTK CSS parser ────────────────────────────────────────────
269
270    /// Parse `@define-color` declarations from a GTK theme's CSS and apply
271    /// well-known color names to the `OsThemeColors` struct.
272    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        // Resolve well-known GTK color names to actual Color values.
281        // Try both bare names and DE-specific suffixed names (e.g. `_breeze`).
282        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        // Derive accent from selection color if not already set
315        if colors.accent.is_none()
316            && let Some(sel) = resolve(&["theme_selected_bg_color"])
317        {
318            colors.accent = Some(sel);
319        }
320    }
321
322    /// Load GTK CSS for a theme. Tries gtk-4.0 first, falls back to gtk-3.0.
323    /// Also checks user override directories.
324    fn load_gtk_css(theme_name: &str) -> String {
325        let candidates = [
326            // User themes
327            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            // System themes
338            format!("/usr/share/themes/{}/gtk-4.0/gtk.css", theme_name),
339            format!("/usr/share/themes/{}/gtk-3.0/gtk.css", theme_name),
340            // Flatpak/snap locations
341            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                // Skip placeholder files (e.g., Adwaita's "this file is no longer used")
347                if content.contains("@define-color") {
348                    return content;
349                }
350            }
351        }
352
353        String::new()
354    }
355
356    /// Parse all `@define-color name value;` declarations from GTK CSS.
357    /// Returns a map of name → raw value string.
358    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    /// Resolve a GTK CSS color name to a `Color`, following `@name` references.
374    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; // prevent infinite loops
388        }
389
390        let value = defined.get(name)?;
391
392        // Reference to another color: @other_name
393        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    /// Parse a CSS color value: #hex, rgb(...), rgba(...), or named color.
401    fn parse_css_color(value: &str) -> Option<Color> {
402        let value = value.trim();
403
404        // #rrggbb or #rrggbbaa
405        if value.starts_with('#') {
406            return Some(Color::from_hex(value));
407        }
408
409        // rgba(r, g, b, a) — values are 0-255 integers or percentages
410        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        // rgb(r, g, b)
425        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        // Named colors
436        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    /// Parse a CSS color component: integer (0-255) or float (already 0.0-1.0).
445    /// GTK CSS uses integer 0-255 for rgb/rgba channels.
446    fn parse_css_component(s: &str) -> Option<f32> {
447        let s = s.trim();
448        if s.contains('.') {
449            // Fractional value — treat as 0.0-1.0 range
450            s.parse::<f32>().ok()
451        } else if let Ok(i) = s.parse::<u16>() {
452            // Integer — treat as 0-255 range
453            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// ── macOS ───────────────────────────────────────────────────────────────────
538#[cfg(target_os = "macos")]
539mod platform {
540    use super::*;
541
542    pub(super) fn query_color_scheme() -> ColorSchemePreference {
543        // Windowless color-scheme detection isn't implemented on macOS yet; the
544        // reliable source is winit's `window.theme()` at/after window creation
545        // (the app layer should drive `ThemeMode::Native` from that). Warn once
546        // in debug so this fallback isn't silent.
547        // TODO: use NSAppearance.current.name via objc2 for windowless detection.
548        #[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// ── Windows ─────────────────────────────────────────────────────────────────
572#[cfg(target_os = "windows")]
573mod platform {
574    use super::*;
575
576    pub(super) fn query_color_scheme() -> ColorSchemePreference {
577        // Windowless color-scheme detection isn't implemented on Windows yet
578        // (UI_ViewManagement::UISettings or the AppsUseLightTheme registry key
579        // would do it); winit's `window.theme()` is the reliable path. Warn
580        // once in debug so this fallback isn't silent.
581        // TODO: read HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme
582        // 0 = dark, 1 = light
583        #[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// ── Fallback ────────────────────────────────────────────────────────────────
607#[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}