1#[doc = include_str!("../README.md")]
9#[cfg(doctest)]
10pub struct ReadmeDoctests;
11
12#[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 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 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};
91pub 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
130pub type Result<T> = std::result::Result<T, Error>;
132
133#[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#[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#[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#[cfg(target_os = "linux")]
193fn detect_is_dark_inner() -> bool {
194 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 #[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#[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#[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#[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 #[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 #[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#[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#[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 _ => None,
425 }
426}
427
428#[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#[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 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 if let Some(svg) = provider.icon_svg(set) {
521 return Some(IconData::Svg(svg.to_vec()));
522 }
523
524 None
526}
527
528#[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 #[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 #[test]
603 fn from_linux_non_kde_returns_adwaita() {
604 let _guard = crate::ENV_MUTEX.lock().unwrap();
605 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 #[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 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 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 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 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 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 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 #[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 #[test]
718 fn from_system_returns_result() {
719 let _guard = crate::ENV_MUTEX.lock().unwrap();
720 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 let result = load_icon(IconRole::ActionCopy, "unknown-theme");
771 let _ = result;
775 }
776
777 #[test]
778 #[cfg(feature = "material-icons")]
779 fn load_icon_all_roles_material() {
780 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 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 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 let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
814 }
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 #[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 #[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 #[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 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 #[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}