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