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//! On a KDE Plasma session ([`teksilo_kde_scheme::is_kde_session`]) the
11//! colours are KDE's own: the `kdeglobals` cascade, with its system layers
12//! and `kdedefaults`, and the colour scheme in effect with the user's
13//! accent, read through `teksilo-kde-scheme`. [`query_native_desktop`]
14//! also hands over the KDE desktop's settings (fonts, animation speed,
15//! widget style, Breeze style settings) as a [`KdeDesktop`].
16//!
17//! An [`OsThemeWatch`] hears these change while the application runs; see
18//! [`watch`].
19
20#[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/// What the desktop reports for `ThemeMode::Native`: its colours, and on a
32/// KDE Plasma session its desktop settings.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub struct NativeDesktop {
36    /// The desktop's colour report.
37    pub os: OsThemeColors,
38    /// The KDE desktop's colour scheme and settings, on a KDE session whose
39    /// `kdeglobals` could be read; `None` elsewhere.
40    ///
41    /// When present, `os` is its scheme's colour report
42    /// ([`KdeColorScheme::os_theme_colors`](teksilo_kde_scheme::KdeColorScheme::os_theme_colors)).
43    pub kde: Option<KdeDesktop>,
44}
45
46impl NativeDesktop {
47    /// A desktop reporting `os` and no KDE settings.
48    pub fn new(os: OsThemeColors) -> Self {
49        NativeDesktop { os, kde: None }
50    }
51}
52
53/// Query only the OS light/dark preference (lightweight).
54/// Used by `ThemeMode::FollowSystem`.
55///
56/// On Linux the desktop portal is asked first. When it does not answer, a
57/// KDE session answers from its colour scheme (dark when the window
58/// background is dark, the rule KDE reports to applications), and GNOME and
59/// Cinnamon from their GTK theme's name.
60pub fn query_color_scheme() -> ColorSchemePreference {
61    platform::query_color_scheme()
62}
63
64/// Query the desktop's colours and, on a KDE Plasma session, its settings.
65/// Used by `ThemeMode::Native`.
66///
67/// On a KDE session the colours are the KDE colour scheme's
68/// ([`KdeColorScheme::os_theme_colors`](teksilo_kde_scheme::KdeColorScheme::os_theme_colors)),
69/// accent and light or dark preference included; only when no
70/// `kdeglobals` file can be read does the report fall back to the desktop
71/// portal's preference and accent colour, with no [`KdeDesktop`]. Elsewhere
72/// the report is the one [`query_os_theme_colors`] has always given.
73pub fn query_native_desktop() -> NativeDesktop {
74    platform::query_native_desktop()
75}
76
77/// Query full OS theme colors from desktop config files.
78/// Used by `ThemeMode::Native`.
79///
80/// The colour report of [`query_native_desktop`].
81pub fn query_os_theme_colors() -> OsThemeColors {
82    query_native_desktop().os
83}
84
85// ── Linux ───────────────────────────────────────────────────────────────────
86#[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        // XDG portal: 0 = no preference, 1 = dark, 2 = light.
95        // Works on GNOME and on KDE Plasma 5.27+ (with the
96        // `xdg-desktop-portal-kde` package installed). On older or
97        // headless KDE setups the portal call returns None and we
98        // fall through.
99        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        // Desktop-specific fallback. Picks the right config source
108        // for the current DE rather than blindly trying GNOME's
109        // gsettings (which is empty on a clean KDE install and
110        // would otherwise leave us at `NoPreference`, mapping
111        // dark-mode KDE users to a light theme).
112        match detect_desktop() {
113            Desktop::Kde => {
114                // The scheme in effect decides, by its window background,
115                // as KDE reports it to applications and to the portal.
116                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    /// The desktop portal's accent colour (RGB doubles).
173    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    // ── GNOME ────────────────────────────────────────────────────────────
179
180    fn query_gnome(colors: &mut OsThemeColors) {
181        // Accent color from XDG portal (RGB doubles)
182        colors.accent = portal_accent();
183
184        // Fallback: GNOME 47 named accent
185        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        // Read surface/selection colors from GTK CSS
192        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    /// Map GNOME 47 named accent colors to RGB.
198    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    // ── Cinnamon ─────────────────────────────────────────────────────────
215
216    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            // Infer accent from Mint-Y theme name suffix
222            colors.accent = mint_y_accent(name);
223
224            // Read surface/selection colors from GTK CSS
225            apply_gtk_css_colors(colors, name);
226        }
227    }
228
229    /// Map Mint-Y theme name suffixes to accent colors.
230    fn mint_y_accent(theme_name: &str) -> Option<Color> {
231        // Theme names like "Mint-Y-Dark-Aqua" → extract the last segment
232        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            // Default Mint-Y (no accent suffix) uses green
244            _ if theme_name.starts_with("Mint-Y") => "#92b372",
245            _ => return None,
246        };
247        Some(Color::from_hex(hex))
248    }
249
250    // ── Shared GTK CSS parser ────────────────────────────────────────────
251
252    /// Parse `@define-color` declarations from a GTK theme's CSS and apply
253    /// well-known color names to the `OsThemeColors` struct.
254    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        // Resolve well-known GTK color names to actual Color values.
263        // Try both bare names and DE-specific suffixed names (e.g. `_breeze`).
264        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        // Derive accent from selection color if not already set
298        if colors.accent.is_none()
299            && let Some(sel) = resolve(&["theme_selected_bg_color"])
300        {
301            colors.accent = Some(sel);
302        }
303    }
304
305    /// Fill the content-view and semantic colours from the GTK names that
306    /// carry them, leaving any a desktop reader already set. GTK themes
307    /// publish no alternate-row or secondary-text colour (its
308    /// `insensitive_fg_color` is disabled text, not secondary), so
309    /// `alternate_bg` and `inactive_fg` stay `None` here. Breeze GTK names its
310    /// links and its focus decoration only with a `_breeze` suffix, hence the
311    /// second name in those lists.
312    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    /// Load GTK CSS for a theme. Tries gtk-4.0 first, falls back to gtk-3.0.
335    /// Also checks user override directories.
336    fn load_gtk_css(theme_name: &str) -> String {
337        let candidates = [
338            // User themes
339            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            // System themes
350            format!("/usr/share/themes/{}/gtk-4.0/gtk.css", theme_name),
351            format!("/usr/share/themes/{}/gtk-3.0/gtk.css", theme_name),
352            // Flatpak/snap locations
353            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                // Skip placeholder files (e.g., Adwaita's "this file is no longer used")
359                if content.contains("@define-color") {
360                    return content;
361                }
362            }
363        }
364
365        String::new()
366    }
367
368    /// Parse all `@define-color name value;` declarations from GTK CSS.
369    /// Returns a map of name → raw value string.
370    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    /// Resolve a GTK CSS color name to a `Color`, following `@name` references.
386    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; // prevent infinite loops
400        }
401
402        let value = defined.get(name)?;
403
404        // Reference to another color: @other_name
405        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    /// Parse a CSS color value: #hex, rgb(...), rgba(...), or named color.
413    fn parse_css_color(value: &str) -> Option<Color> {
414        let value = value.trim();
415
416        // #rrggbb or #rrggbbaa
417        if value.starts_with('#') {
418            return Some(Color::from_hex(value));
419        }
420
421        // rgba(r, g, b, a) — values are 0-255 integers or percentages
422        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        // rgb(r, g, b)
437        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        // Named colors
448        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    /// Parse a CSS color component: integer (0-255) or float (already 0.0-1.0).
457    /// GTK CSS uses integer 0-255 for rgb/rgba channels.
458    fn parse_css_component(s: &str) -> Option<f32> {
459        let s = s.trim();
460        if s.contains('.') {
461            // Fractional value — treat as 0.0-1.0 range
462            s.parse::<f32>().ok()
463        } else if let Ok(i) = s.parse::<u16>() {
464            // Integer — treat as 0-255 range
465            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            // A cut-down Breeze GTK theme: the bare names reference the
494            // `_breeze` ones, and links and focus exist only suffixed.
495            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            // A colour a desktop reader already set is kept.
514            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            // GTK carries neither of these.
525            assert_eq!(colors.alternate_bg, None);
526            assert_eq!(colors.inactive_fg, None);
527
528            // A theme without the names leaves every field absent.
529            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// ── macOS ───────────────────────────────────────────────────────────────────
575#[cfg(target_os = "macos")]
576mod platform {
577    use super::*;
578
579    pub(super) fn query_color_scheme() -> ColorSchemePreference {
580        // Windowless color-scheme detection isn't implemented on macOS yet; the
581        // reliable source is winit's `window.theme()` at/after window creation
582        // (the app layer should drive `ThemeMode::Native` from that). Warn once
583        // in debug so this fallback isn't silent.
584        // TODO: use NSAppearance.current.name via objc2 for windowless detection.
585        #[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// ── Windows ─────────────────────────────────────────────────────────────────
606#[cfg(target_os = "windows")]
607mod platform {
608    use super::*;
609
610    pub(super) fn query_color_scheme() -> ColorSchemePreference {
611        // Windowless color-scheme detection isn't implemented on Windows yet
612        // (UI_ViewManagement::UISettings or the AppsUseLightTheme registry key
613        // would do it); winit's `window.theme()` is the reliable path. Warn
614        // once in debug so this fallback isn't silent.
615        // TODO: read HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize\AppsUseLightTheme
616        // 0 = dark, 1 = light
617        #[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// ── Fallback ────────────────────────────────────────────────────────────────
638#[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}