1#[cfg(target_os = "linux")]
6use std::sync::RwLock;
7
8#[cfg(target_os = "linux")]
9static CACHED_ICON_THEME: RwLock<Option<&'static str>> = RwLock::new(None);
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[non_exhaustive]
46pub enum IconRole {
47 DialogWarning,
50 DialogError,
52 DialogInfo,
54 DialogQuestion,
56 DialogSuccess,
58 Shield,
60
61 WindowClose,
64 WindowMinimize,
66 WindowMaximize,
68 WindowRestore,
70
71 ActionSave,
74 ActionDelete,
76 ActionCopy,
78 ActionPaste,
80 ActionCut,
82 ActionUndo,
84 ActionRedo,
86 ActionSearch,
88 ActionSettings,
90 ActionEdit,
92 ActionAdd,
94 ActionRemove,
96 ActionRefresh,
98 ActionPrint,
100
101 NavBack,
104 NavForward,
106 NavUp,
108 NavDown,
110 NavHome,
112 NavMenu,
114
115 FileGeneric,
118 FolderClosed,
120 FolderOpen,
122 TrashEmpty,
124 TrashFull,
126
127 StatusBusy,
130 StatusCheck,
132 StatusError,
134
135 UserAccount,
138 Notification,
140 Help,
142 Lock,
144}
145
146impl std::fmt::Display for IconRole {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 std::fmt::Debug::fmt(self, f)
149 }
150}
151
152impl IconRole {
153 pub const ALL: [IconRole; 42] = [
157 Self::DialogWarning,
159 Self::DialogError,
160 Self::DialogInfo,
161 Self::DialogQuestion,
162 Self::DialogSuccess,
163 Self::Shield,
164 Self::WindowClose,
166 Self::WindowMinimize,
167 Self::WindowMaximize,
168 Self::WindowRestore,
169 Self::ActionSave,
171 Self::ActionDelete,
172 Self::ActionCopy,
173 Self::ActionPaste,
174 Self::ActionCut,
175 Self::ActionUndo,
176 Self::ActionRedo,
177 Self::ActionSearch,
178 Self::ActionSettings,
179 Self::ActionEdit,
180 Self::ActionAdd,
181 Self::ActionRemove,
182 Self::ActionRefresh,
183 Self::ActionPrint,
184 Self::NavBack,
186 Self::NavForward,
187 Self::NavUp,
188 Self::NavDown,
189 Self::NavHome,
190 Self::NavMenu,
191 Self::FileGeneric,
193 Self::FolderClosed,
194 Self::FolderOpen,
195 Self::TrashEmpty,
196 Self::TrashFull,
197 Self::StatusBusy,
199 Self::StatusCheck,
200 Self::StatusError,
201 Self::UserAccount,
203 Self::Notification,
204 Self::Help,
205 Self::Lock,
206 ];
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[non_exhaustive]
236#[must_use = "loading icon data without using it is likely a bug"]
237pub enum IconData {
238 Svg(Vec<u8>),
240
241 Rgba {
243 width: u32,
245 height: u32,
247 data: Vec<u8>,
249 },
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
275#[serde(rename_all = "kebab-case")]
276#[non_exhaustive]
277pub enum IconSet {
278 SfSymbols,
280 #[serde(rename = "segoe-fluent")]
282 SegoeIcons,
283 #[default]
290 Freedesktop,
291 Material,
293 Lucide,
295}
296
297impl std::fmt::Display for IconSet {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 f.write_str(self.name())
300 }
301}
302
303impl IconSet {
304 #[must_use]
311 pub fn from_name(name: &str) -> Option<Self> {
312 match name {
313 "sf-symbols" => Some(Self::SfSymbols),
314 "segoe-fluent" => Some(Self::SegoeIcons),
315 "freedesktop" => Some(Self::Freedesktop),
316 "material" => Some(Self::Material),
317 "lucide" => Some(Self::Lucide),
318 _ => None,
319 }
320 }
321
322 #[must_use]
324 pub fn name(&self) -> &'static str {
325 match self {
326 Self::SfSymbols => "sf-symbols",
327 Self::SegoeIcons => "segoe-fluent",
328 Self::Freedesktop => "freedesktop",
329 Self::Material => "material",
330 Self::Lucide => "lucide",
331 }
332 }
333}
334
335pub trait IconProvider: std::fmt::Debug {
377 fn icon_name(&self, set: IconSet) -> Option<&str>;
379
380 fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]>;
382}
383
384impl IconProvider for IconRole {
385 fn icon_name(&self, set: IconSet) -> Option<&str> {
386 icon_name(*self, set)
387 }
388
389 fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]> {
390 crate::model::bundled::bundled_icon_svg(*self, set)
391 }
392}
393
394#[must_use]
410#[allow(unreachable_patterns)] pub fn icon_name(role: IconRole, set: IconSet) -> Option<&'static str> {
412 match set {
413 IconSet::SfSymbols => sf_symbols_name(role),
414 IconSet::SegoeIcons => segoe_name(role),
415 IconSet::Freedesktop => freedesktop_name(role),
416 IconSet::Material => material_name(role),
417 IconSet::Lucide => lucide_name(role),
418 _ => None,
419 }
420}
421
422#[must_use = "this returns the current icon set for the platform"]
439pub fn system_icon_set() -> IconSet {
440 if cfg!(any(target_os = "macos", target_os = "ios")) {
441 IconSet::SfSymbols
442 } else if cfg!(target_os = "windows") {
443 IconSet::SegoeIcons
444 } else if cfg!(target_os = "linux") {
445 IconSet::Freedesktop
446 } else {
447 IconSet::Material
448 }
449}
450
451#[must_use = "this returns the current icon theme name"]
478pub fn system_icon_theme() -> &'static str {
479 #[cfg(any(target_os = "macos", target_os = "ios"))]
483 {
484 return "sf-symbols";
485 }
486
487 #[cfg(target_os = "windows")]
488 {
489 return "segoe-fluent";
490 }
491
492 #[cfg(target_os = "linux")]
493 {
494 if let Ok(guard) = CACHED_ICON_THEME.read()
495 && let Some(v) = *guard
496 {
497 return v;
498 }
499 let value = detect_linux_icon_theme();
500 let leaked: &'static str = Box::leak(value.into_boxed_str());
503 if let Ok(mut guard) = CACHED_ICON_THEME.write() {
504 *guard = Some(leaked);
505 }
506 leaked
507 }
508
509 #[cfg(not(any(
510 target_os = "linux",
511 target_os = "windows",
512 target_os = "macos",
513 target_os = "ios"
514 )))]
515 {
516 "material"
517 }
518}
519
520#[cfg(target_os = "linux")]
523pub(crate) fn invalidate_icon_theme_cache() {
524 if let Ok(mut g) = CACHED_ICON_THEME.write() {
525 *g = None;
526 }
527}
528
529#[cfg(not(target_os = "linux"))]
531pub(crate) fn invalidate_icon_theme_cache() {}
532
533#[must_use = "this returns the current icon theme name"]
542#[allow(unreachable_code)]
543pub fn detect_icon_theme() -> String {
544 #[cfg(any(target_os = "macos", target_os = "ios"))]
545 {
546 return "sf-symbols".to_string();
547 }
548
549 #[cfg(target_os = "windows")]
550 {
551 return "segoe-fluent".to_string();
552 }
553
554 #[cfg(target_os = "linux")]
555 {
556 detect_linux_icon_theme()
557 }
558
559 #[cfg(not(any(
560 target_os = "linux",
561 target_os = "windows",
562 target_os = "macos",
563 target_os = "ios"
564 )))]
565 {
566 "material".to_string()
567 }
568}
569
570#[cfg(target_os = "linux")]
572fn detect_linux_icon_theme() -> String {
573 let de = crate::detect::detect_linux_de(&crate::detect::xdg_current_desktop());
574
575 match de {
576 crate::detect::LinuxDesktop::Kde => detect_kde_icon_theme(),
577 crate::detect::LinuxDesktop::Gnome | crate::detect::LinuxDesktop::Budgie => {
578 gsettings_icon_theme("org.gnome.desktop.interface")
579 }
580 crate::detect::LinuxDesktop::Cinnamon => {
581 gsettings_icon_theme("org.cinnamon.desktop.interface")
582 }
583 crate::detect::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
584 crate::detect::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
585 crate::detect::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
586 crate::detect::LinuxDesktop::Unknown => {
587 let kde = detect_kde_icon_theme();
588 if kde != "hicolor" {
589 return kde;
590 }
591 let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
592 if gnome != "hicolor" {
593 return gnome;
594 }
595 "hicolor".to_string()
596 }
597 }
598}
599
600#[cfg(target_os = "linux")]
608fn detect_kde_icon_theme() -> String {
609 let Some(config_dir) = xdg_config_dir() else {
610 return "hicolor".to_string();
611 };
612 let paths = [
613 config_dir.join("kdeglobals"),
614 config_dir.join("kdedefaults").join("kdeglobals"),
615 ];
616
617 for path in &paths {
618 if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
619 return theme;
620 }
621 }
622 "hicolor".to_string()
623}
624
625#[cfg(target_os = "linux")]
627fn gsettings_icon_theme(schema: &str) -> String {
628 std::process::Command::new("gsettings")
629 .args(["get", schema, "icon-theme"])
630 .output()
631 .ok()
632 .filter(|o| o.status.success())
633 .and_then(|o| String::from_utf8(o.stdout).ok())
634 .map(|s| s.trim().trim_matches('\'').to_string())
635 .filter(|s| !s.is_empty())
636 .unwrap_or_else(|| "hicolor".to_string())
637}
638
639#[cfg(target_os = "linux")]
641fn detect_xfce_icon_theme() -> String {
642 std::process::Command::new("xfconf-query")
643 .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
644 .output()
645 .ok()
646 .filter(|o| o.status.success())
647 .and_then(|o| String::from_utf8(o.stdout).ok())
648 .map(|s| s.trim().to_string())
649 .filter(|s| !s.is_empty())
650 .unwrap_or_else(|| "hicolor".to_string())
651}
652
653#[cfg(target_os = "linux")]
658fn detect_lxqt_icon_theme() -> String {
659 let Some(config_dir) = xdg_config_dir() else {
660 return "hicolor".to_string();
661 };
662 let path = config_dir.join("lxqt").join("lxqt.conf");
663
664 if let Ok(content) = std::fs::read_to_string(&path) {
665 for line in content.lines() {
666 let trimmed = line.trim();
667 if let Some(value) = trimmed.strip_prefix("icon_theme=") {
668 let value = value.trim();
669 if !value.is_empty() {
670 return value.to_string();
671 }
672 }
673 }
674 }
675 "hicolor".to_string()
676}
677
678#[cfg(target_os = "linux")]
683fn xdg_config_dir() -> Option<std::path::PathBuf> {
684 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
685 && !config_home.is_empty()
686 {
687 return Some(std::path::PathBuf::from(config_home));
688 }
689 std::env::var("HOME")
690 .ok()
691 .filter(|h| !h.is_empty())
692 .map(|h| std::path::PathBuf::from(h).join(".config"))
693}
694
695#[cfg(target_os = "linux")]
701fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
702 let content = std::fs::read_to_string(path).ok()?;
703 let target_section = format!("[{}]", section);
704 let mut in_section = false;
705
706 for line in content.lines() {
707 let trimmed = line.trim();
708 if trimmed.starts_with('[') {
709 in_section = trimmed == target_section;
710 continue;
711 }
712 if in_section && let Some(value) = trimmed.strip_prefix(key) {
713 let value = value.trim_start();
714 if let Some(value) = value.strip_prefix('=') {
715 let value = value.trim();
716 if !value.is_empty() {
717 return Some(value.to_string());
718 }
719 }
720 }
721 }
722 None
723}
724
725#[allow(unreachable_patterns)]
728fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
729 Some(match role {
730 IconRole::DialogWarning => "exclamationmark.triangle.fill",
732 IconRole::DialogError => "xmark.circle.fill",
733 IconRole::DialogInfo => "info.circle.fill",
734 IconRole::DialogQuestion => "questionmark.circle.fill",
735 IconRole::DialogSuccess => "checkmark.circle.fill",
736 IconRole::Shield => "shield.fill",
737
738 IconRole::WindowClose => "xmark",
740 IconRole::WindowMinimize => "minus",
741 IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
742 IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
743
744 IconRole::ActionSave => "square.and.arrow.down",
746 IconRole::ActionDelete => "trash",
747 IconRole::ActionCopy => "doc.on.doc",
748 IconRole::ActionPaste => "doc.on.clipboard",
749 IconRole::ActionCut => "scissors",
750 IconRole::ActionUndo => "arrow.uturn.backward",
751 IconRole::ActionRedo => "arrow.uturn.forward",
752 IconRole::ActionSearch => "magnifyingglass",
753 IconRole::ActionSettings => "gearshape",
754 IconRole::ActionEdit => "pencil",
755 IconRole::ActionAdd => "plus",
756 IconRole::ActionRemove => "minus",
757 IconRole::ActionRefresh => "arrow.clockwise",
758 IconRole::ActionPrint => "printer",
759
760 IconRole::NavBack => "chevron.backward",
762 IconRole::NavForward => "chevron.forward",
763 IconRole::NavUp => "chevron.up",
764 IconRole::NavDown => "chevron.down",
765 IconRole::NavHome => "house",
766 IconRole::NavMenu => "line.horizontal.3",
767
768 IconRole::FileGeneric => "doc",
770 IconRole::FolderClosed => "folder",
771 IconRole::FolderOpen => return None,
773 IconRole::TrashEmpty => "trash",
774 IconRole::TrashFull => "trash.fill",
775
776 IconRole::StatusBusy => return None,
779 IconRole::StatusCheck => "checkmark",
780 IconRole::StatusError => "xmark.circle.fill",
781
782 IconRole::UserAccount => "person.fill",
784 IconRole::Notification => "bell.fill",
785 IconRole::Help => "questionmark.circle",
786 IconRole::Lock => "lock.fill",
787
788 _ => return None,
789 })
790}
791
792#[allow(unreachable_patterns)]
793fn segoe_name(role: IconRole) -> Option<&'static str> {
794 Some(match role {
795 IconRole::DialogWarning => "SIID_WARNING",
797 IconRole::DialogError => "SIID_ERROR",
798 IconRole::DialogInfo => "SIID_INFO",
799 IconRole::DialogQuestion => "IDI_QUESTION",
800 IconRole::DialogSuccess => "CheckMark",
801 IconRole::Shield => "SIID_SHIELD",
802
803 IconRole::WindowClose => "ChromeClose",
805 IconRole::WindowMinimize => "ChromeMinimize",
806 IconRole::WindowMaximize => "ChromeMaximize",
807 IconRole::WindowRestore => "ChromeRestore",
808
809 IconRole::ActionSave => "Save",
811 IconRole::ActionDelete => "SIID_DELETE",
812 IconRole::ActionCopy => "Copy",
813 IconRole::ActionPaste => "Paste",
814 IconRole::ActionCut => "Cut",
815 IconRole::ActionUndo => "Undo",
816 IconRole::ActionRedo => "Redo",
817 IconRole::ActionSearch => "SIID_FIND",
818 IconRole::ActionSettings => "SIID_SETTINGS",
819 IconRole::ActionEdit => "Edit",
820 IconRole::ActionAdd => "Add",
821 IconRole::ActionRemove => "Remove",
822 IconRole::ActionRefresh => "Refresh",
823 IconRole::ActionPrint => "SIID_PRINTER",
824
825 IconRole::NavBack => "Back",
827 IconRole::NavForward => "Forward",
828 IconRole::NavUp => "Up",
829 IconRole::NavDown => "Down",
830 IconRole::NavHome => "Home",
831 IconRole::NavMenu => "GlobalNavigationButton",
832
833 IconRole::FileGeneric => "SIID_DOCNOASSOC",
835 IconRole::FolderClosed => "SIID_FOLDER",
836 IconRole::FolderOpen => "SIID_FOLDEROPEN",
837 IconRole::TrashEmpty => "SIID_RECYCLER",
838 IconRole::TrashFull => "SIID_RECYCLERFULL",
839
840 IconRole::StatusBusy => return None,
843 IconRole::StatusCheck => "CheckMark",
844 IconRole::StatusError => "SIID_ERROR",
845
846 IconRole::UserAccount => "SIID_USERS",
848 IconRole::Notification => "Ringer",
849 IconRole::Help => "SIID_HELP",
850 IconRole::Lock => "SIID_LOCK",
851
852 _ => return None,
853 })
854}
855
856#[allow(unreachable_patterns)]
857fn freedesktop_name(role: IconRole) -> Option<&'static str> {
858 Some(match role {
859 IconRole::DialogWarning => "dialog-warning",
861 IconRole::DialogError => "dialog-error",
862 IconRole::DialogInfo => "dialog-information",
863 IconRole::DialogQuestion => "dialog-question",
864 IconRole::DialogSuccess => "emblem-ok-symbolic",
865 IconRole::Shield => "security-high",
866
867 IconRole::WindowClose => "window-close",
869 IconRole::WindowMinimize => "window-minimize",
870 IconRole::WindowMaximize => "window-maximize",
871 IconRole::WindowRestore => "window-restore",
872
873 IconRole::ActionSave => "document-save",
875 IconRole::ActionDelete => "edit-delete",
876 IconRole::ActionCopy => "edit-copy",
877 IconRole::ActionPaste => "edit-paste",
878 IconRole::ActionCut => "edit-cut",
879 IconRole::ActionUndo => "edit-undo",
880 IconRole::ActionRedo => "edit-redo",
881 IconRole::ActionSearch => "edit-find",
882 IconRole::ActionSettings => "preferences-system",
883 IconRole::ActionEdit => "document-edit",
884 IconRole::ActionAdd => "list-add",
885 IconRole::ActionRemove => "list-remove",
886 IconRole::ActionRefresh => "view-refresh",
887 IconRole::ActionPrint => "document-print",
888
889 IconRole::NavBack => "go-previous",
891 IconRole::NavForward => "go-next",
892 IconRole::NavUp => "go-up",
893 IconRole::NavDown => "go-down",
894 IconRole::NavHome => "go-home",
895 IconRole::NavMenu => "open-menu",
896
897 IconRole::FileGeneric => "text-x-generic",
899 IconRole::FolderClosed => "folder",
900 IconRole::FolderOpen => "folder-open",
901 IconRole::TrashEmpty => "user-trash",
902 IconRole::TrashFull => "user-trash-full",
903
904 IconRole::StatusBusy => "process-working",
906 IconRole::StatusCheck => "emblem-default",
907 IconRole::StatusError => "dialog-error",
908
909 IconRole::UserAccount => "system-users",
911 IconRole::Notification => "notification-active",
913 IconRole::Help => "help-browser",
914 IconRole::Lock => "system-lock-screen",
915
916 _ => return None,
917 })
918}
919
920#[allow(unreachable_patterns)]
921fn material_name(role: IconRole) -> Option<&'static str> {
922 Some(match role {
923 IconRole::DialogWarning => "warning",
925 IconRole::DialogError => "error",
926 IconRole::DialogInfo => "info",
927 IconRole::DialogQuestion => "help",
928 IconRole::DialogSuccess => "check_circle",
929 IconRole::Shield => "shield",
930
931 IconRole::WindowClose => "close",
933 IconRole::WindowMinimize => "minimize",
934 IconRole::WindowMaximize => "open_in_full",
935 IconRole::WindowRestore => "close_fullscreen",
936
937 IconRole::ActionSave => "save",
939 IconRole::ActionDelete => "delete",
940 IconRole::ActionCopy => "content_copy",
941 IconRole::ActionPaste => "content_paste",
942 IconRole::ActionCut => "content_cut",
943 IconRole::ActionUndo => "undo",
944 IconRole::ActionRedo => "redo",
945 IconRole::ActionSearch => "search",
946 IconRole::ActionSettings => "settings",
947 IconRole::ActionEdit => "edit",
948 IconRole::ActionAdd => "add",
949 IconRole::ActionRemove => "remove",
950 IconRole::ActionRefresh => "refresh",
951 IconRole::ActionPrint => "print",
952
953 IconRole::NavBack => "arrow_back",
955 IconRole::NavForward => "arrow_forward",
956 IconRole::NavUp => "arrow_upward",
957 IconRole::NavDown => "arrow_downward",
958 IconRole::NavHome => "home",
959 IconRole::NavMenu => "menu",
960
961 IconRole::FileGeneric => "description",
963 IconRole::FolderClosed => "folder",
964 IconRole::FolderOpen => "folder_open",
965 IconRole::TrashEmpty => "delete",
966 IconRole::TrashFull => "delete",
968
969 IconRole::StatusBusy => "progress_activity",
971 IconRole::StatusCheck => "check",
972 IconRole::StatusError => "error",
973
974 IconRole::UserAccount => "person",
976 IconRole::Notification => "notifications",
977 IconRole::Help => "help",
978 IconRole::Lock => "lock",
979
980 _ => return None,
981 })
982}
983
984#[allow(unreachable_patterns)]
985fn lucide_name(role: IconRole) -> Option<&'static str> {
986 Some(match role {
987 IconRole::DialogWarning => "triangle-alert",
989 IconRole::DialogError => "circle-x",
990 IconRole::DialogInfo => "info",
991 IconRole::DialogQuestion => "circle-question-mark",
992 IconRole::DialogSuccess => "circle-check",
993 IconRole::Shield => "shield",
994
995 IconRole::WindowClose => "x",
997 IconRole::WindowMinimize => "minimize",
998 IconRole::WindowMaximize => "maximize",
999 IconRole::WindowRestore => "minimize-2",
1000
1001 IconRole::ActionSave => "save",
1003 IconRole::ActionDelete => "trash-2",
1004 IconRole::ActionCopy => "copy",
1005 IconRole::ActionPaste => "clipboard-paste",
1006 IconRole::ActionCut => "scissors",
1007 IconRole::ActionUndo => "undo-2",
1008 IconRole::ActionRedo => "redo-2",
1009 IconRole::ActionSearch => "search",
1010 IconRole::ActionSettings => "settings",
1011 IconRole::ActionEdit => "pencil",
1012 IconRole::ActionAdd => "plus",
1013 IconRole::ActionRemove => "minus",
1014 IconRole::ActionRefresh => "refresh-cw",
1015 IconRole::ActionPrint => "printer",
1016
1017 IconRole::NavBack => "chevron-left",
1019 IconRole::NavForward => "chevron-right",
1020 IconRole::NavUp => "chevron-up",
1021 IconRole::NavDown => "chevron-down",
1022 IconRole::NavHome => "house",
1023 IconRole::NavMenu => "menu",
1024
1025 IconRole::FileGeneric => "file",
1027 IconRole::FolderClosed => "folder-closed",
1028 IconRole::FolderOpen => "folder-open",
1029 IconRole::TrashEmpty => "trash-2",
1030 IconRole::TrashFull => "trash-2",
1032
1033 IconRole::StatusBusy => "loader",
1035 IconRole::StatusCheck => "check",
1036 IconRole::StatusError => "circle-x",
1037
1038 IconRole::UserAccount => "user",
1040 IconRole::Notification => "bell",
1041 IconRole::Help => "circle-question-mark",
1042 IconRole::Lock => "lock",
1043
1044 _ => return None,
1045 })
1046}
1047
1048#[cfg(test)]
1049#[allow(clippy::unwrap_used, clippy::expect_used)]
1050mod tests {
1051 use super::*;
1052
1053 #[test]
1056 fn icon_role_all_has_42_variants() {
1057 assert_eq!(IconRole::ALL.len(), 42);
1058 }
1059
1060 #[test]
1061 fn icon_role_all_contains_every_variant() {
1062 use std::collections::HashSet;
1066 let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1067 let check = |role: IconRole| {
1068 assert!(
1069 all_set.contains(&role),
1070 "IconRole::{role:?} missing from ALL array"
1071 );
1072 };
1073
1074 #[deny(unreachable_patterns)]
1076 match IconRole::DialogWarning {
1077 IconRole::DialogWarning
1078 | IconRole::DialogError
1079 | IconRole::DialogInfo
1080 | IconRole::DialogQuestion
1081 | IconRole::DialogSuccess
1082 | IconRole::Shield
1083 | IconRole::WindowClose
1084 | IconRole::WindowMinimize
1085 | IconRole::WindowMaximize
1086 | IconRole::WindowRestore
1087 | IconRole::ActionSave
1088 | IconRole::ActionDelete
1089 | IconRole::ActionCopy
1090 | IconRole::ActionPaste
1091 | IconRole::ActionCut
1092 | IconRole::ActionUndo
1093 | IconRole::ActionRedo
1094 | IconRole::ActionSearch
1095 | IconRole::ActionSettings
1096 | IconRole::ActionEdit
1097 | IconRole::ActionAdd
1098 | IconRole::ActionRemove
1099 | IconRole::ActionRefresh
1100 | IconRole::ActionPrint
1101 | IconRole::NavBack
1102 | IconRole::NavForward
1103 | IconRole::NavUp
1104 | IconRole::NavDown
1105 | IconRole::NavHome
1106 | IconRole::NavMenu
1107 | IconRole::FileGeneric
1108 | IconRole::FolderClosed
1109 | IconRole::FolderOpen
1110 | IconRole::TrashEmpty
1111 | IconRole::TrashFull
1112 | IconRole::StatusBusy
1113 | IconRole::StatusCheck
1114 | IconRole::StatusError
1115 | IconRole::UserAccount
1116 | IconRole::Notification
1117 | IconRole::Help
1118 | IconRole::Lock => {}
1119 }
1120
1121 check(IconRole::DialogWarning);
1123 check(IconRole::DialogError);
1124 check(IconRole::DialogInfo);
1125 check(IconRole::DialogQuestion);
1126 check(IconRole::DialogSuccess);
1127 check(IconRole::Shield);
1128 check(IconRole::WindowClose);
1129 check(IconRole::WindowMinimize);
1130 check(IconRole::WindowMaximize);
1131 check(IconRole::WindowRestore);
1132 check(IconRole::ActionSave);
1133 check(IconRole::ActionDelete);
1134 check(IconRole::ActionCopy);
1135 check(IconRole::ActionPaste);
1136 check(IconRole::ActionCut);
1137 check(IconRole::ActionUndo);
1138 check(IconRole::ActionRedo);
1139 check(IconRole::ActionSearch);
1140 check(IconRole::ActionSettings);
1141 check(IconRole::ActionEdit);
1142 check(IconRole::ActionAdd);
1143 check(IconRole::ActionRemove);
1144 check(IconRole::ActionRefresh);
1145 check(IconRole::ActionPrint);
1146 check(IconRole::NavBack);
1147 check(IconRole::NavForward);
1148 check(IconRole::NavUp);
1149 check(IconRole::NavDown);
1150 check(IconRole::NavHome);
1151 check(IconRole::NavMenu);
1152 check(IconRole::FileGeneric);
1153 check(IconRole::FolderClosed);
1154 check(IconRole::FolderOpen);
1155 check(IconRole::TrashEmpty);
1156 check(IconRole::TrashFull);
1157 check(IconRole::StatusBusy);
1158 check(IconRole::StatusCheck);
1159 check(IconRole::StatusError);
1160 check(IconRole::UserAccount);
1161 check(IconRole::Notification);
1162 check(IconRole::Help);
1163 check(IconRole::Lock);
1164 }
1165
1166 #[test]
1167 fn icon_role_all_no_duplicates() {
1168 let all = &IconRole::ALL;
1169 for (i, role) in all.iter().enumerate() {
1170 for (j, other) in all.iter().enumerate() {
1171 if i != j {
1172 assert_ne!(role, other, "Duplicate at index {i} and {j}");
1173 }
1174 }
1175 }
1176 }
1177
1178 #[test]
1179 fn icon_role_derives_copy_clone() {
1180 let role = IconRole::ActionCopy;
1181 let copied1 = role;
1182 let copied2 = role;
1183 assert_eq!(role, copied1);
1184 assert_eq!(role, copied2);
1185 }
1186
1187 #[test]
1188 fn icon_role_derives_debug() {
1189 let s = format!("{:?}", IconRole::DialogWarning);
1190 assert!(s.contains("DialogWarning"));
1191 }
1192
1193 #[test]
1194 fn icon_role_derives_hash() {
1195 use std::collections::HashSet;
1196 let mut set = HashSet::new();
1197 set.insert(IconRole::ActionSave);
1198 set.insert(IconRole::ActionDelete);
1199 assert_eq!(set.len(), 2);
1200 assert!(set.contains(&IconRole::ActionSave));
1201 }
1202
1203 #[test]
1206 fn icon_data_svg_construct_and_match() {
1207 let svg_bytes = b"<svg></svg>".to_vec();
1208 let data = IconData::Svg(svg_bytes.clone());
1209 match data {
1210 IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1211 _ => panic!("Expected Svg variant"),
1212 }
1213 }
1214
1215 #[test]
1216 fn icon_data_rgba_construct_and_match() {
1217 let pixels = vec![255, 0, 0, 255]; let data = IconData::Rgba {
1219 width: 1,
1220 height: 1,
1221 data: pixels.clone(),
1222 };
1223 match data {
1224 IconData::Rgba {
1225 width,
1226 height,
1227 data,
1228 } => {
1229 assert_eq!(width, 1);
1230 assert_eq!(height, 1);
1231 assert_eq!(data, pixels);
1232 }
1233 _ => panic!("Expected Rgba variant"),
1234 }
1235 }
1236
1237 #[test]
1238 fn icon_data_derives_debug() {
1239 let data = IconData::Svg(vec![]);
1240 let s = format!("{:?}", data);
1241 assert!(s.contains("Svg"));
1242 }
1243
1244 #[test]
1245 fn icon_data_derives_clone() {
1246 let data = IconData::Rgba {
1247 width: 16,
1248 height: 16,
1249 data: vec![0; 16 * 16 * 4],
1250 };
1251 let cloned = data.clone();
1252 assert_eq!(data, cloned);
1253 }
1254
1255 #[test]
1256 fn icon_data_derives_eq() {
1257 let a = IconData::Svg(b"<svg/>".to_vec());
1258 let b = IconData::Svg(b"<svg/>".to_vec());
1259 assert_eq!(a, b);
1260
1261 let c = IconData::Svg(b"<other/>".to_vec());
1262 assert_ne!(a, c);
1263 }
1264
1265 #[test]
1268 fn icon_set_from_name_sf_symbols() {
1269 assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1270 }
1271
1272 #[test]
1273 fn icon_set_from_name_segoe_fluent() {
1274 assert_eq!(
1275 IconSet::from_name("segoe-fluent"),
1276 Some(IconSet::SegoeIcons)
1277 );
1278 }
1279
1280 #[test]
1281 fn icon_set_from_name_freedesktop() {
1282 assert_eq!(
1283 IconSet::from_name("freedesktop"),
1284 Some(IconSet::Freedesktop)
1285 );
1286 }
1287
1288 #[test]
1289 fn icon_set_from_name_material() {
1290 assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1291 }
1292
1293 #[test]
1294 fn icon_set_from_name_lucide() {
1295 assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1296 }
1297
1298 #[test]
1299 fn icon_set_from_name_unknown() {
1300 assert_eq!(IconSet::from_name("unknown"), None);
1301 }
1302
1303 #[test]
1304 fn icon_set_name_sf_symbols() {
1305 assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1306 }
1307
1308 #[test]
1309 fn icon_set_name_segoe_fluent() {
1310 assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1311 }
1312
1313 #[test]
1314 fn icon_set_name_freedesktop() {
1315 assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1316 }
1317
1318 #[test]
1319 fn icon_set_name_material() {
1320 assert_eq!(IconSet::Material.name(), "material");
1321 }
1322
1323 #[test]
1324 fn icon_set_name_lucide() {
1325 assert_eq!(IconSet::Lucide.name(), "lucide");
1326 }
1327
1328 #[test]
1329 fn icon_set_from_name_name_round_trip() {
1330 let sets = [
1331 IconSet::SfSymbols,
1332 IconSet::SegoeIcons,
1333 IconSet::Freedesktop,
1334 IconSet::Material,
1335 IconSet::Lucide,
1336 ];
1337 for set in &sets {
1338 let name = set.name();
1339 let parsed = IconSet::from_name(name);
1340 assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1341 }
1342 }
1343
1344 #[test]
1345 fn icon_set_derives_copy_clone() {
1346 let set = IconSet::Material;
1347 let copied1 = set;
1348 let copied2 = set;
1349 assert_eq!(set, copied1);
1350 assert_eq!(set, copied2);
1351 }
1352
1353 #[test]
1354 fn icon_set_derives_hash() {
1355 use std::collections::HashSet;
1356 let mut map = HashSet::new();
1357 map.insert(IconSet::SfSymbols);
1358 map.insert(IconSet::Lucide);
1359 assert_eq!(map.len(), 2);
1360 }
1361
1362 #[test]
1363 fn icon_set_derives_debug() {
1364 let s = format!("{:?}", IconSet::Freedesktop);
1365 assert!(s.contains("Freedesktop"));
1366 }
1367
1368 #[test]
1369 fn icon_set_serde_round_trip() {
1370 let set = IconSet::SfSymbols;
1371 let json = serde_json::to_string(&set).unwrap();
1372 let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1373 assert_eq!(set, deserialized);
1374 }
1375
1376 #[test]
1379 fn icon_name_sf_symbols_action_copy() {
1380 assert_eq!(
1381 icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1382 Some("doc.on.doc")
1383 );
1384 }
1385
1386 #[test]
1387 fn icon_name_segoe_action_copy() {
1388 assert_eq!(
1389 icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1390 Some("Copy")
1391 );
1392 }
1393
1394 #[test]
1395 fn icon_name_freedesktop_action_copy() {
1396 assert_eq!(
1397 icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1398 Some("edit-copy")
1399 );
1400 }
1401
1402 #[test]
1403 fn icon_name_material_action_copy() {
1404 assert_eq!(
1405 icon_name(IconRole::ActionCopy, IconSet::Material),
1406 Some("content_copy")
1407 );
1408 }
1409
1410 #[test]
1411 fn icon_name_lucide_action_copy() {
1412 assert_eq!(
1413 icon_name(IconRole::ActionCopy, IconSet::Lucide),
1414 Some("copy")
1415 );
1416 }
1417
1418 #[test]
1419 fn icon_name_sf_symbols_dialog_warning() {
1420 assert_eq!(
1421 icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1422 Some("exclamationmark.triangle.fill")
1423 );
1424 }
1425
1426 #[test]
1428 fn icon_name_sf_symbols_folder_open_is_none() {
1429 assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1430 }
1431
1432 #[test]
1433 fn icon_name_sf_symbols_trash_full() {
1434 assert_eq!(
1435 icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1436 Some("trash.fill")
1437 );
1438 }
1439
1440 #[test]
1441 fn icon_name_sf_symbols_status_busy_is_none() {
1442 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1443 }
1444
1445 #[test]
1446 fn icon_name_sf_symbols_window_restore() {
1447 assert_eq!(
1448 icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1449 Some("arrow.down.right.and.arrow.up.left")
1450 );
1451 }
1452
1453 #[test]
1454 fn icon_name_segoe_dialog_success() {
1455 assert_eq!(
1456 icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1457 Some("CheckMark")
1458 );
1459 }
1460
1461 #[test]
1462 fn icon_name_segoe_status_busy_is_none() {
1463 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1464 }
1465
1466 #[test]
1467 fn icon_name_freedesktop_notification() {
1468 assert_eq!(
1469 icon_name(IconRole::Notification, IconSet::Freedesktop),
1470 Some("notification-active")
1471 );
1472 }
1473
1474 #[test]
1475 fn icon_name_material_trash_full() {
1476 assert_eq!(
1477 icon_name(IconRole::TrashFull, IconSet::Material),
1478 Some("delete")
1479 );
1480 }
1481
1482 #[test]
1483 fn icon_name_lucide_trash_full() {
1484 assert_eq!(
1485 icon_name(IconRole::TrashFull, IconSet::Lucide),
1486 Some("trash-2")
1487 );
1488 }
1489
1490 #[test]
1492 fn icon_name_spot_check_dialog_error() {
1493 assert_eq!(
1494 icon_name(IconRole::DialogError, IconSet::SfSymbols),
1495 Some("xmark.circle.fill")
1496 );
1497 assert_eq!(
1498 icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1499 Some("SIID_ERROR")
1500 );
1501 assert_eq!(
1502 icon_name(IconRole::DialogError, IconSet::Freedesktop),
1503 Some("dialog-error")
1504 );
1505 assert_eq!(
1506 icon_name(IconRole::DialogError, IconSet::Material),
1507 Some("error")
1508 );
1509 assert_eq!(
1510 icon_name(IconRole::DialogError, IconSet::Lucide),
1511 Some("circle-x")
1512 );
1513 }
1514
1515 #[test]
1516 fn icon_name_spot_check_nav_home() {
1517 assert_eq!(
1518 icon_name(IconRole::NavHome, IconSet::SfSymbols),
1519 Some("house")
1520 );
1521 assert_eq!(
1522 icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1523 Some("Home")
1524 );
1525 assert_eq!(
1526 icon_name(IconRole::NavHome, IconSet::Freedesktop),
1527 Some("go-home")
1528 );
1529 assert_eq!(
1530 icon_name(IconRole::NavHome, IconSet::Material),
1531 Some("home")
1532 );
1533 assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1534 }
1535
1536 #[test]
1538 fn icon_name_sf_symbols_expected_count() {
1539 let some_count = IconRole::ALL
1541 .iter()
1542 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1543 .count();
1544 assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1545 }
1546
1547 #[test]
1548 fn icon_name_segoe_expected_count() {
1549 let some_count = IconRole::ALL
1551 .iter()
1552 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1553 .count();
1554 assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1555 }
1556
1557 #[test]
1558 fn icon_name_freedesktop_expected_count() {
1559 let some_count = IconRole::ALL
1561 .iter()
1562 .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1563 .count();
1564 assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1565 }
1566
1567 #[test]
1568 fn icon_name_material_expected_count() {
1569 let some_count = IconRole::ALL
1571 .iter()
1572 .filter(|r| icon_name(**r, IconSet::Material).is_some())
1573 .count();
1574 assert_eq!(some_count, 42, "Material should have 42 mappings");
1575 }
1576
1577 #[test]
1578 fn icon_name_lucide_expected_count() {
1579 let some_count = IconRole::ALL
1581 .iter()
1582 .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1583 .count();
1584 assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1585 }
1586
1587 #[test]
1590 #[cfg(target_os = "linux")]
1591 fn system_icon_set_returns_freedesktop_on_linux() {
1592 assert_eq!(system_icon_set(), IconSet::Freedesktop);
1593 }
1594
1595 #[test]
1596 fn system_icon_theme_returns_non_empty() {
1597 let theme = system_icon_theme();
1598 assert!(
1599 !theme.is_empty(),
1600 "system_icon_theme() should return a non-empty string"
1601 );
1602 }
1603
1604 #[test]
1607 fn icon_provider_is_object_safe() {
1608 let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1610 let debug_str = format!("{:?}", provider);
1611 assert!(
1612 debug_str.contains("ActionCopy"),
1613 "Debug should print variant name"
1614 );
1615 }
1616
1617 #[test]
1618 fn icon_role_provider_icon_name() {
1619 let role = IconRole::ActionCopy;
1621 let name = IconProvider::icon_name(&role, IconSet::Material);
1622 assert_eq!(name, Some("content_copy"));
1623 }
1624
1625 #[test]
1626 fn icon_role_provider_icon_name_sf_symbols() {
1627 let role = IconRole::ActionCopy;
1628 let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1629 assert_eq!(name, Some("doc.on.doc"));
1630 }
1631
1632 #[test]
1633 #[cfg(feature = "material-icons")]
1634 fn icon_role_provider_icon_svg_material() {
1635 let role = IconRole::ActionCopy;
1636 let svg = IconProvider::icon_svg(&role, IconSet::Material);
1637 assert!(svg.is_some(), "Material SVG should be Some");
1638 let content = std::str::from_utf8(svg.unwrap()).expect("valid UTF-8");
1639 assert!(content.contains("<svg"), "should contain <svg tag");
1640 }
1641
1642 #[test]
1643 fn icon_role_provider_icon_svg_non_bundled() {
1644 let role = IconRole::ActionCopy;
1646 let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1647 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1648 }
1649
1650 #[test]
1651 fn icon_role_provider_all_roles() {
1652 for role in IconRole::ALL {
1654 let _name = IconProvider::icon_name(&role, IconSet::Material);
1656 }
1658 }
1659
1660 #[test]
1661 fn icon_provider_dyn_dispatch() {
1662 let role = IconRole::ActionCopy;
1664 let provider: &dyn IconProvider = &role;
1665 let name = provider.icon_name(IconSet::Material);
1666 assert_eq!(name, Some("content_copy"));
1667 let svg = provider.icon_svg(IconSet::SfSymbols);
1668 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1669 }
1670
1671 fn known_gaps() -> &'static [(IconSet, IconRole)] {
1674 &[
1675 (IconSet::SfSymbols, IconRole::FolderOpen),
1676 (IconSet::SfSymbols, IconRole::StatusBusy),
1677 (IconSet::SegoeIcons, IconRole::StatusBusy),
1678 ]
1679 }
1680
1681 #[test]
1682 fn no_unexpected_icon_gaps() {
1683 let gaps = known_gaps();
1684 let system_sets = [
1685 IconSet::SfSymbols,
1686 IconSet::SegoeIcons,
1687 IconSet::Freedesktop,
1688 ];
1689 for &set in &system_sets {
1690 for role in IconRole::ALL {
1691 let is_known_gap = gaps.contains(&(set, role));
1692 let is_mapped = icon_name(role, set).is_some();
1693 if !is_known_gap {
1694 assert!(
1695 is_mapped,
1696 "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1697 );
1698 }
1699 }
1700 }
1701 }
1702
1703 #[test]
1704 #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1705 fn all_roles_have_bundled_svg() {
1706 use crate::bundled_icon_svg;
1707 for set in [IconSet::Material, IconSet::Lucide] {
1708 for role in IconRole::ALL {
1709 assert!(
1710 bundled_icon_svg(role, set).is_some(),
1711 "{role:?} has no bundled SVG for {set:?}"
1712 );
1713 }
1714 }
1715 }
1716
1717 #[test]
1720 fn icon_name_exhaustive_all_sets() {
1721 for role in IconRole::ALL {
1723 assert!(
1724 icon_name(role, IconSet::Material).is_some(),
1725 "Material must map {role:?} to Some"
1726 );
1727 }
1728 for role in IconRole::ALL {
1730 assert!(
1731 icon_name(role, IconSet::Lucide).is_some(),
1732 "Lucide must map {role:?} to Some"
1733 );
1734 }
1735 for role in IconRole::ALL {
1737 assert!(
1738 icon_name(role, IconSet::Freedesktop).is_some(),
1739 "Freedesktop must map {role:?} to Some"
1740 );
1741 }
1742 let segoe_count = IconRole::ALL
1744 .iter()
1745 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1746 .count();
1747 assert!(
1748 segoe_count >= 41,
1749 "Segoe should map at least 41 roles, got {segoe_count}"
1750 );
1751 let sf_count = IconRole::ALL
1753 .iter()
1754 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1755 .count();
1756 assert!(
1757 sf_count >= 40,
1758 "SfSymbols should map at least 40 roles, got {sf_count}"
1759 );
1760 }
1761
1762 #[test]
1764 fn icon_name_returns_nonempty_strings() {
1765 let all_sets = [
1766 IconSet::SfSymbols,
1767 IconSet::SegoeIcons,
1768 IconSet::Freedesktop,
1769 IconSet::Material,
1770 IconSet::Lucide,
1771 ];
1772 for set in all_sets {
1773 for role in IconRole::ALL {
1774 if let Some(name) = icon_name(role, set) {
1775 assert!(
1776 !name.is_empty(),
1777 "icon_name({role:?}, {set:?}) returned empty string"
1778 );
1779 }
1780 }
1781 }
1782 }
1783}