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