Skip to main content

native_theme/
lib.rs

1//! # native-theme
2//!
3//! Cross-platform native theme detection and loading for Rust GUI applications.
4//!
5//! Any Rust GUI app can look native on any platform by loading a single theme
6//! file or reading live OS settings, without coupling to any specific toolkit.
7
8#![warn(missing_docs)]
9#![deny(unsafe_code)]
10#![deny(clippy::unwrap_used)]
11#![deny(clippy::expect_used)]
12
13#[doc = include_str!("../README.md")]
14#[cfg(doctest)]
15pub struct ReadmeDoctests;
16
17/// Generates `merge()` and `is_empty()` methods for theme structs.
18///
19/// Four field categories:
20/// - `option { field1, field2, ... }` -- `Option<T>` leaf fields
21/// - `soft_option { field1, field2, ... }` -- `Option<T>` leaf fields (same merge/is_empty as option)
22/// - `nested { field1, field2, ... }` -- nested struct fields with their own `merge()`
23/// - `optional_nested { field1, field2, ... }` -- `Option<T>` where T has its own `merge()`
24///
25/// For `option` and `soft_option` fields, `Some` values in the overlay replace the
26/// corresponding fields in self; `None` fields are left unchanged.
27/// For `nested` fields, merge is called recursively.
28/// For `optional_nested` fields: if both base and overlay are `Some`, the inner values
29/// are merged recursively. If base is `None` and overlay is `Some`, overlay is cloned.
30/// If overlay is `None`, the base field is preserved unchanged.
31///
32/// # Examples
33///
34/// ```ignore
35/// impl_merge!(MyColors {
36///     option { accent, background }
37/// });
38/// ```
39macro_rules! impl_merge {
40    (
41        $struct_name:ident {
42            $(option { $($opt_field:ident),* $(,)? })?
43            $(soft_option { $($so_field:ident),* $(,)? })?
44            $(nested { $($nest_field:ident),* $(,)? })?
45            $(optional_nested { $($on_field:ident),* $(,)? })?
46        }
47    ) => {
48        impl $struct_name {
49            /// Merge an overlay into this value. `Some` fields in the overlay
50            /// replace the corresponding fields in self; `None` fields are
51            /// left unchanged. Nested structs are merged recursively.
52            pub fn merge(&mut self, overlay: &Self) {
53                $($(
54                    if overlay.$opt_field.is_some() {
55                        self.$opt_field = overlay.$opt_field.clone();
56                    }
57                )*)?
58                $($(
59                    if overlay.$so_field.is_some() {
60                        self.$so_field = overlay.$so_field.clone();
61                    }
62                )*)?
63                $($(
64                    self.$nest_field.merge(&overlay.$nest_field);
65                )*)?
66                $($(
67                    match (&mut self.$on_field, &overlay.$on_field) {
68                        (Some(base), Some(over)) => base.merge(over),
69                        (None, Some(over)) => self.$on_field = Some(over.clone()),
70                        _ => {}
71                    }
72                )*)?
73            }
74
75            /// Returns true if all fields are at their default (None/empty) state.
76            pub fn is_empty(&self) -> bool {
77                true
78                $($(&& self.$opt_field.is_none())*)?
79                $($(&& self.$so_field.is_none())*)?
80                $($(&& self.$nest_field.is_empty())*)?
81                $($(&& self.$on_field.as_ref().map_or(true, |v| v.is_empty()))*)?
82            }
83        }
84    };
85}
86
87/// Color types and sRGB utilities.
88pub mod color;
89/// Error types for theme operations.
90pub mod error;
91/// GNOME portal theme reader.
92#[cfg(all(target_os = "linux", feature = "portal"))]
93pub mod gnome;
94/// KDE theme reader.
95#[cfg(all(target_os = "linux", feature = "kde"))]
96pub mod kde;
97/// Theme data model types.
98pub mod model;
99/// Bundled theme presets.
100pub mod presets;
101/// Theme resolution engine (inheritance + validation).
102mod resolve;
103#[cfg(any(
104    feature = "material-icons",
105    feature = "lucide-icons",
106    feature = "system-icons"
107))]
108mod spinners;
109
110pub use color::{ParseColorError, Rgba};
111pub use error::{Error, ThemeResolutionError};
112pub use model::{
113    AnimatedIcon, BorderSpec, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme,
114    DialogButtonOrder, DialogTheme, ExpanderTheme, FontSize, FontSpec, FontStyle, IconData,
115    IconProvider, IconRole, IconSet, IconSizes, InputTheme, LayoutTheme, LinkTheme, ListTheme,
116    MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedBorderSpec, ResolvedFontSpec,
117    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
118    ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme, SeparatorTheme, SidebarTheme,
119    SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme, SwitchTheme, TabTheme, TextScale,
120    TextScaleEntry, ThemeDefaults, ThemeSpec, ThemeVariant, ToolbarTheme, TooltipTheme,
121    TransformAnimation, WindowTheme, bundled_icon_by_name, bundled_icon_svg,
122};
123// icon helper functions re-exported from this module
124pub use model::icons::{detect_icon_theme, icon_name, system_icon_set, system_icon_theme};
125
126/// Freedesktop icon theme lookup (Linux).
127#[cfg(all(target_os = "linux", feature = "system-icons"))]
128pub mod freedesktop;
129/// macOS platform helpers.
130#[cfg(target_os = "macos")]
131pub mod macos;
132#[cfg(not(target_os = "macos"))]
133pub(crate) mod macos;
134/// SVG-to-RGBA rasterization utilities.
135#[cfg(feature = "svg-rasterize")]
136pub mod rasterize;
137/// SF Symbols icon loader (macOS).
138#[cfg(all(target_os = "macos", feature = "system-icons"))]
139pub mod sficons;
140/// Windows platform theme reader.
141#[cfg(target_os = "windows")]
142pub mod windows;
143#[cfg(not(target_os = "windows"))]
144#[allow(dead_code, unused_variables)]
145pub(crate) mod windows;
146/// Windows Segoe Fluent / stock icon loader.
147#[cfg(all(target_os = "windows", feature = "system-icons"))]
148pub mod winicons;
149#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
150#[allow(dead_code, unused_imports)]
151pub(crate) mod winicons;
152
153#[cfg(all(target_os = "linux", feature = "system-icons"))]
154pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
155#[cfg(all(target_os = "linux", feature = "portal"))]
156pub use gnome::from_gnome;
157#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
158pub use gnome::from_kde_with_portal;
159#[cfg(all(target_os = "linux", feature = "kde"))]
160pub use kde::from_kde;
161#[cfg(all(target_os = "macos", feature = "macos"))]
162pub use macos::from_macos;
163#[cfg(feature = "svg-rasterize")]
164pub use rasterize::rasterize_svg;
165#[cfg(all(target_os = "macos", feature = "system-icons"))]
166pub use sficons::load_sf_icon;
167#[cfg(all(target_os = "macos", feature = "system-icons"))]
168pub use sficons::load_sf_icon_by_name;
169#[cfg(all(target_os = "windows", feature = "windows"))]
170pub use windows::from_windows;
171#[cfg(all(target_os = "windows", feature = "system-icons"))]
172pub use winicons::load_windows_icon;
173#[cfg(all(target_os = "windows", feature = "system-icons"))]
174pub use winicons::load_windows_icon_by_name;
175
176/// Convenience Result type alias for this crate.
177pub type Result<T> = std::result::Result<T, Error>;
178
179/// Desktop environments recognized on Linux.
180#[cfg(target_os = "linux")]
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
182pub enum LinuxDesktop {
183    /// KDE Plasma desktop.
184    Kde,
185    /// GNOME desktop.
186    Gnome,
187    /// Xfce desktop.
188    Xfce,
189    /// Cinnamon desktop (Linux Mint).
190    Cinnamon,
191    /// MATE desktop.
192    Mate,
193    /// LXQt desktop.
194    LxQt,
195    /// Budgie desktop.
196    Budgie,
197    /// Unrecognized or unset desktop environment.
198    Unknown,
199}
200
201/// Read the `XDG_CURRENT_DESKTOP` environment variable, returning an
202/// empty string if unset or invalid UTF-8.
203#[cfg(target_os = "linux")]
204pub(crate) fn xdg_current_desktop() -> String {
205    std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default()
206}
207
208/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
209/// the recognized desktop environment.
210///
211/// Checks components in order; first recognized DE wins. Budgie is checked
212/// before GNOME because Budgie sets `Budgie:GNOME`.
213#[cfg(target_os = "linux")]
214#[must_use]
215pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
216    for component in xdg_current_desktop.split(':') {
217        match component {
218            "KDE" => return LinuxDesktop::Kde,
219            "Budgie" => return LinuxDesktop::Budgie,
220            "GNOME" => return LinuxDesktop::Gnome,
221            "XFCE" => return LinuxDesktop::Xfce,
222            "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
223            "MATE" => return LinuxDesktop::Mate,
224            "LXQt" => return LinuxDesktop::LxQt,
225            _ => {}
226        }
227    }
228    LinuxDesktop::Unknown
229}
230
231static CACHED_IS_DARK: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
232
233/// Detect whether the system is using a dark color scheme.
234///
235/// Uses synchronous, platform-specific checks so the result is available
236/// immediately at window creation time (before any async portal response).
237///
238/// # Caching
239///
240/// The result is cached after the first call and reused on subsequent calls.
241/// Call [`invalidate_caches()`] to clear the cached value so the next call
242/// re-queries the OS. For a fresh reading without affecting the cache, use
243/// [`detect_is_dark()`] instead.
244///
245/// For live dark-mode tracking, subscribe to OS appearance-change events
246/// (D-Bus `SettingChanged` on Linux, `NSAppearance` KVO on macOS,
247/// `UISettings.ColorValuesChanged` on Windows) and call [`SystemTheme::from_system()`]
248/// to get a fresh [`SystemTheme`] with updated resolved variants.
249///
250/// # Platform Behavior
251///
252/// - **Linux:** Checks `GTK_THEME` env var for `:dark` suffix or `-dark`
253///   in name; queries `gsettings` for `color-scheme` (with 2-second
254///   timeout); falls back to KDE `kdeglobals` background luminance (with
255///   `kde` feature); reads `gtk-3.0/settings.ini` for
256///   `gtk-application-prefer-dark-theme=1` as final fallback.
257/// - **macOS:** Reads `AppleInterfaceStyle` via `NSUserDefaults` (with
258///   `macos` feature) or `defaults` subprocess (without).
259/// - **Windows:** Checks foreground color luminance from `UISettings` via
260///   BT.601 coefficients (requires `windows` feature).
261/// - **Other platforms / missing features:** Returns `false` (light).
262#[must_use = "this returns whether the system uses dark mode"]
263pub fn system_is_dark() -> bool {
264    if let Ok(guard) = CACHED_IS_DARK.read()
265        && let Some(v) = *guard
266    {
267        return v;
268    }
269    let value = detect_is_dark_inner();
270    if let Ok(mut guard) = CACHED_IS_DARK.write() {
271        *guard = Some(value);
272    }
273    value
274}
275
276/// Reset all process-wide caches so the next call to [`system_is_dark()`],
277/// [`prefers_reduced_motion()`], or [`system_icon_theme()`] re-queries the OS.
278///
279/// Call this when you detect that the user has changed system settings (e.g.,
280/// dark mode toggle, icon theme switch, accessibility preferences).
281///
282/// The `detect_*()` family of functions are unaffected — they always query
283/// the OS directly.
284pub fn invalidate_caches() {
285    if let Ok(mut g) = CACHED_IS_DARK.write() {
286        *g = None;
287    }
288    if let Ok(mut g) = CACHED_REDUCED_MOTION.write() {
289        *g = None;
290    }
291    crate::model::icons::invalidate_icon_theme_cache();
292}
293
294/// Detect whether the system is using a dark color scheme without caching.
295///
296/// Unlike [`system_is_dark()`], this function queries the OS every time it is
297/// called and never caches the result. Use this when polling for theme changes
298/// or implementing live dark-mode tracking.
299///
300/// See [`system_is_dark()`] for platform behavior details.
301#[must_use = "this returns whether the system uses dark mode"]
302pub fn detect_is_dark() -> bool {
303    detect_is_dark_inner()
304}
305
306/// Run a gsettings command with a 2-second timeout.
307///
308/// Spawns `gsettings` with the given arguments, waits up to 2 seconds
309/// for completion, and returns the trimmed stdout on success.  Returns
310/// `None` if the command fails, times out, or produces empty output.
311///
312/// Used by [`detect_is_dark_inner()`] and [`gnome::read_gsetting()`] to
313/// prevent gsettings from blocking indefinitely when D-Bus is unresponsive.
314#[cfg(target_os = "linux")]
315pub(crate) fn run_gsettings_with_timeout(args: &[&str]) -> Option<String> {
316    use std::io::Read;
317    use std::time::{Duration, Instant};
318
319    let deadline = Instant::now() + Duration::from_secs(2);
320    let mut child = std::process::Command::new("gsettings")
321        .args(args)
322        .stdout(std::process::Stdio::piped())
323        .stderr(std::process::Stdio::null())
324        .spawn()
325        .ok()?;
326
327    loop {
328        match child.try_wait() {
329            Ok(Some(status)) if status.success() => {
330                let mut buf = String::new();
331                if let Some(mut stdout) = child.stdout.take() {
332                    let _ = stdout.read_to_string(&mut buf);
333                }
334                let trimmed = buf.trim().to_string();
335                return if trimmed.is_empty() {
336                    None
337                } else {
338                    Some(trimmed)
339                };
340            }
341            Ok(Some(_)) => return None,
342            Ok(None) => {
343                if Instant::now() >= deadline {
344                    let _ = child.kill();
345                    return None;
346                }
347                std::thread::sleep(Duration::from_millis(50));
348            }
349            Err(_) => return None,
350        }
351    }
352}
353
354/// Read `Xft.dpi` from X resources via `xrdb -query`.
355///
356/// Returns `None` if xrdb is not installed, times out (2 seconds),
357/// or the output does not contain a valid positive `Xft.dpi` value.
358#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
359pub(crate) fn read_xft_dpi() -> Option<f32> {
360    use std::io::Read;
361    use std::time::{Duration, Instant};
362
363    let deadline = Instant::now() + Duration::from_secs(2);
364    let mut child = std::process::Command::new("xrdb")
365        .arg("-query")
366        .stdout(std::process::Stdio::piped())
367        .stderr(std::process::Stdio::null())
368        .spawn()
369        .ok()?;
370
371    loop {
372        match child.try_wait() {
373            Ok(Some(status)) if status.success() => {
374                let mut buf = String::new();
375                if let Some(mut stdout) = child.stdout.take() {
376                    let _ = stdout.read_to_string(&mut buf);
377                }
378                // Parse "Xft.dpi:\t96" from multi-line output
379                for line in buf.lines() {
380                    if let Some(rest) = line.strip_prefix("Xft.dpi:")
381                        && let Ok(dpi) = rest.trim().parse::<f32>()
382                        && dpi > 0.0
383                    {
384                        return Some(dpi);
385                    }
386                }
387                return None;
388            }
389            Ok(Some(_)) => return None,
390            Ok(None) => {
391                if Instant::now() >= deadline {
392                    let _ = child.kill();
393                    return None;
394                }
395                std::thread::sleep(Duration::from_millis(50));
396            }
397            Err(_) => return None,
398        }
399    }
400}
401
402/// Detect physical DPI from display hardware via `xrandr`.
403///
404/// Parses the primary connected output's resolution and physical dimensions
405/// to compute DPI. Falls back to the first connected output if no primary
406/// is found. Returns `None` if `xrandr` is unavailable, times out (2 seconds),
407/// or the output cannot be parsed.
408///
409/// This is a last-resort fallback: prefer `forceFontDPI` (KDE), `Xft.dpi`
410/// (X resources), or `GetDpiForSystem` (Windows) before calling this.
411#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
412pub(crate) fn detect_physical_dpi() -> Option<f32> {
413    use std::io::Read;
414    use std::time::{Duration, Instant};
415
416    let deadline = Instant::now() + Duration::from_secs(2);
417    let mut child = std::process::Command::new("xrandr")
418        .stdout(std::process::Stdio::piped())
419        .stderr(std::process::Stdio::null())
420        .spawn()
421        .ok()?;
422
423    loop {
424        match child.try_wait() {
425            Ok(Some(status)) if status.success() => {
426                let mut buf = String::new();
427                if let Some(mut stdout) = child.stdout.take() {
428                    let _ = stdout.read_to_string(&mut buf);
429                }
430                return parse_xrandr_dpi(&buf);
431            }
432            Ok(Some(_)) => return None,
433            Ok(None) => {
434                if Instant::now() >= deadline {
435                    let _ = child.kill();
436                    return None;
437                }
438                std::thread::sleep(Duration::from_millis(50));
439            }
440            Err(_) => return None,
441        }
442    }
443}
444
445/// Parse DPI from xrandr output.
446///
447/// Looks for lines like:
448/// ```text
449/// DP-1 connected primary 3840x2160+0+0 (...) 700mm x 390mm
450/// ```
451/// Extracts the current resolution from the mode string and the physical
452/// dimensions from the trailing `NNNmm x NNNmm`, then computes average DPI.
453#[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
454fn parse_xrandr_dpi(output: &str) -> Option<f32> {
455    // Prefer the primary output; fall back to the first connected output.
456    let line = output
457        .lines()
458        .find(|l| l.contains(" connected") && l.contains("primary"))
459        .or_else(|| {
460            output
461                .lines()
462                .find(|l| l.contains(" connected") && !l.contains("disconnected"))
463        })?;
464
465    // Resolution: "3840x2160+0+0" (digits x digits + offset)
466    let res_token = line
467        .split_whitespace()
468        .find(|s| s.contains('x') && s.contains('+'))?;
469    let (w_str, rest) = res_token.split_once('x')?;
470    let h_str = rest.split('+').next()?;
471    let w_px: f32 = w_str.parse().ok()?;
472    let h_px: f32 = h_str.parse().ok()?;
473
474    // Physical size: "700mm x 390mm" at the end of the line
475    let words: Vec<&str> = line.split_whitespace().collect();
476    let mut w_mm = None;
477    let mut h_mm = None;
478    for i in 1..words.len().saturating_sub(1) {
479        if words[i] == "x" {
480            w_mm = words[i - 1]
481                .strip_suffix("mm")
482                .and_then(|n| n.parse::<f32>().ok());
483            h_mm = words[i + 1]
484                .strip_suffix("mm")
485                .and_then(|n| n.parse::<f32>().ok());
486        }
487    }
488    let w_mm = w_mm.filter(|&v| v > 0.0)?;
489    let h_mm = h_mm.filter(|&v| v > 0.0)?;
490
491    let h_dpi = w_px / (w_mm / 25.4);
492    let v_dpi = h_px / (h_mm / 25.4);
493    let avg = (h_dpi + v_dpi) / 2.0;
494
495    if avg > 0.0 { Some(avg) } else { None }
496}
497
498#[cfg(all(test, target_os = "linux", any(feature = "kde", feature = "portal")))]
499#[allow(clippy::unwrap_used)]
500mod xrandr_dpi_tests {
501    use super::parse_xrandr_dpi;
502
503    #[test]
504    fn primary_4k_display() {
505        // Real xrandr output: 4K display at 700mm wide
506        let output = "Screen 0: minimum 16 x 16, current 3840 x 2160, maximum 32767 x 32767\n\
507                       DP-1 connected primary 3840x2160+0+0 (normal left inverted right x axis y axis) 700mm x 390mm\n\
508                          3840x2160     60.00*+\n";
509        let dpi = parse_xrandr_dpi(output).unwrap();
510        // 3840/(700/25.4) = 139.3, 2160/(390/25.4) = 140.7, avg ~140
511        assert!((dpi - 140.0).abs() < 1.0, "expected ~140 DPI, got {dpi}");
512    }
513
514    #[test]
515    fn standard_1080p_display() {
516        let output = "DP-2 connected primary 1920x1080+0+0 (normal) 530mm x 300mm\n";
517        let dpi = parse_xrandr_dpi(output).unwrap();
518        // 1920/(530/25.4) = 92.0, 1080/(300/25.4) = 91.4, avg ~91.7
519        assert!((dpi - 92.0).abs() < 1.0, "expected ~92 DPI, got {dpi}");
520    }
521
522    #[test]
523    fn no_primary_falls_back_to_first_connected() {
524        let output = "HDMI-1 connected 1920x1080+0+0 (normal) 480mm x 270mm\n\
525                       DP-1 disconnected\n";
526        let dpi = parse_xrandr_dpi(output).unwrap();
527        assert!(dpi > 90.0 && dpi < 110.0, "expected ~100 DPI, got {dpi}");
528    }
529
530    #[test]
531    fn disconnected_only_returns_none() {
532        let output = "DP-1 disconnected\nHDMI-1 disconnected\n";
533        assert!(parse_xrandr_dpi(output).is_none());
534    }
535
536    #[test]
537    fn missing_physical_dimensions_returns_none() {
538        // No "NNNmm x NNNmm" in the line
539        let output = "DP-1 connected primary 1920x1080+0+0 (normal)\n";
540        assert!(parse_xrandr_dpi(output).is_none());
541    }
542
543    #[test]
544    fn zero_mm_returns_none() {
545        let output = "DP-1 connected primary 1920x1080+0+0 (normal) 0mm x 0mm\n";
546        assert!(parse_xrandr_dpi(output).is_none());
547    }
548
549    #[test]
550    fn empty_output_returns_none() {
551        assert!(parse_xrandr_dpi("").is_none());
552    }
553}
554
555/// Detect the font DPI for the current system.
556///
557/// Used by [`ThemeVariant::into_resolved()`] as a fallback when no OS reader
558/// has provided `font_dpi`. Returns the platform-appropriate DPI for
559/// converting typographic points to logical pixels.
560///
561/// - **Linux (KDE)**: `forceFontDPI` from kdeglobals/kcmfontsrc → `Xft.dpi` → xrandr → 96.0
562/// - **Linux (other)**: `Xft.dpi` → xrandr → 96.0
563/// - **macOS**: 72.0 (Apple coordinate system: 1pt = 1px)
564/// - **Windows**: `GetDpiForSystem()` → 96.0
565/// - **Other**: 96.0
566#[allow(unreachable_code)]
567pub(crate) fn detect_system_font_dpi() -> f32 {
568    #[cfg(target_os = "macos")]
569    {
570        return 72.0;
571    }
572
573    #[cfg(all(target_os = "windows", feature = "windows"))]
574    {
575        return crate::windows::read_dpi() as f32;
576    }
577
578    // KDE: check forceFontDPI first (same chain as the KDE reader)
579    #[cfg(all(target_os = "linux", feature = "kde"))]
580    {
581        if let Some(dpi) = read_kde_force_font_dpi() {
582            return dpi;
583        }
584    }
585
586    #[cfg(all(target_os = "linux", any(feature = "kde", feature = "portal")))]
587    {
588        if let Some(dpi) = read_xft_dpi() {
589            return dpi;
590        }
591        if let Some(dpi) = detect_physical_dpi() {
592            return dpi;
593        }
594    }
595
596    96.0
597}
598
599/// Read KDE's `forceFontDPI` from kdeglobals or kcmfontsrc.
600///
601/// This mirrors the first step of [`crate::kde::detect_font_dpi()`] so that
602/// standalone preset loading (via [`ThemeVariant::into_resolved()`]) uses the
603/// same DPI as the full KDE reader pipeline.
604#[cfg(all(target_os = "linux", feature = "kde"))]
605fn read_kde_force_font_dpi() -> Option<f32> {
606    // Try kdeglobals [General] forceFontDPI
607    let path = crate::kde::kdeglobals_path();
608    if let Ok(content) = std::fs::read_to_string(&path) {
609        let mut ini = crate::kde::create_kde_parser();
610        if ini.read(content).is_ok()
611            && let Some(dpi_str) = ini.get("General", "forceFontDPI")
612            && let Ok(dpi) = dpi_str.trim().parse::<f32>()
613            && dpi > 0.0
614        {
615            return Some(dpi);
616        }
617    }
618    // Try kcmfontsrc [General] forceFontDPI
619    if let Some(dpi_str) = crate::kde::read_kcmfontsrc_key("General", "forceFontDPI")
620        && let Ok(dpi) = dpi_str.trim().parse::<f32>()
621        && dpi > 0.0
622    {
623        return Some(dpi);
624    }
625    None
626}
627
628/// Inner detection logic for [`system_is_dark()`].
629///
630/// Separated from the public function to allow caching via `OnceLock`.
631#[allow(unreachable_code)]
632fn detect_is_dark_inner() -> bool {
633    #[cfg(target_os = "linux")]
634    {
635        // Check GTK_THEME env var (works across all GTK-based DEs)
636        if let Ok(gtk_theme) = std::env::var("GTK_THEME") {
637            let lower = gtk_theme.to_lowercase();
638            if lower.ends_with(":dark") || lower.contains("-dark") {
639                return true;
640            }
641        }
642
643        // gsettings color-scheme (with 2-second timeout)
644        if let Some(val) =
645            run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "color-scheme"])
646        {
647            if val.contains("prefer-dark") {
648                return true;
649            }
650            if val.contains("prefer-light") || val.contains("default") {
651                return false;
652            }
653        }
654
655        // Fallback: read KDE's kdeglobals background luminance
656        #[cfg(feature = "kde")]
657        {
658            let path = crate::kde::kdeglobals_path();
659            if let Ok(content) = std::fs::read_to_string(&path) {
660                let mut ini = crate::kde::create_kde_parser();
661                if ini.read(content).is_ok() {
662                    return crate::kde::is_dark_theme(&ini);
663                }
664            }
665        }
666
667        // Fallback: gtk-3.0/settings.ini for DEs that set the GTK dark preference
668        let config_home = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
669            let home = std::env::var("HOME").unwrap_or_default();
670            format!("{home}/.config")
671        });
672        let ini_path = format!("{config_home}/gtk-3.0/settings.ini");
673        if let Ok(content) = std::fs::read_to_string(&ini_path) {
674            for line in content.lines() {
675                let trimmed = line.trim();
676                if trimmed.starts_with("gtk-application-prefer-dark-theme")
677                    && let Some(val) = trimmed.split('=').nth(1)
678                    && (val.trim() == "1" || val.trim().eq_ignore_ascii_case("true"))
679                {
680                    return true;
681                }
682            }
683        }
684
685        false
686    }
687
688    #[cfg(target_os = "macos")]
689    {
690        // AppleInterfaceStyle is "Dark" when dark mode is active.
691        // The key is absent in light mode, so any failure means light.
692        #[cfg(feature = "macos")]
693        {
694            use objc2_foundation::NSUserDefaults;
695            let defaults = NSUserDefaults::standardUserDefaults();
696            let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
697            if let Some(value) = defaults.stringForKey(key) {
698                return value.to_string().eq_ignore_ascii_case("dark");
699            }
700            return false;
701        }
702        #[cfg(not(feature = "macos"))]
703        {
704            if let Ok(output) = std::process::Command::new("defaults")
705                .args(["read", "-g", "AppleInterfaceStyle"])
706                .output()
707                && output.status.success()
708            {
709                let val = String::from_utf8_lossy(&output.stdout);
710                return val.trim().eq_ignore_ascii_case("dark");
711            }
712            return false;
713        }
714    }
715
716    #[cfg(target_os = "windows")]
717    {
718        #[cfg(feature = "windows")]
719        {
720            // BT.601 luminance: light foreground indicates dark background.
721            let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
722                return false;
723            };
724            let Ok(fg) =
725                settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
726            else {
727                return false;
728            };
729            let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
730            return luma > 128.0;
731        }
732        #[cfg(not(feature = "windows"))]
733        return false;
734    }
735
736    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
737    {
738        false
739    }
740}
741
742static CACHED_REDUCED_MOTION: std::sync::RwLock<Option<bool>> = std::sync::RwLock::new(None);
743
744/// Query whether the user prefers reduced motion.
745///
746/// Returns `true` when the OS accessibility setting indicates animations
747/// should be reduced or disabled. Returns `false` (allow animations) on
748/// unsupported platforms or when the query fails.
749///
750/// # Caching
751///
752/// The result is cached after the first call and reused on subsequent calls.
753/// Call [`invalidate_caches()`] to clear the cached value so the next call
754/// re-queries the OS. For live accessibility-change tracking, subscribe to
755/// OS accessibility events and call `invalidate_caches()` when notified.
756///
757/// # Platform Behavior
758///
759/// - **Linux:** Queries `gsettings get org.gnome.desktop.interface enable-animations`.
760///   Returns `true` when animations are disabled (`enable-animations` is `false`).
761/// - **macOS:** Queries `NSWorkspace.accessibilityDisplayShouldReduceMotion`
762///   (requires `macos` feature).
763/// - **Windows:** Queries `UISettings.AnimationsEnabled()` (requires `windows` feature).
764/// - **Other platforms:** Returns `false`.
765///
766/// # Examples
767///
768/// ```
769/// let reduced = native_theme::prefers_reduced_motion();
770/// // On this platform, the result depends on OS accessibility settings.
771/// // The function always returns a bool (false on unsupported platforms).
772/// assert!(reduced == true || reduced == false);
773/// ```
774#[must_use = "this returns whether reduced motion is preferred"]
775pub fn prefers_reduced_motion() -> bool {
776    if let Ok(guard) = CACHED_REDUCED_MOTION.read()
777        && let Some(v) = *guard
778    {
779        return v;
780    }
781    let value = detect_reduced_motion_inner();
782    if let Ok(mut guard) = CACHED_REDUCED_MOTION.write() {
783        *guard = Some(value);
784    }
785    value
786}
787
788/// Detect whether the user prefers reduced motion without caching.
789///
790/// Unlike [`prefers_reduced_motion()`], this function queries the OS every time
791/// it is called and never caches the result. Use this when polling for
792/// accessibility preference changes.
793///
794/// See [`prefers_reduced_motion()`] for platform behavior details.
795#[must_use = "this returns whether reduced motion is preferred"]
796pub fn detect_reduced_motion() -> bool {
797    detect_reduced_motion_inner()
798}
799
800/// Inner detection logic for [`prefers_reduced_motion()`].
801///
802/// Separated from the public function to allow caching via `OnceLock`.
803#[allow(unreachable_code)]
804fn detect_reduced_motion_inner() -> bool {
805    #[cfg(target_os = "linux")]
806    {
807        // gsettings boolean output is bare "true\n" or "false\n" (no quotes)
808        // enable-animations has INVERTED semantics: false => reduced motion preferred
809        if let Some(val) =
810            run_gsettings_with_timeout(&["get", "org.gnome.desktop.interface", "enable-animations"])
811        {
812            return val.trim() == "false";
813        }
814        false
815    }
816
817    #[cfg(target_os = "macos")]
818    {
819        #[cfg(feature = "macos")]
820        {
821            let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
822            // Direct semantics: true = reduce motion preferred (no inversion needed)
823            return workspace.accessibilityDisplayShouldReduceMotion();
824        }
825        #[cfg(not(feature = "macos"))]
826        return false;
827    }
828
829    #[cfg(target_os = "windows")]
830    {
831        #[cfg(feature = "windows")]
832        {
833            let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
834                return false;
835            };
836            // AnimationsEnabled has INVERTED semantics: false => reduced motion preferred
837            return match settings.AnimationsEnabled() {
838                Ok(enabled) => !enabled,
839                Err(_) => false,
840            };
841        }
842        #[cfg(not(feature = "windows"))]
843        return false;
844    }
845
846    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
847    {
848        false
849    }
850}
851
852/// Result of the OS-first pipeline. Holds both resolved variants.
853///
854/// Produced by [`SystemTheme::from_system()`] and [`SystemTheme::from_system_async()`].
855/// Both light and dark are always populated: the OS-active variant
856/// comes from the reader + preset + resolve, the inactive variant
857/// comes from the preset + resolve.
858#[derive(Clone, Debug)]
859pub struct SystemTheme {
860    /// Theme name (from reader or preset).
861    pub name: String,
862    /// Whether the OS is currently in dark mode.
863    pub is_dark: bool,
864    /// Resolved light variant (always populated).
865    pub light: ResolvedThemeVariant,
866    /// Resolved dark variant (always populated).
867    pub dark: ResolvedThemeVariant,
868    /// Pre-resolve light variant (retained for overlay support).
869    pub(crate) light_variant: ThemeVariant,
870    /// Pre-resolve dark variant (retained for overlay support).
871    pub(crate) dark_variant: ThemeVariant,
872    /// The platform preset used (e.g., "kde-breeze", "adwaita", "macos-sonoma").
873    pub preset: String,
874    /// The live preset name used internally (e.g., "kde-breeze-live").
875    pub(crate) live_preset: String,
876}
877
878impl SystemTheme {
879    /// Returns the OS-active resolved variant.
880    ///
881    /// If `is_dark` is true, returns `&self.dark`; otherwise `&self.light`.
882    #[must_use]
883    pub fn active(&self) -> &ResolvedThemeVariant {
884        if self.is_dark {
885            &self.dark
886        } else {
887            &self.light
888        }
889    }
890
891    /// Pick a resolved variant by explicit preference.
892    ///
893    /// `pick(true)` returns `&self.dark`, `pick(false)` returns `&self.light`.
894    #[must_use]
895    pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
896        if is_dark { &self.dark } else { &self.light }
897    }
898
899    /// Apply an app-level TOML overlay and re-resolve.
900    ///
901    /// Merges the overlay onto the pre-resolve [`ThemeVariant`] (not the
902    /// already-resolved [`ResolvedThemeVariant`]) so that changed source fields
903    /// propagate correctly through `resolve()`. For example, changing
904    /// `defaults.accent_color` in the overlay will cause `button.primary_background`,
905    /// `checkbox.checked_background`, `slider.fill`, etc. to be re-derived from
906    /// the new accent color.
907    ///
908    /// # Examples
909    ///
910    /// ```no_run
911    /// let system = native_theme::SystemTheme::from_system().unwrap();
912    /// let overlay = native_theme::ThemeSpec::from_toml(r##"
913    ///     [light.defaults]
914    ///     accent = "#ff6600"
915    ///     [dark.defaults]
916    ///     accent = "#ff6600"
917    /// "##).unwrap();
918    /// let customized = system.with_overlay(&overlay).unwrap();
919    /// // customized.active().defaults.accent_color is now #ff6600
920    /// // and all accent-derived fields are updated
921    /// ```
922    #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
923    pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
924        // Start from pre-resolve variants (avoids double-resolve idempotency issue)
925        let mut light = self.light_variant.clone();
926        let mut dark = self.dark_variant.clone();
927
928        // Merge overlay onto pre-resolve variants (overlay values win)
929        if let Some(over) = &overlay.light {
930            light.merge(over);
931        }
932        if let Some(over) = &overlay.dark {
933            dark.merge(over);
934        }
935
936        // Resolve and validate both
937        let resolved_light = light.clone().into_resolved()?;
938        let resolved_dark = dark.clone().into_resolved()?;
939
940        Ok(SystemTheme {
941            name: self.name.clone(),
942            is_dark: self.is_dark,
943            light: resolved_light,
944            dark: resolved_dark,
945            light_variant: light,
946            dark_variant: dark,
947            live_preset: self.live_preset.clone(),
948            preset: self.preset.clone(),
949        })
950    }
951
952    /// Apply an app overlay from a TOML string.
953    ///
954    /// Parses the TOML as a [`ThemeSpec`] and calls [`with_overlay`](Self::with_overlay).
955    #[must_use = "this returns a new theme with the overlay applied; it does not modify self"]
956    pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
957        let overlay = ThemeSpec::from_toml(toml)?;
958        self.with_overlay(&overlay)
959    }
960
961    /// Load the OS theme synchronously.
962    ///
963    /// Detects the platform and desktop environment, reads the current theme
964    /// settings, merges with a platform preset, and returns a fully resolved
965    /// [`SystemTheme`] with both light and dark variants.
966    ///
967    /// The return value goes through the full pipeline: reader output →
968    /// resolve → validate → [`SystemTheme`] with both light and dark
969    /// [`ResolvedThemeVariant`] variants.
970    ///
971    /// # Platform Behavior
972    ///
973    /// - **macOS:** Calls `from_macos()` when the `macos` feature is enabled.
974    ///   Reads both light and dark variants via NSAppearance, merges with
975    ///   `macos-sonoma` preset.
976    /// - **Linux (KDE):** Calls `from_kde()` when `XDG_CURRENT_DESKTOP` contains
977    ///   "KDE" and the `kde` feature is enabled, merges with `kde-breeze` preset.
978    /// - **Linux (other):** Uses the `adwaita` preset. For live GNOME portal
979    ///   data, use [`from_system_async()`](Self::from_system_async) (requires
980    ///   `portal-tokio` or `portal-async-io` feature).
981    /// - **Windows:** Calls `from_windows()` when the `windows` feature is enabled,
982    ///   merges with `windows-11` preset.
983    /// - **Other platforms:** Returns `Error::Unsupported`.
984    ///
985    /// # Errors
986    ///
987    /// - `Error::Unsupported` if the platform has no reader or the required feature
988    ///   is not enabled.
989    /// - `Error::Unavailable` if the platform reader cannot access theme data.
990    ///
991    /// # Examples
992    ///
993    /// ```no_run
994    /// let system = native_theme::SystemTheme::from_system().unwrap();
995    /// let active = system.active();
996    /// ```
997    #[must_use = "this returns the detected theme; it does not apply it"]
998    pub fn from_system() -> crate::Result<Self> {
999        from_system_inner()
1000    }
1001
1002    /// Async version of [`from_system()`](Self::from_system) that uses D-Bus
1003    /// portal backend detection to improve desktop environment heuristics on
1004    /// Linux.
1005    ///
1006    /// When `XDG_CURRENT_DESKTOP` is unset or unrecognized, queries the
1007    /// D-Bus session bus for portal backend activatable names to determine
1008    /// whether KDE or GNOME portal is running, then dispatches to the
1009    /// appropriate reader.
1010    ///
1011    /// Returns a [`SystemTheme`] with both resolved light and dark variants,
1012    /// same as [`from_system()`](Self::from_system).
1013    ///
1014    /// On non-Linux platforms, behaves identically to
1015    /// [`from_system()`](Self::from_system).
1016    #[cfg(target_os = "linux")]
1017    #[must_use = "this returns the detected theme; it does not apply it"]
1018    pub async fn from_system_async() -> crate::Result<Self> {
1019        from_system_async_inner().await
1020    }
1021
1022    /// Async version of [`from_system()`](Self::from_system).
1023    ///
1024    /// On non-Linux platforms, this is equivalent to calling
1025    /// [`from_system()`](Self::from_system).
1026    #[cfg(not(target_os = "linux"))]
1027    #[must_use = "this returns the detected theme; it does not apply it"]
1028    pub async fn from_system_async() -> crate::Result<Self> {
1029        from_system_inner()
1030    }
1031}
1032
1033/// Run the OS-first pipeline: merge reader output onto a platform
1034/// preset, resolve both light and dark variants, validate.
1035///
1036/// For the variant the reader supplied, the merged (reader + live preset)
1037/// version is used. For the variant the reader did NOT supply, the full
1038/// platform preset (with colors/fonts) is used as fallback.
1039fn run_pipeline(
1040    reader_output: ThemeSpec,
1041    preset_name: &str,
1042    is_dark: bool,
1043) -> crate::Result<SystemTheme> {
1044    let live_preset = ThemeSpec::preset(preset_name)?;
1045
1046    // For the inactive variant, load the full preset (with colors).
1047    // Falls back to original name if not a live preset (e.g. user preset).
1048    let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
1049    debug_assert!(
1050        full_preset_name != preset_name || !preset_name.ends_with("-live"),
1051        "live preset '{preset_name}' should have -live suffix stripped"
1052    );
1053    let full_preset = ThemeSpec::preset(full_preset_name)?;
1054
1055    // Merge: full preset provides color/font defaults, live preset overrides
1056    // geometry, reader output provides live OS data on top.
1057    let mut merged = full_preset.clone();
1058    merged.merge(&live_preset);
1059    merged.merge(&reader_output);
1060
1061    // Keep reader name if non-empty, else use preset name
1062    let name = if reader_output.name.is_empty() {
1063        merged.name.clone()
1064    } else {
1065        reader_output.name.clone()
1066    };
1067
1068    // For the variant the reader provided: use merged (live geometry + reader colors)
1069    // For the variant the reader didn't provide: use FULL preset (has colors).
1070    // unwrap_or_default() yields an empty ThemeVariant -- valid for merge.
1071    let mut light_variant = if reader_output.light.is_some() {
1072        merged.light.unwrap_or_default()
1073    } else {
1074        full_preset.light.unwrap_or_default()
1075    };
1076
1077    let mut dark_variant = if reader_output.dark.is_some() {
1078        merged.dark.unwrap_or_default()
1079    } else {
1080        full_preset.dark.unwrap_or_default()
1081    };
1082
1083    // Propagate font_dpi from the reader to both variants so the
1084    // pt->px conversion uses the system-detected DPI for both.
1085    // The active variant already has font_dpi via merge; the inactive
1086    // variant comes from the full preset (no reader data) and needs it.
1087    if let Some(reader_dpi) = reader_output
1088        .light
1089        .as_ref()
1090        .and_then(|v| v.defaults.font_dpi)
1091        .or_else(|| {
1092            reader_output
1093                .dark
1094                .as_ref()
1095                .and_then(|v| v.defaults.font_dpi)
1096        })
1097    {
1098        if light_variant.defaults.font_dpi.is_none() {
1099            light_variant.defaults.font_dpi = Some(reader_dpi);
1100        }
1101        if dark_variant.defaults.font_dpi.is_none() {
1102            dark_variant.defaults.font_dpi = Some(reader_dpi);
1103        }
1104    }
1105
1106    // Clone pre-resolve variants for overlay support (Plan 02)
1107    let light_variant_pre = light_variant.clone();
1108    let dark_variant_pre = dark_variant.clone();
1109
1110    let light = light_variant.into_resolved()?;
1111    let dark = dark_variant.into_resolved()?;
1112
1113    Ok(SystemTheme {
1114        name,
1115        is_dark,
1116        light,
1117        dark,
1118        light_variant: light_variant_pre,
1119        dark_variant: dark_variant_pre,
1120        preset: full_preset_name.to_string(),
1121        live_preset: preset_name.to_string(),
1122    })
1123}
1124
1125/// Map a Linux desktop environment to its matching live preset name.
1126///
1127/// This is the single source of truth for the DE-to-preset mapping used
1128/// by [`from_linux()`], [`from_system_async_inner()`], and
1129/// [`platform_preset_name()`].
1130///
1131/// - KDE -> `"kde-breeze-live"`
1132/// - All others (GNOME, XFCE, Cinnamon, MATE, LXQt, Budgie, Unknown)
1133///   -> `"adwaita-live"`
1134#[cfg(target_os = "linux")]
1135fn linux_preset_for_de(de: LinuxDesktop) -> &'static str {
1136    match de {
1137        LinuxDesktop::Kde => "kde-breeze-live",
1138        _ => "adwaita-live",
1139    }
1140}
1141
1142/// Map the current platform to its matching live preset name.
1143///
1144/// Live presets contain only geometry/metrics (no colors, fonts, or icons)
1145/// and are used as the merge base in the OS-first pipeline.
1146///
1147/// - macOS -> `"macos-sonoma-live"`
1148/// - Windows -> `"windows-11-live"`
1149/// - Linux KDE -> `"kde-breeze-live"`
1150/// - Linux other/GNOME -> `"adwaita-live"`
1151/// - Unknown platform -> `"adwaita-live"`
1152///
1153/// Returns the live preset name for the current platform.
1154///
1155/// This is the public API for what [`SystemTheme::from_system()`] uses internally.
1156/// Showcase UIs use this to build the "default (...)" label.
1157#[allow(unreachable_code)]
1158#[must_use]
1159pub fn platform_preset_name() -> &'static str {
1160    #[cfg(target_os = "macos")]
1161    {
1162        return "macos-sonoma-live";
1163    }
1164    #[cfg(target_os = "windows")]
1165    {
1166        return "windows-11-live";
1167    }
1168    #[cfg(target_os = "linux")]
1169    {
1170        linux_preset_for_de(detect_linux_de(&xdg_current_desktop()))
1171    }
1172    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
1173    {
1174        "adwaita-live"
1175    }
1176}
1177
1178/// Check whether OS theme detection is available on this platform.
1179///
1180/// Returns a list of human-readable diagnostic messages describing what
1181/// detection capabilities are available and what might be missing. Useful
1182/// for debugging theme detection failures in end-user applications.
1183///
1184/// # Platform Behavior
1185///
1186/// - **Linux:** Reports detected desktop environment, `gsettings`
1187///   availability, `XDG_CURRENT_DESKTOP` value, and KDE config file
1188///   presence (when the `kde` feature is enabled).
1189/// - **macOS:** Reports whether the `macos` feature is enabled.
1190/// - **Windows:** Reports whether the `windows` feature is enabled.
1191/// - **Other:** Reports that no platform detection is available.
1192///
1193/// # Examples
1194///
1195/// ```
1196/// let diagnostics = native_theme::diagnose_platform_support();
1197/// for line in &diagnostics {
1198///     println!("{}", line);
1199/// }
1200/// ```
1201#[must_use]
1202pub fn diagnose_platform_support() -> Vec<String> {
1203    let mut diagnostics = Vec::new();
1204
1205    #[cfg(target_os = "linux")]
1206    {
1207        diagnostics.push("Platform: Linux".to_string());
1208
1209        // Check XDG_CURRENT_DESKTOP
1210        match std::env::var("XDG_CURRENT_DESKTOP") {
1211            Ok(val) if !val.is_empty() => {
1212                let de = detect_linux_de(&val);
1213                diagnostics.push(format!("XDG_CURRENT_DESKTOP: {val}"));
1214                diagnostics.push(format!("Detected DE: {de:?}"));
1215            }
1216            _ => {
1217                diagnostics.push("XDG_CURRENT_DESKTOP: not set".to_string());
1218                diagnostics.push("Detected DE: Unknown (env var missing)".to_string());
1219            }
1220        }
1221
1222        // Check gsettings availability
1223        match std::process::Command::new("gsettings")
1224            .arg("--version")
1225            .output()
1226        {
1227            Ok(output) if output.status.success() => {
1228                let version = String::from_utf8_lossy(&output.stdout);
1229                diagnostics.push(format!("gsettings: available ({})", version.trim()));
1230            }
1231            Ok(_) => {
1232                diagnostics.push("gsettings: found but returned error".to_string());
1233            }
1234            Err(_) => {
1235                diagnostics.push(
1236                    "gsettings: not found (dark mode and icon theme detection may be limited)"
1237                        .to_string(),
1238                );
1239            }
1240        }
1241
1242        // Check KDE config files
1243        #[cfg(feature = "kde")]
1244        {
1245            let path = crate::kde::kdeglobals_path();
1246            if path.exists() {
1247                diagnostics.push(format!("KDE kdeglobals: found at {}", path.display()));
1248            } else {
1249                diagnostics.push(format!("KDE kdeglobals: not found at {}", path.display()));
1250            }
1251        }
1252
1253        #[cfg(not(feature = "kde"))]
1254        {
1255            diagnostics.push("KDE support: disabled (kde feature not enabled)".to_string());
1256        }
1257
1258        // Report portal feature status
1259        #[cfg(feature = "portal")]
1260        diagnostics.push("Portal support: enabled".to_string());
1261
1262        #[cfg(not(feature = "portal"))]
1263        diagnostics.push("Portal support: disabled (portal feature not enabled)".to_string());
1264    }
1265
1266    #[cfg(target_os = "macos")]
1267    {
1268        diagnostics.push("Platform: macOS".to_string());
1269
1270        #[cfg(feature = "macos")]
1271        diagnostics.push("macOS theme detection: enabled (macos feature active)".to_string());
1272
1273        #[cfg(not(feature = "macos"))]
1274        diagnostics.push(
1275            "macOS theme detection: limited (macos feature not enabled, using subprocess fallback)"
1276                .to_string(),
1277        );
1278    }
1279
1280    #[cfg(target_os = "windows")]
1281    {
1282        diagnostics.push("Platform: Windows".to_string());
1283
1284        #[cfg(feature = "windows")]
1285        diagnostics.push("Windows theme detection: enabled (windows feature active)".to_string());
1286
1287        #[cfg(not(feature = "windows"))]
1288        diagnostics
1289            .push("Windows theme detection: disabled (windows feature not enabled)".to_string());
1290    }
1291
1292    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1293    {
1294        diagnostics.push("Platform: unsupported (no native theme detection available)".to_string());
1295    }
1296
1297    diagnostics
1298}
1299
1300/// Infer dark-mode preference from the reader's output.
1301///
1302/// Returns `true` if the reader populated only the dark variant,
1303/// `false` if it populated only light or both variants.
1304/// On platforms that produce both variants (macOS), this defaults to
1305/// `false` (light); callers can use [`SystemTheme::pick()`] for
1306/// explicit variant selection regardless of this default.
1307#[allow(dead_code)]
1308fn reader_is_dark(reader: &ThemeSpec) -> bool {
1309    reader.dark.is_some() && reader.light.is_none()
1310}
1311
1312/// Read the current system theme on Linux by detecting the desktop
1313/// environment and calling the appropriate reader or returning a
1314/// preset fallback.
1315///
1316/// Runs the full OS-first pipeline: reader -> preset merge -> resolve -> validate.
1317#[cfg(target_os = "linux")]
1318fn from_linux() -> crate::Result<SystemTheme> {
1319    let is_dark = system_is_dark();
1320    let de = detect_linux_de(&xdg_current_desktop());
1321    let preset = linux_preset_for_de(de);
1322    match de {
1323        #[cfg(feature = "kde")]
1324        LinuxDesktop::Kde => {
1325            let reader = crate::kde::from_kde()?;
1326            run_pipeline(reader, preset, is_dark)
1327        }
1328        #[cfg(not(feature = "kde"))]
1329        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
1330        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1331            // GNOME sync path: no portal, just adwaita preset
1332            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1333        }
1334        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
1335            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1336        }
1337        LinuxDesktop::Unknown => {
1338            #[cfg(feature = "kde")]
1339            {
1340                let path = crate::kde::kdeglobals_path();
1341                if path.exists() {
1342                    let reader = crate::kde::from_kde()?;
1343                    return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
1344                }
1345            }
1346            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1347        }
1348    }
1349}
1350
1351fn from_system_inner() -> crate::Result<SystemTheme> {
1352    #[cfg(target_os = "macos")]
1353    {
1354        #[cfg(feature = "macos")]
1355        {
1356            let reader = crate::macos::from_macos()?;
1357            let is_dark = reader_is_dark(&reader);
1358            return run_pipeline(reader, "macos-sonoma-live", is_dark);
1359        }
1360
1361        #[cfg(not(feature = "macos"))]
1362        return Err(crate::Error::Unsupported(
1363            "macOS theme detection requires the `macos` feature",
1364        ));
1365    }
1366
1367    #[cfg(target_os = "windows")]
1368    {
1369        #[cfg(feature = "windows")]
1370        {
1371            let reader = crate::windows::from_windows()?;
1372            let is_dark = reader_is_dark(&reader);
1373            return run_pipeline(reader, "windows-11-live", is_dark);
1374        }
1375
1376        #[cfg(not(feature = "windows"))]
1377        return Err(crate::Error::Unsupported(
1378            "Windows theme detection requires the `windows` feature",
1379        ));
1380    }
1381
1382    #[cfg(target_os = "linux")]
1383    {
1384        from_linux()
1385    }
1386
1387    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
1388    {
1389        Err(crate::Error::Unsupported(
1390            "no theme reader available for this platform",
1391        ))
1392    }
1393}
1394
1395#[cfg(target_os = "linux")]
1396async fn from_system_async_inner() -> crate::Result<SystemTheme> {
1397    let is_dark = system_is_dark();
1398    let de = detect_linux_de(&xdg_current_desktop());
1399    let preset = linux_preset_for_de(de);
1400    match de {
1401        #[cfg(feature = "kde")]
1402        LinuxDesktop::Kde => {
1403            #[cfg(feature = "portal")]
1404            {
1405                let reader = crate::gnome::from_kde_with_portal().await?;
1406                run_pipeline(reader, preset, is_dark)
1407            }
1408            #[cfg(not(feature = "portal"))]
1409            {
1410                let reader = crate::kde::from_kde()?;
1411                run_pipeline(reader, preset, is_dark)
1412            }
1413        }
1414        #[cfg(not(feature = "kde"))]
1415        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
1416        #[cfg(feature = "portal")]
1417        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1418            let reader = crate::gnome::from_gnome().await?;
1419            run_pipeline(reader, preset, is_dark)
1420        }
1421        #[cfg(not(feature = "portal"))]
1422        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
1423            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1424        }
1425        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
1426            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1427        }
1428        LinuxDesktop::Unknown => {
1429            // Use D-Bus portal backend detection to refine heuristic
1430            #[cfg(feature = "portal")]
1431            {
1432                if let Some(detected) = crate::gnome::detect_portal_backend().await {
1433                    let detected_preset = linux_preset_for_de(detected);
1434                    return match detected {
1435                        #[cfg(feature = "kde")]
1436                        LinuxDesktop::Kde => {
1437                            let reader = crate::gnome::from_kde_with_portal().await?;
1438                            run_pipeline(reader, detected_preset, is_dark)
1439                        }
1440                        #[cfg(not(feature = "kde"))]
1441                        LinuxDesktop::Kde => {
1442                            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
1443                        }
1444                        LinuxDesktop::Gnome => {
1445                            let reader = crate::gnome::from_gnome().await?;
1446                            run_pipeline(reader, detected_preset, is_dark)
1447                        }
1448                        _ => {
1449                            // detect_portal_backend only returns Kde or Gnome;
1450                            // fall back to Adwaita if the set ever grows.
1451                            run_pipeline(ThemeSpec::preset("adwaita")?, detected_preset, is_dark)
1452                        }
1453                    };
1454                }
1455            }
1456            // Sync fallback: try kdeglobals, then Adwaita
1457            #[cfg(feature = "kde")]
1458            {
1459                let path = crate::kde::kdeglobals_path();
1460                if path.exists() {
1461                    let reader = crate::kde::from_kde()?;
1462                    return run_pipeline(reader, linux_preset_for_de(LinuxDesktop::Kde), is_dark);
1463                }
1464            }
1465            run_pipeline(ThemeSpec::preset("adwaita")?, preset, is_dark)
1466        }
1467    }
1468}
1469
1470/// Load an icon for the given role using the specified icon set.
1471///
1472/// Dispatches to the appropriate platform loader or bundled icon set
1473/// based on the [`IconSet`] variant:
1474///
1475/// - [`IconSet::Freedesktop`] -- freedesktop theme lookup at 24 px using the
1476///   system's installed icon theme (requires `system-icons` feature, Linux only)
1477/// - [`IconSet::SfSymbols`] -- SF Symbols lookup
1478///   (requires `system-icons` feature, macOS only)
1479/// - [`IconSet::SegoeIcons`] -- Segoe Fluent lookup
1480///   (requires `system-icons` feature, Windows only)
1481/// - [`IconSet::Material`] -- bundled Material SVG
1482///   (requires `material-icons` feature)
1483/// - [`IconSet::Lucide`] -- bundled Lucide SVG
1484///   (requires `lucide-icons` feature)
1485///
1486/// Returns `None` when the required feature is not enabled, the platform
1487/// doesn't match, or the role has no icon in the requested set.
1488/// There is **no cross-set fallback** -- each set is self-contained.
1489///
1490/// # Examples
1491///
1492/// ```
1493/// use native_theme::{load_icon, IconRole, IconSet};
1494///
1495/// // With material-icons feature enabled
1496/// # #[cfg(feature = "material-icons")]
1497/// # {
1498/// let icon = load_icon(IconRole::ActionCopy, IconSet::Material);
1499/// assert!(icon.is_some());
1500/// # }
1501/// ```
1502#[must_use = "this returns the loaded icon data; it does not display it"]
1503#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1504pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
1505    match set {
1506        #[cfg(all(target_os = "linux", feature = "system-icons"))]
1507        IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
1508
1509        #[cfg(all(target_os = "macos", feature = "system-icons"))]
1510        IconSet::SfSymbols => sficons::load_sf_icon(role),
1511
1512        #[cfg(all(target_os = "windows", feature = "system-icons"))]
1513        IconSet::SegoeIcons => winicons::load_windows_icon(role),
1514
1515        #[cfg(feature = "material-icons")]
1516        IconSet::Material => {
1517            bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1518        }
1519
1520        #[cfg(feature = "lucide-icons")]
1521        IconSet::Lucide => {
1522            bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1523        }
1524
1525        // Non-matching platform or unknown set: no cross-set fallback
1526        _ => None,
1527    }
1528}
1529
1530/// Load an icon using a specific freedesktop icon theme instead of the
1531/// system default.
1532///
1533/// For [`IconSet::Freedesktop`], loads from the `preferred_theme` directory
1534/// (e.g. `"Adwaita"`, `"breeze"`). For bundled icon sets ([`IconSet::Material`],
1535/// [`IconSet::Lucide`]), `preferred_theme` is ignored — the icons are compiled
1536/// in and always available.
1537///
1538/// Use [`is_freedesktop_theme_available()`] first to check whether the theme
1539/// is installed. If the theme is not installed, freedesktop lookups will fall
1540/// through to `hicolor` and may return unexpected icons.
1541///
1542/// # Examples
1543///
1544/// ```
1545/// use native_theme::{load_icon_from_theme, IconRole, IconSet};
1546///
1547/// # #[cfg(feature = "material-icons")]
1548/// # {
1549/// // Bundled sets ignore the theme parameter
1550/// let icon = load_icon_from_theme(IconRole::ActionCopy, IconSet::Material, "anything");
1551/// assert!(icon.is_some());
1552/// # }
1553/// ```
1554#[must_use = "this returns the loaded icon data; it does not display it"]
1555#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
1556pub fn load_icon_from_theme(
1557    role: IconRole,
1558    set: IconSet,
1559    preferred_theme: &str,
1560) -> Option<IconData> {
1561    match set {
1562        #[cfg(all(target_os = "linux", feature = "system-icons"))]
1563        IconSet::Freedesktop => {
1564            let name = icon_name(role, IconSet::Freedesktop)?;
1565            freedesktop::load_freedesktop_icon_by_name(name, preferred_theme, 24)
1566        }
1567
1568        // Bundled and platform sets — preferred_theme is irrelevant
1569        _ => load_icon(role, set),
1570    }
1571}
1572
1573/// Check whether a freedesktop icon theme is installed on this system.
1574///
1575/// Looks for the theme's `index.theme` file in the standard XDG icon
1576/// directories (`$XDG_DATA_DIRS/icons/<theme>/` and
1577/// `$XDG_DATA_HOME/icons/<theme>/`).
1578///
1579/// Always returns `false` on non-Linux platforms.
1580#[must_use]
1581pub fn is_freedesktop_theme_available(theme: &str) -> bool {
1582    #[cfg(target_os = "linux")]
1583    {
1584        let data_dirs = std::env::var("XDG_DATA_DIRS")
1585            .unwrap_or_else(|_| "/usr/share:/usr/local/share".to_string());
1586        for dir in data_dirs.split(':') {
1587            if std::path::Path::new(dir)
1588                .join("icons")
1589                .join(theme)
1590                .join("index.theme")
1591                .exists()
1592            {
1593                return true;
1594            }
1595        }
1596        let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
1597            std::env::var("HOME")
1598                .map(|h| format!("{h}/.local/share"))
1599                .unwrap_or_default()
1600        });
1601        if !data_home.is_empty() {
1602            return std::path::Path::new(&data_home)
1603                .join("icons")
1604                .join(theme)
1605                .join("index.theme")
1606                .exists();
1607        }
1608        false
1609    }
1610    #[cfg(not(target_os = "linux"))]
1611    {
1612        false
1613    }
1614}
1615
1616/// Load a system icon by its platform-specific name string.
1617///
1618/// Dispatches to the appropriate platform loader based on the icon set:
1619/// - [`IconSet::Freedesktop`] -- freedesktop icon theme lookup (system theme)
1620/// - [`IconSet::SfSymbols`] -- macOS SF Symbols
1621/// - [`IconSet::SegoeIcons`] -- Windows Segoe Fluent / stock icons
1622/// - [`IconSet::Material`] / [`IconSet::Lucide`] -- bundled SVG lookup by name
1623///
1624/// Returns `None` if the icon is not found on the current platform or
1625/// the icon set is not available.
1626///
1627/// # Examples
1628///
1629/// ```
1630/// use native_theme::{load_system_icon_by_name, IconSet};
1631///
1632/// # #[cfg(feature = "material-icons")]
1633/// # {
1634/// let icon = load_system_icon_by_name("content_copy", IconSet::Material);
1635/// assert!(icon.is_some());
1636/// # }
1637/// ```
1638#[must_use = "this returns the loaded icon data; it does not display it"]
1639#[allow(unreachable_patterns, unused_variables)]
1640pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
1641    match set {
1642        #[cfg(all(target_os = "linux", feature = "system-icons"))]
1643        IconSet::Freedesktop => {
1644            let theme = system_icon_theme();
1645            freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
1646        }
1647
1648        #[cfg(all(target_os = "macos", feature = "system-icons"))]
1649        IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
1650
1651        #[cfg(all(target_os = "windows", feature = "system-icons"))]
1652        IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
1653
1654        #[cfg(feature = "material-icons")]
1655        IconSet::Material => {
1656            bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
1657        }
1658
1659        #[cfg(feature = "lucide-icons")]
1660        IconSet::Lucide => {
1661            bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
1662        }
1663
1664        _ => None,
1665    }
1666}
1667
1668/// Return the loading/spinner animation for the given icon set.
1669///
1670/// This is the animated-icon counterpart of [`load_icon()`].
1671///
1672/// # Dispatch
1673///
1674/// - [`IconSet::Material`] -- `progress_activity.svg` with continuous spin transform (1000ms)
1675/// - [`IconSet::Lucide`] -- `loader.svg` with continuous spin transform (1000ms)
1676/// - [`IconSet::Freedesktop`] -- loads `process-working` sprite sheet from active icon theme
1677/// - Other sets -- `None`
1678///
1679/// # Examples
1680///
1681/// ```
1682/// // Result depends on enabled features and platform
1683/// let anim = native_theme::loading_indicator(native_theme::IconSet::Lucide);
1684/// # #[cfg(feature = "lucide-icons")]
1685/// # assert!(anim.is_some());
1686/// ```
1687#[must_use = "this returns animation data; it does not display anything"]
1688pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
1689    match set {
1690        #[cfg(all(target_os = "linux", feature = "system-icons"))]
1691        IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
1692
1693        #[cfg(feature = "material-icons")]
1694        IconSet::Material => Some(spinners::material_spinner()),
1695
1696        #[cfg(feature = "lucide-icons")]
1697        IconSet::Lucide => Some(spinners::lucide_spinner()),
1698
1699        _ => None,
1700    }
1701}
1702
1703/// Load an icon from any [`IconProvider`], dispatching through the standard
1704/// platform loading chain.
1705///
1706/// # Fallback chain
1707///
1708/// 1. Provider's [`icon_name()`](IconProvider::icon_name) -- passed to platform
1709///    system loader via [`load_system_icon_by_name()`]
1710/// 2. Provider's [`icon_svg()`](IconProvider::icon_svg) -- bundled SVG data
1711/// 3. `None` -- **no cross-set fallback** (mixing icon sets is forbidden)
1712///
1713/// # Examples
1714///
1715/// ```
1716/// use native_theme::{load_custom_icon, IconRole, IconSet};
1717///
1718/// // IconRole implements IconProvider, so it works with load_custom_icon
1719/// # #[cfg(feature = "material-icons")]
1720/// # {
1721/// let icon = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1722/// assert!(icon.is_some());
1723/// # }
1724/// ```
1725#[must_use = "this returns the loaded icon data; it does not display it"]
1726pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1727    // Step 1: Try system loader with provider's name mapping
1728    if let Some(name) = provider.icon_name(set)
1729        && let Some(data) = load_system_icon_by_name(name, set)
1730    {
1731        return Some(data);
1732    }
1733
1734    // Step 2: Try bundled SVG from provider
1735    if let Some(svg) = provider.icon_svg(set) {
1736        return Some(IconData::Svg(svg.to_vec()));
1737    }
1738
1739    // No cross-set fallback -- return None
1740    None
1741}
1742
1743/// Mutex to serialize tests that manipulate environment variables.
1744/// Env vars are process-global state, so tests that call set_var/remove_var
1745/// must hold this lock to avoid races with parallel test execution.
1746#[cfg(test)]
1747pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1748
1749#[cfg(all(test, target_os = "linux"))]
1750#[allow(clippy::unwrap_used, clippy::expect_used)]
1751mod dispatch_tests {
1752    use super::*;
1753
1754    // -- detect_linux_de() pure function tests --
1755
1756    #[test]
1757    fn detect_kde_simple() {
1758        assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1759    }
1760
1761    #[test]
1762    fn detect_kde_colon_separated_after() {
1763        assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1764    }
1765
1766    #[test]
1767    fn detect_kde_colon_separated_before() {
1768        assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1769    }
1770
1771    #[test]
1772    fn detect_gnome_simple() {
1773        assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1774    }
1775
1776    #[test]
1777    fn detect_gnome_ubuntu() {
1778        assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1779    }
1780
1781    #[test]
1782    fn detect_xfce() {
1783        assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1784    }
1785
1786    #[test]
1787    fn detect_cinnamon() {
1788        assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1789    }
1790
1791    #[test]
1792    fn detect_cinnamon_short() {
1793        assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1794    }
1795
1796    #[test]
1797    fn detect_mate() {
1798        assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1799    }
1800
1801    #[test]
1802    fn detect_lxqt() {
1803        assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1804    }
1805
1806    #[test]
1807    fn detect_budgie() {
1808        assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1809    }
1810
1811    #[test]
1812    fn detect_empty_string() {
1813        assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1814    }
1815
1816    // -- from_linux() fallback test --
1817
1818    #[test]
1819    #[allow(unsafe_code)]
1820    fn from_linux_non_kde_returns_adwaita() {
1821        let _guard = crate::ENV_MUTEX.lock().unwrap();
1822        // Temporarily set XDG_CURRENT_DESKTOP to GNOME so from_linux()
1823        // takes the preset fallback path.
1824        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1825        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1826        let result = from_linux();
1827        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1828
1829        let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1830        assert_eq!(theme.name, "Adwaita");
1831    }
1832
1833    // -- from_linux() kdeglobals fallback tests --
1834
1835    #[test]
1836    #[cfg(feature = "kde")]
1837    #[allow(unsafe_code)]
1838    fn from_linux_unknown_de_with_kdeglobals_fallback() {
1839        let _guard = crate::ENV_MUTEX.lock().unwrap();
1840        use std::io::Write;
1841
1842        // Create a temp dir with a minimal kdeglobals file
1843        let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1844        std::fs::create_dir_all(&tmp_dir).unwrap();
1845        let kdeglobals = tmp_dir.join("kdeglobals");
1846        let mut f = std::fs::File::create(&kdeglobals).unwrap();
1847        writeln!(
1848            f,
1849            "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1850        )
1851        .unwrap();
1852
1853        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1854        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1855        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1856
1857        unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1858        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1859
1860        let result = from_linux();
1861
1862        // Restore env
1863        match orig_xdg {
1864            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1865            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1866        }
1867        match orig_desktop {
1868            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1869            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1870        }
1871
1872        // Cleanup
1873        let _ = std::fs::remove_dir_all(&tmp_dir);
1874
1875        let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1876        assert_eq!(
1877            theme.name, "TestTheme",
1878            "should use KDE theme name from kdeglobals"
1879        );
1880    }
1881
1882    #[test]
1883    #[allow(unsafe_code)]
1884    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1885        let _guard = crate::ENV_MUTEX.lock().unwrap();
1886        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1887        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1888        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1889
1890        unsafe {
1891            std::env::set_var(
1892                "XDG_CONFIG_HOME",
1893                "/tmp/nonexistent_native_theme_test_no_kde",
1894            )
1895        };
1896        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1897
1898        let result = from_linux();
1899
1900        // Restore env
1901        match orig_xdg {
1902            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1903            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1904        }
1905        match orig_desktop {
1906            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1907            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1908        }
1909
1910        let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1911        assert_eq!(
1912            theme.name, "Adwaita",
1913            "should fall back to Adwaita without kdeglobals"
1914        );
1915    }
1916
1917    // -- LNXDE-03: Hyprland, Sway, COSMIC map to Unknown --
1918
1919    #[test]
1920    fn detect_hyprland_returns_unknown() {
1921        assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1922    }
1923
1924    #[test]
1925    fn detect_sway_returns_unknown() {
1926        assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1927    }
1928
1929    #[test]
1930    fn detect_cosmic_returns_unknown() {
1931        assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1932    }
1933
1934    // -- from_system() smoke test --
1935
1936    #[test]
1937    #[allow(unsafe_code)]
1938    fn from_system_returns_result() {
1939        let _guard = crate::ENV_MUTEX.lock().unwrap();
1940        // On Linux (our test platform), from_system() should return a Result.
1941        // With GNOME set, it should return the Adwaita preset.
1942        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1943        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1944        let result = SystemTheme::from_system();
1945        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1946
1947        let theme = result.expect("from_system() should return Ok on Linux");
1948        assert_eq!(theme.name, "Adwaita");
1949    }
1950}
1951
1952#[cfg(test)]
1953#[allow(clippy::unwrap_used, clippy::expect_used)]
1954mod load_icon_tests {
1955    use super::*;
1956
1957    #[test]
1958    #[cfg(feature = "material-icons")]
1959    fn load_icon_material_returns_svg() {
1960        let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1961        assert!(result.is_some(), "material ActionCopy should return Some");
1962        match result.unwrap() {
1963            IconData::Svg(bytes) => {
1964                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1965                assert!(content.contains("<svg"), "should contain SVG data");
1966            }
1967            _ => panic!("expected IconData::Svg for bundled material icon"),
1968        }
1969    }
1970
1971    #[test]
1972    #[cfg(feature = "lucide-icons")]
1973    fn load_icon_lucide_returns_svg() {
1974        let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1975        assert!(result.is_some(), "lucide ActionCopy should return Some");
1976        match result.unwrap() {
1977            IconData::Svg(bytes) => {
1978                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1979                assert!(content.contains("<svg"), "should contain SVG data");
1980            }
1981            _ => panic!("expected IconData::Svg for bundled lucide icon"),
1982        }
1983    }
1984
1985    #[test]
1986    #[cfg(feature = "material-icons")]
1987    fn load_icon_unknown_theme_no_cross_set_fallback() {
1988        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
1989        // Without system-icons feature, Freedesktop falls through to wildcard -> None.
1990        // No cross-set Material fallback.
1991        let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1992        // Without system-icons, this falls to wildcard which returns None
1993        // With system-icons, this dispatches to load_freedesktop_icon which may return Some
1994        // Either way, no panic
1995        let _ = result;
1996    }
1997
1998    #[test]
1999    #[cfg(feature = "material-icons")]
2000    fn load_icon_all_roles_material() {
2001        // Material has 42 of 42 roles mapped, all return Some
2002        let mut some_count = 0;
2003        for role in IconRole::ALL {
2004            if load_icon(role, IconSet::Material).is_some() {
2005                some_count += 1;
2006            }
2007        }
2008        // bundled_icon_svg covers all 42 roles for Material
2009        assert_eq!(
2010            some_count, 42,
2011            "Material should cover all 42 roles via bundled SVGs"
2012        );
2013    }
2014
2015    #[test]
2016    #[cfg(feature = "lucide-icons")]
2017    fn load_icon_all_roles_lucide() {
2018        let mut some_count = 0;
2019        for role in IconRole::ALL {
2020            if load_icon(role, IconSet::Lucide).is_some() {
2021                some_count += 1;
2022            }
2023        }
2024        // bundled_icon_svg covers all 42 roles for Lucide
2025        assert_eq!(
2026            some_count, 42,
2027            "Lucide should cover all 42 roles via bundled SVGs"
2028        );
2029    }
2030
2031    #[test]
2032    fn load_icon_unrecognized_set_no_features() {
2033        // SfSymbols on Linux without system-icons: falls through to wildcard -> None
2034        let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
2035        // Just verifying it doesn't panic
2036    }
2037}
2038
2039#[cfg(test)]
2040#[allow(clippy::unwrap_used, clippy::expect_used)]
2041mod load_system_icon_by_name_tests {
2042    use super::*;
2043
2044    #[test]
2045    #[cfg(feature = "material-icons")]
2046    fn system_icon_by_name_material() {
2047        let result = load_system_icon_by_name("content_copy", IconSet::Material);
2048        assert!(
2049            result.is_some(),
2050            "content_copy should be found in Material set"
2051        );
2052        assert!(matches!(result.unwrap(), IconData::Svg(_)));
2053    }
2054
2055    #[test]
2056    #[cfg(feature = "lucide-icons")]
2057    fn system_icon_by_name_lucide() {
2058        let result = load_system_icon_by_name("copy", IconSet::Lucide);
2059        assert!(result.is_some(), "copy should be found in Lucide set");
2060        assert!(matches!(result.unwrap(), IconData::Svg(_)));
2061    }
2062
2063    #[test]
2064    #[cfg(feature = "material-icons")]
2065    fn system_icon_by_name_unknown_returns_none() {
2066        let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
2067        assert!(result.is_none(), "nonexistent name should return None");
2068    }
2069
2070    #[test]
2071    fn system_icon_by_name_sf_on_linux_returns_none() {
2072        // On Linux, SfSymbols set is not available (cfg-gated to macOS)
2073        #[cfg(not(target_os = "macos"))]
2074        {
2075            let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
2076            assert!(
2077                result.is_none(),
2078                "SF Symbols should return None on non-macOS"
2079            );
2080        }
2081    }
2082}
2083
2084#[cfg(test)]
2085#[allow(clippy::unwrap_used, clippy::expect_used)]
2086mod load_custom_icon_tests {
2087    use super::*;
2088
2089    #[test]
2090    #[cfg(feature = "material-icons")]
2091    fn custom_icon_with_icon_role_material() {
2092        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
2093        assert!(
2094            result.is_some(),
2095            "IconRole::ActionCopy should load via material"
2096        );
2097    }
2098
2099    #[test]
2100    #[cfg(feature = "lucide-icons")]
2101    fn custom_icon_with_icon_role_lucide() {
2102        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
2103        assert!(
2104            result.is_some(),
2105            "IconRole::ActionCopy should load via lucide"
2106        );
2107    }
2108
2109    #[test]
2110    fn custom_icon_no_cross_set_fallback() {
2111        // Provider that returns None for all sets -- should NOT fall back
2112        #[derive(Debug)]
2113        struct NullProvider;
2114        impl IconProvider for NullProvider {
2115            fn icon_name(&self, _set: IconSet) -> Option<&str> {
2116                None
2117            }
2118            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2119                None
2120            }
2121        }
2122
2123        let result = load_custom_icon(&NullProvider, IconSet::Material);
2124        assert!(
2125            result.is_none(),
2126            "NullProvider should return None (no cross-set fallback)"
2127        );
2128    }
2129
2130    #[test]
2131    fn custom_icon_unknown_set_uses_system() {
2132        // "unknown-set" is not a known IconSet name, falls through to system_icon_set()
2133        #[derive(Debug)]
2134        struct NullProvider;
2135        impl IconProvider for NullProvider {
2136            fn icon_name(&self, _set: IconSet) -> Option<&str> {
2137                None
2138            }
2139            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2140                None
2141            }
2142        }
2143
2144        // Just verify it doesn't panic -- the actual set chosen depends on platform
2145        let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
2146    }
2147
2148    #[test]
2149    #[cfg(feature = "material-icons")]
2150    fn custom_icon_via_dyn_dispatch() {
2151        let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
2152        let result = load_custom_icon(&*boxed, IconSet::Material);
2153        assert!(
2154            result.is_some(),
2155            "dyn dispatch through Box<dyn IconProvider> should work"
2156        );
2157    }
2158
2159    #[test]
2160    #[cfg(feature = "material-icons")]
2161    fn custom_icon_bundled_svg_fallback() {
2162        // Provider that returns None from icon_name but Some from icon_svg
2163        #[derive(Debug)]
2164        struct SvgOnlyProvider;
2165        impl IconProvider for SvgOnlyProvider {
2166            fn icon_name(&self, _set: IconSet) -> Option<&str> {
2167                None
2168            }
2169            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
2170                Some(b"<svg>test</svg>")
2171            }
2172        }
2173
2174        let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
2175        assert!(
2176            result.is_some(),
2177            "provider with icon_svg should return Some"
2178        );
2179        match result.unwrap() {
2180            IconData::Svg(bytes) => {
2181                assert_eq!(bytes, b"<svg>test</svg>");
2182            }
2183            _ => panic!("expected IconData::Svg"),
2184        }
2185    }
2186}
2187
2188#[cfg(test)]
2189#[allow(clippy::unwrap_used, clippy::expect_used)]
2190mod loading_indicator_tests {
2191    use super::*;
2192
2193    // === Dispatch tests (through loading_indicator public API) ===
2194
2195    #[test]
2196    #[cfg(feature = "lucide-icons")]
2197    fn loading_indicator_lucide_returns_frames() {
2198        let anim = loading_indicator(IconSet::Lucide);
2199        assert!(anim.is_some(), "lucide should return Some");
2200        let anim = anim.unwrap();
2201        assert!(
2202            matches!(anim, AnimatedIcon::Frames { .. }),
2203            "lucide should be pre-rotated Frames"
2204        );
2205        if let AnimatedIcon::Frames {
2206            frames,
2207            frame_duration_ms,
2208        } = &anim
2209        {
2210            assert_eq!(frames.len(), 24);
2211            assert_eq!(*frame_duration_ms, 42);
2212        }
2213    }
2214
2215    /// Freedesktop loading_indicator returns Some if the active icon theme
2216    /// has a `process-working` sprite sheet (e.g. Breeze), None otherwise.
2217    #[test]
2218    #[cfg(all(target_os = "linux", feature = "system-icons"))]
2219    fn loading_indicator_freedesktop_depends_on_theme() {
2220        let anim = loading_indicator(IconSet::Freedesktop);
2221        // Result depends on installed icon theme -- Some if process-working exists
2222        if let Some(anim) = anim {
2223            match anim {
2224                AnimatedIcon::Frames { frames, .. } => {
2225                    assert!(
2226                        !frames.is_empty(),
2227                        "Frames variant should have at least one frame"
2228                    );
2229                }
2230                AnimatedIcon::Transform { .. } => {
2231                    // Single-frame theme icon with Spin -- valid result
2232                }
2233            }
2234        }
2235    }
2236
2237    /// Freedesktop spinner depends on platform and icon theme.
2238    #[test]
2239    fn loading_indicator_freedesktop_does_not_panic() {
2240        let _result = loading_indicator(IconSet::Freedesktop);
2241    }
2242
2243    // === Direct spinner construction tests (any platform) ===
2244
2245    #[test]
2246    #[cfg(feature = "lucide-icons")]
2247    fn lucide_spinner_is_frames() {
2248        let anim = spinners::lucide_spinner();
2249        assert!(
2250            matches!(anim, AnimatedIcon::Frames { .. }),
2251            "lucide should be pre-rotated Frames"
2252        );
2253    }
2254}
2255
2256#[cfg(all(test, feature = "svg-rasterize"))]
2257#[allow(clippy::unwrap_used, clippy::expect_used)]
2258mod spinner_rasterize_tests {
2259    use super::*;
2260
2261    #[test]
2262    #[cfg(feature = "lucide-icons")]
2263    fn lucide_spinner_icon_rasterizes() {
2264        let anim = spinners::lucide_spinner();
2265        if let AnimatedIcon::Frames { frames, .. } = &anim {
2266            let first = frames.first().expect("should have at least one frame");
2267            if let IconData::Svg(bytes) = first {
2268                let result = crate::rasterize::rasterize_svg(bytes, 24);
2269                assert!(result.is_ok(), "lucide loader should rasterize");
2270                if let Ok(IconData::Rgba { data, .. }) = &result {
2271                    assert!(
2272                        data.iter().any(|&b| b != 0),
2273                        "lucide loader rasterized to empty image"
2274                    );
2275                }
2276            } else {
2277                panic!("lucide spinner frame should be Svg");
2278            }
2279        } else {
2280            panic!("lucide spinner should be Frames");
2281        }
2282    }
2283}
2284
2285#[cfg(test)]
2286#[allow(
2287    clippy::unwrap_used,
2288    clippy::expect_used,
2289    clippy::field_reassign_with_default
2290)]
2291mod system_theme_tests {
2292    use super::*;
2293
2294    // --- SystemTheme::active() / pick() tests ---
2295
2296    #[test]
2297    fn test_system_theme_active_dark() {
2298        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2299        let mut light_v = preset.light.clone().unwrap();
2300        let mut dark_v = preset.dark.clone().unwrap();
2301        // Give them distinct accents so we can tell them apart
2302        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2303        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2304        light_v.resolve_all();
2305        dark_v.resolve_all();
2306        let light_resolved = light_v.validate().unwrap();
2307        let dark_resolved = dark_v.validate().unwrap();
2308
2309        let st = SystemTheme {
2310            name: "test".into(),
2311            is_dark: true,
2312            light: light_resolved.clone(),
2313            dark: dark_resolved.clone(),
2314            light_variant: preset.light.unwrap(),
2315            dark_variant: preset.dark.unwrap(),
2316            live_preset: "catppuccin-mocha".into(),
2317            preset: "catppuccin-mocha".into(),
2318        };
2319        assert_eq!(
2320            st.active().defaults.accent_color,
2321            dark_resolved.defaults.accent_color
2322        );
2323    }
2324
2325    #[test]
2326    fn test_system_theme_active_light() {
2327        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2328        let mut light_v = preset.light.clone().unwrap();
2329        let mut dark_v = preset.dark.clone().unwrap();
2330        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2331        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2332        light_v.resolve_all();
2333        dark_v.resolve_all();
2334        let light_resolved = light_v.validate().unwrap();
2335        let dark_resolved = dark_v.validate().unwrap();
2336
2337        let st = SystemTheme {
2338            name: "test".into(),
2339            is_dark: false,
2340            light: light_resolved.clone(),
2341            dark: dark_resolved.clone(),
2342            light_variant: preset.light.unwrap(),
2343            dark_variant: preset.dark.unwrap(),
2344            live_preset: "catppuccin-mocha".into(),
2345            preset: "catppuccin-mocha".into(),
2346        };
2347        assert_eq!(
2348            st.active().defaults.accent_color,
2349            light_resolved.defaults.accent_color
2350        );
2351    }
2352
2353    #[test]
2354    fn test_system_theme_pick() {
2355        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
2356        let mut light_v = preset.light.clone().unwrap();
2357        let mut dark_v = preset.dark.clone().unwrap();
2358        light_v.defaults.accent_color = Some(Rgba::rgb(0, 0, 255));
2359        dark_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2360        light_v.resolve_all();
2361        dark_v.resolve_all();
2362        let light_resolved = light_v.validate().unwrap();
2363        let dark_resolved = dark_v.validate().unwrap();
2364
2365        let st = SystemTheme {
2366            name: "test".into(),
2367            is_dark: false,
2368            light: light_resolved.clone(),
2369            dark: dark_resolved.clone(),
2370            light_variant: preset.light.unwrap(),
2371            dark_variant: preset.dark.unwrap(),
2372            live_preset: "catppuccin-mocha".into(),
2373            preset: "catppuccin-mocha".into(),
2374        };
2375        assert_eq!(
2376            st.pick(true).defaults.accent_color,
2377            dark_resolved.defaults.accent_color
2378        );
2379        assert_eq!(
2380            st.pick(false).defaults.accent_color,
2381            light_resolved.defaults.accent_color
2382        );
2383    }
2384
2385    // --- platform_preset_name() tests ---
2386
2387    #[test]
2388    #[cfg(target_os = "linux")]
2389    #[allow(unsafe_code)]
2390    fn test_platform_preset_name_kde() {
2391        let _guard = crate::ENV_MUTEX.lock().unwrap();
2392        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
2393        let name = platform_preset_name();
2394        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
2395        assert_eq!(name, "kde-breeze-live");
2396    }
2397
2398    #[test]
2399    #[cfg(target_os = "linux")]
2400    #[allow(unsafe_code)]
2401    fn test_platform_preset_name_gnome() {
2402        let _guard = crate::ENV_MUTEX.lock().unwrap();
2403        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
2404        let name = platform_preset_name();
2405        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
2406        assert_eq!(name, "adwaita-live");
2407    }
2408
2409    // --- run_pipeline() tests ---
2410
2411    #[test]
2412    fn test_run_pipeline_produces_both_variants() {
2413        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
2414        let result = run_pipeline(reader, "catppuccin-mocha", false);
2415        assert!(result.is_ok(), "run_pipeline should succeed");
2416        let st = result.unwrap();
2417        // Both light and dark exist as ResolvedThemeVariant (non-Option)
2418        assert!(!st.name.is_empty(), "name should be populated");
2419        // If we get here, both variants validated successfully
2420    }
2421
2422    #[test]
2423    fn test_run_pipeline_reader_values_win() {
2424        // Create a reader with a custom accent color
2425        let custom_accent = Rgba::rgb(42, 100, 200);
2426        let mut reader = ThemeSpec::default();
2427        reader.name = "CustomTheme".into();
2428        let mut variant = ThemeVariant::default();
2429        variant.defaults.accent_color = Some(custom_accent);
2430        reader.light = Some(variant);
2431
2432        let result = run_pipeline(reader, "catppuccin-mocha", false);
2433        assert!(result.is_ok(), "run_pipeline should succeed");
2434        let st = result.unwrap();
2435        // The reader's accent should win over the preset's accent
2436        assert_eq!(
2437            st.light.defaults.accent_color, custom_accent,
2438            "reader accent should win over preset accent"
2439        );
2440        assert_eq!(st.name, "CustomTheme", "reader name should win");
2441    }
2442
2443    #[test]
2444    fn test_run_pipeline_single_variant() {
2445        // Simulate a real OS reader that provides a complete dark variant
2446        // (like KDE's from_kde() would) but no light variant.
2447        // Use a live preset so the inactive light variant gets the full preset.
2448        let full = ThemeSpec::preset("kde-breeze").unwrap();
2449        let mut reader = ThemeSpec::default();
2450        let mut dark_v = full.dark.clone().unwrap();
2451        // Override accent to prove reader values win
2452        dark_v.defaults.accent_color = Some(Rgba::rgb(200, 50, 50));
2453        reader.dark = Some(dark_v);
2454        reader.light = None;
2455
2456        let result = run_pipeline(reader, "kde-breeze-live", true);
2457        assert!(
2458            result.is_ok(),
2459            "run_pipeline should succeed with single variant"
2460        );
2461        let st = result.unwrap();
2462        // Dark should have the reader's overridden accent
2463        assert_eq!(
2464            st.dark.defaults.accent_color,
2465            Rgba::rgb(200, 50, 50),
2466            "dark variant should have reader accent"
2467        );
2468        // Light should still exist (from full preset, which has colors)
2469        // If we get here, both variants validated successfully
2470        assert_eq!(st.live_preset, "kde-breeze-live");
2471        assert_eq!(st.preset, "kde-breeze");
2472    }
2473
2474    #[test]
2475    fn test_run_pipeline_inactive_variant_from_full_preset() {
2476        // When reader provides only dark, light must come from the full preset
2477        // (not the live preset, which has no colors and would fail validation).
2478        let full = ThemeSpec::preset("kde-breeze").unwrap();
2479        let mut reader = ThemeSpec::default();
2480        reader.dark = Some(full.dark.clone().unwrap());
2481        reader.light = None;
2482
2483        let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
2484
2485        // The light variant should have colors from the full "kde-breeze" preset
2486        let full_light = full.light.unwrap();
2487        assert_eq!(
2488            st.light.defaults.accent_color,
2489            full_light.defaults.accent_color.unwrap(),
2490            "inactive light variant should get accent from full preset"
2491        );
2492        assert_eq!(
2493            st.light.defaults.background_color,
2494            full_light.defaults.background_color.unwrap(),
2495            "inactive light variant should get background from full preset"
2496        );
2497    }
2498
2499    // --- run_pipeline with preset-as-reader (GNOME double-merge test) ---
2500
2501    #[test]
2502    fn test_run_pipeline_with_preset_as_reader() {
2503        // Simulates GNOME sync fallback: adwaita used as both reader and preset.
2504        // Double-merge is harmless: merge is idempotent for matching values.
2505        let reader = ThemeSpec::preset("adwaita").unwrap();
2506        let result = run_pipeline(reader, "adwaita", false);
2507        assert!(
2508            result.is_ok(),
2509            "double-merge with same preset should succeed"
2510        );
2511        let st = result.unwrap();
2512        assert_eq!(st.name, "Adwaita");
2513    }
2514
2515    // --- run_pipeline font_dpi propagation ---
2516
2517    #[test]
2518    fn test_run_pipeline_propagates_font_dpi_to_inactive_variant() {
2519        // Create a reader that provides only dark variant with font_dpi=120
2520        let mut reader = ThemeSpec::default();
2521        reader.dark = Some(ThemeVariant {
2522            defaults: ThemeDefaults {
2523                font_dpi: Some(120.0),
2524                ..Default::default()
2525            },
2526            ..Default::default()
2527        });
2528
2529        let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
2530        // The light variant (inactive, from full preset) should have gotten
2531        // font_dpi propagated and used for conversion.
2532        //
2533        // After resolution, font_dpi is consumed (cleared during conversion,
2534        // then filled with DEFAULT_FONT_DPI in validate.rs), so we cannot
2535        // check font_dpi directly. Instead, verify the conversion effect:
2536        // the preset's default font size is in points. With font_dpi=120:
2537        //   px = pt * 120 / 72 = pt * 1.6667
2538        //
2539        // The Breeze preset default font size is 10.0 pt.
2540        // With DPI 120: 10.0 * 120/72 = 16.667 px
2541        // Without propagation (no conversion): 10.0 px
2542        let resolved_size = st.light.defaults.font.size;
2543        assert!(
2544            resolved_size > 10.0,
2545            "inactive variant font size should be DPI-converted (got {resolved_size}, expected > 10.0)"
2546        );
2547        // Check it matches the expected conversion
2548        let expected = 10.0 * 120.0 / 72.0; // ~16.667
2549        assert!(
2550            (resolved_size - expected).abs() < 0.1,
2551            "font size should be 10pt * 120/72 = {expected:.1}px, got {resolved_size}"
2552        );
2553    }
2554
2555    // --- reader_is_dark() tests ---
2556
2557    #[test]
2558    fn test_reader_is_dark_only_dark() {
2559        let mut theme = ThemeSpec::default();
2560        theme.dark = Some(ThemeVariant::default());
2561        theme.light = None;
2562        assert!(
2563            reader_is_dark(&theme),
2564            "should be true when only dark is set"
2565        );
2566    }
2567
2568    #[test]
2569    fn test_reader_is_dark_only_light() {
2570        let mut theme = ThemeSpec::default();
2571        theme.light = Some(ThemeVariant::default());
2572        theme.dark = None;
2573        assert!(
2574            !reader_is_dark(&theme),
2575            "should be false when only light is set"
2576        );
2577    }
2578
2579    #[test]
2580    fn test_reader_is_dark_both() {
2581        let mut theme = ThemeSpec::default();
2582        theme.light = Some(ThemeVariant::default());
2583        theme.dark = Some(ThemeVariant::default());
2584        assert!(
2585            !reader_is_dark(&theme),
2586            "should be false when both are set (macOS case)"
2587        );
2588    }
2589
2590    #[test]
2591    fn test_reader_is_dark_neither() {
2592        let theme = ThemeSpec::default();
2593        assert!(
2594            !reader_is_dark(&theme),
2595            "should be false when neither is set"
2596        );
2597    }
2598}
2599
2600#[cfg(test)]
2601#[allow(clippy::unwrap_used, clippy::expect_used)]
2602mod reduced_motion_tests {
2603    use super::*;
2604
2605    #[test]
2606    fn prefers_reduced_motion_smoke_test() {
2607        // Smoke test: function should not panic on any platform.
2608        // Cannot assert a specific value because OnceLock caches the first call
2609        // and CI environments have varying accessibility settings.
2610        let _result = prefers_reduced_motion();
2611    }
2612
2613    #[cfg(target_os = "linux")]
2614    #[test]
2615    fn detect_reduced_motion_inner_linux() {
2616        // Bypass OnceLock to test actual detection logic.
2617        // On CI without gsettings, returns false (animations enabled).
2618        // On developer machines, depends on accessibility settings.
2619        let result = detect_reduced_motion_inner();
2620        // Just verify it returns a bool without panicking.
2621        let _ = result;
2622    }
2623
2624    #[cfg(target_os = "macos")]
2625    #[test]
2626    fn detect_reduced_motion_inner_macos() {
2627        let result = detect_reduced_motion_inner();
2628        let _ = result;
2629    }
2630
2631    #[cfg(target_os = "windows")]
2632    #[test]
2633    fn detect_reduced_motion_inner_windows() {
2634        let result = detect_reduced_motion_inner();
2635        let _ = result;
2636    }
2637}
2638
2639#[cfg(test)]
2640#[allow(clippy::unwrap_used, clippy::expect_used)]
2641mod overlay_tests {
2642    use super::*;
2643
2644    /// Helper: build a SystemTheme from a preset via run_pipeline.
2645    fn default_system_theme() -> SystemTheme {
2646        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
2647        run_pipeline(reader, "catppuccin-mocha", false).unwrap()
2648    }
2649
2650    #[test]
2651    fn test_overlay_accent_propagates() {
2652        let st = default_system_theme();
2653        let new_accent = Rgba::rgb(255, 0, 0);
2654
2655        // Build overlay with accent on both light and dark
2656        let mut overlay = ThemeSpec::default();
2657        let mut light_v = ThemeVariant::default();
2658        light_v.defaults.accent_color = Some(new_accent);
2659        let mut dark_v = ThemeVariant::default();
2660        dark_v.defaults.accent_color = Some(new_accent);
2661        overlay.light = Some(light_v);
2662        overlay.dark = Some(dark_v);
2663
2664        let result = st.with_overlay(&overlay).unwrap();
2665
2666        // Accent itself
2667        assert_eq!(result.light.defaults.accent_color, new_accent);
2668        // Accent-derived widget fields
2669        assert_eq!(result.light.button.primary_background, new_accent);
2670        assert_eq!(result.light.checkbox.checked_background, new_accent);
2671        assert_eq!(result.light.slider.fill_color, new_accent);
2672        assert_eq!(result.light.progress_bar.fill_color, new_accent);
2673        assert_eq!(result.light.switch.checked_background, new_accent);
2674        // Additional accent-derived fields re-resolved via safety nets
2675        assert_eq!(
2676            result.light.spinner.fill_color, new_accent,
2677            "spinner.fill should re-derive from new accent"
2678        );
2679    }
2680
2681    #[test]
2682    fn test_overlay_preserves_unrelated_fields() {
2683        let st = default_system_theme();
2684        let original_bg = st.light.defaults.background_color;
2685
2686        // Apply overlay changing only accent
2687        let mut overlay = ThemeSpec::default();
2688        let mut light_v = ThemeVariant::default();
2689        light_v.defaults.accent_color = Some(Rgba::rgb(255, 0, 0));
2690        overlay.light = Some(light_v);
2691
2692        let result = st.with_overlay(&overlay).unwrap();
2693        assert_eq!(
2694            result.light.defaults.background_color, original_bg,
2695            "background should be unchanged"
2696        );
2697    }
2698
2699    #[test]
2700    fn test_overlay_empty_noop() {
2701        let st = default_system_theme();
2702        let original_light_accent = st.light.defaults.accent_color;
2703        let original_dark_accent = st.dark.defaults.accent_color;
2704        let original_light_bg = st.light.defaults.background_color;
2705
2706        // Empty overlay
2707        let overlay = ThemeSpec::default();
2708        let result = st.with_overlay(&overlay).unwrap();
2709
2710        assert_eq!(result.light.defaults.accent_color, original_light_accent);
2711        assert_eq!(result.dark.defaults.accent_color, original_dark_accent);
2712        assert_eq!(result.light.defaults.background_color, original_light_bg);
2713    }
2714
2715    #[test]
2716    fn test_overlay_both_variants() {
2717        let st = default_system_theme();
2718        let red = Rgba::rgb(255, 0, 0);
2719        let green = Rgba::rgb(0, 255, 0);
2720
2721        let mut overlay = ThemeSpec::default();
2722        let mut light_v = ThemeVariant::default();
2723        light_v.defaults.accent_color = Some(red);
2724        let mut dark_v = ThemeVariant::default();
2725        dark_v.defaults.accent_color = Some(green);
2726        overlay.light = Some(light_v);
2727        overlay.dark = Some(dark_v);
2728
2729        let result = st.with_overlay(&overlay).unwrap();
2730        assert_eq!(
2731            result.light.defaults.accent_color, red,
2732            "light accent = red"
2733        );
2734        assert_eq!(
2735            result.dark.defaults.accent_color, green,
2736            "dark accent = green"
2737        );
2738    }
2739
2740    #[test]
2741    fn test_overlay_font_family() {
2742        let st = default_system_theme();
2743
2744        let mut overlay = ThemeSpec::default();
2745        let mut light_v = ThemeVariant::default();
2746        light_v.defaults.font.family = Some("Comic Sans".into());
2747        overlay.light = Some(light_v);
2748
2749        let result = st.with_overlay(&overlay).unwrap();
2750        assert_eq!(result.light.defaults.font.family, "Comic Sans");
2751    }
2752
2753    #[test]
2754    fn test_overlay_toml_convenience() {
2755        let st = default_system_theme();
2756        let result = st
2757            .with_overlay_toml(
2758                r##"
2759            name = "overlay"
2760            [light.defaults]
2761            accent_color = "#ff0000"
2762        "##,
2763            )
2764            .unwrap();
2765        assert_eq!(result.light.defaults.accent_color, Rgba::rgb(255, 0, 0));
2766    }
2767}