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