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