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, IconRole, IconSet, NativeTheme, ThemeColors, ThemeFonts, ThemeGeometry, ThemeSpacing,
89    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(all(target_os = "windows", feature = "system-icons"))]
104pub mod winicons;
105
106#[cfg(all(target_os = "linux", feature = "system-icons"))]
107pub use freedesktop::{load_freedesktop_icon, load_freedesktop_icon_by_name};
108#[cfg(all(target_os = "linux", feature = "portal"))]
109pub use gnome::from_gnome;
110#[cfg(all(target_os = "linux", feature = "portal", feature = "kde"))]
111pub use gnome::from_kde_with_portal;
112#[cfg(all(target_os = "linux", feature = "kde"))]
113pub use kde::from_kde;
114#[cfg(all(target_os = "macos", feature = "macos"))]
115pub use macos::from_macos;
116#[cfg(feature = "svg-rasterize")]
117pub use rasterize::rasterize_svg;
118#[cfg(all(target_os = "macos", feature = "system-icons"))]
119pub use sficons::load_sf_icon;
120#[cfg(all(target_os = "windows", feature = "windows"))]
121pub use windows::from_windows;
122#[cfg(all(target_os = "windows", feature = "system-icons"))]
123pub use winicons::load_windows_icon;
124
125/// Convenience Result type alias for this crate.
126pub type Result<T> = std::result::Result<T, Error>;
127
128/// Desktop environments recognized on Linux.
129#[cfg(target_os = "linux")]
130#[derive(Debug, Clone, Copy, PartialEq)]
131pub enum LinuxDesktop {
132    Kde,
133    Gnome,
134    Xfce,
135    Cinnamon,
136    Mate,
137    LxQt,
138    Budgie,
139    Unknown,
140}
141
142/// Parse `XDG_CURRENT_DESKTOP` (a colon-separated list) and return
143/// the recognized desktop environment.
144///
145/// Checks components in order; first recognized DE wins. Budgie is checked
146/// before GNOME because Budgie sets `Budgie:GNOME`.
147#[cfg(target_os = "linux")]
148pub fn detect_linux_de(xdg_current_desktop: &str) -> LinuxDesktop {
149    for component in xdg_current_desktop.split(':') {
150        match component {
151            "KDE" => return LinuxDesktop::Kde,
152            "Budgie" => return LinuxDesktop::Budgie,
153            "GNOME" => return LinuxDesktop::Gnome,
154            "XFCE" => return LinuxDesktop::Xfce,
155            "X-Cinnamon" | "Cinnamon" => return LinuxDesktop::Cinnamon,
156            "MATE" => return LinuxDesktop::Mate,
157            "LXQt" => return LinuxDesktop::LxQt,
158            _ => {}
159        }
160    }
161    LinuxDesktop::Unknown
162}
163
164/// Detect whether the system is using a dark color scheme.
165///
166/// Uses synchronous, platform-specific checks so the result is available
167/// immediately at window creation time (before any async portal response).
168/// The result is cached after the first call using `OnceLock`.
169///
170/// # Fallback chain
171///
172/// 1. `gsettings get org.gnome.desktop.interface color-scheme` — works on
173///    all DEs that implement the freedesktop color-scheme setting (GNOME,
174///    KDE 5.x+, XFCE, etc.).
175/// 2. **(with `kde` feature)** `~/.config/kdeglobals` background luminance.
176/// 3. Returns `false` (light) if neither source is available.
177#[cfg(target_os = "linux")]
178#[must_use = "this returns whether the system uses dark mode"]
179pub fn system_is_dark() -> bool {
180    static CACHED_IS_DARK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
181    *CACHED_IS_DARK.get_or_init(detect_is_dark_inner)
182}
183
184/// Inner detection logic for [`system_is_dark()`].
185///
186/// Separated from the public function to allow caching via `OnceLock`.
187#[cfg(target_os = "linux")]
188fn detect_is_dark_inner() -> bool {
189    // gsettings works across all modern DEs (GNOME, KDE, XFCE, …)
190    if let Ok(output) = std::process::Command::new("gsettings")
191        .args(["get", "org.gnome.desktop.interface", "color-scheme"])
192        .output()
193        && output.status.success()
194    {
195        let val = String::from_utf8_lossy(&output.stdout);
196        if val.contains("prefer-dark") {
197            return true;
198        }
199        if val.contains("prefer-light") || val.contains("default") {
200            return false;
201        }
202    }
203
204    // Fallback: read KDE's kdeglobals background luminance
205    #[cfg(feature = "kde")]
206    {
207        let path = crate::kde::kdeglobals_path();
208        if let Ok(content) = std::fs::read_to_string(&path) {
209            let mut ini = crate::kde::create_kde_parser();
210            if ini.read(content).is_ok() {
211                return crate::kde::is_dark_theme(&ini);
212            }
213        }
214    }
215
216    false
217}
218
219/// Read the current system theme on Linux by detecting the desktop
220/// environment and calling the appropriate reader or returning a
221/// preset fallback.
222#[cfg(target_os = "linux")]
223fn from_linux() -> crate::Result<NativeTheme> {
224    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
225    match detect_linux_de(&desktop) {
226        #[cfg(feature = "kde")]
227        LinuxDesktop::Kde => crate::kde::from_kde(),
228        #[cfg(not(feature = "kde"))]
229        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
230        LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
231        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
232            NativeTheme::preset("adwaita")
233        }
234        LinuxDesktop::Unknown => {
235            #[cfg(feature = "kde")]
236            {
237                let path = crate::kde::kdeglobals_path();
238                if path.exists() {
239                    return crate::kde::from_kde();
240                }
241            }
242            NativeTheme::preset("adwaita")
243        }
244    }
245}
246
247/// Read the current system theme, auto-detecting the platform and
248/// desktop environment.
249///
250/// # Platform Behavior
251///
252/// - **macOS:** Calls `from_macos()` when the `macos` feature is enabled.
253///   Reads both light and dark variants via NSAppearance.
254/// - **Linux (KDE):** Calls `from_kde()` when `XDG_CURRENT_DESKTOP` contains
255///   "KDE" and the `kde` feature is enabled.
256/// - **Linux (other):** Returns the bundled Adwaita preset. For live GNOME
257///   portal data, call `from_gnome()` directly (requires `portal-tokio` or
258///   `portal-async-io` feature).
259/// - **Windows:** Calls `from_windows()` when the `windows` feature is enabled.
260/// - **Other platforms:** Returns `Error::Unsupported`.
261///
262/// # Errors
263///
264/// - `Error::Unsupported` if the platform has no reader or the required feature
265///   is not enabled.
266/// - `Error::Unavailable` if the platform reader cannot access theme data.
267#[must_use = "this returns the detected theme; it does not apply it"]
268pub fn from_system() -> crate::Result<NativeTheme> {
269    #[cfg(target_os = "macos")]
270    {
271        #[cfg(feature = "macos")]
272        return crate::macos::from_macos();
273
274        #[cfg(not(feature = "macos"))]
275        return Err(crate::Error::Unsupported);
276    }
277
278    #[cfg(target_os = "windows")]
279    {
280        #[cfg(feature = "windows")]
281        return crate::windows::from_windows();
282
283        #[cfg(not(feature = "windows"))]
284        return Err(crate::Error::Unsupported);
285    }
286
287    #[cfg(target_os = "linux")]
288    {
289        from_linux()
290    }
291
292    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
293    {
294        Err(crate::Error::Unsupported)
295    }
296}
297
298/// Async version of [`from_system()`] that uses D-Bus portal backend
299/// detection to improve desktop environment heuristics on Linux.
300///
301/// When `XDG_CURRENT_DESKTOP` is unset or unrecognized, queries the
302/// D-Bus session bus for portal backend activatable names to determine
303/// whether KDE or GNOME portal is running, then dispatches to the
304/// appropriate reader.
305///
306/// On non-Linux platforms, behaves identically to [`from_system()`].
307#[cfg(target_os = "linux")]
308#[must_use = "this returns the detected theme; it does not apply it"]
309pub async fn from_system_async() -> crate::Result<NativeTheme> {
310    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
311    match detect_linux_de(&desktop) {
312        #[cfg(feature = "kde")]
313        LinuxDesktop::Kde => {
314            #[cfg(feature = "portal")]
315            return crate::gnome::from_kde_with_portal().await;
316            #[cfg(not(feature = "portal"))]
317            return crate::kde::from_kde();
318        }
319        #[cfg(not(feature = "kde"))]
320        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
321        #[cfg(feature = "portal")]
322        LinuxDesktop::Gnome | LinuxDesktop::Budgie => crate::gnome::from_gnome().await,
323        #[cfg(not(feature = "portal"))]
324        LinuxDesktop::Gnome | LinuxDesktop::Budgie => NativeTheme::preset("adwaita"),
325        LinuxDesktop::Xfce | LinuxDesktop::Cinnamon | LinuxDesktop::Mate | LinuxDesktop::LxQt => {
326            NativeTheme::preset("adwaita")
327        }
328        LinuxDesktop::Unknown => {
329            // Use D-Bus portal backend detection to refine heuristic
330            #[cfg(feature = "portal")]
331            {
332                if let Some(detected) = crate::gnome::detect_portal_backend().await {
333                    return match detected {
334                        #[cfg(feature = "kde")]
335                        LinuxDesktop::Kde => crate::gnome::from_kde_with_portal().await,
336                        #[cfg(not(feature = "kde"))]
337                        LinuxDesktop::Kde => NativeTheme::preset("adwaita"),
338                        LinuxDesktop::Gnome => crate::gnome::from_gnome().await,
339                        _ => {
340                            unreachable!("detect_portal_backend only returns Kde or Gnome")
341                        }
342                    };
343                }
344            }
345            // Sync fallback: try kdeglobals, then Adwaita
346            #[cfg(feature = "kde")]
347            {
348                let path = crate::kde::kdeglobals_path();
349                if path.exists() {
350                    return crate::kde::from_kde();
351                }
352            }
353            NativeTheme::preset("adwaita")
354        }
355    }
356}
357
358/// Async version of [`from_system()`].
359///
360/// On non-Linux platforms, this is equivalent to calling [`from_system()`].
361#[cfg(not(target_os = "linux"))]
362#[must_use = "this returns the detected theme; it does not apply it"]
363pub async fn from_system_async() -> crate::Result<NativeTheme> {
364    from_system()
365}
366
367/// Load an icon for the given role using the specified icon set.
368///
369/// Resolves `icon_set` to an [`IconSet`] via [`IconSet::from_name()`],
370/// falling back to [`system_icon_set()`] if the set string is not
371/// recognized. Then dispatches to the appropriate platform loader or
372/// bundled icon set.
373///
374/// # Fallback chain
375///
376/// 1. Parse `icon_set` to `IconSet` (unknown names fall back to system set)
377/// 2. Platform loader (freedesktop/sf-symbols/segoe-fluent) when `system-icons` enabled
378/// 3. Bundled SVGs (material/lucide) when the corresponding feature is enabled
379/// 4. Wildcard: try bundled Material, else `None`
380///
381/// # Examples
382///
383/// ```
384/// use native_theme::{load_icon, IconRole};
385///
386/// // With material-icons feature enabled
387/// # #[cfg(feature = "material-icons")]
388/// # {
389/// let icon = load_icon(IconRole::ActionCopy, "material");
390/// assert!(icon.is_some());
391/// # }
392/// ```
393#[must_use = "this returns the loaded icon data; it does not display it"]
394#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
395pub fn load_icon(role: IconRole, icon_set: &str) -> Option<IconData> {
396    let set = IconSet::from_name(icon_set).unwrap_or_else(system_icon_set);
397
398    match set {
399        #[cfg(all(target_os = "linux", feature = "system-icons"))]
400        IconSet::Freedesktop => freedesktop::load_freedesktop_icon(role),
401
402        #[cfg(all(target_os = "macos", feature = "system-icons"))]
403        IconSet::SfSymbols => sficons::load_sf_icon(role),
404
405        #[cfg(all(target_os = "windows", feature = "system-icons"))]
406        IconSet::SegoeIcons => winicons::load_windows_icon(role),
407
408        #[cfg(feature = "material-icons")]
409        IconSet::Material => {
410            bundled_icon_svg(IconSet::Material, role).map(|b| IconData::Svg(b.to_vec()))
411        }
412
413        #[cfg(feature = "lucide-icons")]
414        IconSet::Lucide => {
415            bundled_icon_svg(IconSet::Lucide, role).map(|b| IconData::Svg(b.to_vec()))
416        }
417
418        // Non-matching platform or unknown set: try bundled fallback
419        _ => {
420            #[cfg(feature = "material-icons")]
421            {
422                return bundled_icon_svg(IconSet::Material, role)
423                    .map(|b| IconData::Svg(b.to_vec()));
424            }
425            #[cfg(not(feature = "material-icons"))]
426            {
427                None
428            }
429        }
430    }
431}
432
433/// Mutex to serialize tests that manipulate environment variables.
434/// Env vars are process-global state, so tests that call set_var/remove_var
435/// must hold this lock to avoid races with parallel test execution.
436#[cfg(test)]
437pub(crate) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
438
439#[cfg(all(test, target_os = "linux"))]
440mod dispatch_tests {
441    use super::*;
442
443    // -- detect_linux_de() pure function tests --
444
445    #[test]
446    fn detect_kde_simple() {
447        assert_eq!(detect_linux_de("KDE"), LinuxDesktop::Kde);
448    }
449
450    #[test]
451    fn detect_kde_colon_separated_after() {
452        assert_eq!(detect_linux_de("ubuntu:KDE"), LinuxDesktop::Kde);
453    }
454
455    #[test]
456    fn detect_kde_colon_separated_before() {
457        assert_eq!(detect_linux_de("KDE:plasma"), LinuxDesktop::Kde);
458    }
459
460    #[test]
461    fn detect_gnome_simple() {
462        assert_eq!(detect_linux_de("GNOME"), LinuxDesktop::Gnome);
463    }
464
465    #[test]
466    fn detect_gnome_ubuntu() {
467        assert_eq!(detect_linux_de("ubuntu:GNOME"), LinuxDesktop::Gnome);
468    }
469
470    #[test]
471    fn detect_xfce() {
472        assert_eq!(detect_linux_de("XFCE"), LinuxDesktop::Xfce);
473    }
474
475    #[test]
476    fn detect_cinnamon() {
477        assert_eq!(detect_linux_de("X-Cinnamon"), LinuxDesktop::Cinnamon);
478    }
479
480    #[test]
481    fn detect_cinnamon_short() {
482        assert_eq!(detect_linux_de("Cinnamon"), LinuxDesktop::Cinnamon);
483    }
484
485    #[test]
486    fn detect_mate() {
487        assert_eq!(detect_linux_de("MATE"), LinuxDesktop::Mate);
488    }
489
490    #[test]
491    fn detect_lxqt() {
492        assert_eq!(detect_linux_de("LXQt"), LinuxDesktop::LxQt);
493    }
494
495    #[test]
496    fn detect_budgie() {
497        assert_eq!(detect_linux_de("Budgie:GNOME"), LinuxDesktop::Budgie);
498    }
499
500    #[test]
501    fn detect_empty_string() {
502        assert_eq!(detect_linux_de(""), LinuxDesktop::Unknown);
503    }
504
505    // -- from_linux() fallback test --
506
507    #[test]
508    fn from_linux_non_kde_returns_adwaita() {
509        let _guard = crate::ENV_MUTEX.lock().unwrap();
510        // Temporarily set XDG_CURRENT_DESKTOP to GNOME so from_linux()
511        // takes the preset fallback path.
512        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
513        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
514        let result = from_linux();
515        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
516
517        let theme = result.expect("from_linux() should return Ok for non-KDE desktop");
518        assert_eq!(theme.name, "Adwaita");
519    }
520
521    // -- from_linux() kdeglobals fallback tests --
522
523    #[test]
524    #[cfg(feature = "kde")]
525    fn from_linux_unknown_de_with_kdeglobals_fallback() {
526        let _guard = crate::ENV_MUTEX.lock().unwrap();
527        use std::io::Write;
528
529        // Create a temp dir with a minimal kdeglobals file
530        let tmp_dir = std::env::temp_dir().join("native_theme_test_kde_fallback");
531        std::fs::create_dir_all(&tmp_dir).unwrap();
532        let kdeglobals = tmp_dir.join("kdeglobals");
533        let mut f = std::fs::File::create(&kdeglobals).unwrap();
534        writeln!(
535            f,
536            "[General]\nColorScheme=TestTheme\n\n[Colors:Window]\nBackgroundNormal=239,240,241\n"
537        )
538        .unwrap();
539
540        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
541        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
542        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
543
544        unsafe { std::env::set_var("XDG_CONFIG_HOME", &tmp_dir) };
545        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
546
547        let result = from_linux();
548
549        // Restore env
550        match orig_xdg {
551            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
552            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
553        }
554        match orig_desktop {
555            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
556            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
557        }
558
559        // Cleanup
560        let _ = std::fs::remove_dir_all(&tmp_dir);
561
562        let theme = result.expect("from_linux() should return Ok with kdeglobals fallback");
563        assert_eq!(
564            theme.name, "TestTheme",
565            "should use KDE theme name from kdeglobals"
566        );
567    }
568
569    #[test]
570    fn from_linux_unknown_de_without_kdeglobals_returns_adwaita() {
571        let _guard = crate::ENV_MUTEX.lock().unwrap();
572        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
573        let orig_xdg = std::env::var("XDG_CONFIG_HOME").ok();
574        let orig_desktop = std::env::var("XDG_CURRENT_DESKTOP").ok();
575
576        unsafe {
577            std::env::set_var(
578                "XDG_CONFIG_HOME",
579                "/tmp/nonexistent_native_theme_test_no_kde",
580            )
581        };
582        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "SomeUnknownDE") };
583
584        let result = from_linux();
585
586        // Restore env
587        match orig_xdg {
588            Some(val) => unsafe { std::env::set_var("XDG_CONFIG_HOME", val) },
589            None => unsafe { std::env::remove_var("XDG_CONFIG_HOME") },
590        }
591        match orig_desktop {
592            Some(val) => unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", val) },
593            None => unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") },
594        }
595
596        let theme = result.expect("from_linux() should return Ok (adwaita fallback)");
597        assert_eq!(
598            theme.name, "Adwaita",
599            "should fall back to Adwaita without kdeglobals"
600        );
601    }
602
603    // -- from_system() smoke test --
604
605    #[test]
606    fn from_system_returns_result() {
607        let _guard = crate::ENV_MUTEX.lock().unwrap();
608        // On Linux (our test platform), from_system() should return a Result.
609        // With GNOME set, it should return the Adwaita preset.
610        // SAFETY: ENV_MUTEX serializes env var access across parallel tests
611        unsafe { std::env::set_var("XDG_CURRENT_DESKTOP", "GNOME") };
612        let result = from_system();
613        unsafe { std::env::remove_var("XDG_CURRENT_DESKTOP") };
614
615        let theme = result.expect("from_system() should return Ok on Linux");
616        assert_eq!(theme.name, "Adwaita");
617    }
618}
619
620#[cfg(test)]
621mod load_icon_tests {
622    use super::*;
623
624    #[test]
625    #[cfg(feature = "material-icons")]
626    fn load_icon_material_returns_svg() {
627        let result = load_icon(IconRole::ActionCopy, "material");
628        assert!(result.is_some(), "material ActionCopy should return Some");
629        match result.unwrap() {
630            IconData::Svg(bytes) => {
631                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
632                assert!(content.contains("<svg"), "should contain SVG data");
633            }
634            _ => panic!("expected IconData::Svg for bundled material icon"),
635        }
636    }
637
638    #[test]
639    #[cfg(feature = "lucide-icons")]
640    fn load_icon_lucide_returns_svg() {
641        let result = load_icon(IconRole::ActionCopy, "lucide");
642        assert!(result.is_some(), "lucide ActionCopy should return Some");
643        match result.unwrap() {
644            IconData::Svg(bytes) => {
645                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
646                assert!(content.contains("<svg"), "should contain SVG data");
647            }
648            _ => panic!("expected IconData::Svg for bundled lucide icon"),
649        }
650    }
651
652    #[test]
653    #[cfg(feature = "material-icons")]
654    fn load_icon_unknown_theme_falls_back() {
655        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
656        // Without system-icons feature, Freedesktop falls through to wildcard -> Material fallback.
657        let result = load_icon(IconRole::ActionCopy, "unknown-theme");
658        assert!(
659            result.is_some(),
660            "unknown theme should fall back to bundled Material"
661        );
662    }
663
664    #[test]
665    #[cfg(feature = "material-icons")]
666    fn load_icon_all_roles_material() {
667        // Material has 41 of 42 roles mapped (TrashFull returns None from icon_name,
668        // but bundled_icon_svg maps it to delete.svg, so all 42 return Some)
669        let mut some_count = 0;
670        for role in IconRole::ALL {
671            if load_icon(role, "material").is_some() {
672                some_count += 1;
673            }
674        }
675        // bundled_icon_svg covers all 42 roles for Material
676        assert_eq!(
677            some_count, 42,
678            "Material should cover all 42 roles via bundled SVGs"
679        );
680    }
681
682    #[test]
683    #[cfg(feature = "lucide-icons")]
684    fn load_icon_all_roles_lucide() {
685        let mut some_count = 0;
686        for role in IconRole::ALL {
687            if load_icon(role, "lucide").is_some() {
688                some_count += 1;
689            }
690        }
691        // bundled_icon_svg covers all 42 roles for Lucide
692        assert_eq!(
693            some_count, 42,
694            "Lucide should cover all 42 roles via bundled SVGs"
695        );
696    }
697
698    #[test]
699    fn load_icon_unrecognized_set_no_features() {
700        // SfSymbols on Linux without system-icons: falls through to wildcard
701        // The wildcard arm behavior depends on material-icons feature
702        let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
703        // Just verifying it doesn't panic
704    }
705}