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_linux_de(&crate::xdg_current_desktop());
574
575 match de {
576 crate::LinuxDesktop::Kde => detect_kde_icon_theme(),
577 crate::LinuxDesktop::Gnome | crate::LinuxDesktop::Budgie => {
578 gsettings_icon_theme("org.gnome.desktop.interface")
579 }
580 crate::LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
581 crate::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
582 crate::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
583 crate::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
584 crate::LinuxDesktop::Unknown => {
585 let kde = detect_kde_icon_theme();
586 if kde != "hicolor" {
587 return kde;
588 }
589 let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
590 if gnome != "hicolor" {
591 return gnome;
592 }
593 "hicolor".to_string()
594 }
595 }
596}
597
598#[cfg(target_os = "linux")]
606fn detect_kde_icon_theme() -> String {
607 let Some(config_dir) = xdg_config_dir() else {
608 return "hicolor".to_string();
609 };
610 let paths = [
611 config_dir.join("kdeglobals"),
612 config_dir.join("kdedefaults").join("kdeglobals"),
613 ];
614
615 for path in &paths {
616 if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
617 return theme;
618 }
619 }
620 "hicolor".to_string()
621}
622
623#[cfg(target_os = "linux")]
625fn gsettings_icon_theme(schema: &str) -> String {
626 std::process::Command::new("gsettings")
627 .args(["get", schema, "icon-theme"])
628 .output()
629 .ok()
630 .filter(|o| o.status.success())
631 .and_then(|o| String::from_utf8(o.stdout).ok())
632 .map(|s| s.trim().trim_matches('\'').to_string())
633 .filter(|s| !s.is_empty())
634 .unwrap_or_else(|| "hicolor".to_string())
635}
636
637#[cfg(target_os = "linux")]
639fn detect_xfce_icon_theme() -> String {
640 std::process::Command::new("xfconf-query")
641 .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
642 .output()
643 .ok()
644 .filter(|o| o.status.success())
645 .and_then(|o| String::from_utf8(o.stdout).ok())
646 .map(|s| s.trim().to_string())
647 .filter(|s| !s.is_empty())
648 .unwrap_or_else(|| "hicolor".to_string())
649}
650
651#[cfg(target_os = "linux")]
656fn detect_lxqt_icon_theme() -> String {
657 let Some(config_dir) = xdg_config_dir() else {
658 return "hicolor".to_string();
659 };
660 let path = config_dir.join("lxqt").join("lxqt.conf");
661
662 if let Ok(content) = std::fs::read_to_string(&path) {
663 for line in content.lines() {
664 let trimmed = line.trim();
665 if let Some(value) = trimmed.strip_prefix("icon_theme=") {
666 let value = value.trim();
667 if !value.is_empty() {
668 return value.to_string();
669 }
670 }
671 }
672 }
673 "hicolor".to_string()
674}
675
676#[cfg(target_os = "linux")]
681fn xdg_config_dir() -> Option<std::path::PathBuf> {
682 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
683 && !config_home.is_empty()
684 {
685 return Some(std::path::PathBuf::from(config_home));
686 }
687 std::env::var("HOME")
688 .ok()
689 .filter(|h| !h.is_empty())
690 .map(|h| std::path::PathBuf::from(h).join(".config"))
691}
692
693#[cfg(target_os = "linux")]
699fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
700 let content = std::fs::read_to_string(path).ok()?;
701 let target_section = format!("[{}]", section);
702 let mut in_section = false;
703
704 for line in content.lines() {
705 let trimmed = line.trim();
706 if trimmed.starts_with('[') {
707 in_section = trimmed == target_section;
708 continue;
709 }
710 if in_section && let Some(value) = trimmed.strip_prefix(key) {
711 let value = value.trim_start();
712 if let Some(value) = value.strip_prefix('=') {
713 let value = value.trim();
714 if !value.is_empty() {
715 return Some(value.to_string());
716 }
717 }
718 }
719 }
720 None
721}
722
723#[allow(unreachable_patterns)]
726fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
727 Some(match role {
728 IconRole::DialogWarning => "exclamationmark.triangle.fill",
730 IconRole::DialogError => "xmark.circle.fill",
731 IconRole::DialogInfo => "info.circle.fill",
732 IconRole::DialogQuestion => "questionmark.circle.fill",
733 IconRole::DialogSuccess => "checkmark.circle.fill",
734 IconRole::Shield => "shield.fill",
735
736 IconRole::WindowClose => "xmark",
738 IconRole::WindowMinimize => "minus",
739 IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
740 IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
741
742 IconRole::ActionSave => "square.and.arrow.down",
744 IconRole::ActionDelete => "trash",
745 IconRole::ActionCopy => "doc.on.doc",
746 IconRole::ActionPaste => "doc.on.clipboard",
747 IconRole::ActionCut => "scissors",
748 IconRole::ActionUndo => "arrow.uturn.backward",
749 IconRole::ActionRedo => "arrow.uturn.forward",
750 IconRole::ActionSearch => "magnifyingglass",
751 IconRole::ActionSettings => "gearshape",
752 IconRole::ActionEdit => "pencil",
753 IconRole::ActionAdd => "plus",
754 IconRole::ActionRemove => "minus",
755 IconRole::ActionRefresh => "arrow.clockwise",
756 IconRole::ActionPrint => "printer",
757
758 IconRole::NavBack => "chevron.backward",
760 IconRole::NavForward => "chevron.forward",
761 IconRole::NavUp => "chevron.up",
762 IconRole::NavDown => "chevron.down",
763 IconRole::NavHome => "house",
764 IconRole::NavMenu => "line.horizontal.3",
765
766 IconRole::FileGeneric => "doc",
768 IconRole::FolderClosed => "folder",
769 IconRole::FolderOpen => return None,
771 IconRole::TrashEmpty => "trash",
772 IconRole::TrashFull => "trash.fill",
773
774 IconRole::StatusBusy => return None,
777 IconRole::StatusCheck => "checkmark",
778 IconRole::StatusError => "xmark.circle.fill",
779
780 IconRole::UserAccount => "person.fill",
782 IconRole::Notification => "bell.fill",
783 IconRole::Help => "questionmark.circle",
784 IconRole::Lock => "lock.fill",
785
786 _ => return None,
787 })
788}
789
790#[allow(unreachable_patterns)]
791fn segoe_name(role: IconRole) -> Option<&'static str> {
792 Some(match role {
793 IconRole::DialogWarning => "SIID_WARNING",
795 IconRole::DialogError => "SIID_ERROR",
796 IconRole::DialogInfo => "SIID_INFO",
797 IconRole::DialogQuestion => "IDI_QUESTION",
798 IconRole::DialogSuccess => "CheckMark",
799 IconRole::Shield => "SIID_SHIELD",
800
801 IconRole::WindowClose => "ChromeClose",
803 IconRole::WindowMinimize => "ChromeMinimize",
804 IconRole::WindowMaximize => "ChromeMaximize",
805 IconRole::WindowRestore => "ChromeRestore",
806
807 IconRole::ActionSave => "Save",
809 IconRole::ActionDelete => "SIID_DELETE",
810 IconRole::ActionCopy => "Copy",
811 IconRole::ActionPaste => "Paste",
812 IconRole::ActionCut => "Cut",
813 IconRole::ActionUndo => "Undo",
814 IconRole::ActionRedo => "Redo",
815 IconRole::ActionSearch => "SIID_FIND",
816 IconRole::ActionSettings => "SIID_SETTINGS",
817 IconRole::ActionEdit => "Edit",
818 IconRole::ActionAdd => "Add",
819 IconRole::ActionRemove => "Remove",
820 IconRole::ActionRefresh => "Refresh",
821 IconRole::ActionPrint => "SIID_PRINTER",
822
823 IconRole::NavBack => "Back",
825 IconRole::NavForward => "Forward",
826 IconRole::NavUp => "Up",
827 IconRole::NavDown => "Down",
828 IconRole::NavHome => "Home",
829 IconRole::NavMenu => "GlobalNavigationButton",
830
831 IconRole::FileGeneric => "SIID_DOCNOASSOC",
833 IconRole::FolderClosed => "SIID_FOLDER",
834 IconRole::FolderOpen => "SIID_FOLDEROPEN",
835 IconRole::TrashEmpty => "SIID_RECYCLER",
836 IconRole::TrashFull => "SIID_RECYCLERFULL",
837
838 IconRole::StatusBusy => return None,
841 IconRole::StatusCheck => "CheckMark",
842 IconRole::StatusError => "SIID_ERROR",
843
844 IconRole::UserAccount => "SIID_USERS",
846 IconRole::Notification => "Ringer",
847 IconRole::Help => "SIID_HELP",
848 IconRole::Lock => "SIID_LOCK",
849
850 _ => return None,
851 })
852}
853
854#[allow(unreachable_patterns)]
855fn freedesktop_name(role: IconRole) -> Option<&'static str> {
856 Some(match role {
857 IconRole::DialogWarning => "dialog-warning",
859 IconRole::DialogError => "dialog-error",
860 IconRole::DialogInfo => "dialog-information",
861 IconRole::DialogQuestion => "dialog-question",
862 IconRole::DialogSuccess => "emblem-ok-symbolic",
863 IconRole::Shield => "security-high",
864
865 IconRole::WindowClose => "window-close",
867 IconRole::WindowMinimize => "window-minimize",
868 IconRole::WindowMaximize => "window-maximize",
869 IconRole::WindowRestore => "window-restore",
870
871 IconRole::ActionSave => "document-save",
873 IconRole::ActionDelete => "edit-delete",
874 IconRole::ActionCopy => "edit-copy",
875 IconRole::ActionPaste => "edit-paste",
876 IconRole::ActionCut => "edit-cut",
877 IconRole::ActionUndo => "edit-undo",
878 IconRole::ActionRedo => "edit-redo",
879 IconRole::ActionSearch => "edit-find",
880 IconRole::ActionSettings => "preferences-system",
881 IconRole::ActionEdit => "document-edit",
882 IconRole::ActionAdd => "list-add",
883 IconRole::ActionRemove => "list-remove",
884 IconRole::ActionRefresh => "view-refresh",
885 IconRole::ActionPrint => "document-print",
886
887 IconRole::NavBack => "go-previous",
889 IconRole::NavForward => "go-next",
890 IconRole::NavUp => "go-up",
891 IconRole::NavDown => "go-down",
892 IconRole::NavHome => "go-home",
893 IconRole::NavMenu => "open-menu",
894
895 IconRole::FileGeneric => "text-x-generic",
897 IconRole::FolderClosed => "folder",
898 IconRole::FolderOpen => "folder-open",
899 IconRole::TrashEmpty => "user-trash",
900 IconRole::TrashFull => "user-trash-full",
901
902 IconRole::StatusBusy => "process-working",
904 IconRole::StatusCheck => "emblem-default",
905 IconRole::StatusError => "dialog-error",
906
907 IconRole::UserAccount => "system-users",
909 IconRole::Notification => "notification-active",
911 IconRole::Help => "help-browser",
912 IconRole::Lock => "system-lock-screen",
913
914 _ => return None,
915 })
916}
917
918#[allow(unreachable_patterns)]
919fn material_name(role: IconRole) -> Option<&'static str> {
920 Some(match role {
921 IconRole::DialogWarning => "warning",
923 IconRole::DialogError => "error",
924 IconRole::DialogInfo => "info",
925 IconRole::DialogQuestion => "help",
926 IconRole::DialogSuccess => "check_circle",
927 IconRole::Shield => "shield",
928
929 IconRole::WindowClose => "close",
931 IconRole::WindowMinimize => "minimize",
932 IconRole::WindowMaximize => "open_in_full",
933 IconRole::WindowRestore => "close_fullscreen",
934
935 IconRole::ActionSave => "save",
937 IconRole::ActionDelete => "delete",
938 IconRole::ActionCopy => "content_copy",
939 IconRole::ActionPaste => "content_paste",
940 IconRole::ActionCut => "content_cut",
941 IconRole::ActionUndo => "undo",
942 IconRole::ActionRedo => "redo",
943 IconRole::ActionSearch => "search",
944 IconRole::ActionSettings => "settings",
945 IconRole::ActionEdit => "edit",
946 IconRole::ActionAdd => "add",
947 IconRole::ActionRemove => "remove",
948 IconRole::ActionRefresh => "refresh",
949 IconRole::ActionPrint => "print",
950
951 IconRole::NavBack => "arrow_back",
953 IconRole::NavForward => "arrow_forward",
954 IconRole::NavUp => "arrow_upward",
955 IconRole::NavDown => "arrow_downward",
956 IconRole::NavHome => "home",
957 IconRole::NavMenu => "menu",
958
959 IconRole::FileGeneric => "description",
961 IconRole::FolderClosed => "folder",
962 IconRole::FolderOpen => "folder_open",
963 IconRole::TrashEmpty => "delete",
964 IconRole::TrashFull => "delete",
966
967 IconRole::StatusBusy => "progress_activity",
969 IconRole::StatusCheck => "check",
970 IconRole::StatusError => "error",
971
972 IconRole::UserAccount => "person",
974 IconRole::Notification => "notifications",
975 IconRole::Help => "help",
976 IconRole::Lock => "lock",
977
978 _ => return None,
979 })
980}
981
982#[allow(unreachable_patterns)]
983fn lucide_name(role: IconRole) -> Option<&'static str> {
984 Some(match role {
985 IconRole::DialogWarning => "triangle-alert",
987 IconRole::DialogError => "circle-x",
988 IconRole::DialogInfo => "info",
989 IconRole::DialogQuestion => "circle-question-mark",
990 IconRole::DialogSuccess => "circle-check",
991 IconRole::Shield => "shield",
992
993 IconRole::WindowClose => "x",
995 IconRole::WindowMinimize => "minimize",
996 IconRole::WindowMaximize => "maximize",
997 IconRole::WindowRestore => "minimize-2",
998
999 IconRole::ActionSave => "save",
1001 IconRole::ActionDelete => "trash-2",
1002 IconRole::ActionCopy => "copy",
1003 IconRole::ActionPaste => "clipboard-paste",
1004 IconRole::ActionCut => "scissors",
1005 IconRole::ActionUndo => "undo-2",
1006 IconRole::ActionRedo => "redo-2",
1007 IconRole::ActionSearch => "search",
1008 IconRole::ActionSettings => "settings",
1009 IconRole::ActionEdit => "pencil",
1010 IconRole::ActionAdd => "plus",
1011 IconRole::ActionRemove => "minus",
1012 IconRole::ActionRefresh => "refresh-cw",
1013 IconRole::ActionPrint => "printer",
1014
1015 IconRole::NavBack => "chevron-left",
1017 IconRole::NavForward => "chevron-right",
1018 IconRole::NavUp => "chevron-up",
1019 IconRole::NavDown => "chevron-down",
1020 IconRole::NavHome => "house",
1021 IconRole::NavMenu => "menu",
1022
1023 IconRole::FileGeneric => "file",
1025 IconRole::FolderClosed => "folder-closed",
1026 IconRole::FolderOpen => "folder-open",
1027 IconRole::TrashEmpty => "trash-2",
1028 IconRole::TrashFull => "trash-2",
1030
1031 IconRole::StatusBusy => "loader",
1033 IconRole::StatusCheck => "check",
1034 IconRole::StatusError => "circle-x",
1035
1036 IconRole::UserAccount => "user",
1038 IconRole::Notification => "bell",
1039 IconRole::Help => "circle-question-mark",
1040 IconRole::Lock => "lock",
1041
1042 _ => return None,
1043 })
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::unwrap_used, clippy::expect_used)]
1048mod tests {
1049 use super::*;
1050
1051 #[test]
1054 fn icon_role_all_has_42_variants() {
1055 assert_eq!(IconRole::ALL.len(), 42);
1056 }
1057
1058 #[test]
1059 fn icon_role_all_contains_every_variant() {
1060 use std::collections::HashSet;
1064 let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1065 let check = |role: IconRole| {
1066 assert!(
1067 all_set.contains(&role),
1068 "IconRole::{role:?} missing from ALL array"
1069 );
1070 };
1071
1072 #[deny(unreachable_patterns)]
1074 match IconRole::DialogWarning {
1075 IconRole::DialogWarning
1076 | IconRole::DialogError
1077 | IconRole::DialogInfo
1078 | IconRole::DialogQuestion
1079 | IconRole::DialogSuccess
1080 | IconRole::Shield
1081 | IconRole::WindowClose
1082 | IconRole::WindowMinimize
1083 | IconRole::WindowMaximize
1084 | IconRole::WindowRestore
1085 | IconRole::ActionSave
1086 | IconRole::ActionDelete
1087 | IconRole::ActionCopy
1088 | IconRole::ActionPaste
1089 | IconRole::ActionCut
1090 | IconRole::ActionUndo
1091 | IconRole::ActionRedo
1092 | IconRole::ActionSearch
1093 | IconRole::ActionSettings
1094 | IconRole::ActionEdit
1095 | IconRole::ActionAdd
1096 | IconRole::ActionRemove
1097 | IconRole::ActionRefresh
1098 | IconRole::ActionPrint
1099 | IconRole::NavBack
1100 | IconRole::NavForward
1101 | IconRole::NavUp
1102 | IconRole::NavDown
1103 | IconRole::NavHome
1104 | IconRole::NavMenu
1105 | IconRole::FileGeneric
1106 | IconRole::FolderClosed
1107 | IconRole::FolderOpen
1108 | IconRole::TrashEmpty
1109 | IconRole::TrashFull
1110 | IconRole::StatusBusy
1111 | IconRole::StatusCheck
1112 | IconRole::StatusError
1113 | IconRole::UserAccount
1114 | IconRole::Notification
1115 | IconRole::Help
1116 | IconRole::Lock => {}
1117 }
1118
1119 check(IconRole::DialogWarning);
1121 check(IconRole::DialogError);
1122 check(IconRole::DialogInfo);
1123 check(IconRole::DialogQuestion);
1124 check(IconRole::DialogSuccess);
1125 check(IconRole::Shield);
1126 check(IconRole::WindowClose);
1127 check(IconRole::WindowMinimize);
1128 check(IconRole::WindowMaximize);
1129 check(IconRole::WindowRestore);
1130 check(IconRole::ActionSave);
1131 check(IconRole::ActionDelete);
1132 check(IconRole::ActionCopy);
1133 check(IconRole::ActionPaste);
1134 check(IconRole::ActionCut);
1135 check(IconRole::ActionUndo);
1136 check(IconRole::ActionRedo);
1137 check(IconRole::ActionSearch);
1138 check(IconRole::ActionSettings);
1139 check(IconRole::ActionEdit);
1140 check(IconRole::ActionAdd);
1141 check(IconRole::ActionRemove);
1142 check(IconRole::ActionRefresh);
1143 check(IconRole::ActionPrint);
1144 check(IconRole::NavBack);
1145 check(IconRole::NavForward);
1146 check(IconRole::NavUp);
1147 check(IconRole::NavDown);
1148 check(IconRole::NavHome);
1149 check(IconRole::NavMenu);
1150 check(IconRole::FileGeneric);
1151 check(IconRole::FolderClosed);
1152 check(IconRole::FolderOpen);
1153 check(IconRole::TrashEmpty);
1154 check(IconRole::TrashFull);
1155 check(IconRole::StatusBusy);
1156 check(IconRole::StatusCheck);
1157 check(IconRole::StatusError);
1158 check(IconRole::UserAccount);
1159 check(IconRole::Notification);
1160 check(IconRole::Help);
1161 check(IconRole::Lock);
1162 }
1163
1164 #[test]
1165 fn icon_role_all_no_duplicates() {
1166 let all = &IconRole::ALL;
1167 for (i, role) in all.iter().enumerate() {
1168 for (j, other) in all.iter().enumerate() {
1169 if i != j {
1170 assert_ne!(role, other, "Duplicate at index {i} and {j}");
1171 }
1172 }
1173 }
1174 }
1175
1176 #[test]
1177 fn icon_role_derives_copy_clone() {
1178 let role = IconRole::ActionCopy;
1179 let copied1 = role;
1180 let copied2 = role;
1181 assert_eq!(role, copied1);
1182 assert_eq!(role, copied2);
1183 }
1184
1185 #[test]
1186 fn icon_role_derives_debug() {
1187 let s = format!("{:?}", IconRole::DialogWarning);
1188 assert!(s.contains("DialogWarning"));
1189 }
1190
1191 #[test]
1192 fn icon_role_derives_hash() {
1193 use std::collections::HashSet;
1194 let mut set = HashSet::new();
1195 set.insert(IconRole::ActionSave);
1196 set.insert(IconRole::ActionDelete);
1197 assert_eq!(set.len(), 2);
1198 assert!(set.contains(&IconRole::ActionSave));
1199 }
1200
1201 #[test]
1204 fn icon_data_svg_construct_and_match() {
1205 let svg_bytes = b"<svg></svg>".to_vec();
1206 let data = IconData::Svg(svg_bytes.clone());
1207 match data {
1208 IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1209 _ => panic!("Expected Svg variant"),
1210 }
1211 }
1212
1213 #[test]
1214 fn icon_data_rgba_construct_and_match() {
1215 let pixels = vec![255, 0, 0, 255]; let data = IconData::Rgba {
1217 width: 1,
1218 height: 1,
1219 data: pixels.clone(),
1220 };
1221 match data {
1222 IconData::Rgba {
1223 width,
1224 height,
1225 data,
1226 } => {
1227 assert_eq!(width, 1);
1228 assert_eq!(height, 1);
1229 assert_eq!(data, pixels);
1230 }
1231 _ => panic!("Expected Rgba variant"),
1232 }
1233 }
1234
1235 #[test]
1236 fn icon_data_derives_debug() {
1237 let data = IconData::Svg(vec![]);
1238 let s = format!("{:?}", data);
1239 assert!(s.contains("Svg"));
1240 }
1241
1242 #[test]
1243 fn icon_data_derives_clone() {
1244 let data = IconData::Rgba {
1245 width: 16,
1246 height: 16,
1247 data: vec![0; 16 * 16 * 4],
1248 };
1249 let cloned = data.clone();
1250 assert_eq!(data, cloned);
1251 }
1252
1253 #[test]
1254 fn icon_data_derives_eq() {
1255 let a = IconData::Svg(b"<svg/>".to_vec());
1256 let b = IconData::Svg(b"<svg/>".to_vec());
1257 assert_eq!(a, b);
1258
1259 let c = IconData::Svg(b"<other/>".to_vec());
1260 assert_ne!(a, c);
1261 }
1262
1263 #[test]
1266 fn icon_set_from_name_sf_symbols() {
1267 assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1268 }
1269
1270 #[test]
1271 fn icon_set_from_name_segoe_fluent() {
1272 assert_eq!(
1273 IconSet::from_name("segoe-fluent"),
1274 Some(IconSet::SegoeIcons)
1275 );
1276 }
1277
1278 #[test]
1279 fn icon_set_from_name_freedesktop() {
1280 assert_eq!(
1281 IconSet::from_name("freedesktop"),
1282 Some(IconSet::Freedesktop)
1283 );
1284 }
1285
1286 #[test]
1287 fn icon_set_from_name_material() {
1288 assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1289 }
1290
1291 #[test]
1292 fn icon_set_from_name_lucide() {
1293 assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1294 }
1295
1296 #[test]
1297 fn icon_set_from_name_unknown() {
1298 assert_eq!(IconSet::from_name("unknown"), None);
1299 }
1300
1301 #[test]
1302 fn icon_set_name_sf_symbols() {
1303 assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1304 }
1305
1306 #[test]
1307 fn icon_set_name_segoe_fluent() {
1308 assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1309 }
1310
1311 #[test]
1312 fn icon_set_name_freedesktop() {
1313 assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1314 }
1315
1316 #[test]
1317 fn icon_set_name_material() {
1318 assert_eq!(IconSet::Material.name(), "material");
1319 }
1320
1321 #[test]
1322 fn icon_set_name_lucide() {
1323 assert_eq!(IconSet::Lucide.name(), "lucide");
1324 }
1325
1326 #[test]
1327 fn icon_set_from_name_name_round_trip() {
1328 let sets = [
1329 IconSet::SfSymbols,
1330 IconSet::SegoeIcons,
1331 IconSet::Freedesktop,
1332 IconSet::Material,
1333 IconSet::Lucide,
1334 ];
1335 for set in &sets {
1336 let name = set.name();
1337 let parsed = IconSet::from_name(name);
1338 assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1339 }
1340 }
1341
1342 #[test]
1343 fn icon_set_derives_copy_clone() {
1344 let set = IconSet::Material;
1345 let copied1 = set;
1346 let copied2 = set;
1347 assert_eq!(set, copied1);
1348 assert_eq!(set, copied2);
1349 }
1350
1351 #[test]
1352 fn icon_set_derives_hash() {
1353 use std::collections::HashSet;
1354 let mut map = HashSet::new();
1355 map.insert(IconSet::SfSymbols);
1356 map.insert(IconSet::Lucide);
1357 assert_eq!(map.len(), 2);
1358 }
1359
1360 #[test]
1361 fn icon_set_derives_debug() {
1362 let s = format!("{:?}", IconSet::Freedesktop);
1363 assert!(s.contains("Freedesktop"));
1364 }
1365
1366 #[test]
1367 fn icon_set_serde_round_trip() {
1368 let set = IconSet::SfSymbols;
1369 let json = serde_json::to_string(&set).unwrap();
1370 let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1371 assert_eq!(set, deserialized);
1372 }
1373
1374 #[test]
1377 fn icon_name_sf_symbols_action_copy() {
1378 assert_eq!(
1379 icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1380 Some("doc.on.doc")
1381 );
1382 }
1383
1384 #[test]
1385 fn icon_name_segoe_action_copy() {
1386 assert_eq!(
1387 icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1388 Some("Copy")
1389 );
1390 }
1391
1392 #[test]
1393 fn icon_name_freedesktop_action_copy() {
1394 assert_eq!(
1395 icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1396 Some("edit-copy")
1397 );
1398 }
1399
1400 #[test]
1401 fn icon_name_material_action_copy() {
1402 assert_eq!(
1403 icon_name(IconRole::ActionCopy, IconSet::Material),
1404 Some("content_copy")
1405 );
1406 }
1407
1408 #[test]
1409 fn icon_name_lucide_action_copy() {
1410 assert_eq!(
1411 icon_name(IconRole::ActionCopy, IconSet::Lucide),
1412 Some("copy")
1413 );
1414 }
1415
1416 #[test]
1417 fn icon_name_sf_symbols_dialog_warning() {
1418 assert_eq!(
1419 icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1420 Some("exclamationmark.triangle.fill")
1421 );
1422 }
1423
1424 #[test]
1426 fn icon_name_sf_symbols_folder_open_is_none() {
1427 assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1428 }
1429
1430 #[test]
1431 fn icon_name_sf_symbols_trash_full() {
1432 assert_eq!(
1433 icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1434 Some("trash.fill")
1435 );
1436 }
1437
1438 #[test]
1439 fn icon_name_sf_symbols_status_busy_is_none() {
1440 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1441 }
1442
1443 #[test]
1444 fn icon_name_sf_symbols_window_restore() {
1445 assert_eq!(
1446 icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1447 Some("arrow.down.right.and.arrow.up.left")
1448 );
1449 }
1450
1451 #[test]
1452 fn icon_name_segoe_dialog_success() {
1453 assert_eq!(
1454 icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1455 Some("CheckMark")
1456 );
1457 }
1458
1459 #[test]
1460 fn icon_name_segoe_status_busy_is_none() {
1461 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1462 }
1463
1464 #[test]
1465 fn icon_name_freedesktop_notification() {
1466 assert_eq!(
1467 icon_name(IconRole::Notification, IconSet::Freedesktop),
1468 Some("notification-active")
1469 );
1470 }
1471
1472 #[test]
1473 fn icon_name_material_trash_full() {
1474 assert_eq!(
1475 icon_name(IconRole::TrashFull, IconSet::Material),
1476 Some("delete")
1477 );
1478 }
1479
1480 #[test]
1481 fn icon_name_lucide_trash_full() {
1482 assert_eq!(
1483 icon_name(IconRole::TrashFull, IconSet::Lucide),
1484 Some("trash-2")
1485 );
1486 }
1487
1488 #[test]
1490 fn icon_name_spot_check_dialog_error() {
1491 assert_eq!(
1492 icon_name(IconRole::DialogError, IconSet::SfSymbols),
1493 Some("xmark.circle.fill")
1494 );
1495 assert_eq!(
1496 icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1497 Some("SIID_ERROR")
1498 );
1499 assert_eq!(
1500 icon_name(IconRole::DialogError, IconSet::Freedesktop),
1501 Some("dialog-error")
1502 );
1503 assert_eq!(
1504 icon_name(IconRole::DialogError, IconSet::Material),
1505 Some("error")
1506 );
1507 assert_eq!(
1508 icon_name(IconRole::DialogError, IconSet::Lucide),
1509 Some("circle-x")
1510 );
1511 }
1512
1513 #[test]
1514 fn icon_name_spot_check_nav_home() {
1515 assert_eq!(
1516 icon_name(IconRole::NavHome, IconSet::SfSymbols),
1517 Some("house")
1518 );
1519 assert_eq!(
1520 icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1521 Some("Home")
1522 );
1523 assert_eq!(
1524 icon_name(IconRole::NavHome, IconSet::Freedesktop),
1525 Some("go-home")
1526 );
1527 assert_eq!(
1528 icon_name(IconRole::NavHome, IconSet::Material),
1529 Some("home")
1530 );
1531 assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1532 }
1533
1534 #[test]
1536 fn icon_name_sf_symbols_expected_count() {
1537 let some_count = IconRole::ALL
1539 .iter()
1540 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1541 .count();
1542 assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1543 }
1544
1545 #[test]
1546 fn icon_name_segoe_expected_count() {
1547 let some_count = IconRole::ALL
1549 .iter()
1550 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1551 .count();
1552 assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1553 }
1554
1555 #[test]
1556 fn icon_name_freedesktop_expected_count() {
1557 let some_count = IconRole::ALL
1559 .iter()
1560 .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1561 .count();
1562 assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1563 }
1564
1565 #[test]
1566 fn icon_name_material_expected_count() {
1567 let some_count = IconRole::ALL
1569 .iter()
1570 .filter(|r| icon_name(**r, IconSet::Material).is_some())
1571 .count();
1572 assert_eq!(some_count, 42, "Material should have 42 mappings");
1573 }
1574
1575 #[test]
1576 fn icon_name_lucide_expected_count() {
1577 let some_count = IconRole::ALL
1579 .iter()
1580 .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1581 .count();
1582 assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1583 }
1584
1585 #[test]
1588 #[cfg(target_os = "linux")]
1589 fn system_icon_set_returns_freedesktop_on_linux() {
1590 assert_eq!(system_icon_set(), IconSet::Freedesktop);
1591 }
1592
1593 #[test]
1594 fn system_icon_theme_returns_non_empty() {
1595 let theme = system_icon_theme();
1596 assert!(
1597 !theme.is_empty(),
1598 "system_icon_theme() should return a non-empty string"
1599 );
1600 }
1601
1602 #[test]
1605 fn icon_provider_is_object_safe() {
1606 let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1608 let debug_str = format!("{:?}", provider);
1609 assert!(
1610 debug_str.contains("ActionCopy"),
1611 "Debug should print variant name"
1612 );
1613 }
1614
1615 #[test]
1616 fn icon_role_provider_icon_name() {
1617 let role = IconRole::ActionCopy;
1619 let name = IconProvider::icon_name(&role, IconSet::Material);
1620 assert_eq!(name, Some("content_copy"));
1621 }
1622
1623 #[test]
1624 fn icon_role_provider_icon_name_sf_symbols() {
1625 let role = IconRole::ActionCopy;
1626 let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1627 assert_eq!(name, Some("doc.on.doc"));
1628 }
1629
1630 #[test]
1631 #[cfg(feature = "material-icons")]
1632 fn icon_role_provider_icon_svg_material() {
1633 let role = IconRole::ActionCopy;
1634 let svg = IconProvider::icon_svg(&role, IconSet::Material);
1635 assert!(svg.is_some(), "Material SVG should be Some");
1636 let content = std::str::from_utf8(svg.unwrap()).expect("valid UTF-8");
1637 assert!(content.contains("<svg"), "should contain <svg tag");
1638 }
1639
1640 #[test]
1641 fn icon_role_provider_icon_svg_non_bundled() {
1642 let role = IconRole::ActionCopy;
1644 let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1645 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1646 }
1647
1648 #[test]
1649 fn icon_role_provider_all_roles() {
1650 for role in IconRole::ALL {
1652 let _name = IconProvider::icon_name(&role, IconSet::Material);
1654 }
1656 }
1657
1658 #[test]
1659 fn icon_provider_dyn_dispatch() {
1660 let role = IconRole::ActionCopy;
1662 let provider: &dyn IconProvider = &role;
1663 let name = provider.icon_name(IconSet::Material);
1664 assert_eq!(name, Some("content_copy"));
1665 let svg = provider.icon_svg(IconSet::SfSymbols);
1666 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1667 }
1668
1669 fn known_gaps() -> &'static [(IconSet, IconRole)] {
1672 &[
1673 (IconSet::SfSymbols, IconRole::FolderOpen),
1674 (IconSet::SfSymbols, IconRole::StatusBusy),
1675 (IconSet::SegoeIcons, IconRole::StatusBusy),
1676 ]
1677 }
1678
1679 #[test]
1680 fn no_unexpected_icon_gaps() {
1681 let gaps = known_gaps();
1682 let system_sets = [
1683 IconSet::SfSymbols,
1684 IconSet::SegoeIcons,
1685 IconSet::Freedesktop,
1686 ];
1687 for &set in &system_sets {
1688 for role in IconRole::ALL {
1689 let is_known_gap = gaps.contains(&(set, role));
1690 let is_mapped = icon_name(role, set).is_some();
1691 if !is_known_gap {
1692 assert!(
1693 is_mapped,
1694 "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1695 );
1696 }
1697 }
1698 }
1699 }
1700
1701 #[test]
1702 #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1703 fn all_roles_have_bundled_svg() {
1704 use crate::bundled_icon_svg;
1705 for set in [IconSet::Material, IconSet::Lucide] {
1706 for role in IconRole::ALL {
1707 assert!(
1708 bundled_icon_svg(role, set).is_some(),
1709 "{role:?} has no bundled SVG for {set:?}"
1710 );
1711 }
1712 }
1713 }
1714
1715 #[test]
1718 fn icon_name_exhaustive_all_sets() {
1719 for role in IconRole::ALL {
1721 assert!(
1722 icon_name(role, IconSet::Material).is_some(),
1723 "Material must map {role:?} to Some"
1724 );
1725 }
1726 for role in IconRole::ALL {
1728 assert!(
1729 icon_name(role, IconSet::Lucide).is_some(),
1730 "Lucide must map {role:?} to Some"
1731 );
1732 }
1733 for role in IconRole::ALL {
1735 assert!(
1736 icon_name(role, IconSet::Freedesktop).is_some(),
1737 "Freedesktop must map {role:?} to Some"
1738 );
1739 }
1740 let segoe_count = IconRole::ALL
1742 .iter()
1743 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1744 .count();
1745 assert!(
1746 segoe_count >= 41,
1747 "Segoe should map at least 41 roles, got {segoe_count}"
1748 );
1749 let sf_count = IconRole::ALL
1751 .iter()
1752 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1753 .count();
1754 assert!(
1755 sf_count >= 40,
1756 "SfSymbols should map at least 40 roles, got {sf_count}"
1757 );
1758 }
1759
1760 #[test]
1762 fn icon_name_returns_nonempty_strings() {
1763 let all_sets = [
1764 IconSet::SfSymbols,
1765 IconSet::SegoeIcons,
1766 IconSet::Freedesktop,
1767 IconSet::Material,
1768 IconSet::Lucide,
1769 ];
1770 for set in all_sets {
1771 for role in IconRole::ALL {
1772 if let Some(name) = icon_name(role, set) {
1773 assert!(
1774 !name.is_empty(),
1775 "icon_name({role:?}, {set:?}) returned empty string"
1776 );
1777 }
1778 }
1779 }
1780 }
1781}