1#[cfg(target_os = "linux")]
6use std::sync::OnceLock;
7
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42#[non_exhaustive]
43pub enum IconRole {
44 DialogWarning,
47 DialogError,
49 DialogInfo,
51 DialogQuestion,
53 DialogSuccess,
55 Shield,
57
58 WindowClose,
61 WindowMinimize,
63 WindowMaximize,
65 WindowRestore,
67
68 ActionSave,
71 ActionDelete,
73 ActionCopy,
75 ActionPaste,
77 ActionCut,
79 ActionUndo,
81 ActionRedo,
83 ActionSearch,
85 ActionSettings,
87 ActionEdit,
89 ActionAdd,
91 ActionRemove,
93 ActionRefresh,
95 ActionPrint,
97
98 NavBack,
101 NavForward,
103 NavUp,
105 NavDown,
107 NavHome,
109 NavMenu,
111
112 FileGeneric,
115 FolderClosed,
117 FolderOpen,
119 TrashEmpty,
121 TrashFull,
123
124 StatusBusy,
127 StatusCheck,
129 StatusError,
131
132 UserAccount,
135 Notification,
137 Help,
139 Lock,
141}
142
143impl IconRole {
144 pub const ALL: [IconRole; 42] = [
148 Self::DialogWarning,
150 Self::DialogError,
151 Self::DialogInfo,
152 Self::DialogQuestion,
153 Self::DialogSuccess,
154 Self::Shield,
155 Self::WindowClose,
157 Self::WindowMinimize,
158 Self::WindowMaximize,
159 Self::WindowRestore,
160 Self::ActionSave,
162 Self::ActionDelete,
163 Self::ActionCopy,
164 Self::ActionPaste,
165 Self::ActionCut,
166 Self::ActionUndo,
167 Self::ActionRedo,
168 Self::ActionSearch,
169 Self::ActionSettings,
170 Self::ActionEdit,
171 Self::ActionAdd,
172 Self::ActionRemove,
173 Self::ActionRefresh,
174 Self::ActionPrint,
175 Self::NavBack,
177 Self::NavForward,
178 Self::NavUp,
179 Self::NavDown,
180 Self::NavHome,
181 Self::NavMenu,
182 Self::FileGeneric,
184 Self::FolderClosed,
185 Self::FolderOpen,
186 Self::TrashEmpty,
187 Self::TrashFull,
188 Self::StatusBusy,
190 Self::StatusCheck,
191 Self::StatusError,
192 Self::UserAccount,
194 Self::Notification,
195 Self::Help,
196 Self::Lock,
197 ];
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226#[non_exhaustive]
227#[must_use = "loading icon data without using it is likely a bug"]
228pub enum IconData {
229 Svg(Vec<u8>),
231
232 Rgba {
234 width: u32,
236 height: u32,
238 data: Vec<u8>,
240 },
241}
242
243#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
266#[serde(rename_all = "kebab-case")]
267#[non_exhaustive]
268pub enum IconSet {
269 SfSymbols,
271 #[serde(rename = "segoe-fluent")]
273 SegoeIcons,
274 #[default]
276 Freedesktop,
277 Material,
279 Lucide,
281}
282
283impl IconSet {
284 #[must_use]
291 pub fn from_name(name: &str) -> Option<Self> {
292 match name {
293 "sf-symbols" => Some(Self::SfSymbols),
294 "segoe-fluent" => Some(Self::SegoeIcons),
295 "freedesktop" => Some(Self::Freedesktop),
296 "material" => Some(Self::Material),
297 "lucide" => Some(Self::Lucide),
298 _ => None,
299 }
300 }
301
302 #[must_use]
304 pub fn name(&self) -> &'static str {
305 match self {
306 Self::SfSymbols => "sf-symbols",
307 Self::SegoeIcons => "segoe-fluent",
308 Self::Freedesktop => "freedesktop",
309 Self::Material => "material",
310 Self::Lucide => "lucide",
311 }
312 }
313}
314
315pub trait IconProvider: std::fmt::Debug {
357 fn icon_name(&self, set: IconSet) -> Option<&str>;
359
360 fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]>;
362}
363
364impl IconProvider for IconRole {
365 fn icon_name(&self, set: IconSet) -> Option<&str> {
366 icon_name(*self, set)
367 }
368
369 fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]> {
370 crate::model::bundled::bundled_icon_svg(*self, set)
371 }
372}
373
374#[must_use]
390#[allow(unreachable_patterns)] pub fn icon_name(role: IconRole, set: IconSet) -> Option<&'static str> {
392 match set {
393 IconSet::SfSymbols => sf_symbols_name(role),
394 IconSet::SegoeIcons => segoe_name(role),
395 IconSet::Freedesktop => freedesktop_name(role),
396 IconSet::Material => material_name(role),
397 IconSet::Lucide => lucide_name(role),
398 _ => None,
399 }
400}
401
402#[must_use = "this returns the current icon set for the platform"]
419pub fn system_icon_set() -> IconSet {
420 if cfg!(any(target_os = "macos", target_os = "ios")) {
421 IconSet::SfSymbols
422 } else if cfg!(target_os = "windows") {
423 IconSet::SegoeIcons
424 } else if cfg!(target_os = "linux") {
425 IconSet::Freedesktop
426 } else {
427 IconSet::Material
428 }
429}
430
431#[must_use = "this returns the current icon theme name"]
458pub fn system_icon_theme() -> &'static str {
459 #[cfg(target_os = "linux")]
460 static CACHED_ICON_THEME: OnceLock<String> = OnceLock::new();
461
462 #[cfg(any(target_os = "macos", target_os = "ios"))]
463 {
464 return "sf-symbols";
465 }
466
467 #[cfg(target_os = "windows")]
468 {
469 return "segoe-fluent";
470 }
471
472 #[cfg(target_os = "linux")]
473 {
474 CACHED_ICON_THEME
475 .get_or_init(detect_linux_icon_theme)
476 .as_str()
477 }
478
479 #[cfg(not(any(
480 target_os = "linux",
481 target_os = "windows",
482 target_os = "macos",
483 target_os = "ios"
484 )))]
485 {
486 "material"
487 }
488}
489
490#[must_use = "this returns the current icon theme name"]
499#[allow(unreachable_code)]
500pub fn detect_icon_theme() -> String {
501 #[cfg(any(target_os = "macos", target_os = "ios"))]
502 {
503 return "sf-symbols".to_string();
504 }
505
506 #[cfg(target_os = "windows")]
507 {
508 return "segoe-fluent".to_string();
509 }
510
511 #[cfg(target_os = "linux")]
512 {
513 detect_linux_icon_theme()
514 }
515
516 #[cfg(not(any(
517 target_os = "linux",
518 target_os = "windows",
519 target_os = "macos",
520 target_os = "ios"
521 )))]
522 {
523 "material".to_string()
524 }
525}
526
527#[cfg(target_os = "linux")]
529fn detect_linux_icon_theme() -> String {
530 let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
531 let de = crate::detect_linux_de(&desktop);
532
533 match de {
534 crate::LinuxDesktop::Kde => detect_kde_icon_theme(),
535 crate::LinuxDesktop::Gnome | crate::LinuxDesktop::Budgie => {
536 gsettings_icon_theme("org.gnome.desktop.interface")
537 }
538 crate::LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
539 crate::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
540 crate::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
541 crate::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
542 crate::LinuxDesktop::Unknown => {
543 let kde = detect_kde_icon_theme();
544 if kde != "hicolor" {
545 return kde;
546 }
547 let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
548 if gnome != "hicolor" {
549 return gnome;
550 }
551 "hicolor".to_string()
552 }
553 }
554}
555
556#[cfg(target_os = "linux")]
564fn detect_kde_icon_theme() -> String {
565 let config_dir = xdg_config_dir();
566 let paths = [
567 config_dir.join("kdeglobals"),
568 config_dir.join("kdedefaults").join("kdeglobals"),
569 ];
570
571 for path in &paths {
572 if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
573 return theme;
574 }
575 }
576 "hicolor".to_string()
577}
578
579#[cfg(target_os = "linux")]
581fn gsettings_icon_theme(schema: &str) -> String {
582 std::process::Command::new("gsettings")
583 .args(["get", schema, "icon-theme"])
584 .output()
585 .ok()
586 .filter(|o| o.status.success())
587 .and_then(|o| String::from_utf8(o.stdout).ok())
588 .map(|s| s.trim().trim_matches('\'').to_string())
589 .filter(|s| !s.is_empty())
590 .unwrap_or_else(|| "hicolor".to_string())
591}
592
593#[cfg(target_os = "linux")]
595fn detect_xfce_icon_theme() -> String {
596 std::process::Command::new("xfconf-query")
597 .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
598 .output()
599 .ok()
600 .filter(|o| o.status.success())
601 .and_then(|o| String::from_utf8(o.stdout).ok())
602 .map(|s| s.trim().to_string())
603 .filter(|s| !s.is_empty())
604 .unwrap_or_else(|| "hicolor".to_string())
605}
606
607#[cfg(target_os = "linux")]
612fn detect_lxqt_icon_theme() -> String {
613 let path = xdg_config_dir().join("lxqt").join("lxqt.conf");
614
615 if let Ok(content) = std::fs::read_to_string(&path) {
616 for line in content.lines() {
617 let trimmed = line.trim();
618 if let Some(value) = trimmed.strip_prefix("icon_theme=") {
619 let value = value.trim();
620 if !value.is_empty() {
621 return value.to_string();
622 }
623 }
624 }
625 }
626 "hicolor".to_string()
627}
628
629#[cfg(target_os = "linux")]
631fn xdg_config_dir() -> std::path::PathBuf {
632 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
633 && !config_home.is_empty()
634 {
635 return std::path::PathBuf::from(config_home);
636 }
637 std::env::var("HOME")
638 .map(std::path::PathBuf::from)
639 .unwrap_or_else(|_| std::path::PathBuf::from("/tmp"))
640 .join(".config")
641}
642
643#[cfg(target_os = "linux")]
649fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
650 let content = std::fs::read_to_string(path).ok()?;
651 let target_section = format!("[{}]", section);
652 let mut in_section = false;
653
654 for line in content.lines() {
655 let trimmed = line.trim();
656 if trimmed.starts_with('[') {
657 in_section = trimmed == target_section;
658 continue;
659 }
660 if in_section && let Some(value) = trimmed.strip_prefix(key) {
661 let value = value.trim_start();
662 if let Some(value) = value.strip_prefix('=') {
663 let value = value.trim();
664 if !value.is_empty() {
665 return Some(value.to_string());
666 }
667 }
668 }
669 }
670 None
671}
672
673#[allow(unreachable_patterns)]
676fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
677 Some(match role {
678 IconRole::DialogWarning => "exclamationmark.triangle.fill",
680 IconRole::DialogError => "xmark.circle.fill",
681 IconRole::DialogInfo => "info.circle.fill",
682 IconRole::DialogQuestion => "questionmark.circle.fill",
683 IconRole::DialogSuccess => "checkmark.circle.fill",
684 IconRole::Shield => "shield.fill",
685
686 IconRole::WindowClose => "xmark",
688 IconRole::WindowMinimize => "minus",
689 IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
690 IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
691
692 IconRole::ActionSave => "square.and.arrow.down",
694 IconRole::ActionDelete => "trash",
695 IconRole::ActionCopy => "doc.on.doc",
696 IconRole::ActionPaste => "doc.on.clipboard",
697 IconRole::ActionCut => "scissors",
698 IconRole::ActionUndo => "arrow.uturn.backward",
699 IconRole::ActionRedo => "arrow.uturn.forward",
700 IconRole::ActionSearch => "magnifyingglass",
701 IconRole::ActionSettings => "gearshape",
702 IconRole::ActionEdit => "pencil",
703 IconRole::ActionAdd => "plus",
704 IconRole::ActionRemove => "minus",
705 IconRole::ActionRefresh => "arrow.clockwise",
706 IconRole::ActionPrint => "printer",
707
708 IconRole::NavBack => "chevron.backward",
710 IconRole::NavForward => "chevron.forward",
711 IconRole::NavUp => "chevron.up",
712 IconRole::NavDown => "chevron.down",
713 IconRole::NavHome => "house",
714 IconRole::NavMenu => "line.horizontal.3",
715
716 IconRole::FileGeneric => "doc",
718 IconRole::FolderClosed => "folder",
719 IconRole::FolderOpen => return None,
721 IconRole::TrashEmpty => "trash",
722 IconRole::TrashFull => "trash.fill",
723
724 IconRole::StatusBusy => return None,
727 IconRole::StatusCheck => "checkmark",
728 IconRole::StatusError => "xmark.circle.fill",
729
730 IconRole::UserAccount => "person.fill",
732 IconRole::Notification => "bell.fill",
733 IconRole::Help => "questionmark.circle",
734 IconRole::Lock => "lock.fill",
735
736 _ => return None,
737 })
738}
739
740#[allow(unreachable_patterns)]
741fn segoe_name(role: IconRole) -> Option<&'static str> {
742 Some(match role {
743 IconRole::DialogWarning => "SIID_WARNING",
745 IconRole::DialogError => "SIID_ERROR",
746 IconRole::DialogInfo => "SIID_INFO",
747 IconRole::DialogQuestion => "IDI_QUESTION",
748 IconRole::DialogSuccess => "CheckMark",
749 IconRole::Shield => "SIID_SHIELD",
750
751 IconRole::WindowClose => "ChromeClose",
753 IconRole::WindowMinimize => "ChromeMinimize",
754 IconRole::WindowMaximize => "ChromeMaximize",
755 IconRole::WindowRestore => "ChromeRestore",
756
757 IconRole::ActionSave => "Save",
759 IconRole::ActionDelete => "SIID_DELETE",
760 IconRole::ActionCopy => "Copy",
761 IconRole::ActionPaste => "Paste",
762 IconRole::ActionCut => "Cut",
763 IconRole::ActionUndo => "Undo",
764 IconRole::ActionRedo => "Redo",
765 IconRole::ActionSearch => "SIID_FIND",
766 IconRole::ActionSettings => "SIID_SETTINGS",
767 IconRole::ActionEdit => "Edit",
768 IconRole::ActionAdd => "Add",
769 IconRole::ActionRemove => "Remove",
770 IconRole::ActionRefresh => "Refresh",
771 IconRole::ActionPrint => "SIID_PRINTER",
772
773 IconRole::NavBack => "Back",
775 IconRole::NavForward => "Forward",
776 IconRole::NavUp => "Up",
777 IconRole::NavDown => "Down",
778 IconRole::NavHome => "Home",
779 IconRole::NavMenu => "GlobalNavigationButton",
780
781 IconRole::FileGeneric => "SIID_DOCNOASSOC",
783 IconRole::FolderClosed => "SIID_FOLDER",
784 IconRole::FolderOpen => "SIID_FOLDEROPEN",
785 IconRole::TrashEmpty => "SIID_RECYCLER",
786 IconRole::TrashFull => "SIID_RECYCLERFULL",
787
788 IconRole::StatusBusy => return None,
791 IconRole::StatusCheck => "CheckMark",
792 IconRole::StatusError => "SIID_ERROR",
793
794 IconRole::UserAccount => "SIID_USERS",
796 IconRole::Notification => "Ringer",
797 IconRole::Help => "SIID_HELP",
798 IconRole::Lock => "SIID_LOCK",
799
800 _ => return None,
801 })
802}
803
804#[allow(unreachable_patterns)]
805fn freedesktop_name(role: IconRole) -> Option<&'static str> {
806 Some(match role {
807 IconRole::DialogWarning => "dialog-warning",
809 IconRole::DialogError => "dialog-error",
810 IconRole::DialogInfo => "dialog-information",
811 IconRole::DialogQuestion => "dialog-question",
812 IconRole::DialogSuccess => "emblem-ok-symbolic",
813 IconRole::Shield => "security-high",
814
815 IconRole::WindowClose => "window-close",
817 IconRole::WindowMinimize => "window-minimize",
818 IconRole::WindowMaximize => "window-maximize",
819 IconRole::WindowRestore => "window-restore",
820
821 IconRole::ActionSave => "document-save",
823 IconRole::ActionDelete => "edit-delete",
824 IconRole::ActionCopy => "edit-copy",
825 IconRole::ActionPaste => "edit-paste",
826 IconRole::ActionCut => "edit-cut",
827 IconRole::ActionUndo => "edit-undo",
828 IconRole::ActionRedo => "edit-redo",
829 IconRole::ActionSearch => "edit-find",
830 IconRole::ActionSettings => "preferences-system",
831 IconRole::ActionEdit => "document-edit",
832 IconRole::ActionAdd => "list-add",
833 IconRole::ActionRemove => "list-remove",
834 IconRole::ActionRefresh => "view-refresh",
835 IconRole::ActionPrint => "document-print",
836
837 IconRole::NavBack => "go-previous",
839 IconRole::NavForward => "go-next",
840 IconRole::NavUp => "go-up",
841 IconRole::NavDown => "go-down",
842 IconRole::NavHome => "go-home",
843 IconRole::NavMenu => "open-menu",
844
845 IconRole::FileGeneric => "text-x-generic",
847 IconRole::FolderClosed => "folder",
848 IconRole::FolderOpen => "folder-open",
849 IconRole::TrashEmpty => "user-trash",
850 IconRole::TrashFull => "user-trash-full",
851
852 IconRole::StatusBusy => "process-working",
854 IconRole::StatusCheck => "emblem-default",
855 IconRole::StatusError => "dialog-error",
856
857 IconRole::UserAccount => "system-users",
859 IconRole::Notification => "notification-active",
861 IconRole::Help => "help-browser",
862 IconRole::Lock => "system-lock-screen",
863
864 _ => return None,
865 })
866}
867
868#[allow(unreachable_patterns)]
869fn material_name(role: IconRole) -> Option<&'static str> {
870 Some(match role {
871 IconRole::DialogWarning => "warning",
873 IconRole::DialogError => "error",
874 IconRole::DialogInfo => "info",
875 IconRole::DialogQuestion => "help",
876 IconRole::DialogSuccess => "check_circle",
877 IconRole::Shield => "shield",
878
879 IconRole::WindowClose => "close",
881 IconRole::WindowMinimize => "minimize",
882 IconRole::WindowMaximize => "open_in_full",
883 IconRole::WindowRestore => "close_fullscreen",
884
885 IconRole::ActionSave => "save",
887 IconRole::ActionDelete => "delete",
888 IconRole::ActionCopy => "content_copy",
889 IconRole::ActionPaste => "content_paste",
890 IconRole::ActionCut => "content_cut",
891 IconRole::ActionUndo => "undo",
892 IconRole::ActionRedo => "redo",
893 IconRole::ActionSearch => "search",
894 IconRole::ActionSettings => "settings",
895 IconRole::ActionEdit => "edit",
896 IconRole::ActionAdd => "add",
897 IconRole::ActionRemove => "remove",
898 IconRole::ActionRefresh => "refresh",
899 IconRole::ActionPrint => "print",
900
901 IconRole::NavBack => "arrow_back",
903 IconRole::NavForward => "arrow_forward",
904 IconRole::NavUp => "arrow_upward",
905 IconRole::NavDown => "arrow_downward",
906 IconRole::NavHome => "home",
907 IconRole::NavMenu => "menu",
908
909 IconRole::FileGeneric => "description",
911 IconRole::FolderClosed => "folder",
912 IconRole::FolderOpen => "folder_open",
913 IconRole::TrashEmpty => "delete",
914 IconRole::TrashFull => "delete",
916
917 IconRole::StatusBusy => "progress_activity",
919 IconRole::StatusCheck => "check",
920 IconRole::StatusError => "error",
921
922 IconRole::UserAccount => "person",
924 IconRole::Notification => "notifications",
925 IconRole::Help => "help",
926 IconRole::Lock => "lock",
927
928 _ => return None,
929 })
930}
931
932#[allow(unreachable_patterns)]
933fn lucide_name(role: IconRole) -> Option<&'static str> {
934 Some(match role {
935 IconRole::DialogWarning => "triangle-alert",
937 IconRole::DialogError => "circle-x",
938 IconRole::DialogInfo => "info",
939 IconRole::DialogQuestion => "circle-question-mark",
940 IconRole::DialogSuccess => "circle-check",
941 IconRole::Shield => "shield",
942
943 IconRole::WindowClose => "x",
945 IconRole::WindowMinimize => "minimize",
946 IconRole::WindowMaximize => "maximize",
947 IconRole::WindowRestore => "minimize-2",
948
949 IconRole::ActionSave => "save",
951 IconRole::ActionDelete => "trash-2",
952 IconRole::ActionCopy => "copy",
953 IconRole::ActionPaste => "clipboard-paste",
954 IconRole::ActionCut => "scissors",
955 IconRole::ActionUndo => "undo-2",
956 IconRole::ActionRedo => "redo-2",
957 IconRole::ActionSearch => "search",
958 IconRole::ActionSettings => "settings",
959 IconRole::ActionEdit => "pencil",
960 IconRole::ActionAdd => "plus",
961 IconRole::ActionRemove => "minus",
962 IconRole::ActionRefresh => "refresh-cw",
963 IconRole::ActionPrint => "printer",
964
965 IconRole::NavBack => "chevron-left",
967 IconRole::NavForward => "chevron-right",
968 IconRole::NavUp => "chevron-up",
969 IconRole::NavDown => "chevron-down",
970 IconRole::NavHome => "house",
971 IconRole::NavMenu => "menu",
972
973 IconRole::FileGeneric => "file",
975 IconRole::FolderClosed => "folder-closed",
976 IconRole::FolderOpen => "folder-open",
977 IconRole::TrashEmpty => "trash-2",
978 IconRole::TrashFull => "trash-2",
980
981 IconRole::StatusBusy => "loader",
983 IconRole::StatusCheck => "check",
984 IconRole::StatusError => "circle-x",
985
986 IconRole::UserAccount => "user",
988 IconRole::Notification => "bell",
989 IconRole::Help => "circle-question-mark",
990 IconRole::Lock => "lock",
991
992 _ => return None,
993 })
994}
995
996#[cfg(test)]
997#[allow(clippy::unwrap_used, clippy::expect_used)]
998mod tests {
999 use super::*;
1000
1001 #[test]
1004 fn icon_role_all_has_42_variants() {
1005 assert_eq!(IconRole::ALL.len(), 42);
1006 }
1007
1008 #[test]
1009 fn icon_role_all_contains_every_variant() {
1010 use std::collections::HashSet;
1014 let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1015 let check = |role: IconRole| {
1016 assert!(
1017 all_set.contains(&role),
1018 "IconRole::{role:?} missing from ALL array"
1019 );
1020 };
1021
1022 #[deny(unreachable_patterns)]
1024 match IconRole::DialogWarning {
1025 IconRole::DialogWarning
1026 | IconRole::DialogError
1027 | IconRole::DialogInfo
1028 | IconRole::DialogQuestion
1029 | IconRole::DialogSuccess
1030 | IconRole::Shield
1031 | IconRole::WindowClose
1032 | IconRole::WindowMinimize
1033 | IconRole::WindowMaximize
1034 | IconRole::WindowRestore
1035 | IconRole::ActionSave
1036 | IconRole::ActionDelete
1037 | IconRole::ActionCopy
1038 | IconRole::ActionPaste
1039 | IconRole::ActionCut
1040 | IconRole::ActionUndo
1041 | IconRole::ActionRedo
1042 | IconRole::ActionSearch
1043 | IconRole::ActionSettings
1044 | IconRole::ActionEdit
1045 | IconRole::ActionAdd
1046 | IconRole::ActionRemove
1047 | IconRole::ActionRefresh
1048 | IconRole::ActionPrint
1049 | IconRole::NavBack
1050 | IconRole::NavForward
1051 | IconRole::NavUp
1052 | IconRole::NavDown
1053 | IconRole::NavHome
1054 | IconRole::NavMenu
1055 | IconRole::FileGeneric
1056 | IconRole::FolderClosed
1057 | IconRole::FolderOpen
1058 | IconRole::TrashEmpty
1059 | IconRole::TrashFull
1060 | IconRole::StatusBusy
1061 | IconRole::StatusCheck
1062 | IconRole::StatusError
1063 | IconRole::UserAccount
1064 | IconRole::Notification
1065 | IconRole::Help
1066 | IconRole::Lock => {}
1067 }
1068
1069 check(IconRole::DialogWarning);
1071 check(IconRole::DialogError);
1072 check(IconRole::DialogInfo);
1073 check(IconRole::DialogQuestion);
1074 check(IconRole::DialogSuccess);
1075 check(IconRole::Shield);
1076 check(IconRole::WindowClose);
1077 check(IconRole::WindowMinimize);
1078 check(IconRole::WindowMaximize);
1079 check(IconRole::WindowRestore);
1080 check(IconRole::ActionSave);
1081 check(IconRole::ActionDelete);
1082 check(IconRole::ActionCopy);
1083 check(IconRole::ActionPaste);
1084 check(IconRole::ActionCut);
1085 check(IconRole::ActionUndo);
1086 check(IconRole::ActionRedo);
1087 check(IconRole::ActionSearch);
1088 check(IconRole::ActionSettings);
1089 check(IconRole::ActionEdit);
1090 check(IconRole::ActionAdd);
1091 check(IconRole::ActionRemove);
1092 check(IconRole::ActionRefresh);
1093 check(IconRole::ActionPrint);
1094 check(IconRole::NavBack);
1095 check(IconRole::NavForward);
1096 check(IconRole::NavUp);
1097 check(IconRole::NavDown);
1098 check(IconRole::NavHome);
1099 check(IconRole::NavMenu);
1100 check(IconRole::FileGeneric);
1101 check(IconRole::FolderClosed);
1102 check(IconRole::FolderOpen);
1103 check(IconRole::TrashEmpty);
1104 check(IconRole::TrashFull);
1105 check(IconRole::StatusBusy);
1106 check(IconRole::StatusCheck);
1107 check(IconRole::StatusError);
1108 check(IconRole::UserAccount);
1109 check(IconRole::Notification);
1110 check(IconRole::Help);
1111 check(IconRole::Lock);
1112 }
1113
1114 #[test]
1115 fn icon_role_all_no_duplicates() {
1116 let all = &IconRole::ALL;
1117 for (i, role) in all.iter().enumerate() {
1118 for (j, other) in all.iter().enumerate() {
1119 if i != j {
1120 assert_ne!(role, other, "Duplicate at index {i} and {j}");
1121 }
1122 }
1123 }
1124 }
1125
1126 #[test]
1127 fn icon_role_derives_copy_clone() {
1128 let role = IconRole::ActionCopy;
1129 let copied1 = role;
1130 let copied2 = role;
1131 assert_eq!(role, copied1);
1132 assert_eq!(role, copied2);
1133 }
1134
1135 #[test]
1136 fn icon_role_derives_debug() {
1137 let s = format!("{:?}", IconRole::DialogWarning);
1138 assert!(s.contains("DialogWarning"));
1139 }
1140
1141 #[test]
1142 fn icon_role_derives_hash() {
1143 use std::collections::HashSet;
1144 let mut set = HashSet::new();
1145 set.insert(IconRole::ActionSave);
1146 set.insert(IconRole::ActionDelete);
1147 assert_eq!(set.len(), 2);
1148 assert!(set.contains(&IconRole::ActionSave));
1149 }
1150
1151 #[test]
1154 fn icon_data_svg_construct_and_match() {
1155 let svg_bytes = b"<svg></svg>".to_vec();
1156 let data = IconData::Svg(svg_bytes.clone());
1157 match data {
1158 IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1159 _ => panic!("Expected Svg variant"),
1160 }
1161 }
1162
1163 #[test]
1164 fn icon_data_rgba_construct_and_match() {
1165 let pixels = vec![255, 0, 0, 255]; let data = IconData::Rgba {
1167 width: 1,
1168 height: 1,
1169 data: pixels.clone(),
1170 };
1171 match data {
1172 IconData::Rgba {
1173 width,
1174 height,
1175 data,
1176 } => {
1177 assert_eq!(width, 1);
1178 assert_eq!(height, 1);
1179 assert_eq!(data, pixels);
1180 }
1181 _ => panic!("Expected Rgba variant"),
1182 }
1183 }
1184
1185 #[test]
1186 fn icon_data_derives_debug() {
1187 let data = IconData::Svg(vec![]);
1188 let s = format!("{:?}", data);
1189 assert!(s.contains("Svg"));
1190 }
1191
1192 #[test]
1193 fn icon_data_derives_clone() {
1194 let data = IconData::Rgba {
1195 width: 16,
1196 height: 16,
1197 data: vec![0; 16 * 16 * 4],
1198 };
1199 let cloned = data.clone();
1200 assert_eq!(data, cloned);
1201 }
1202
1203 #[test]
1204 fn icon_data_derives_eq() {
1205 let a = IconData::Svg(b"<svg/>".to_vec());
1206 let b = IconData::Svg(b"<svg/>".to_vec());
1207 assert_eq!(a, b);
1208
1209 let c = IconData::Svg(b"<other/>".to_vec());
1210 assert_ne!(a, c);
1211 }
1212
1213 #[test]
1216 fn icon_set_from_name_sf_symbols() {
1217 assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1218 }
1219
1220 #[test]
1221 fn icon_set_from_name_segoe_fluent() {
1222 assert_eq!(
1223 IconSet::from_name("segoe-fluent"),
1224 Some(IconSet::SegoeIcons)
1225 );
1226 }
1227
1228 #[test]
1229 fn icon_set_from_name_freedesktop() {
1230 assert_eq!(
1231 IconSet::from_name("freedesktop"),
1232 Some(IconSet::Freedesktop)
1233 );
1234 }
1235
1236 #[test]
1237 fn icon_set_from_name_material() {
1238 assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1239 }
1240
1241 #[test]
1242 fn icon_set_from_name_lucide() {
1243 assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1244 }
1245
1246 #[test]
1247 fn icon_set_from_name_unknown() {
1248 assert_eq!(IconSet::from_name("unknown"), None);
1249 }
1250
1251 #[test]
1252 fn icon_set_name_sf_symbols() {
1253 assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1254 }
1255
1256 #[test]
1257 fn icon_set_name_segoe_fluent() {
1258 assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1259 }
1260
1261 #[test]
1262 fn icon_set_name_freedesktop() {
1263 assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1264 }
1265
1266 #[test]
1267 fn icon_set_name_material() {
1268 assert_eq!(IconSet::Material.name(), "material");
1269 }
1270
1271 #[test]
1272 fn icon_set_name_lucide() {
1273 assert_eq!(IconSet::Lucide.name(), "lucide");
1274 }
1275
1276 #[test]
1277 fn icon_set_from_name_name_round_trip() {
1278 let sets = [
1279 IconSet::SfSymbols,
1280 IconSet::SegoeIcons,
1281 IconSet::Freedesktop,
1282 IconSet::Material,
1283 IconSet::Lucide,
1284 ];
1285 for set in &sets {
1286 let name = set.name();
1287 let parsed = IconSet::from_name(name);
1288 assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1289 }
1290 }
1291
1292 #[test]
1293 fn icon_set_derives_copy_clone() {
1294 let set = IconSet::Material;
1295 let copied1 = set;
1296 let copied2 = set;
1297 assert_eq!(set, copied1);
1298 assert_eq!(set, copied2);
1299 }
1300
1301 #[test]
1302 fn icon_set_derives_hash() {
1303 use std::collections::HashSet;
1304 let mut map = HashSet::new();
1305 map.insert(IconSet::SfSymbols);
1306 map.insert(IconSet::Lucide);
1307 assert_eq!(map.len(), 2);
1308 }
1309
1310 #[test]
1311 fn icon_set_derives_debug() {
1312 let s = format!("{:?}", IconSet::Freedesktop);
1313 assert!(s.contains("Freedesktop"));
1314 }
1315
1316 #[test]
1317 fn icon_set_serde_round_trip() {
1318 let set = IconSet::SfSymbols;
1319 let json = serde_json::to_string(&set).unwrap();
1320 let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1321 assert_eq!(set, deserialized);
1322 }
1323
1324 #[test]
1327 fn icon_name_sf_symbols_action_copy() {
1328 assert_eq!(
1329 icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1330 Some("doc.on.doc")
1331 );
1332 }
1333
1334 #[test]
1335 fn icon_name_segoe_action_copy() {
1336 assert_eq!(
1337 icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1338 Some("Copy")
1339 );
1340 }
1341
1342 #[test]
1343 fn icon_name_freedesktop_action_copy() {
1344 assert_eq!(
1345 icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1346 Some("edit-copy")
1347 );
1348 }
1349
1350 #[test]
1351 fn icon_name_material_action_copy() {
1352 assert_eq!(
1353 icon_name(IconRole::ActionCopy, IconSet::Material),
1354 Some("content_copy")
1355 );
1356 }
1357
1358 #[test]
1359 fn icon_name_lucide_action_copy() {
1360 assert_eq!(
1361 icon_name(IconRole::ActionCopy, IconSet::Lucide),
1362 Some("copy")
1363 );
1364 }
1365
1366 #[test]
1367 fn icon_name_sf_symbols_dialog_warning() {
1368 assert_eq!(
1369 icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1370 Some("exclamationmark.triangle.fill")
1371 );
1372 }
1373
1374 #[test]
1376 fn icon_name_sf_symbols_folder_open_is_none() {
1377 assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1378 }
1379
1380 #[test]
1381 fn icon_name_sf_symbols_trash_full() {
1382 assert_eq!(
1383 icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1384 Some("trash.fill")
1385 );
1386 }
1387
1388 #[test]
1389 fn icon_name_sf_symbols_status_busy_is_none() {
1390 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1391 }
1392
1393 #[test]
1394 fn icon_name_sf_symbols_window_restore() {
1395 assert_eq!(
1396 icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1397 Some("arrow.down.right.and.arrow.up.left")
1398 );
1399 }
1400
1401 #[test]
1402 fn icon_name_segoe_dialog_success() {
1403 assert_eq!(
1404 icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1405 Some("CheckMark")
1406 );
1407 }
1408
1409 #[test]
1410 fn icon_name_segoe_status_busy_is_none() {
1411 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1412 }
1413
1414 #[test]
1415 fn icon_name_freedesktop_notification() {
1416 assert_eq!(
1417 icon_name(IconRole::Notification, IconSet::Freedesktop),
1418 Some("notification-active")
1419 );
1420 }
1421
1422 #[test]
1423 fn icon_name_material_trash_full() {
1424 assert_eq!(
1425 icon_name(IconRole::TrashFull, IconSet::Material),
1426 Some("delete")
1427 );
1428 }
1429
1430 #[test]
1431 fn icon_name_lucide_trash_full() {
1432 assert_eq!(
1433 icon_name(IconRole::TrashFull, IconSet::Lucide),
1434 Some("trash-2")
1435 );
1436 }
1437
1438 #[test]
1440 fn icon_name_spot_check_dialog_error() {
1441 assert_eq!(
1442 icon_name(IconRole::DialogError, IconSet::SfSymbols),
1443 Some("xmark.circle.fill")
1444 );
1445 assert_eq!(
1446 icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1447 Some("SIID_ERROR")
1448 );
1449 assert_eq!(
1450 icon_name(IconRole::DialogError, IconSet::Freedesktop),
1451 Some("dialog-error")
1452 );
1453 assert_eq!(
1454 icon_name(IconRole::DialogError, IconSet::Material),
1455 Some("error")
1456 );
1457 assert_eq!(
1458 icon_name(IconRole::DialogError, IconSet::Lucide),
1459 Some("circle-x")
1460 );
1461 }
1462
1463 #[test]
1464 fn icon_name_spot_check_nav_home() {
1465 assert_eq!(
1466 icon_name(IconRole::NavHome, IconSet::SfSymbols),
1467 Some("house")
1468 );
1469 assert_eq!(
1470 icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1471 Some("Home")
1472 );
1473 assert_eq!(
1474 icon_name(IconRole::NavHome, IconSet::Freedesktop),
1475 Some("go-home")
1476 );
1477 assert_eq!(
1478 icon_name(IconRole::NavHome, IconSet::Material),
1479 Some("home")
1480 );
1481 assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1482 }
1483
1484 #[test]
1486 fn icon_name_sf_symbols_expected_count() {
1487 let some_count = IconRole::ALL
1489 .iter()
1490 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1491 .count();
1492 assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1493 }
1494
1495 #[test]
1496 fn icon_name_segoe_expected_count() {
1497 let some_count = IconRole::ALL
1499 .iter()
1500 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1501 .count();
1502 assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1503 }
1504
1505 #[test]
1506 fn icon_name_freedesktop_expected_count() {
1507 let some_count = IconRole::ALL
1509 .iter()
1510 .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1511 .count();
1512 assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1513 }
1514
1515 #[test]
1516 fn icon_name_material_expected_count() {
1517 let some_count = IconRole::ALL
1519 .iter()
1520 .filter(|r| icon_name(**r, IconSet::Material).is_some())
1521 .count();
1522 assert_eq!(some_count, 42, "Material should have 42 mappings");
1523 }
1524
1525 #[test]
1526 fn icon_name_lucide_expected_count() {
1527 let some_count = IconRole::ALL
1529 .iter()
1530 .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1531 .count();
1532 assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1533 }
1534
1535 #[test]
1538 #[cfg(target_os = "linux")]
1539 fn system_icon_set_returns_freedesktop_on_linux() {
1540 assert_eq!(system_icon_set(), IconSet::Freedesktop);
1541 }
1542
1543 #[test]
1544 fn system_icon_theme_returns_non_empty() {
1545 let theme = system_icon_theme();
1546 assert!(
1547 !theme.is_empty(),
1548 "system_icon_theme() should return a non-empty string"
1549 );
1550 }
1551
1552 #[test]
1555 fn icon_provider_is_object_safe() {
1556 let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1558 let debug_str = format!("{:?}", provider);
1559 assert!(
1560 debug_str.contains("ActionCopy"),
1561 "Debug should print variant name"
1562 );
1563 }
1564
1565 #[test]
1566 fn icon_role_provider_icon_name() {
1567 let role = IconRole::ActionCopy;
1569 let name = IconProvider::icon_name(&role, IconSet::Material);
1570 assert_eq!(name, Some("content_copy"));
1571 }
1572
1573 #[test]
1574 fn icon_role_provider_icon_name_sf_symbols() {
1575 let role = IconRole::ActionCopy;
1576 let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1577 assert_eq!(name, Some("doc.on.doc"));
1578 }
1579
1580 #[test]
1581 #[cfg(feature = "material-icons")]
1582 fn icon_role_provider_icon_svg_material() {
1583 let role = IconRole::ActionCopy;
1584 let svg = IconProvider::icon_svg(&role, IconSet::Material);
1585 assert!(svg.is_some(), "Material SVG should be Some");
1586 let content = std::str::from_utf8(svg.unwrap()).expect("valid UTF-8");
1587 assert!(content.contains("<svg"), "should contain <svg tag");
1588 }
1589
1590 #[test]
1591 fn icon_role_provider_icon_svg_non_bundled() {
1592 let role = IconRole::ActionCopy;
1594 let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1595 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1596 }
1597
1598 #[test]
1599 fn icon_role_provider_all_roles() {
1600 for role in IconRole::ALL {
1602 let _name = IconProvider::icon_name(&role, IconSet::Material);
1604 }
1606 }
1607
1608 #[test]
1609 fn icon_provider_dyn_dispatch() {
1610 let role = IconRole::ActionCopy;
1612 let provider: &dyn IconProvider = &role;
1613 let name = provider.icon_name(IconSet::Material);
1614 assert_eq!(name, Some("content_copy"));
1615 let svg = provider.icon_svg(IconSet::SfSymbols);
1616 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1617 }
1618
1619 fn known_gaps() -> &'static [(IconSet, IconRole)] {
1622 &[
1623 (IconSet::SfSymbols, IconRole::FolderOpen),
1624 (IconSet::SfSymbols, IconRole::StatusBusy),
1625 (IconSet::SegoeIcons, IconRole::StatusBusy),
1626 ]
1627 }
1628
1629 #[test]
1630 fn no_unexpected_icon_gaps() {
1631 let gaps = known_gaps();
1632 let system_sets = [
1633 IconSet::SfSymbols,
1634 IconSet::SegoeIcons,
1635 IconSet::Freedesktop,
1636 ];
1637 for &set in &system_sets {
1638 for role in IconRole::ALL {
1639 let is_known_gap = gaps.contains(&(set, role));
1640 let is_mapped = icon_name(role, set).is_some();
1641 if !is_known_gap {
1642 assert!(
1643 is_mapped,
1644 "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1645 );
1646 }
1647 }
1648 }
1649 }
1650
1651 #[test]
1652 #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1653 fn all_roles_have_bundled_svg() {
1654 use crate::bundled_icon_svg;
1655 for set in [IconSet::Material, IconSet::Lucide] {
1656 for role in IconRole::ALL {
1657 assert!(
1658 bundled_icon_svg(role, set).is_some(),
1659 "{role:?} has no bundled SVG for {set:?}"
1660 );
1661 }
1662 }
1663 }
1664}