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/// Three field categories:
20/// - `option { field1, field2, ... }` -- `Option<T>` leaf fields
21/// - `nested { field1, field2, ... }` -- nested struct fields with their own `merge()`
22/// - `optional_nested { field1, field2, ... }` -- `Option<T>` where T has its own `merge()`
23///
24/// For `option` fields, `Some` values in the overlay replace the corresponding
25/// fields in self; `None` fields are left unchanged.
26/// For `nested` fields, merge is called recursively.
27/// For `optional_nested` fields: if both base and overlay are `Some`, the inner values
28/// are merged recursively. If base is `None` and overlay is `Some`, overlay is cloned.
29/// If overlay is `None`, the base field is preserved unchanged.
30///
31/// # Examples
32///
33/// ```ignore
34/// impl_merge!(MyColors {
35///     option { accent, background }
36/// });
37/// ```
38macro_rules! impl_merge {
39    (
40        $struct_name:ident {
41            $(option { $($opt_field:ident),* $(,)? })?
42            $(nested { $($nest_field:ident),* $(,)? })?
43            $(optional_nested { $($on_field:ident),* $(,)? })?
44        }
45    ) => {
46        impl $struct_name {
47            /// Merge an overlay into this value. `Some` fields in the overlay
48            /// replace the corresponding fields in self; `None` fields are
49            /// left unchanged. Nested structs are merged recursively.
50            pub fn merge(&mut self, overlay: &Self) {
51                $($(
52                    if overlay.$opt_field.is_some() {
53                        self.$opt_field = overlay.$opt_field.clone();
54                    }
55                )*)?
56                $($(
57                    self.$nest_field.merge(&overlay.$nest_field);
58                )*)?
59                $($(
60                    match (&mut self.$on_field, &overlay.$on_field) {
61                        (Some(base), Some(over)) => base.merge(over),
62                        (None, Some(over)) => self.$on_field = Some(over.clone()),
63                        _ => {}
64                    }
65                )*)?
66            }
67
68            /// Returns true if all fields are at their default (None/empty) state.
69            pub fn is_empty(&self) -> bool {
70                true
71                $($(&& self.$opt_field.is_none())*)?
72                $($(&& self.$nest_field.is_empty())*)?
73                $($(&& self.$on_field.is_none())*)?
74            }
75        }
76    };
77}
78
79/// Color types and sRGB utilities.
80pub mod color;
81/// Error types for theme operations.
82pub mod error;
83/// GNOME portal theme reader.
84#[cfg(all(target_os = "linux", feature = "portal"))]
85pub mod gnome;
86/// KDE theme reader.
87#[cfg(all(target_os = "linux", feature = "kde"))]
88pub mod kde;
89/// Theme data model types.
90pub mod model;
91/// Bundled theme presets.
92pub mod presets;
93/// Theme resolution engine (inheritance + validation).
94mod resolve;
95#[cfg(any(
96    feature = "material-icons",
97    feature = "lucide-icons",
98    feature = "system-icons"
99))]
100mod spinners;
101
102pub use color::{ParseColorError, Rgba};
103pub use error::{Error, ThemeResolutionError};
104pub use model::{
105    AnimatedIcon, ButtonTheme, CardTheme, CheckboxTheme, ComboBoxTheme, DialogButtonOrder,
106    DialogTheme, ExpanderTheme, FontSpec, IconData, IconProvider, IconRole, IconSet, IconSizes,
107    InputTheme, LinkTheme, ListTheme, MenuTheme, PopoverTheme, ProgressBarTheme, ResolvedFontSpec,
108    ResolvedIconSizes, ResolvedTextScale, ResolvedTextScaleEntry, ResolvedThemeDefaults,
109    ResolvedThemeSpacing, ResolvedThemeVariant, ScrollbarTheme, SegmentedControlTheme,
110    SeparatorTheme, SidebarTheme, SliderTheme, SpinnerTheme, SplitterTheme, StatusBarTheme,
111    SwitchTheme, TabTheme, TextScale, TextScaleEntry, ThemeDefaults, ThemeSpacing, ThemeSpec,
112    ThemeVariant, ToolbarTheme, TooltipTheme, TransformAnimation, WindowTheme,
113    bundled_icon_by_name, bundled_icon_svg,
114};
115// icon helper functions re-exported from this module
116pub use model::icons::{icon_name, system_icon_set, system_icon_theme};
117
118/// Freedesktop icon theme lookup (Linux).
119#[cfg(all(target_os = "linux", feature = "system-icons"))]
120pub mod freedesktop;
121/// macOS platform helpers.
122#[cfg(target_os = "macos")]
123pub mod macos;
124#[cfg(not(target_os = "macos"))]
125pub(crate) mod macos;
126/// SVG-to-RGBA rasterization utilities.
127#[cfg(feature = "svg-rasterize")]
128pub mod rasterize;
129/// SF Symbols icon loader (macOS).
130#[cfg(all(target_os = "macos", feature = "system-icons"))]
131pub mod sficons;
132/// Windows platform theme reader.
133#[cfg(target_os = "windows")]
134pub mod windows;
135#[cfg(not(target_os = "windows"))]
136#[allow(dead_code, unused_variables)]
137pub(crate) mod windows;
138/// Windows Segoe Fluent / stock icon loader.
139#[cfg(all(target_os = "windows", feature = "system-icons"))]
140pub mod winicons;
141#[cfg(all(not(target_os = "windows"), feature = "system-icons"))]
142#[allow(dead_code, unused_imports)]
143pub(crate) mod winicons;
144
145#[cfg(all(target_os = "linux", feature = "system-icons"))]
146pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
147#[cfg(all(target_os = "linux", feature = "portal"))]
148pub use gnome::from_gnome;
149#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
150pub use gnome::from_kde_with_portal;
151#[cfg(all(target_os = "linux", feature = "kde"))]
152pub use kde::from_kde;
153#[cfg(all(target_os = "macos", feature = "macos"))]
154pub use macos::from_macos;
155#[cfg(feature = "svg-rasterize")]
156pub use rasterize::rasterize_svg;
157#[cfg(all(target_os = "macos", feature = "system-icons"))]
158pub use sficons::load_sf_icon;
159#[cfg(all(target_os = "macos", feature = "system-icons"))]
160pub use sficons::load_sf_icon_by_name;
161#[cfg(all(target_os = "windows", feature = "windows"))]
162pub use windows::from_windows;
163#[cfg(all(target_os = "windows", feature = "system-icons"))]
164pub use winicons::load_windows_icon;
165#[cfg(all(target_os = "windows", feature = "system-icons"))]
166pub use winicons::load_windows_icon_by_name;
167
168/// Convenience Result type alias for this crate.
169pub type Result<T> = std::result::Result<T, Error>;
170
171/// Desktop environments recognized on Linux.
172#[cfg(target_os = "linux")]
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum LinuxDesktop {
175    /// KDE Plasma desktop.
176    Kde,
177    /// GNOME desktop.
178    Gnome,
179    /// Xfce desktop.
180    Xfce,
181    /// Cinnamon desktop (Linux Mint).
182    Cinnamon,
183    /// MATE desktop.
184    Mate,
185    /// LXQt desktop.
186    LxQt,
187    /// Budgie desktop.
188    Budgie,
189    /// Unrecognized or unset desktop environment.
190    Unknown,
191}
192
193/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
194/// the recognized desktop environment.
195///
196/// Checks components in order; first recognized DE wins. Budgie is checked
197/// before GNOME because Budgie sets `Budgie:GNOME`.
198#[cfg(target_os = "linux")]
199pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
200    for component in xdg_current_desktop.split(':') {
201        match component {
202            "KDE" => return LinuxDesktop::Kde,
203            "Budgie" => return LinuxDesktop::Budgie,
204            "GNOME" => return LinuxDesktop::Gnome,
205            "XFCE" => return LinuxDesktop::Xfce,
206            "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
207            "MATE" => return LinuxDesktop::Mate,
208            "LXQt" => return LinuxDesktop::LxQt,
209            _ => {}
210        }
211    }
212    LinuxDesktop::Unknown
213}
214
215/// Detect whether the system is using a dark color scheme.
216///
217/// Uses synchronous, platform-specific checks so the result is available
218/// immediately at window creation time (before any async portal response).
219///
220/// # Caching
221///
222/// The result is cached after the first call using `OnceLock` and never
223/// refreshed. If the user toggles dark mode while the app is running,
224/// this function will return stale data.
225///
226/// For live dark-mode tracking, subscribe to OS appearance-change events
227/// (D-Bus `SettingChanged` on Linux, `NSAppearance` KVO on macOS,
228/// `UISettings.ColorValuesChanged` on Windows) and call [`SystemTheme::from_system()`]
229/// to get a fresh [`SystemTheme`] with updated resolved variants.
230///
231/// # Platform Behavior
232///
233/// - **Linux:** Queries `gsettings` for `color-scheme` via subprocess;
234///   falls back to KDE `kdeglobals` background luminance (with `kde`
235///   feature).
236/// - **macOS:** Reads `AppleInterfaceStyle` via `NSUserDefaults` (with
237///   `macos` feature) or `defaults` subprocess (without).
238/// - **Windows:** Checks foreground color luminance from `UISettings` via
239///   BT.601 coefficients (requires `windows` feature).
240/// - **Other platforms / missing features:** Returns `false` (light).
241#[must_use = "this returns whether the system uses dark mode"]
242pub fn system_is_dark() -> bool {
243    static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
244    *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
245}
246
247/// Inner detection logic for [`system_is_dark()`].
248///
249/// Separated from the public function to allow caching via `OnceLock`.
250#[allow(unreachable_code)]
251fn detect_is_dark_inner() -> bool {
252    #[cfg(target_os = "linux")]
253    {
254        // gsettings works across all modern DEs (GNOME, KDE, XFCE, …)
255        if let Ok(output) = std::process::Command::new("gsettings")
256            .args(["get", "org.gnome.desktop.interface", "color-scheme"])
257            .output()
258            && output.status.success()
259        {
260            let val = String::from_utf8_lossy(&output.stdout);
261            if val.contains("prefer-dark") {
262                return true;
263            }
264            if val.contains("prefer-light") || val.contains("default") {
265                return false;
266            }
267        }
268
269        // Fallback: read KDE's kdeglobals background luminance
270        #[cfg(feature = "kde")]
271        {
272            let path = crate::kde::kdeglobals_path();
273            if let Ok(content) = std::fs::read_to_string(&path) {
274                let mut ini = crate::kde::create_kde_parser();
275                if ini.read(content).is_ok() {
276                    return crate::kde::is_dark_theme(&ini);
277                }
278            }
279        }
280
281        false
282    }
283
284    #[cfg(target_os = "macos")]
285    {
286        // AppleInterfaceStyle is "Dark" when dark mode is active.
287        // The key is absent in light mode, so any failure means light.
288        #[cfg(feature = "macos")]
289        {
290            use objc2_foundation::NSUserDefaults;
291            let defaults = NSUserDefaults::standardUserDefaults();
292            let key = objc2_foundation::ns_string!("AppleInterfaceStyle");
293            if let Some(value) = defaults.stringForKey(key) {
294                return value.to_string().eq_ignore_ascii_case("dark");
295            }
296            return false;
297        }
298        #[cfg(not(feature = "macos"))]
299        {
300            if let Ok(output) = std::process::Command::new("defaults")
301                .args(["read", "-g", "AppleInterfaceStyle"])
302                .output()
303                && output.status.success()
304            {
305                let val = String::from_utf8_lossy(&output.stdout);
306                return val.trim().eq_ignore_ascii_case("dark");
307            }
308            return false;
309        }
310    }
311
312    #[cfg(target_os = "windows")]
313    {
314        #[cfg(feature = "windows")]
315        {
316            // BT.601 luminance: light foreground indicates dark background.
317            let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
318                return false;
319            };
320            let Ok(fg) =
321                settings.GetColorValue(::windows::UI::ViewManagement::UIColorType::Foreground)
322            else {
323                return false;
324            };
325            let luma = 0.299 * (fg.R as f32) + 0.587 * (fg.G as f32) + 0.114 * (fg.B as f32);
326            return luma > 128.0;
327        }
328        #[cfg(not(feature = "windows"))]
329        return false;
330    }
331
332    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
333    {
334        false
335    }
336}
337
338/// Query whether the user prefers reduced motion.
339///
340/// Returns `true` when the OS accessibility setting indicates animations
341/// should be reduced or disabled. Returns `false` (allow animations) on
342/// unsupported platforms or when the query fails.
343///
344/// # Caching
345///
346/// The result is cached after the first call using `OnceLock` and never
347/// refreshed. For live accessibility-change tracking, subscribe to OS
348/// accessibility events and re-query as needed.
349///
350/// # Platform Behavior
351///
352/// - **Linux:** Queries `gsettings get org.gnome.desktop.interface enable-animations`.
353///   Returns `true` when animations are disabled (`enable-animations` is `false`).
354/// - **macOS:** Queries `NSWorkspace.accessibilityDisplayShouldReduceMotion`
355///   (requires `macos` feature).
356/// - **Windows:** Queries `UISettings.AnimationsEnabled()` (requires `windows` feature).
357/// - **Other platforms:** Returns `false`.
358///
359/// # Examples
360///
361/// ```
362/// let reduced = native_theme::prefers_reduced_motion();
363/// // On this platform, the result depends on OS accessibility settings.
364/// // The function always returns a bool (false on unsupported platforms).
365/// assert!(reduced == true || reduced == false);
366/// ```
367#[must_use = "this returns whether reduced motion is preferred"]
368pub fn prefers_reduced_motion() -> bool {
369    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
370    *CACHED.get_or_init(detect_reduced_motion_inner)
371}
372
373/// Inner detection logic for [`prefers_reduced_motion()`].
374///
375/// Separated from the public function to allow caching via `OnceLock`.
376#[allow(unreachable_code)]
377fn detect_reduced_motion_inner() -> bool {
378    #[cfg(target_os = "linux")]
379    {
380        // gsettings boolean output is bare "true\n" or "false\n" (no quotes)
381        // enable-animations has INVERTED semantics: false => reduced motion preferred
382        if let Ok(output) = std::process::Command::new("gsettings")
383            .args(["get", "org.gnome.desktop.interface", "enable-animations"])
384            .output()
385            && output.status.success()
386        {
387            let val = String::from_utf8_lossy(&output.stdout);
388            return val.trim() == "false";
389        }
390        false
391    }
392
393    #[cfg(target_os = "macos")]
394    {
395        #[cfg(feature = "macos")]
396        {
397            let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
398            // Direct semantics: true = reduce motion preferred (no inversion needed)
399            return workspace.accessibilityDisplayShouldReduceMotion();
400        }
401        #[cfg(not(feature = "macos"))]
402        return false;
403    }
404
405    #[cfg(target_os = "windows")]
406    {
407        #[cfg(feature = "windows")]
408        {
409            let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
410                return false;
411            };
412            // AnimationsEnabled has INVERTED semantics: false => reduced motion preferred
413            return match settings.AnimationsEnabled() {
414                Ok(enabled) => !enabled,
415                Err(_) => false,
416            };
417        }
418        #[cfg(not(feature = "windows"))]
419        return false;
420    }
421
422    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
423    {
424        false
425    }
426}
427
428/// Result of the OS-first pipeline. Holds both resolved variants.
429///
430/// Produced by [`SystemTheme::from_system()`] and [`SystemTheme::from_system_async()`].
431/// Both light and dark are always populated: the OS-active variant
432/// comes from the reader + preset + resolve, the inactive variant
433/// comes from the preset + resolve.
434#[derive(Clone, Debug)]
435pub struct SystemTheme {
436    /// Theme name (from reader or preset).
437    pub name: String,
438    /// Whether the OS is currently in dark mode.
439    pub is_dark: bool,
440    /// Resolved light variant (always populated).
441    pub light: ResolvedThemeVariant,
442    /// Resolved dark variant (always populated).
443    pub dark: ResolvedThemeVariant,
444    /// Pre-resolve light variant (retained for overlay support).
445    pub(crate) light_variant: ThemeVariant,
446    /// Pre-resolve dark variant (retained for overlay support).
447    pub(crate) dark_variant: ThemeVariant,
448    /// The platform preset used (e.g., "kde-breeze", "adwaita", "macos-sonoma").
449    pub preset: String,
450    /// The live preset name used internally (e.g., "kde-breeze-live").
451    pub(crate) live_preset: String,
452}
453
454impl SystemTheme {
455    /// Returns the OS-active resolved variant.
456    ///
457    /// If `is_dark` is true, returns `&self.dark`; otherwise `&self.light`.
458    pub fn active(&self) -> &ResolvedThemeVariant {
459        if self.is_dark {
460            &self.dark
461        } else {
462            &self.light
463        }
464    }
465
466    /// Pick a resolved variant by explicit preference.
467    ///
468    /// `pick(true)` returns `&self.dark`, `pick(false)` returns `&self.light`.
469    pub fn pick(&self, is_dark: bool) -> &ResolvedThemeVariant {
470        if is_dark { &self.dark } else { &self.light }
471    }
472
473    /// Apply an app-level TOML overlay and re-resolve.
474    ///
475    /// Merges the overlay onto the pre-resolve [`ThemeVariant`] (not the
476    /// already-resolved [`ResolvedThemeVariant`]) so that changed source fields
477    /// propagate correctly through `resolve()`. For example, changing
478    /// `defaults.accent` in the overlay will cause `button.primary_bg`,
479    /// `checkbox.checked_bg`, `slider.fill`, etc. to be re-derived from
480    /// the new accent color.
481    ///
482    /// # Examples
483    ///
484    /// ```no_run
485    /// let system = native_theme::SystemTheme::from_system().unwrap();
486    /// let overlay = native_theme::ThemeSpec::from_toml(r##"
487    ///     [light.defaults]
488    ///     accent = "#ff6600"
489    ///     [dark.defaults]
490    ///     accent = "#ff6600"
491    /// "##).unwrap();
492    /// let customized = system.with_overlay(&overlay).unwrap();
493    /// // customized.active().defaults.accent is now #ff6600
494    /// // and all accent-derived fields are updated
495    /// ```
496    pub fn with_overlay(&self, overlay: &ThemeSpec) -> crate::Result<Self> {
497        // Start from pre-resolve variants (avoids double-resolve idempotency issue)
498        let mut light = self.light_variant.clone();
499        let mut dark = self.dark_variant.clone();
500
501        // Merge overlay onto pre-resolve variants (overlay values win)
502        if let Some(over) = &overlay.light {
503            light.merge(over);
504        }
505        if let Some(over) = &overlay.dark {
506            dark.merge(over);
507        }
508
509        // Resolve and validate both
510        let resolved_light = light.clone().into_resolved()?;
511        let resolved_dark = dark.clone().into_resolved()?;
512
513        Ok(SystemTheme {
514            name: self.name.clone(),
515            is_dark: self.is_dark,
516            light: resolved_light,
517            dark: resolved_dark,
518            light_variant: light,
519            dark_variant: dark,
520            live_preset: self.live_preset.clone(),
521            preset: self.preset.clone(),
522        })
523    }
524
525    /// Apply an app overlay from a TOML string.
526    ///
527    /// Parses the TOML as a [`ThemeSpec`] and calls [`with_overlay`](Self::with_overlay).
528    pub fn with_overlay_toml(&self, toml: &str) -> crate::Result<Self> {
529        let overlay = ThemeSpec::from_toml(toml)?;
530        self.with_overlay(&overlay)
531    }
532
533    /// Load the OS theme synchronously.
534    ///
535    /// Detects the platform and desktop environment, reads the current theme
536    /// settings, merges with a platform preset, and returns a fully resolved
537    /// [`SystemTheme`] with both light and dark variants.
538    ///
539    /// The return value goes through the full pipeline: reader output →
540    /// resolve → validate → [`SystemTheme`] with both light and dark
541    /// [`ResolvedThemeVariant`] variants.
542    ///
543    /// # Platform Behavior
544    ///
545    /// - **macOS:** Calls `from_macos()` when the `macos` feature is enabled.
546    ///   Reads both light and dark variants via NSAppearance, merges with
547    ///   `macos-sonoma` preset.
548    /// - **Linux (KDE):** Calls `from_kde()` when `XDG_CURRENT_DESKTOP` contains
549    ///   "KDE" and the `kde` feature is enabled, merges with `kde-breeze` preset.
550    /// - **Linux (other):** Uses the `adwaita` preset. For live GNOME portal
551    ///   data, use [`from_system_async()`](Self::from_system_async) (requires
552    ///   `portal-tokio` or `portal-async-io` feature).
553    /// - **Windows:** Calls `from_windows()` when the `windows` feature is enabled,
554    ///   merges with `windows-11` preset.
555    /// - **Other platforms:** Returns `Error::Unsupported`.
556    ///
557    /// # Errors
558    ///
559    /// - `Error::Unsupported` if the platform has no reader or the required feature
560    ///   is not enabled.
561    /// - `Error::Unavailable` if the platform reader cannot access theme data.
562    ///
563    /// # Examples
564    ///
565    /// ```no_run
566    /// let system = native_theme::SystemTheme::from_system().unwrap();
567    /// let active = system.active();
568    /// ```
569    #[must_use = "this returns the detected theme; it does not apply it"]
570    pub fn from_system() -> crate::Result<Self> {
571        from_system_inner()
572    }
573
574    /// Async version of [`from_system()`](Self::from_system) that uses D-Bus
575    /// portal backend detection to improve desktop environment heuristics on
576    /// Linux.
577    ///
578    /// When `XDG_CURRENT_DESKTOP` is unset or unrecognized, queries the
579    /// D-Bus session bus for portal backend activatable names to determine
580    /// whether KDE or GNOME portal is running, then dispatches to the
581    /// appropriate reader.
582    ///
583    /// Returns a [`SystemTheme`] with both resolved light and dark variants,
584    /// same as [`from_system()`](Self::from_system).
585    ///
586    /// On non-Linux platforms, behaves identically to
587    /// [`from_system()`](Self::from_system).
588    #[cfg(target_os = "linux")]
589    #[must_use = "this returns the detected theme; it does not apply it"]
590    pub async fn from_system_async() -> crate::Result<Self> {
591        from_system_async_inner().await
592    }
593
594    /// Async version of [`from_system()`](Self::from_system).
595    ///
596    /// On non-Linux platforms, this is equivalent to calling
597    /// [`from_system()`](Self::from_system).
598    #[cfg(not(target_os = "linux"))]
599    #[must_use = "this returns the detected theme; it does not apply it"]
600    pub async fn from_system_async() -> crate::Result<Self> {
601        from_system_inner()
602    }
603}
604
605/// Run the OS-first pipeline: merge reader output onto a platform
606/// preset, resolve both light and dark variants, validate.
607///
608/// For the variant the reader supplied, the merged (reader + live preset)
609/// version is used. For the variant the reader did NOT supply, the full
610/// platform preset (with colors/fonts) is used as fallback.
611fn run_pipeline(
612    reader_output: ThemeSpec,
613    preset_name: &str,
614    is_dark: bool,
615) -> crate::Result<SystemTheme> {
616    let live_preset = ThemeSpec::preset(preset_name)?;
617
618    // For the inactive variant, load the full preset (with colors)
619    let full_preset_name = preset_name.strip_suffix("-live").unwrap_or(preset_name);
620    let full_preset = ThemeSpec::preset(full_preset_name)?;
621
622    // Merge: full preset provides color/font defaults, live preset overrides
623    // geometry, reader output provides live OS data on top.
624    let mut merged = full_preset.clone();
625    merged.merge(&live_preset);
626    merged.merge(&reader_output);
627
628    // Keep reader name if non-empty, else use preset name
629    let name = if reader_output.name.is_empty() {
630        merged.name.clone()
631    } else {
632        reader_output.name.clone()
633    };
634
635    // For the variant the reader provided: use merged (live geometry + reader colors)
636    // For the variant the reader didn't provide: use FULL preset (has colors)
637    let light_variant = if reader_output.light.is_some() {
638        merged.light.unwrap_or_default()
639    } else {
640        full_preset.light.unwrap_or_default()
641    };
642
643    let dark_variant = if reader_output.dark.is_some() {
644        merged.dark.unwrap_or_default()
645    } else {
646        full_preset.dark.unwrap_or_default()
647    };
648
649    // Clone pre-resolve variants for overlay support (Plan 02)
650    let light_variant_pre = light_variant.clone();
651    let dark_variant_pre = dark_variant.clone();
652
653    let light = light_variant.into_resolved()?;
654    let dark = dark_variant.into_resolved()?;
655
656    Ok(SystemTheme {
657        name,
658        is_dark,
659        light,
660        dark,
661        light_variant: light_variant_pre,
662        dark_variant: dark_variant_pre,
663        preset: full_preset_name.to_string(),
664        live_preset: preset_name.to_string(),
665    })
666}
667
668/// Map the current platform to its matching live preset name.
669///
670/// Live presets contain only geometry/metrics (no colors, fonts, or icons)
671/// and are used as the merge base in the OS-first pipeline.
672///
673/// - macOS -> `"macos-sonoma-live"`
674/// - Windows -> `"windows-11-live"`
675/// - Linux KDE -> `"kde-breeze-live"`
676/// - Linux other/GNOME -> `"adwaita-live"`
677/// - Unknown platform -> `"adwaita-live"`
678///
679/// Returns the live preset name for the current platform.
680///
681/// This is the public API for what [`SystemTheme::from_system()`] uses internally.
682/// Showcase UIs use this to build the "default (...)" label.
683#[allow(unreachable_code)]
684pub fn platform_preset_name() -> &'static str {
685    #[cfg(target_os = "macos")]
686    {
687        return "macos-sonoma-live";
688    }
689    #[cfg(target_os = "windows")]
690    {
691        return "windows-11-live";
692    }
693    #[cfg(target_os = "linux")]
694    {
695        let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
696        match detect_linux_de(&desktop) {
697            LinuxDesktop::Kde => "kde-breeze-live",
698            _ => "adwaita-live",
699        }
700    }
701    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
702    {
703        "adwaita-live"
704    }
705}
706
707/// Infer dark-mode preference from the reader's output.
708///
709/// Returns `true` if the reader populated only the dark variant,
710/// `false` if it populated only light or both variants.
711/// On platforms that produce both variants (macOS), this defaults to
712/// `false` (light); callers can use [`SystemTheme::pick()`] for
713/// explicit variant selection regardless of this default.
714#[allow(dead_code)]
715fn reader_is_dark(reader: &ThemeSpec) -> bool {
716    reader.dark.is_some() && reader.light.is_none()
717}
718
719/// Read the current system theme on Linux by detecting the desktop
720/// environment and calling the appropriate reader or returning a
721/// preset fallback.
722///
723/// Runs the full OS-first pipeline: reader -> preset merge -> resolve -> validate.
724#[cfg(target_os = "linux")]
725fn from_linux() -> crate::Result<SystemTheme> {
726    let is_dark = system_is_dark();
727    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
728    match detect_linux_de(&desktop) {
729        #[cfg(feature = "kde")]
730        LinuxDesktop::Kde => {
731            let reader = crate::kde::from_kde()?;
732            run_pipeline(reader, "kde-breeze-live", is_dark)
733        }
734        #[cfg(not(feature = "kde"))]
735        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
736        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
737            // GNOME sync path: no portal, just adwaita preset
738            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
739        }
740        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
741            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
742        }
743        LinuxDesktop::Unknown => {
744            #[cfg(feature = "kde")]
745            {
746                let path = crate::kde::kdeglobals_path();
747                if path.exists() {
748                    let reader = crate::kde::from_kde()?;
749                    return run_pipeline(reader, "kde-breeze-live", is_dark);
750                }
751            }
752            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
753        }
754    }
755}
756
757fn from_system_inner() -> crate::Result<SystemTheme> {
758    #[cfg(target_os = "macos")]
759    {
760        #[cfg(feature = "macos")]
761        {
762            let reader = crate::macos::from_macos()?;
763            let is_dark = reader_is_dark(&reader);
764            return run_pipeline(reader, "macos-sonoma-live", is_dark);
765        }
766
767        #[cfg(not(feature = "macos"))]
768        return Err(crate::Error::Unsupported);
769    }
770
771    #[cfg(target_os = "windows")]
772    {
773        #[cfg(feature = "windows")]
774        {
775            let reader = crate::windows::from_windows()?;
776            let is_dark = reader_is_dark(&reader);
777            return run_pipeline(reader, "windows-11-live", is_dark);
778        }
779
780        #[cfg(not(feature = "windows"))]
781        return Err(crate::Error::Unsupported);
782    }
783
784    #[cfg(target_os = "linux")]
785    {
786        from_linux()
787    }
788
789    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
790    {
791        Err(crate::Error::Unsupported)
792    }
793}
794
795#[cfg(target_os = "linux")]
796async fn from_system_async_inner() -> crate::Result<SystemTheme> {
797    let is_dark = system_is_dark();
798    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
799    match detect_linux_de(&desktop) {
800        #[cfg(feature = "kde")]
801        LinuxDesktop::Kde => {
802            #[cfg(feature = "portal")]
803            {
804                let reader = crate::gnome::from_kde_with_portal().await?;
805                run_pipeline(reader, "kde-breeze-live", is_dark)
806            }
807            #[cfg(not(feature = "portal"))]
808            {
809                let reader = crate::kde::from_kde()?;
810                run_pipeline(reader, "kde-breeze-live", is_dark)
811            }
812        }
813        #[cfg(not(feature = "kde"))]
814        LinuxDesktop::Kde => run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark),
815        #[cfg(feature = "portal")]
816        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
817            let reader = crate::gnome::from_gnome().await?;
818            run_pipeline(reader, "adwaita-live", is_dark)
819        }
820        #[cfg(not(feature = "portal"))]
821        LinuxDesktop::Gnome | LinuxDesktop::Budgie => {
822            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
823        }
824        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
825            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
826        }
827        LinuxDesktop::Unknown => {
828            // Use D-Bus portal backend detection to refine heuristic
829            #[cfg(feature = "portal")]
830            {
831                if let Some(detected) = crate::gnome::detect_portal_backend().await {
832                    return match detected {
833                        #[cfg(feature = "kde")]
834                        LinuxDesktop::Kde => {
835                            let reader = crate::gnome::from_kde_with_portal().await?;
836                            run_pipeline(reader, "kde-breeze-live", is_dark)
837                        }
838                        #[cfg(not(feature = "kde"))]
839                        LinuxDesktop::Kde => {
840                            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
841                        }
842                        LinuxDesktop::Gnome => {
843                            let reader = crate::gnome::from_gnome().await?;
844                            run_pipeline(reader, "adwaita-live", is_dark)
845                        }
846                        _ => {
847                            unreachable!("detect_portal_backend only returns Kde or Gnome")
848                        }
849                    };
850                }
851            }
852            // Sync fallback: try kdeglobals, then Adwaita
853            #[cfg(feature = "kde")]
854            {
855                let path = crate::kde::kdeglobals_path();
856                if path.exists() {
857                    let reader = crate::kde::from_kde()?;
858                    return run_pipeline(reader, "kde-breeze-live", is_dark);
859                }
860            }
861            run_pipeline(ThemeSpec::preset("adwaita")?, "adwaita-live", is_dark)
862        }
863    }
864}
865
866/// Load an icon for the given role using the specified icon set.
867///
868/// Resolves `icon_set` to an [`IconSet`] via [`IconSet::from_name()`],
869/// falling back to [`system_icon_set()`] if the set string is not
870/// recognized. Then dispatches to the appropriate platform loader or
871/// bundled icon set.
872///
873/// # Dispatch
874///
875/// 1. Platform loader (freedesktop/sf-symbols/segoe-fluent) when `system-icons` enabled
876/// 2. Bundled SVGs (material/lucide) when the corresponding feature is enabled
877/// 3. Non-matching set: `None` (no cross-set fallback)
878///
879/// Freedesktop icons are loaded at 24 px (the standard toolbar size). For
880/// custom sizes, call `freedesktop::load_freedesktop_icon()` directly.
881///
882/// # Examples
883///
884/// ```
885/// use native_theme::{load_icon, IconRole, IconSet};
886///
887/// // With material-icons feature enabled
888/// # #[cfg(feature = "material-icons")]
889/// # {
890/// let icon = load_icon(IconRole::ActionCopy, IconSet::Material);
891/// assert!(icon.is_some());
892/// # }
893/// ```
894#[must_use = "this returns the loaded icon data; it does not display it"]
895#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
896pub fn load_icon(role: IconRole, set: IconSet) -> Option<IconData> {
897    match set {
898        #[cfg(all(target_os = "linux", feature = "system-icons"))]
899        IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role, 24),
900
901        #[cfg(all(target_os = "macos", feature = "system-icons"))]
902        IconSet::SfSymbols => sficons::load_sf_icon(role),
903
904        #[cfg(all(target_os = "windows", feature = "system-icons"))]
905        IconSet::SegoeIcons => winicons::load_windows_icon(role),
906
907        #[cfg(feature = "material-icons")]
908        IconSet::Material => {
909            bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
910        }
911
912        #[cfg(feature = "lucide-icons")]
913        IconSet::Lucide => {
914            bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
915        }
916
917        // Non-matching platform or unknown set: no cross-set fallback
918        _ => None,
919    }
920}
921
922/// Load a system icon by its platform-specific name string.
923///
924/// Dispatches to the appropriate platform loader based on the icon set:
925/// - [`IconSet::Freedesktop`] -- freedesktop icon theme lookup (auto-detects theme)
926/// - [`IconSet::SfSymbols`] -- macOS SF Symbols
927/// - [`IconSet::SegoeIcons`] -- Windows Segoe Fluent / stock icons
928/// - [`IconSet::Material`] / [`IconSet::Lucide`] -- bundled SVG lookup by name
929///
930/// Returns `None` if the icon is not found on the current platform or
931/// the icon set is not available.
932///
933/// # Examples
934///
935/// ```
936/// use native_theme::{load_system_icon_by_name, IconSet};
937///
938/// # #[cfg(feature = "material-icons")]
939/// # {
940/// let icon = load_system_icon_by_name("content_copy", IconSet::Material);
941/// assert!(icon.is_some());
942/// # }
943/// ```
944#[must_use = "this returns the loaded icon data; it does not display it"]
945#[allow(unreachable_patterns, unused_variables)]
946pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
947    match set {
948        #[cfg(all(target_os = "linux", feature = "system-icons"))]
949        IconSet::Freedesktop => {
950            let theme = system_icon_theme();
951            freedesktop::load_freedesktop_icon_by_name(name, theme, 24)
952        }
953
954        #[cfg(all(target_os = "macos", feature = "system-icons"))]
955        IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
956
957        #[cfg(all(target_os = "windows", feature = "system-icons"))]
958        IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
959
960        #[cfg(feature = "material-icons")]
961        IconSet::Material => {
962            bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
963        }
964
965        #[cfg(feature = "lucide-icons")]
966        IconSet::Lucide => {
967            bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
968        }
969
970        _ => None,
971    }
972}
973
974/// Return the loading/spinner animation for the given icon set.
975///
976/// This is the animated-icon counterpart of [`load_icon()`].
977///
978/// # Dispatch
979///
980/// - [`IconSet::Material`] -- `progress_activity.svg` with continuous spin transform (1000ms)
981/// - [`IconSet::Lucide`] -- `loader.svg` with continuous spin transform (1000ms)
982/// - [`IconSet::Freedesktop`] -- loads `process-working` sprite sheet from active icon theme
983/// - Other sets -- `None`
984///
985/// # Examples
986///
987/// ```
988/// // Result depends on enabled features and platform
989/// let anim = native_theme::loading_indicator(native_theme::IconSet::Lucide);
990/// # #[cfg(feature = "lucide-icons")]
991/// # assert!(anim.is_some());
992/// ```
993#[must_use = "this returns animation data; it does not display anything"]
994pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
995    match set {
996        #[cfg(all(target_os = "linux", feature = "system-icons"))]
997        IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
998
999        #[cfg(feature = "material-icons")]
1000        IconSet::Material => Some(spinners::material_spinner()),
1001
1002        #[cfg(feature = "lucide-icons")]
1003        IconSet::Lucide => Some(spinners::lucide_spinner()),
1004
1005        _ => None,
1006    }
1007}
1008
1009/// Load an icon from any [`IconProvider`], dispatching through the standard
1010/// platform loading chain.
1011///
1012/// # Fallback chain
1013///
1014/// 1. Provider's [`icon_name()`](IconProvider::icon_name) -- passed to platform
1015///    system loader via [`load_system_icon_by_name()`]
1016/// 2. Provider's [`icon_svg()`](IconProvider::icon_svg) -- bundled SVG data
1017/// 3. `None` -- **no cross-set fallback** (mixing icon sets is forbidden)
1018///
1019/// # Examples
1020///
1021/// ```
1022/// use native_theme::{load_custom_icon, IconRole, IconSet};
1023///
1024/// // IconRole implements IconProvider, so it works with load_custom_icon
1025/// # #[cfg(feature = "material-icons")]
1026/// # {
1027/// let icon = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1028/// assert!(icon.is_some());
1029/// # }
1030/// ```
1031#[must_use = "this returns the loaded icon data; it does not display it"]
1032pub fn load_custom_icon(provider: &(impl IconProvider + ?Sized), set: IconSet) -> Option<IconData> {
1033    // Step 1: Try system loader with provider's name mapping
1034    if let Some(name) = provider.icon_name(set)
1035        && let Some(data) = load_system_icon_by_name(name, set)
1036    {
1037        return Some(data);
1038    }
1039
1040    // Step 2: Try bundled SVG from provider
1041    if let Some(svg) = provider.icon_svg(set) {
1042        return Some(IconData::Svg(svg.to_vec()));
1043    }
1044
1045    // No cross-set fallback -- return None
1046    None
1047}
1048
1049/// Mutex to serialize tests that manipulate environment variables.
1050/// Env vars are process-global state, so tests that call set_var/remove_var
1051/// must hold this lock to avoid races with parallel test execution.
1052#[cfg(test)]
1053pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
1054
1055#[cfg(all(test, target_os = "linux"))]
1056#[allow(clippy::unwrap_used, clippy::expect_used)]
1057mod dispatch_tests {
1058    use super::*;
1059
1060    // -- detect_linux_de() pure function tests --
1061
1062    #[test]
1063    fn detect_kde_simple() {
1064        assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
1065    }
1066
1067    #[test]
1068    fn detect_kde_colon_separated_after() {
1069        assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
1070    }
1071
1072    #[test]
1073    fn detect_kde_colon_separated_before() {
1074        assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
1075    }
1076
1077    #[test]
1078    fn detect_gnome_simple() {
1079        assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
1080    }
1081
1082    #[test]
1083    fn detect_gnome_ubuntu() {
1084        assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
1085    }
1086
1087    #[test]
1088    fn detect_xfce() {
1089        assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
1090    }
1091
1092    #[test]
1093    fn detect_cinnamon() {
1094        assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
1095    }
1096
1097    #[test]
1098    fn detect_cinnamon_short() {
1099        assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
1100    }
1101
1102    #[test]
1103    fn detect_mate() {
1104        assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
1105    }
1106
1107    #[test]
1108    fn detect_lxqt() {
1109        assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
1110    }
1111
1112    #[test]
1113    fn detect_budgie() {
1114        assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
1115    }
1116
1117    #[test]
1118    fn detect_empty_string() {
1119        assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
1120    }
1121
1122    // -- from_linux() fallback test --
1123
1124    #[test]
1125    #[allow(unsafe_code)]
1126    fn from_linux_non_kde_returns_adwaita() {
1127        let _guard = crate::ENV_MUTEX.lock().unwrap();
1128        // Temporarily set XDG_CURRENT_DESKTOP to GNOME so from_linux()
1129        // takes the preset fallback path.
1130        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1131        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1132        let result = from_linux();
1133        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1134
1135        let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
1136        assert_eq!(theme.name, "Adwaita");
1137    }
1138
1139    // -- from_linux() kdeglobals fallback tests --
1140
1141    #[test]
1142    #[cfg(feature = "kde")]
1143    #[allow(unsafe_code)]
1144    fn from_linux_unknown_de_with_kdeglobals_fallback() {
1145        let _guard = crate::ENV_MUTEX.lock().unwrap();
1146        use std::io::Write;
1147
1148        // Create a temp dir with a minimal kdeglobals file
1149        let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
1150        std::fs::create_dir_all(&tmp_dir).unwrap();
1151        let kdeglobals = tmp_dir.join("kdeglobals");
1152        let mut f = std::fs::File::create(&kdeglobals).unwrap();
1153        writeln!(
1154            f,
1155            "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
1156        )
1157        .unwrap();
1158
1159        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1160        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1161        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1162
1163        unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
1164        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1165
1166        let result = from_linux();
1167
1168        // Restore env
1169        match orig_xdg {
1170            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1171            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1172        }
1173        match orig_desktop {
1174            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1175            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1176        }
1177
1178        // Cleanup
1179        let _ = std::fs::remove_dir_all(&tmp_dir);
1180
1181        let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
1182        assert_eq!(
1183            theme.name, "TestTheme",
1184            "should use KDE theme name from kdeglobals"
1185        );
1186    }
1187
1188    #[test]
1189    #[allow(unsafe_code)]
1190    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
1191        let _guard = crate::ENV_MUTEX.lock().unwrap();
1192        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1193        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
1194        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
1195
1196        unsafe {
1197            std::env::set_var(
1198                "XDG_CONFIG_HOME",
1199                "/tmp/nonexistent_native_theme_test_no_kde",
1200            )
1201        };
1202        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
1203
1204        let result = from_linux();
1205
1206        // Restore env
1207        match orig_xdg {
1208            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
1209            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
1210        }
1211        match orig_desktop {
1212            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
1213            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
1214        }
1215
1216        let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
1217        assert_eq!(
1218            theme.name, "Adwaita",
1219            "should fall back to Adwaita without kdeglobals"
1220        );
1221    }
1222
1223    // -- LNXDE-03: Hyprland, Sway, COSMIC map to Unknown --
1224
1225    #[test]
1226    fn detect_hyprland_returns_unknown() {
1227        assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
1228    }
1229
1230    #[test]
1231    fn detect_sway_returns_unknown() {
1232        assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
1233    }
1234
1235    #[test]
1236    fn detect_cosmic_returns_unknown() {
1237        assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
1238    }
1239
1240    // -- from_system() smoke test --
1241
1242    #[test]
1243    #[allow(unsafe_code)]
1244    fn from_system_returns_result() {
1245        let _guard = crate::ENV_MUTEX.lock().unwrap();
1246        // On Linux (our test platform), from_system() should return a Result.
1247        // With GNOME set, it should return the Adwaita preset.
1248        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
1249        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1250        let result = SystemTheme::from_system();
1251        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1252
1253        let theme = result.expect("from_system() should return Ok on Linux");
1254        assert_eq!(theme.name, "Adwaita");
1255    }
1256}
1257
1258#[cfg(test)]
1259#[allow(clippy::unwrap_used, clippy::expect_used)]
1260mod load_icon_tests {
1261    use super::*;
1262
1263    #[test]
1264    #[cfg(feature = "material-icons")]
1265    fn load_icon_material_returns_svg() {
1266        let result = load_icon(IconRole::ActionCopy, IconSet::Material);
1267        assert!(result.is_some(), "material ActionCopy should return Some");
1268        match result.unwrap() {
1269            IconData::Svg(bytes) => {
1270                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1271                assert!(content.contains("<svg"), "should contain SVG data");
1272            }
1273            _ => panic!("expected IconData::Svg for bundled material icon"),
1274        }
1275    }
1276
1277    #[test]
1278    #[cfg(feature = "lucide-icons")]
1279    fn load_icon_lucide_returns_svg() {
1280        let result = load_icon(IconRole::ActionCopy, IconSet::Lucide);
1281        assert!(result.is_some(), "lucide ActionCopy should return Some");
1282        match result.unwrap() {
1283            IconData::Svg(bytes) => {
1284                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
1285                assert!(content.contains("<svg"), "should contain SVG data");
1286            }
1287            _ => panic!("expected IconData::Svg for bundled lucide icon"),
1288        }
1289    }
1290
1291    #[test]
1292    #[cfg(feature = "material-icons")]
1293    fn load_icon_unknown_theme_no_cross_set_fallback() {
1294        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
1295        // Without system-icons feature, Freedesktop falls through to wildcard -> None.
1296        // No cross-set Material fallback.
1297        let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop);
1298        // Without system-icons, this falls to wildcard which returns None
1299        // With system-icons, this dispatches to load_freedesktop_icon which may return Some
1300        // Either way, no panic
1301        let _ = result;
1302    }
1303
1304    #[test]
1305    #[cfg(feature = "material-icons")]
1306    fn load_icon_all_roles_material() {
1307        // Material has 42 of 42 roles mapped, all return Some
1308        let mut some_count = 0;
1309        for role in IconRole::ALL {
1310            if load_icon(role, IconSet::Material).is_some() {
1311                some_count += 1;
1312            }
1313        }
1314        // bundled_icon_svg covers all 42 roles for Material
1315        assert_eq!(
1316            some_count, 42,
1317            "Material should cover all 42 roles via bundled SVGs"
1318        );
1319    }
1320
1321    #[test]
1322    #[cfg(feature = "lucide-icons")]
1323    fn load_icon_all_roles_lucide() {
1324        let mut some_count = 0;
1325        for role in IconRole::ALL {
1326            if load_icon(role, IconSet::Lucide).is_some() {
1327                some_count += 1;
1328            }
1329        }
1330        // bundled_icon_svg covers all 42 roles for Lucide
1331        assert_eq!(
1332            some_count, 42,
1333            "Lucide should cover all 42 roles via bundled SVGs"
1334        );
1335    }
1336
1337    #[test]
1338    fn load_icon_unrecognized_set_no_features() {
1339        // SfSymbols on Linux without system-icons: falls through to wildcard -> None
1340        let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1341        // Just verifying it doesn't panic
1342    }
1343}
1344
1345#[cfg(test)]
1346#[allow(clippy::unwrap_used, clippy::expect_used)]
1347mod load_system_icon_by_name_tests {
1348    use super::*;
1349
1350    #[test]
1351    #[cfg(feature = "material-icons")]
1352    fn system_icon_by_name_material() {
1353        let result = load_system_icon_by_name("content_copy", IconSet::Material);
1354        assert!(
1355            result.is_some(),
1356            "content_copy should be found in Material set"
1357        );
1358        assert!(matches!(result.unwrap(), IconData::Svg(_)));
1359    }
1360
1361    #[test]
1362    #[cfg(feature = "lucide-icons")]
1363    fn system_icon_by_name_lucide() {
1364        let result = load_system_icon_by_name("copy", IconSet::Lucide);
1365        assert!(result.is_some(), "copy should be found in Lucide set");
1366        assert!(matches!(result.unwrap(), IconData::Svg(_)));
1367    }
1368
1369    #[test]
1370    #[cfg(feature = "material-icons")]
1371    fn system_icon_by_name_unknown_returns_none() {
1372        let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
1373        assert!(result.is_none(), "nonexistent name should return None");
1374    }
1375
1376    #[test]
1377    fn system_icon_by_name_sf_on_linux_returns_none() {
1378        // On Linux, SfSymbols set is not available (cfg-gated to macOS)
1379        #[cfg(not(target_os = "macos"))]
1380        {
1381            let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1382            assert!(
1383                result.is_none(),
1384                "SF Symbols should return None on non-macOS"
1385            );
1386        }
1387    }
1388}
1389
1390#[cfg(test)]
1391#[allow(clippy::unwrap_used, clippy::expect_used)]
1392mod load_custom_icon_tests {
1393    use super::*;
1394
1395    #[test]
1396    #[cfg(feature = "material-icons")]
1397    fn custom_icon_with_icon_role_material() {
1398        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material);
1399        assert!(
1400            result.is_some(),
1401            "IconRole::ActionCopy should load via material"
1402        );
1403    }
1404
1405    #[test]
1406    #[cfg(feature = "lucide-icons")]
1407    fn custom_icon_with_icon_role_lucide() {
1408        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide);
1409        assert!(
1410            result.is_some(),
1411            "IconRole::ActionCopy should load via lucide"
1412        );
1413    }
1414
1415    #[test]
1416    fn custom_icon_no_cross_set_fallback() {
1417        // Provider that returns None for all sets -- should NOT fall back
1418        #[derive(Debug)]
1419        struct NullProvider;
1420        impl IconProvider for NullProvider {
1421            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1422                None
1423            }
1424            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1425                None
1426            }
1427        }
1428
1429        let result = load_custom_icon(&NullProvider, IconSet::Material);
1430        assert!(
1431            result.is_none(),
1432            "NullProvider should return None (no cross-set fallback)"
1433        );
1434    }
1435
1436    #[test]
1437    fn custom_icon_unknown_set_uses_system() {
1438        // "unknown-set" is not a known IconSet name, falls through to system_icon_set()
1439        #[derive(Debug)]
1440        struct NullProvider;
1441        impl IconProvider for NullProvider {
1442            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1443                None
1444            }
1445            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1446                None
1447            }
1448        }
1449
1450        // Just verify it doesn't panic -- the actual set chosen depends on platform
1451        let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop);
1452    }
1453
1454    #[test]
1455    #[cfg(feature = "material-icons")]
1456    fn custom_icon_via_dyn_dispatch() {
1457        let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1458        let result = load_custom_icon(&*boxed, IconSet::Material);
1459        assert!(
1460            result.is_some(),
1461            "dyn dispatch through Box<dyn IconProvider> should work"
1462        );
1463    }
1464
1465    #[test]
1466    #[cfg(feature = "material-icons")]
1467    fn custom_icon_bundled_svg_fallback() {
1468        // Provider that returns None from icon_name but Some from icon_svg
1469        #[derive(Debug)]
1470        struct SvgOnlyProvider;
1471        impl IconProvider for SvgOnlyProvider {
1472            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1473                None
1474            }
1475            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1476                Some(b"<svg>test</svg>")
1477            }
1478        }
1479
1480        let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material);
1481        assert!(
1482            result.is_some(),
1483            "provider with icon_svg should return Some"
1484        );
1485        match result.unwrap() {
1486            IconData::Svg(bytes) => {
1487                assert_eq!(bytes, b"<svg>test</svg>");
1488            }
1489            _ => panic!("expected IconData::Svg"),
1490        }
1491    }
1492}
1493
1494#[cfg(test)]
1495#[allow(clippy::unwrap_used, clippy::expect_used)]
1496mod loading_indicator_tests {
1497    use super::*;
1498
1499    // === Dispatch tests (through loading_indicator public API) ===
1500
1501    #[test]
1502    #[cfg(feature = "lucide-icons")]
1503    fn loading_indicator_lucide_returns_transform_spin() {
1504        let anim = loading_indicator(IconSet::Lucide);
1505        assert!(anim.is_some(), "lucide should return Some");
1506        let anim = anim.unwrap();
1507        assert!(
1508            matches!(
1509                anim,
1510                AnimatedIcon::Transform {
1511                    animation: TransformAnimation::Spin { duration_ms: 1000 },
1512                    ..
1513                }
1514            ),
1515            "lucide should be Transform::Spin at 1000ms"
1516        );
1517    }
1518
1519    /// Freedesktop loading_indicator returns Some if the active icon theme
1520    /// has a `process-working` sprite sheet (e.g. Breeze), None otherwise.
1521    #[test]
1522    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1523    fn loading_indicator_freedesktop_depends_on_theme() {
1524        let anim = loading_indicator(IconSet::Freedesktop);
1525        // Result depends on installed icon theme -- Some if process-working exists
1526        if let Some(anim) = anim {
1527            match anim {
1528                AnimatedIcon::Frames { frames, .. } => {
1529                    assert!(
1530                        !frames.is_empty(),
1531                        "Frames variant should have at least one frame"
1532                    );
1533                }
1534                AnimatedIcon::Transform { .. } => {
1535                    // Single-frame theme icon with Spin -- valid result
1536                }
1537            }
1538        }
1539    }
1540
1541    /// Freedesktop spinner depends on platform and icon theme.
1542    #[test]
1543    fn loading_indicator_freedesktop_does_not_panic() {
1544        let _result = loading_indicator(IconSet::Freedesktop);
1545    }
1546
1547    // === Direct spinner construction tests (any platform) ===
1548
1549    #[test]
1550    #[cfg(feature = "lucide-icons")]
1551    fn lucide_spinner_is_transform() {
1552        let anim = spinners::lucide_spinner();
1553        assert!(matches!(
1554            anim,
1555            AnimatedIcon::Transform {
1556                animation: TransformAnimation::Spin { duration_ms: 1000 },
1557                ..
1558            }
1559        ));
1560    }
1561}
1562
1563#[cfg(all(test, feature = "svg-rasterize"))]
1564#[allow(clippy::unwrap_used, clippy::expect_used)]
1565mod spinner_rasterize_tests {
1566    use super::*;
1567
1568    #[test]
1569    #[cfg(feature = "lucide-icons")]
1570    fn lucide_spinner_icon_rasterizes() {
1571        let anim = spinners::lucide_spinner();
1572        if let AnimatedIcon::Transform { icon, .. } = &anim {
1573            if let IconData::Svg(bytes) = icon {
1574                let result = crate::rasterize::rasterize_svg(bytes, 24);
1575                assert!(result.is_ok(), "lucide loader should rasterize");
1576                if let Ok(IconData::Rgba { data, .. }) = &result {
1577                    assert!(
1578                        data.iter().any(|&b| b != 0),
1579                        "lucide loader rasterized to empty image"
1580                    );
1581                }
1582            } else {
1583                panic!("lucide spinner icon should be Svg");
1584            }
1585        } else {
1586            panic!("lucide spinner should be Transform");
1587        }
1588    }
1589}
1590
1591#[cfg(test)]
1592#[allow(
1593    clippy::unwrap_used,
1594    clippy::expect_used,
1595    clippy::field_reassign_with_default
1596)]
1597mod system_theme_tests {
1598    use super::*;
1599
1600    // --- SystemTheme::active() / pick() tests ---
1601
1602    #[test]
1603    fn test_system_theme_active_dark() {
1604        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1605        let mut light_v = preset.light.clone().unwrap();
1606        let mut dark_v = preset.dark.clone().unwrap();
1607        // Give them distinct accents so we can tell them apart
1608        light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1609        dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1610        light_v.resolve();
1611        dark_v.resolve();
1612        let light_resolved = light_v.validate().unwrap();
1613        let dark_resolved = dark_v.validate().unwrap();
1614
1615        let st = SystemTheme {
1616            name: "test".into(),
1617            is_dark: true,
1618            light: light_resolved.clone(),
1619            dark: dark_resolved.clone(),
1620            light_variant: preset.light.unwrap(),
1621            dark_variant: preset.dark.unwrap(),
1622            live_preset: "catppuccin-mocha".into(),
1623            preset: "catppuccin-mocha".into(),
1624        };
1625        assert_eq!(st.active().defaults.accent, dark_resolved.defaults.accent);
1626    }
1627
1628    #[test]
1629    fn test_system_theme_active_light() {
1630        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1631        let mut light_v = preset.light.clone().unwrap();
1632        let mut dark_v = preset.dark.clone().unwrap();
1633        light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1634        dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1635        light_v.resolve();
1636        dark_v.resolve();
1637        let light_resolved = light_v.validate().unwrap();
1638        let dark_resolved = dark_v.validate().unwrap();
1639
1640        let st = SystemTheme {
1641            name: "test".into(),
1642            is_dark: false,
1643            light: light_resolved.clone(),
1644            dark: dark_resolved.clone(),
1645            light_variant: preset.light.unwrap(),
1646            dark_variant: preset.dark.unwrap(),
1647            live_preset: "catppuccin-mocha".into(),
1648            preset: "catppuccin-mocha".into(),
1649        };
1650        assert_eq!(st.active().defaults.accent, light_resolved.defaults.accent);
1651    }
1652
1653    #[test]
1654    fn test_system_theme_pick() {
1655        let preset = ThemeSpec::preset("catppuccin-mocha").unwrap();
1656        let mut light_v = preset.light.clone().unwrap();
1657        let mut dark_v = preset.dark.clone().unwrap();
1658        light_v.defaults.accent = Some(Rgba::rgb(0, 0, 255));
1659        dark_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1660        light_v.resolve();
1661        dark_v.resolve();
1662        let light_resolved = light_v.validate().unwrap();
1663        let dark_resolved = dark_v.validate().unwrap();
1664
1665        let st = SystemTheme {
1666            name: "test".into(),
1667            is_dark: false,
1668            light: light_resolved.clone(),
1669            dark: dark_resolved.clone(),
1670            light_variant: preset.light.unwrap(),
1671            dark_variant: preset.dark.unwrap(),
1672            live_preset: "catppuccin-mocha".into(),
1673            preset: "catppuccin-mocha".into(),
1674        };
1675        assert_eq!(st.pick(true).defaults.accent, dark_resolved.defaults.accent);
1676        assert_eq!(
1677            st.pick(false).defaults.accent,
1678            light_resolved.defaults.accent
1679        );
1680    }
1681
1682    // --- platform_preset_name() tests ---
1683
1684    #[test]
1685    #[cfg(target_os = "linux")]
1686    #[allow(unsafe_code)]
1687    fn test_platform_preset_name_kde() {
1688        let _guard = crate::ENV_MUTEX.lock().unwrap();
1689        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "KDE") };
1690        let name = platform_preset_name();
1691        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1692        assert_eq!(name, "kde-breeze-live");
1693    }
1694
1695    #[test]
1696    #[cfg(target_os = "linux")]
1697    #[allow(unsafe_code)]
1698    fn test_platform_preset_name_gnome() {
1699        let _guard = crate::ENV_MUTEX.lock().unwrap();
1700        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
1701        let name = platform_preset_name();
1702        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
1703        assert_eq!(name, "adwaita-live");
1704    }
1705
1706    // --- run_pipeline() tests ---
1707
1708    #[test]
1709    fn test_run_pipeline_produces_both_variants() {
1710        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1711        let result = run_pipeline(reader, "catppuccin-mocha", false);
1712        assert!(result.is_ok(), "run_pipeline should succeed");
1713        let st = result.unwrap();
1714        // Both light and dark exist as ResolvedThemeVariant (non-Option)
1715        assert!(!st.name.is_empty(), "name should be populated");
1716        // If we get here, both variants validated successfully
1717    }
1718
1719    #[test]
1720    fn test_run_pipeline_reader_values_win() {
1721        // Create a reader with a custom accent color
1722        let custom_accent = Rgba::rgb(42, 100, 200);
1723        let mut reader = ThemeSpec::default();
1724        reader.name = "CustomTheme".into();
1725        let mut variant = ThemeVariant::default();
1726        variant.defaults.accent = Some(custom_accent);
1727        reader.light = Some(variant);
1728
1729        let result = run_pipeline(reader, "catppuccin-mocha", false);
1730        assert!(result.is_ok(), "run_pipeline should succeed");
1731        let st = result.unwrap();
1732        // The reader's accent should win over the preset's accent
1733        assert_eq!(
1734            st.light.defaults.accent, custom_accent,
1735            "reader accent should win over preset accent"
1736        );
1737        assert_eq!(st.name, "CustomTheme", "reader name should win");
1738    }
1739
1740    #[test]
1741    fn test_run_pipeline_single_variant() {
1742        // Simulate a real OS reader that provides a complete dark variant
1743        // (like KDE's from_kde() would) but no light variant.
1744        // Use a live preset so the inactive light variant gets the full preset.
1745        let full = ThemeSpec::preset("kde-breeze").unwrap();
1746        let mut reader = ThemeSpec::default();
1747        let mut dark_v = full.dark.clone().unwrap();
1748        // Override accent to prove reader values win
1749        dark_v.defaults.accent = Some(Rgba::rgb(200, 50, 50));
1750        reader.dark = Some(dark_v);
1751        reader.light = None;
1752
1753        let result = run_pipeline(reader, "kde-breeze-live", true);
1754        assert!(
1755            result.is_ok(),
1756            "run_pipeline should succeed with single variant"
1757        );
1758        let st = result.unwrap();
1759        // Dark should have the reader's overridden accent
1760        assert_eq!(
1761            st.dark.defaults.accent,
1762            Rgba::rgb(200, 50, 50),
1763            "dark variant should have reader accent"
1764        );
1765        // Light should still exist (from full preset, which has colors)
1766        // If we get here, both variants validated successfully
1767        assert_eq!(st.live_preset, "kde-breeze-live");
1768        assert_eq!(st.preset, "kde-breeze");
1769    }
1770
1771    #[test]
1772    fn test_run_pipeline_inactive_variant_from_full_preset() {
1773        // When reader provides only dark, light must come from the full preset
1774        // (not the live preset, which has no colors and would fail validation).
1775        let full = ThemeSpec::preset("kde-breeze").unwrap();
1776        let mut reader = ThemeSpec::default();
1777        reader.dark = Some(full.dark.clone().unwrap());
1778        reader.light = None;
1779
1780        let st = run_pipeline(reader, "kde-breeze-live", true).unwrap();
1781
1782        // The light variant should have colors from the full "kde-breeze" preset
1783        let full_light = full.light.unwrap();
1784        assert_eq!(
1785            st.light.defaults.accent,
1786            full_light.defaults.accent.unwrap(),
1787            "inactive light variant should get accent from full preset"
1788        );
1789        assert_eq!(
1790            st.light.defaults.background,
1791            full_light.defaults.background.unwrap(),
1792            "inactive light variant should get background from full preset"
1793        );
1794    }
1795
1796    // --- run_pipeline with preset-as-reader (GNOME double-merge test) ---
1797
1798    #[test]
1799    fn test_run_pipeline_with_preset_as_reader() {
1800        // Simulates GNOME sync fallback: adwaita used as both reader and preset.
1801        // Double-merge is harmless: merge is idempotent for matching values.
1802        let reader = ThemeSpec::preset("adwaita").unwrap();
1803        let result = run_pipeline(reader, "adwaita", false);
1804        assert!(
1805            result.is_ok(),
1806            "double-merge with same preset should succeed"
1807        );
1808        let st = result.unwrap();
1809        assert_eq!(st.name, "Adwaita");
1810    }
1811
1812    // --- reader_is_dark() tests ---
1813
1814    #[test]
1815    fn test_reader_is_dark_only_dark() {
1816        let mut theme = ThemeSpec::default();
1817        theme.dark = Some(ThemeVariant::default());
1818        theme.light = None;
1819        assert!(
1820            reader_is_dark(&theme),
1821            "should be true when only dark is set"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_reader_is_dark_only_light() {
1827        let mut theme = ThemeSpec::default();
1828        theme.light = Some(ThemeVariant::default());
1829        theme.dark = None;
1830        assert!(
1831            !reader_is_dark(&theme),
1832            "should be false when only light is set"
1833        );
1834    }
1835
1836    #[test]
1837    fn test_reader_is_dark_both() {
1838        let mut theme = ThemeSpec::default();
1839        theme.light = Some(ThemeVariant::default());
1840        theme.dark = Some(ThemeVariant::default());
1841        assert!(
1842            !reader_is_dark(&theme),
1843            "should be false when both are set (macOS case)"
1844        );
1845    }
1846
1847    #[test]
1848    fn test_reader_is_dark_neither() {
1849        let theme = ThemeSpec::default();
1850        assert!(
1851            !reader_is_dark(&theme),
1852            "should be false when neither is set"
1853        );
1854    }
1855}
1856
1857#[cfg(test)]
1858#[allow(clippy::unwrap_used, clippy::expect_used)]
1859mod reduced_motion_tests {
1860    use super::*;
1861
1862    #[test]
1863    fn prefers_reduced_motion_smoke_test() {
1864        // Smoke test: function should not panic on any platform.
1865        // Cannot assert a specific value because OnceLock caches the first call
1866        // and CI environments have varying accessibility settings.
1867        let _result = prefers_reduced_motion();
1868    }
1869
1870    #[cfg(target_os = "linux")]
1871    #[test]
1872    fn detect_reduced_motion_inner_linux() {
1873        // Bypass OnceLock to test actual detection logic.
1874        // On CI without gsettings, returns false (animations enabled).
1875        // On developer machines, depends on accessibility settings.
1876        let result = detect_reduced_motion_inner();
1877        // Just verify it returns a bool without panicking.
1878        let _ = result;
1879    }
1880
1881    #[cfg(target_os = "macos")]
1882    #[test]
1883    fn detect_reduced_motion_inner_macos() {
1884        let result = detect_reduced_motion_inner();
1885        let _ = result;
1886    }
1887
1888    #[cfg(target_os = "windows")]
1889    #[test]
1890    fn detect_reduced_motion_inner_windows() {
1891        let result = detect_reduced_motion_inner();
1892        let _ = result;
1893    }
1894}
1895
1896#[cfg(test)]
1897#[allow(clippy::unwrap_used, clippy::expect_used)]
1898mod overlay_tests {
1899    use super::*;
1900
1901    /// Helper: build a SystemTheme from a preset via run_pipeline.
1902    fn default_system_theme() -> SystemTheme {
1903        let reader = ThemeSpec::preset("catppuccin-mocha").unwrap();
1904        run_pipeline(reader, "catppuccin-mocha", false).unwrap()
1905    }
1906
1907    #[test]
1908    fn test_overlay_accent_propagates() {
1909        let st = default_system_theme();
1910        let new_accent = Rgba::rgb(255, 0, 0);
1911
1912        // Build overlay with accent on both light and dark
1913        let mut overlay = ThemeSpec::default();
1914        let mut light_v = ThemeVariant::default();
1915        light_v.defaults.accent = Some(new_accent);
1916        let mut dark_v = ThemeVariant::default();
1917        dark_v.defaults.accent = Some(new_accent);
1918        overlay.light = Some(light_v);
1919        overlay.dark = Some(dark_v);
1920
1921        let result = st.with_overlay(&overlay).unwrap();
1922
1923        // Accent itself
1924        assert_eq!(result.light.defaults.accent, new_accent);
1925        // Accent-derived widget fields
1926        assert_eq!(result.light.button.primary_bg, new_accent);
1927        assert_eq!(result.light.checkbox.checked_bg, new_accent);
1928        assert_eq!(result.light.slider.fill, new_accent);
1929        assert_eq!(result.light.progress_bar.fill, new_accent);
1930        assert_eq!(result.light.switch.checked_bg, new_accent);
1931    }
1932
1933    #[test]
1934    fn test_overlay_preserves_unrelated_fields() {
1935        let st = default_system_theme();
1936        let original_bg = st.light.defaults.background;
1937
1938        // Apply overlay changing only accent
1939        let mut overlay = ThemeSpec::default();
1940        let mut light_v = ThemeVariant::default();
1941        light_v.defaults.accent = Some(Rgba::rgb(255, 0, 0));
1942        overlay.light = Some(light_v);
1943
1944        let result = st.with_overlay(&overlay).unwrap();
1945        assert_eq!(
1946            result.light.defaults.background, original_bg,
1947            "background should be unchanged"
1948        );
1949    }
1950
1951    #[test]
1952    fn test_overlay_empty_noop() {
1953        let st = default_system_theme();
1954        let original_light_accent = st.light.defaults.accent;
1955        let original_dark_accent = st.dark.defaults.accent;
1956        let original_light_bg = st.light.defaults.background;
1957
1958        // Empty overlay
1959        let overlay = ThemeSpec::default();
1960        let result = st.with_overlay(&overlay).unwrap();
1961
1962        assert_eq!(result.light.defaults.accent, original_light_accent);
1963        assert_eq!(result.dark.defaults.accent, original_dark_accent);
1964        assert_eq!(result.light.defaults.background, original_light_bg);
1965    }
1966
1967    #[test]
1968    fn test_overlay_both_variants() {
1969        let st = default_system_theme();
1970        let red = Rgba::rgb(255, 0, 0);
1971        let green = Rgba::rgb(0, 255, 0);
1972
1973        let mut overlay = ThemeSpec::default();
1974        let mut light_v = ThemeVariant::default();
1975        light_v.defaults.accent = Some(red);
1976        let mut dark_v = ThemeVariant::default();
1977        dark_v.defaults.accent = Some(green);
1978        overlay.light = Some(light_v);
1979        overlay.dark = Some(dark_v);
1980
1981        let result = st.with_overlay(&overlay).unwrap();
1982        assert_eq!(result.light.defaults.accent, red, "light accent = red");
1983        assert_eq!(result.dark.defaults.accent, green, "dark accent = green");
1984    }
1985
1986    #[test]
1987    fn test_overlay_font_family() {
1988        let st = default_system_theme();
1989
1990        let mut overlay = ThemeSpec::default();
1991        let mut light_v = ThemeVariant::default();
1992        light_v.defaults.font.family = Some("Comic Sans".into());
1993        overlay.light = Some(light_v);
1994
1995        let result = st.with_overlay(&overlay).unwrap();
1996        assert_eq!(result.light.defaults.font.family, "Comic Sans");
1997    }
1998
1999    #[test]
2000    fn test_overlay_toml_convenience() {
2001        let st = default_system_theme();
2002        let result = st
2003            .with_overlay_toml(
2004                r##"
2005            name = "overlay"
2006            [light.defaults]
2007            accent = "#ff0000"
2008        "##,
2009            )
2010            .unwrap();
2011        assert_eq!(result.light.defaults.accent, Rgba::rgb(255, 0, 0));
2012    }
2013}