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
10#[doc = include_str!("../README.md")]
11#[cfg(doctest)]
12pub struct ReadmeDoctests;
13
14/// Generates `merge()` and `is_empty()` methods for theme structs.
15///
16/// Two field categories:
17/// - `option { field1, field2, ... }` -- `Option<T>` leaf fields
18/// - `nested { field1, field2, ... }` -- nested struct fields with their own `merge()`
19///
20/// For `option` fields, `Some` values in the overlay replace the corresponding
21/// fields in self; `None` fields are left unchanged.
22/// For `nested` fields, merge is called recursively.
23///
24/// # Examples
25///
26/// ```
27/// use native_theme::impl_merge;
28///
29/// #[derive(Clone, Debug, Default)]
30/// struct MyColors {
31///     accent: Option<String>,
32///     background: Option<String>,
33/// }
34///
35/// impl_merge!(MyColors {
36///     option { accent, background }
37/// });
38///
39/// let mut base = MyColors { accent: Some("blue".into()), background: None };
40/// let overlay = MyColors { accent: None, background: Some("white".into()) };
41/// base.merge(&overlay);
42/// assert_eq!(base.accent.as_deref(), Some("blue"));
43/// assert_eq!(base.background.as_deref(), Some("white"));
44/// ```
45#[macro_export]
46macro_rules! impl_merge {
47    (
48        $struct_name:ident {
49            $(option { $($opt_field:ident),* $(,)? })?
50            $(nested { $($nest_field:ident),* $(,)? })?
51        }
52    ) => {
53        impl $struct_name {
54            /// Merge an overlay into this value. `Some` fields in the overlay
55            /// replace the corresponding fields in self; `None` fields are
56            /// left unchanged. Nested structs are merged recursively.
57            pub fn merge(&mut self, overlay: &Self) {
58                $($(
59                    if overlay.$opt_field.is_some() {
60                        self.$opt_field = overlay.$opt_field.clone();
61                    }
62                )*)?
63                $($(
64                    self.$nest_field.merge(&overlay.$nest_field);
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            }
74        }
75    };
76}
77
78/// Color types and sRGB utilities.
79pub mod color;
80/// Error types for theme operations.
81pub mod error;
82/// GNOME portal theme reader.
83#[cfg(all(target_os = "linux", feature = "portal"))]
84pub mod gnome;
85/// KDE theme reader.
86#[cfg(all(target_os = "linux", feature = "kde"))]
87pub mod kde;
88/// Theme data model types.
89pub mod model;
90/// Bundled theme presets.
91pub mod presets;
92#[cfg(any(
93    feature = "material-icons",
94    feature = "lucide-icons",
95    feature = "system-icons"
96))]
97mod spinners;
98
99pub use color::Rgba;
100pub use error::Error;
101pub use model::{
102    AnimatedIcon, IconData, IconProvider, IconRole, IconSet, NativeTheme, Repeat, ThemeColors,
103    ThemeFonts, ThemeGeometry, ThemeSpacing, ThemeVariant, TransformAnimation, WidgetMetrics,
104    bundled_icon_by_name, bundled_icon_svg,
105};
106// load_icon re-exported from this module (defined in lib.rs directly)
107pub use model::icons::{icon_name, system_icon_set, system_icon_theme};
108
109/// Freedesktop icon theme lookup (Linux).
110#[cfg(all(target_os = "linux", feature = "system-icons"))]
111pub mod freedesktop;
112/// macOS platform helpers.
113pub mod macos;
114/// SVG-to-RGBA rasterization utilities.
115#[cfg(feature = "svg-rasterize")]
116pub mod rasterize;
117/// SF Symbols icon loader (macOS).
118#[cfg(all(target_os = "macos", feature = "system-icons"))]
119pub mod sficons;
120/// Windows platform theme reader.
121#[cfg(all(target_os = "windows", feature = "windows"))]
122pub mod windows;
123/// Windows Segoe Fluent / stock icon loader.
124#[cfg(feature = "system-icons")]
125#[cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
126pub mod winicons;
127
128#[cfg(all(target_os = "linux", feature = "system-icons"))]
129pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
130#[cfg(all(target_os = "linux", feature = "portal"))]
131pub use gnome::from_gnome;
132#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
133pub use gnome::from_kde_with_portal;
134#[cfg(all(target_os = "linux", feature = "kde"))]
135pub use kde::from_kde;
136#[cfg(all(target_os = "macos", feature = "macos"))]
137pub use macos::from_macos;
138#[cfg(feature = "svg-rasterize")]
139pub use rasterize::rasterize_svg;
140#[cfg(all(target_os = "macos", feature = "system-icons"))]
141pub use sficons::load_sf_icon;
142#[cfg(all(target_os = "macos", feature = "system-icons"))]
143pub use sficons::load_sf_icon_by_name;
144#[cfg(all(target_os = "windows", feature = "windows"))]
145pub use windows::from_windows;
146#[cfg(all(target_os = "windows", feature = "system-icons"))]
147pub use winicons::load_windows_icon;
148#[cfg(all(target_os = "windows", feature = "system-icons"))]
149pub use winicons::load_windows_icon_by_name;
150
151/// Convenience Result type alias for this crate.
152pub type Result<T> = std::result::Result<T, Error>;
153
154/// Desktop environments recognized on Linux.
155#[cfg(target_os = "linux")]
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub enum LinuxDesktop {
158    /// KDE Plasma desktop.
159    Kde,
160    /// GNOME desktop.
161    Gnome,
162    /// Xfce desktop.
163    Xfce,
164    /// Cinnamon desktop (Linux Mint).
165    Cinnamon,
166    /// MATE desktop.
167    Mate,
168    /// LXQt desktop.
169    LxQt,
170    /// Budgie desktop.
171    Budgie,
172    /// Unrecognized or unset desktop environment.
173    Unknown,
174}
175
176/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
177/// the recognized desktop environment.
178///
179/// Checks components in order; first recognized DE wins. Budgie is checked
180/// before GNOME because Budgie sets `Budgie:GNOME`.
181#[cfg(target_os = "linux")]
182pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
183    for component in xdg_current_desktop.split(':') {
184        match component {
185            "KDE" => return LinuxDesktop::Kde,
186            "Budgie" => return LinuxDesktop::Budgie,
187            "GNOME" => return LinuxDesktop::Gnome,
188            "XFCE" => return LinuxDesktop::Xfce,
189            "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
190            "MATE" => return LinuxDesktop::Mate,
191            "LXQt" => return LinuxDesktop::LxQt,
192            _ => {}
193        }
194    }
195    LinuxDesktop::Unknown
196}
197
198/// Detect whether the system is using a dark color scheme.
199///
200/// Uses synchronous, platform-specific checks so the result is available
201/// immediately at window creation time (before any async portal response).
202/// The result is cached after the first call using `OnceLock`.
203///
204/// # Fallback chain
205///
206/// 1. `gsettings get org.gnome.desktop.interface color-scheme` — works on
207///    all DEs that implement the freedesktop color-scheme setting (GNOME,
208///    KDE 5.x+, XFCE, etc.).
209/// 2. **(with `kde` feature)** `~/.config/kdeglobals` background luminance.
210/// 3. Returns `false` (light) if neither source is available.
211#[cfg(target_os = "linux")]
212#[must_use = "this returns whether the system uses dark mode"]
213pub fn system_is_dark() -> bool {
214    static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
215    *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
216}
217
218/// Inner detection logic for [`system_is_dark()`].
219///
220/// Separated from the public function to allow caching via `OnceLock`.
221#[cfg(target_os = "linux")]
222fn detect_is_dark_inner() -> bool {
223    // gsettings works across all modern DEs (GNOME, KDE, XFCE, …)
224    if let Ok(output) = std::process::Command::new("gsettings")
225        .args(["get", "org.gnome.desktop.interface", "color-scheme"])
226        .output()
227        && output.status.success()
228    {
229        let val = String::from_utf8_lossy(&output.stdout);
230        if val.contains("prefer-dark") {
231            return true;
232        }
233        if val.contains("prefer-light") || val.contains("default") {
234            return false;
235        }
236    }
237
238    // Fallback: read KDE's kdeglobals background luminance
239    #[cfg(feature = "kde")]
240    {
241        let path = crate::kde::kdeglobals_path();
242        if let Ok(content) = std::fs::read_to_string(&path) {
243            let mut ini = crate::kde::create_kde_parser();
244            if ini.read(content).is_ok() {
245                return crate::kde::is_dark_theme(&ini);
246            }
247        }
248    }
249
250    false
251}
252
253/// Query whether the user prefers reduced motion.
254///
255/// Returns `true` when the OS accessibility setting indicates animations
256/// should be reduced or disabled. Returns `false` (allow animations) on
257/// unsupported platforms or when the query fails.
258///
259/// The result is cached after the first call using `OnceLock`.
260///
261/// # Platform Behavior
262///
263/// - **Linux:** Queries `gsettings get org.gnome.desktop.interface enable-animations`.
264///   Returns `true` when animations are disabled (`enable-animations` is `false`).
265/// - **macOS:** Queries `NSWorkspace.accessibilityDisplayShouldReduceMotion`
266///   (requires `macos` feature).
267/// - **Windows:** Queries `UISettings.AnimationsEnabled()` (requires `windows` feature).
268/// - **Other platforms:** Returns `false`.
269///
270/// # Examples
271///
272/// ```
273/// let reduced = native_theme::prefers_reduced_motion();
274/// // On this platform, the result depends on OS accessibility settings.
275/// // The function always returns a bool (false on unsupported platforms).
276/// assert!(reduced == true || reduced == false);
277/// ```
278#[must_use = "this returns whether reduced motion is preferred"]
279pub fn prefers_reduced_motion() -> bool {
280    static CACHED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
281    *CACHED.get_or_init(detect_reduced_motion_inner)
282}
283
284/// Inner detection logic for [`prefers_reduced_motion()`].
285///
286/// Separated from the public function to allow caching via `OnceLock`.
287#[allow(unreachable_code)]
288fn detect_reduced_motion_inner() -> bool {
289    #[cfg(target_os = "linux")]
290    {
291        // gsettings boolean output is bare "true\n" or "false\n" (no quotes)
292        // enable-animations has INVERTED semantics: false => reduced motion preferred
293        if let Ok(output) = std::process::Command::new("gsettings")
294            .args(["get", "org.gnome.desktop.interface", "enable-animations"])
295            .output()
296            && output.status.success()
297        {
298            let val = String::from_utf8_lossy(&output.stdout);
299            return val.trim() == "false";
300        }
301        false
302    }
303
304    #[cfg(target_os = "macos")]
305    {
306        #[cfg(feature = "macos")]
307        {
308            let workspace = objc2_app_kit::NSWorkspace::sharedWorkspace();
309            // Direct semantics: true = reduce motion preferred (no inversion needed)
310            return workspace.accessibilityDisplayShouldReduceMotion();
311        }
312        #[cfg(not(feature = "macos"))]
313        return false;
314    }
315
316    #[cfg(target_os = "windows")]
317    {
318        #[cfg(feature = "windows")]
319        {
320            let Ok(settings) = ::windows::UI::ViewManagement::UISettings::new() else {
321                return false;
322            };
323            // AnimationsEnabled has INVERTED semantics: false => reduced motion preferred
324            return match settings.AnimationsEnabled() {
325                Ok(enabled) => !enabled,
326                Err(_) => false,
327            };
328        }
329        #[cfg(not(feature = "windows"))]
330        return false;
331    }
332
333    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
334    {
335        false
336    }
337}
338
339/// Read the current system theme on Linux by detecting the desktop
340/// environment and calling the appropriate reader or returning a
341/// preset fallback.
342#[cfg(target_os = "linux")]
343fn from_linux() -> crate::Result<NativeTheme> {
344    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
345    match detect_linux_de(&desktop) {
346        #[cfg(feature = "kde")]
347        LinuxDesktop::Kde => crate::kde::from_kde(),
348        #[cfg(not(feature = "kde"))]
349        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
350        LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
351        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
352            NativeTheme::preset("adwaita")
353        }
354        LinuxDesktop::Unknown => {
355            #[cfg(feature = "kde")]
356            {
357                let path = crate::kde::kdeglobals_path();
358                if path.exists() {
359                    return crate::kde::from_kde();
360                }
361            }
362            NativeTheme::preset("adwaita")
363        }
364    }
365}
366
367/// Read the current system theme, auto-detecting the platform and
368/// desktop environment.
369///
370/// # Platform Behavior
371///
372/// - **macOS:** Calls `from_macos()` when the `macos` feature is enabled.
373///   Reads both light and dark variants via NSAppearance.
374/// - **Linux (KDE):** Calls `from_kde()` when `XDG_CURRENT_DESKTOP` contains
375///   "KDE" and the `kde` feature is enabled.
376/// - **Linux (other):** Returns the bundled Adwaita preset. For live GNOME
377///   portal data, call `from_gnome()` directly (requires `portal-tokio` or
378///   `portal-async-io` feature).
379/// - **Windows:** Calls `from_windows()` when the `windows` feature is enabled.
380/// - **Other platforms:** Returns `Error::Unsupported`.
381///
382/// # Errors
383///
384/// - `Error::Unsupported` if the platform has no reader or the required feature
385///   is not enabled.
386/// - `Error::Unavailable` if the platform reader cannot access theme data.
387#[must_use = "this returns the detected theme; it does not apply it"]
388pub fn from_system() -> crate::Result<NativeTheme> {
389    #[cfg(target_os = "macos")]
390    {
391        #[cfg(feature = "macos")]
392        return crate::macos::from_macos();
393
394        #[cfg(not(feature = "macos"))]
395        return Err(crate::Error::Unsupported);
396    }
397
398    #[cfg(target_os = "windows")]
399    {
400        #[cfg(feature = "windows")]
401        return crate::windows::from_windows();
402
403        #[cfg(not(feature = "windows"))]
404        return Err(crate::Error::Unsupported);
405    }
406
407    #[cfg(target_os = "linux")]
408    {
409        from_linux()
410    }
411
412    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
413    {
414        Err(crate::Error::Unsupported)
415    }
416}
417
418/// Async version of [`from_system()`] that uses D-Bus portal backend
419/// detection to improve desktop environment heuristics on Linux.
420///
421/// When `XDG_CURRENT_DESKTOP` is unset or unrecognized, queries the
422/// D-Bus session bus for portal backend activatable names to determine
423/// whether KDE or GNOME portal is running, then dispatches to the
424/// appropriate reader.
425///
426/// On non-Linux platforms, behaves identically to [`from_system()`].
427#[cfg(target_os = "linux")]
428#[must_use = "this returns the detected theme; it does not apply it"]
429pub async fn from_system_async() -> crate::Result<NativeTheme> {
430    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
431    match detect_linux_de(&desktop) {
432        #[cfg(feature = "kde")]
433        LinuxDesktop::Kde => {
434            #[cfg(feature = "portal")]
435            return crate::gnome::from_kde_with_portal().await;
436            #[cfg(not(feature = "portal"))]
437            return crate::kde::from_kde();
438        }
439        #[cfg(not(feature = "kde"))]
440        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
441        #[cfg(feature = "portal")]
442        LinuxDesktop::Gnome | LinuxDesktop::Budgie => crate::gnome::from_gnome().await,
443        #[cfg(not(feature = "portal"))]
444        LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
445        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
446            NativeTheme::preset("adwaita")
447        }
448        LinuxDesktop::Unknown => {
449            // Use D-Bus portal backend detection to refine heuristic
450            #[cfg(feature = "portal")]
451            {
452                if let Some(detected) = crate::gnome::detect_portal_backend().await {
453                    return match detected {
454                        #[cfg(feature = "kde")]
455                        LinuxDesktop::Kde => crate::gnome::from_kde_with_portal().await,
456                        #[cfg(not(feature = "kde"))]
457                        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
458                        LinuxDesktop::Gnome => crate::gnome::from_gnome().await,
459                        _ => {
460                            unreachable!("detect_portal_backend only returns Kde or Gnome")
461                        }
462                    };
463                }
464            }
465            // Sync fallback: try kdeglobals, then Adwaita
466            #[cfg(feature = "kde")]
467            {
468                let path = crate::kde::kdeglobals_path();
469                if path.exists() {
470                    return crate::kde::from_kde();
471                }
472            }
473            NativeTheme::preset("adwaita")
474        }
475    }
476}
477
478/// Async version of [`from_system()`].
479///
480/// On non-Linux platforms, this is equivalent to calling [`from_system()`].
481#[cfg(not(target_os = "linux"))]
482#[must_use = "this returns the detected theme; it does not apply it"]
483pub async fn from_system_async() -> crate::Result<NativeTheme> {
484    from_system()
485}
486
487/// Load an icon for the given role using the specified icon set.
488///
489/// Resolves `icon_set` to an [`IconSet`] via [`IconSet::from_name()`],
490/// falling back to [`system_icon_set()`] if the set string is not
491/// recognized. Then dispatches to the appropriate platform loader or
492/// bundled icon set.
493///
494/// # Dispatch
495///
496/// 1. Parse `icon_set` to `IconSet` (unknown names fall back to system set)
497/// 2. Platform loader (freedesktop/sf-symbols/segoe-fluent) when `system-icons` enabled
498/// 3. Bundled SVGs (material/lucide) when the corresponding feature is enabled
499/// 4. Non-matching set: `None` (no cross-set fallback)
500///
501/// # Examples
502///
503/// ```
504/// use native_theme::{load_icon, IconRole};
505///
506/// // With material-icons feature enabled
507/// # #[cfg(feature = "material-icons")]
508/// # {
509/// let icon = load_icon(IconRole::ActionCopy, "material");
510/// assert!(icon.is_some());
511/// # }
512/// ```
513#[must_use = "this returns the loaded icon data; it does not display it"]
514#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
515pub fn load_icon(role: IconRole, icon_set: &str) -> Option<IconData> {
516    let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
517
518    match set {
519        #[cfg(all(target_os = "linux", feature = "system-icons"))]
520        IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role),
521
522        #[cfg(all(target_os = "macos", feature = "system-icons"))]
523        IconSet::SfSymbols => sficons::load_sf_icon(role),
524
525        #[cfg(all(target_os = "windows", feature = "system-icons"))]
526        IconSet::SegoeIcons => winicons::load_windows_icon(role),
527
528        #[cfg(feature = "material-icons")]
529        IconSet::Material => {
530            bundled_icon_svg(IconSet::Material, role).map(|b| IconData::Svg(b.to_vec()))
531        }
532
533        #[cfg(feature = "lucide-icons")]
534        IconSet::Lucide => {
535            bundled_icon_svg(IconSet::Lucide, role).map(|b| IconData::Svg(b.to_vec()))
536        }
537
538        // Non-matching platform or unknown set: no cross-set fallback
539        _ => None,
540    }
541}
542
543/// Load a system icon by its platform-specific name string.
544///
545/// Dispatches to the appropriate platform loader based on the icon set:
546/// - [`IconSet::Freedesktop`] -- freedesktop icon theme lookup (auto-detects theme)
547/// - [`IconSet::SfSymbols`] -- macOS SF Symbols
548/// - [`IconSet::SegoeIcons`] -- Windows Segoe Fluent / stock icons
549/// - [`IconSet::Material`] / [`IconSet::Lucide`] -- bundled SVG lookup by name
550///
551/// Returns `None` if the icon is not found on the current platform or
552/// the icon set is not available.
553///
554/// # Examples
555///
556/// ```
557/// use native_theme::{load_system_icon_by_name, IconSet};
558///
559/// # #[cfg(feature = "material-icons")]
560/// # {
561/// let icon = load_system_icon_by_name("content_copy", IconSet::Material);
562/// assert!(icon.is_some());
563/// # }
564/// ```
565#[must_use = "this returns the loaded icon data; it does not display it"]
566#[allow(unreachable_patterns, unused_variables)]
567pub fn load_system_icon_by_name(name: &str, set: IconSet) -> Option<IconData> {
568    match set {
569        #[cfg(all(target_os = "linux", feature = "system-icons"))]
570        IconSet::Freedesktop => {
571            let theme = system_icon_theme();
572            freedesktop::load_freedesktop_icon_by_name(name, &theme)
573        }
574
575        #[cfg(all(target_os = "macos", feature = "system-icons"))]
576        IconSet::SfSymbols => sficons::load_sf_icon_by_name(name),
577
578        #[cfg(all(target_os = "windows", feature = "system-icons"))]
579        IconSet::SegoeIcons => winicons::load_windows_icon_by_name(name),
580
581        #[cfg(feature = "material-icons")]
582        IconSet::Material => {
583            bundled_icon_by_name(IconSet::Material, name).map(|b| IconData::Svg(b.to_vec()))
584        }
585
586        #[cfg(feature = "lucide-icons")]
587        IconSet::Lucide => {
588            bundled_icon_by_name(IconSet::Lucide, name).map(|b| IconData::Svg(b.to_vec()))
589        }
590
591        _ => None,
592    }
593}
594
595/// Return the loading/spinner animation for the given icon set.
596///
597/// This is the animated-icon counterpart of [`load_icon()`]. It resolves
598/// `icon_set` to an [`IconSet`] via [`IconSet::from_name()`], falling back
599/// to [`system_icon_set()`] for unrecognized names, then dispatches to the
600/// appropriate bundled spinner data.
601///
602/// # Dispatch
603///
604/// - `"material"` -- `progress_activity.svg` with continuous spin transform (1000ms)
605/// - `"lucide"` -- `loader.svg` with continuous spin transform (1000ms)
606/// - `"freedesktop"` -- loads `process-working` sprite sheet from active icon theme
607/// - Unknown set -- `None`
608///
609/// # Examples
610///
611/// ```
612/// // Result depends on enabled features and platform
613/// let anim = native_theme::loading_indicator("lucide");
614/// # #[cfg(feature = "lucide-icons")]
615/// # assert!(anim.is_some());
616/// ```
617#[must_use = "this returns animation data; it does not display anything"]
618pub fn loading_indicator(icon_set: &str) -> Option<AnimatedIcon> {
619    let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
620    match set {
621        #[cfg(all(target_os = "linux", feature = "system-icons"))]
622        IconSet::Freedesktop => freedesktop::load_freedesktop_spinner(),
623
624        #[cfg(feature = "material-icons")]
625        IconSet::Material => Some(spinners::material_spinner()),
626
627        #[cfg(feature = "lucide-icons")]
628        IconSet::Lucide => Some(spinners::lucide_spinner()),
629
630        _ => None,
631    }
632}
633
634/// Load an icon from any [`IconProvider`], dispatching through the standard
635/// platform loading chain.
636///
637/// # Fallback chain
638///
639/// 1. Provider's [`icon_name()`](IconProvider::icon_name) -- passed to platform
640///    system loader via [`load_system_icon_by_name()`]
641/// 2. Provider's [`icon_svg()`](IconProvider::icon_svg) -- bundled SVG data
642/// 3. `None` -- **no cross-set fallback** (mixing icon sets is forbidden)
643///
644/// The `icon_set` string is parsed via [`IconSet::from_name()`], falling back
645/// to [`system_icon_set()`] for unrecognized names.
646///
647/// # Examples
648///
649/// ```
650/// use native_theme::{load_custom_icon, IconRole};
651///
652/// // IconRole implements IconProvider, so it works with load_custom_icon
653/// # #[cfg(feature = "material-icons")]
654/// # {
655/// let icon = load_custom_icon(&IconRole::ActionCopy, "material");
656/// assert!(icon.is_some());
657/// # }
658/// ```
659#[must_use = "this returns the loaded icon data; it does not display it"]
660pub fn load_custom_icon(
661    provider: &(impl IconProvider + ?Sized),
662    icon_set: &str,
663) -> Option<IconData> {
664    let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
665
666    // Step 1: Try system loader with provider's name mapping
667    if let Some(name) = provider.icon_name(set)
668        && let Some(data) = load_system_icon_by_name(name, set)
669    {
670        return Some(data);
671    }
672
673    // Step 2: Try bundled SVG from provider
674    if let Some(svg) = provider.icon_svg(set) {
675        return Some(IconData::Svg(svg.to_vec()));
676    }
677
678    // No cross-set fallback -- return None
679    None
680}
681
682/// Mutex to serialize tests that manipulate environment variables.
683/// Env vars are process-global state, so tests that call set_var/remove_var
684/// must hold this lock to avoid races with parallel test execution.
685#[cfg(test)]
686pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
687
688#[cfg(all(test, target_os = "linux"))]
689mod dispatch_tests {
690    use super::*;
691
692    // -- detect_linux_de() pure function tests --
693
694    #[test]
695    fn detect_kde_simple() {
696        assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
697    }
698
699    #[test]
700    fn detect_kde_colon_separated_after() {
701        assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
702    }
703
704    #[test]
705    fn detect_kde_colon_separated_before() {
706        assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
707    }
708
709    #[test]
710    fn detect_gnome_simple() {
711        assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
712    }
713
714    #[test]
715    fn detect_gnome_ubuntu() {
716        assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
717    }
718
719    #[test]
720    fn detect_xfce() {
721        assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
722    }
723
724    #[test]
725    fn detect_cinnamon() {
726        assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
727    }
728
729    #[test]
730    fn detect_cinnamon_short() {
731        assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
732    }
733
734    #[test]
735    fn detect_mate() {
736        assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
737    }
738
739    #[test]
740    fn detect_lxqt() {
741        assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
742    }
743
744    #[test]
745    fn detect_budgie() {
746        assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
747    }
748
749    #[test]
750    fn detect_empty_string() {
751        assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
752    }
753
754    // -- from_linux() fallback test --
755
756    #[test]
757    fn from_linux_non_kde_returns_adwaita() {
758        let _guard = crate::ENV_MUTEX.lock().unwrap();
759        // Temporarily set XDG_CURRENT_DESKTOP to GNOME so from_linux()
760        // takes the preset fallback path.
761        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
762        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
763        let result = from_linux();
764        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
765
766        let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
767        assert_eq!(theme.name, "Adwaita");
768    }
769
770    // -- from_linux() kdeglobals fallback tests --
771
772    #[test]
773    #[cfg(feature = "kde")]
774    fn from_linux_unknown_de_with_kdeglobals_fallback() {
775        let _guard = crate::ENV_MUTEX.lock().unwrap();
776        use std::io::Write;
777
778        // Create a temp dir with a minimal kdeglobals file
779        let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
780        std::fs::create_dir_all(&tmp_dir).unwrap();
781        let kdeglobals = tmp_dir.join("kdeglobals");
782        let mut f = std::fs::File::create(&kdeglobals).unwrap();
783        writeln!(
784            f,
785            "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
786        )
787        .unwrap();
788
789        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
790        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
791        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
792
793        unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
794        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
795
796        let result = from_linux();
797
798        // Restore env
799        match orig_xdg {
800            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
801            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
802        }
803        match orig_desktop {
804            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
805            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
806        }
807
808        // Cleanup
809        let _ = std::fs::remove_dir_all(&tmp_dir);
810
811        let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
812        assert_eq!(
813            theme.name, "TestTheme",
814            "should use KDE theme name from kdeglobals"
815        );
816    }
817
818    #[test]
819    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
820        let _guard = crate::ENV_MUTEX.lock().unwrap();
821        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
822        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
823        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
824
825        unsafe {
826            std::env::set_var(
827                "XDG_CONFIG_HOME",
828                "/tmp/nonexistent_native_theme_test_no_kde",
829            )
830        };
831        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
832
833        let result = from_linux();
834
835        // Restore env
836        match orig_xdg {
837            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
838            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
839        }
840        match orig_desktop {
841            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
842            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
843        }
844
845        let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
846        assert_eq!(
847            theme.name, "Adwaita",
848            "should fall back to Adwaita without kdeglobals"
849        );
850    }
851
852    // -- LNXDE-03: Hyprland, Sway, COSMIC map to Unknown --
853
854    #[test]
855    fn detect_hyprland_returns_unknown() {
856        assert_eq!(detect_linux_de("Hyprland"), LinuxDesktop::Unknown);
857    }
858
859    #[test]
860    fn detect_sway_returns_unknown() {
861        assert_eq!(detect_linux_de("sway"), LinuxDesktop::Unknown);
862    }
863
864    #[test]
865    fn detect_cosmic_returns_unknown() {
866        assert_eq!(detect_linux_de("COSMIC"), LinuxDesktop::Unknown);
867    }
868
869    // -- from_system() smoke test --
870
871    #[test]
872    fn from_system_returns_result() {
873        let _guard = crate::ENV_MUTEX.lock().unwrap();
874        // On Linux (our test platform), from_system() should return a Result.
875        // With GNOME set, it should return the Adwaita preset.
876        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
877        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
878        let result = from_system();
879        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
880
881        let theme = result.expect("from_system() should return Ok on Linux");
882        assert_eq!(theme.name, "Adwaita");
883    }
884}
885
886#[cfg(test)]
887mod load_icon_tests {
888    use super::*;
889
890    #[test]
891    #[cfg(feature = "material-icons")]
892    fn load_icon_material_returns_svg() {
893        let result = load_icon(IconRole::ActionCopy, "material");
894        assert!(result.is_some(), "material ActionCopy should return Some");
895        match result.unwrap() {
896            IconData::Svg(bytes) => {
897                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
898                assert!(content.contains("<svg"), "should contain SVG data");
899            }
900            _ => panic!("expected IconData::Svg for bundled material icon"),
901        }
902    }
903
904    #[test]
905    #[cfg(feature = "lucide-icons")]
906    fn load_icon_lucide_returns_svg() {
907        let result = load_icon(IconRole::ActionCopy, "lucide");
908        assert!(result.is_some(), "lucide ActionCopy should return Some");
909        match result.unwrap() {
910            IconData::Svg(bytes) => {
911                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
912                assert!(content.contains("<svg"), "should contain SVG data");
913            }
914            _ => panic!("expected IconData::Svg for bundled lucide icon"),
915        }
916    }
917
918    #[test]
919    #[cfg(feature = "material-icons")]
920    fn load_icon_unknown_theme_no_cross_set_fallback() {
921        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
922        // Without system-icons feature, Freedesktop falls through to wildcard -> None.
923        // No cross-set Material fallback.
924        let result = load_icon(IconRole::ActionCopy, "unknown-theme");
925        // Without system-icons, this falls to wildcard which returns None
926        // With system-icons, this dispatches to load_freedesktop_icon which may return Some
927        // Either way, no panic
928        let _ = result;
929    }
930
931    #[test]
932    #[cfg(feature = "material-icons")]
933    fn load_icon_all_roles_material() {
934        // Material has 42 of 42 roles mapped, all return Some
935        let mut some_count = 0;
936        for role in IconRole::ALL {
937            if load_icon(role, "material").is_some() {
938                some_count += 1;
939            }
940        }
941        // bundled_icon_svg covers all 42 roles for Material
942        assert_eq!(
943            some_count, 42,
944            "Material should cover all 42 roles via bundled SVGs"
945        );
946    }
947
948    #[test]
949    #[cfg(feature = "lucide-icons")]
950    fn load_icon_all_roles_lucide() {
951        let mut some_count = 0;
952        for role in IconRole::ALL {
953            if load_icon(role, "lucide").is_some() {
954                some_count += 1;
955            }
956        }
957        // bundled_icon_svg covers all 42 roles for Lucide
958        assert_eq!(
959            some_count, 42,
960            "Lucide should cover all 42 roles via bundled SVGs"
961        );
962    }
963
964    #[test]
965    fn load_icon_unrecognized_set_no_features() {
966        // SfSymbols on Linux without system-icons: falls through to wildcard -> None
967        let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
968        // Just verifying it doesn't panic
969    }
970}
971
972#[cfg(test)]
973mod load_system_icon_by_name_tests {
974    use super::*;
975
976    #[test]
977    #[cfg(feature = "material-icons")]
978    fn system_icon_by_name_material() {
979        let result = load_system_icon_by_name("content_copy", IconSet::Material);
980        assert!(
981            result.is_some(),
982            "content_copy should be found in Material set"
983        );
984        assert!(matches!(result.unwrap(), IconData::Svg(_)));
985    }
986
987    #[test]
988    #[cfg(feature = "lucide-icons")]
989    fn system_icon_by_name_lucide() {
990        let result = load_system_icon_by_name("copy", IconSet::Lucide);
991        assert!(result.is_some(), "copy should be found in Lucide set");
992        assert!(matches!(result.unwrap(), IconData::Svg(_)));
993    }
994
995    #[test]
996    #[cfg(feature = "material-icons")]
997    fn system_icon_by_name_unknown_returns_none() {
998        let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material);
999        assert!(result.is_none(), "nonexistent name should return None");
1000    }
1001
1002    #[test]
1003    fn system_icon_by_name_sf_on_linux_returns_none() {
1004        // On Linux, SfSymbols set is not available (cfg-gated to macOS)
1005        #[cfg(not(target_os = "macos"))]
1006        {
1007            let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols);
1008            assert!(
1009                result.is_none(),
1010                "SF Symbols should return None on non-macOS"
1011            );
1012        }
1013    }
1014}
1015
1016#[cfg(test)]
1017mod load_custom_icon_tests {
1018    use super::*;
1019
1020    #[test]
1021    #[cfg(feature = "material-icons")]
1022    fn custom_icon_with_icon_role_material() {
1023        let result = load_custom_icon(&IconRole::ActionCopy, "material");
1024        assert!(
1025            result.is_some(),
1026            "IconRole::ActionCopy should load via material"
1027        );
1028    }
1029
1030    #[test]
1031    #[cfg(feature = "lucide-icons")]
1032    fn custom_icon_with_icon_role_lucide() {
1033        let result = load_custom_icon(&IconRole::ActionCopy, "lucide");
1034        assert!(
1035            result.is_some(),
1036            "IconRole::ActionCopy should load via lucide"
1037        );
1038    }
1039
1040    #[test]
1041    fn custom_icon_no_cross_set_fallback() {
1042        // Provider that returns None for all sets -- should NOT fall back
1043        #[derive(Debug)]
1044        struct NullProvider;
1045        impl IconProvider for NullProvider {
1046            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1047                None
1048            }
1049            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1050                None
1051            }
1052        }
1053
1054        let result = load_custom_icon(&NullProvider, "material");
1055        assert!(
1056            result.is_none(),
1057            "NullProvider should return None (no cross-set fallback)"
1058        );
1059    }
1060
1061    #[test]
1062    fn custom_icon_unknown_set_uses_system() {
1063        // "unknown-set" is not a known IconSet name, falls through to system_icon_set()
1064        #[derive(Debug)]
1065        struct NullProvider;
1066        impl IconProvider for NullProvider {
1067            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1068                None
1069            }
1070            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1071                None
1072            }
1073        }
1074
1075        // Just verify it doesn't panic -- the actual set chosen depends on platform
1076        let _result = load_custom_icon(&NullProvider, "unknown-set");
1077    }
1078
1079    #[test]
1080    #[cfg(feature = "material-icons")]
1081    fn custom_icon_via_dyn_dispatch() {
1082        let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1083        let result = load_custom_icon(&*boxed, "material");
1084        assert!(
1085            result.is_some(),
1086            "dyn dispatch through Box<dyn IconProvider> should work"
1087        );
1088    }
1089
1090    #[test]
1091    #[cfg(feature = "material-icons")]
1092    fn custom_icon_bundled_svg_fallback() {
1093        // Provider that returns None from icon_name but Some from icon_svg
1094        #[derive(Debug)]
1095        struct SvgOnlyProvider;
1096        impl IconProvider for SvgOnlyProvider {
1097            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1098                None
1099            }
1100            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
1101                Some(b"<svg>test</svg>")
1102            }
1103        }
1104
1105        let result = load_custom_icon(&SvgOnlyProvider, "material");
1106        assert!(
1107            result.is_some(),
1108            "provider with icon_svg should return Some"
1109        );
1110        match result.unwrap() {
1111            IconData::Svg(bytes) => {
1112                assert_eq!(bytes, b"<svg>test</svg>");
1113            }
1114            _ => panic!("expected IconData::Svg"),
1115        }
1116    }
1117}
1118
1119#[cfg(test)]
1120mod loading_indicator_tests {
1121    use super::*;
1122
1123    // === Dispatch tests (through loading_indicator public API) ===
1124
1125    #[test]
1126    #[cfg(feature = "lucide-icons")]
1127    fn loading_indicator_lucide_returns_transform_spin() {
1128        let anim = loading_indicator("lucide");
1129        assert!(anim.is_some(), "lucide should return Some");
1130        let anim = anim.unwrap();
1131        assert!(
1132            matches!(
1133                anim,
1134                AnimatedIcon::Transform {
1135                    animation: TransformAnimation::Spin { duration_ms: 1000 },
1136                    ..
1137                }
1138            ),
1139            "lucide should be Transform::Spin at 1000ms"
1140        );
1141    }
1142
1143    /// Freedesktop loading_indicator returns Some if the active icon theme
1144    /// has a `process-working` sprite sheet (e.g. Breeze), None otherwise.
1145    #[test]
1146    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1147    fn loading_indicator_freedesktop_depends_on_theme() {
1148        let anim = loading_indicator("freedesktop");
1149        // Result depends on installed icon theme -- Some if process-working exists
1150        if let Some(anim) = anim {
1151            match anim {
1152                AnimatedIcon::Frames { frames, .. } => {
1153                    assert!(
1154                        !frames.is_empty(),
1155                        "Frames variant should have at least one frame"
1156                    );
1157                }
1158                AnimatedIcon::Transform { .. } => {
1159                    // Single-frame theme icon with Spin -- valid result
1160                }
1161            }
1162        }
1163    }
1164
1165    /// Unknown icon set name falls back to system_icon_set().
1166    /// Result depends on platform and available icon themes.
1167    #[test]
1168    fn loading_indicator_unknown_falls_back_to_system() {
1169        let _result = loading_indicator("unknown");
1170    }
1171
1172    #[test]
1173    fn loading_indicator_empty_string_falls_back_to_system() {
1174        let _result = loading_indicator("");
1175    }
1176
1177    // === Direct spinner construction tests (any platform) ===
1178
1179    #[test]
1180    #[cfg(feature = "lucide-icons")]
1181    fn lucide_spinner_is_transform() {
1182        let anim = spinners::lucide_spinner();
1183        assert!(matches!(
1184            anim,
1185            AnimatedIcon::Transform {
1186                animation: TransformAnimation::Spin { duration_ms: 1000 },
1187                ..
1188            }
1189        ));
1190    }
1191}
1192
1193#[cfg(all(test, feature = "svg-rasterize"))]
1194mod spinner_rasterize_tests {
1195    use super::*;
1196
1197    #[test]
1198    #[cfg(feature = "lucide-icons")]
1199    fn lucide_spinner_icon_rasterizes() {
1200        let anim = spinners::lucide_spinner();
1201        if let AnimatedIcon::Transform { icon, .. } = &anim {
1202            if let IconData::Svg(bytes) = icon {
1203                let result = crate::rasterize::rasterize_svg(bytes, 24);
1204                assert!(result.is_some(), "lucide loader should rasterize");
1205                if let Some(IconData::Rgba { data, .. }) = &result {
1206                    assert!(
1207                        data.iter().any(|&b| b != 0),
1208                        "lucide loader rasterized to empty image"
1209                    );
1210                }
1211            } else {
1212                panic!("lucide spinner icon should be Svg");
1213            }
1214        } else {
1215            panic!("lucide spinner should be Transform");
1216        }
1217    }
1218}