1#![warn(missing_docs)]
9
10#[doc = include_str!("../README.md")]
11#[cfg(doctest)]
12pub struct ReadmeDoctests;
13
14#[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 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 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
78pub mod color;
80pub mod error;
82#[cfg(all(target_os = "linux", feature = "portal"))]
84pub mod gnome;
85#[cfg(all(target_os = "linux", feature = "kde"))]
87pub mod kde;
88pub mod model;
90pub 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};
106pub use model::icons::{icon_name, system_icon_set, system_icon_theme};
108
109#[cfg(all(target_os = "linux", feature = "system-icons"))]
111pub mod freedesktop;
112pub mod macos;
114#[cfg(feature = "svg-rasterize")]
116pub mod rasterize;
117#[cfg(all(target_os = "macos", feature = "system-icons"))]
119pub mod sficons;
120#[cfg(all(target_os = "windows", feature = "windows"))]
122pub mod windows;
123#[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
151pub type Result<T> = std::result::Result<T, Error>;
153
154#[cfg(target_os = "linux")]
156#[derive(Debug, Clone, Copy, PartialEq)]
157pub enum LinuxDesktop {
158 Kde,
160 Gnome,
162 Xfce,
164 Cinnamon,
166 Mate,
168 LxQt,
170 Budgie,
172 Unknown,
174}
175
176#[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#[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#[cfg(target_os = "linux")]
222fn detect_is_dark_inner() -> bool {
223 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 #[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#[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#[allow(unreachable_code)]
288fn detect_reduced_motion_inner() -> bool {
289 #[cfg(target_os = "linux")]
290 {
291 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 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 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#[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#[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#[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 #[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 #[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#[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#[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 _ => None,
540 }
541}
542
543#[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#[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#[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 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 if let Some(svg) = provider.icon_svg(set) {
675 return Some(IconData::Svg(svg.to_vec()));
676 }
677
678 None
680}
681
682#[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 #[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 #[test]
757 fn from_linux_non_kde_returns_adwaita() {
758 let _guard = crate::ENV_MUTEX.lock().unwrap();
759 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 #[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 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 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 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 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 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 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 #[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 #[test]
872 fn from_system_returns_result() {
873 let _guard = crate::ENV_MUTEX.lock().unwrap();
874 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 let result = load_icon(IconRole::ActionCopy, "unknown-theme");
925 let _ = result;
929 }
930
931 #[test]
932 #[cfg(feature = "material-icons")]
933 fn load_icon_all_roles_material() {
934 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 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 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 let _result = load_icon(IconRole::ActionCopy, "sf-symbols");
968 }
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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 }
1161 }
1162 }
1163 }
1164
1165 #[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 #[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}