1use std::borrow::Cow;
6
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum IconRole {
43 DialogWarning,
46 DialogError,
48 DialogInfo,
50 DialogQuestion,
52 DialogSuccess,
54 Shield,
56
57 WindowClose,
60 WindowMinimize,
62 WindowMaximize,
64 WindowRestore,
66
67 ActionSave,
70 ActionDelete,
72 ActionCopy,
74 ActionPaste,
76 ActionCut,
78 ActionUndo,
80 ActionRedo,
82 ActionSearch,
84 ActionSettings,
86 ActionEdit,
88 ActionAdd,
90 ActionRemove,
92 ActionRefresh,
94 ActionPrint,
96
97 NavBack,
100 NavForward,
102 NavUp,
104 NavDown,
106 NavHome,
108 NavMenu,
110
111 FileGeneric,
114 FolderClosed,
116 FolderOpen,
118 TrashEmpty,
120 TrashFull,
122
123 StatusBusy,
126 StatusCheck,
128 StatusError,
130
131 UserAccount,
134 Notification,
136 Help,
138 Lock,
140}
141
142impl std::fmt::Display for IconRole {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 f.write_str(self.name())
145 }
146}
147
148impl IconRole {
149 #[must_use]
164 pub fn name(&self) -> &'static str {
165 match self {
166 Self::DialogWarning => "dialog-warning",
168 Self::DialogError => "dialog-error",
169 Self::DialogInfo => "dialog-info",
170 Self::DialogQuestion => "dialog-question",
171 Self::DialogSuccess => "dialog-success",
172 Self::Shield => "shield",
173 Self::WindowClose => "window-close",
175 Self::WindowMinimize => "window-minimize",
176 Self::WindowMaximize => "window-maximize",
177 Self::WindowRestore => "window-restore",
178 Self::ActionSave => "action-save",
180 Self::ActionDelete => "action-delete",
181 Self::ActionCopy => "action-copy",
182 Self::ActionPaste => "action-paste",
183 Self::ActionCut => "action-cut",
184 Self::ActionUndo => "action-undo",
185 Self::ActionRedo => "action-redo",
186 Self::ActionSearch => "action-search",
187 Self::ActionSettings => "action-settings",
188 Self::ActionEdit => "action-edit",
189 Self::ActionAdd => "action-add",
190 Self::ActionRemove => "action-remove",
191 Self::ActionRefresh => "action-refresh",
192 Self::ActionPrint => "action-print",
193 Self::NavBack => "nav-back",
195 Self::NavForward => "nav-forward",
196 Self::NavUp => "nav-up",
197 Self::NavDown => "nav-down",
198 Self::NavHome => "nav-home",
199 Self::NavMenu => "nav-menu",
200 Self::FileGeneric => "file-generic",
202 Self::FolderClosed => "folder-closed",
203 Self::FolderOpen => "folder-open",
204 Self::TrashEmpty => "trash-empty",
205 Self::TrashFull => "trash-full",
206 Self::StatusBusy => "status-busy",
208 Self::StatusCheck => "status-check",
209 Self::StatusError => "status-error",
210 Self::UserAccount => "user-account",
212 Self::Notification => "notification",
213 Self::Help => "help",
214 Self::Lock => "lock",
215 }
216 }
217
218 pub const ALL: [IconRole; 42] = [
222 Self::DialogWarning,
224 Self::DialogError,
225 Self::DialogInfo,
226 Self::DialogQuestion,
227 Self::DialogSuccess,
228 Self::Shield,
229 Self::WindowClose,
231 Self::WindowMinimize,
232 Self::WindowMaximize,
233 Self::WindowRestore,
234 Self::ActionSave,
236 Self::ActionDelete,
237 Self::ActionCopy,
238 Self::ActionPaste,
239 Self::ActionCut,
240 Self::ActionUndo,
241 Self::ActionRedo,
242 Self::ActionSearch,
243 Self::ActionSettings,
244 Self::ActionEdit,
245 Self::ActionAdd,
246 Self::ActionRemove,
247 Self::ActionRefresh,
248 Self::ActionPrint,
249 Self::NavBack,
251 Self::NavForward,
252 Self::NavUp,
253 Self::NavDown,
254 Self::NavHome,
255 Self::NavMenu,
256 Self::FileGeneric,
258 Self::FolderClosed,
259 Self::FolderOpen,
260 Self::TrashEmpty,
261 Self::TrashFull,
262 Self::StatusBusy,
264 Self::StatusCheck,
265 Self::StatusError,
266 Self::UserAccount,
268 Self::Notification,
269 Self::Help,
270 Self::Lock,
271 ];
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[non_exhaustive]
293#[must_use]
294pub enum IconData {
295 Svg(Cow<'static, [u8]>),
301
302 Rgba {
304 width: u32,
306 height: u32,
308 data: Vec<u8>,
310 },
311}
312
313impl IconData {
314 #[must_use]
319 pub fn bytes(&self) -> &[u8] {
320 match self {
321 IconData::Svg(cow) => cow,
322 IconData::Rgba { data, .. } => data,
323 }
324 }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
350#[serde(rename_all = "kebab-case")]
351#[non_exhaustive]
352pub enum IconSet {
353 SfSymbols,
355 #[serde(rename = "segoe-fluent")]
357 SegoeIcons,
358 Freedesktop,
360 Material,
362 Lucide,
364}
365
366impl std::fmt::Display for IconSet {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 f.write_str(self.name())
369 }
370}
371
372impl IconSet {
373 #[must_use]
380 pub fn from_name(name: &str) -> Option<Self> {
381 match name {
382 "sf-symbols" => Some(Self::SfSymbols),
383 "segoe-fluent" => Some(Self::SegoeIcons),
384 "freedesktop" => Some(Self::Freedesktop),
385 "material" => Some(Self::Material),
386 "lucide" => Some(Self::Lucide),
387 _ => None,
388 }
389 }
390
391 #[must_use]
393 pub fn name(&self) -> &'static str {
394 match self {
395 Self::SfSymbols => "sf-symbols",
396 Self::SegoeIcons => "segoe-fluent",
397 Self::Freedesktop => "freedesktop",
398 Self::Material => "material",
399 Self::Lucide => "lucide",
400 }
401 }
402}
403
404pub trait IconProvider: std::fmt::Debug {
447 fn icon_name(&self, set: IconSet) -> Option<&str>;
449
450 fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>>;
455}
456
457impl IconProvider for IconRole {
458 fn icon_name(&self, set: IconSet) -> Option<&str> {
459 icon_name(*self, set)
460 }
461
462 fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
463 crate::model::bundled::bundled_icon_svg(*self, set).map(Cow::Borrowed)
464 }
465}
466
467#[must_use]
483#[allow(unreachable_patterns)] pub fn icon_name(role: IconRole, set: IconSet) -> Option<&'static str> {
485 match set {
486 IconSet::SfSymbols => sf_symbols_name(role),
487 IconSet::SegoeIcons => segoe_name(role),
488 IconSet::Freedesktop => freedesktop_name(role),
489 IconSet::Material => material_name(role),
490 IconSet::Lucide => lucide_name(role),
491 _ => None,
492 }
493}
494
495#[must_use]
512pub fn system_icon_set() -> IconSet {
513 if cfg!(any(target_os = "macos", target_os = "ios")) {
514 IconSet::SfSymbols
515 } else if cfg!(target_os = "windows") {
516 IconSet::SegoeIcons
517 } else if cfg!(target_os = "linux") {
518 IconSet::Freedesktop
519 } else {
520 IconSet::Material
521 }
522}
523
524pub fn system_icon_theme() -> crate::Result<String> {
573 crate::detect::system().icon_theme()
574}
575
576pub fn detect_icon_theme() -> crate::Result<String> {
589 detect_icon_theme_outcome().map_err(|failure| failure.to_error())
590}
591
592#[allow(unreachable_code)]
594pub(crate) fn detect_icon_theme_outcome() -> Result<String, IconThemeFailure> {
595 #[cfg(any(target_os = "macos", target_os = "ios"))]
596 {
597 return Ok("sf-symbols".to_string());
598 }
599
600 #[cfg(target_os = "windows")]
601 {
602 return Ok("segoe-fluent".to_string());
603 }
604
605 #[cfg(target_os = "linux")]
606 {
607 detect_linux_icon_theme()
608 }
609
610 #[cfg(not(any(
611 target_os = "linux",
612 target_os = "windows",
613 target_os = "macos",
614 target_os = "ios"
615 )))]
616 {
617 Err(IconThemeFailure::Unsupported(std::env::consts::OS))
618 }
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
624#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
625pub(crate) struct SourceFailure {
626 source: String,
627 reason: String,
628}
629
630impl std::fmt::Display for SourceFailure {
631 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
632 write!(f, "{}: {}", self.source, self.reason)
633 }
634}
635
636#[derive(Debug, Clone, PartialEq, Eq)]
641#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
642pub(crate) struct IconThemeUndetected(Vec<SourceFailure>);
643
644impl std::fmt::Display for IconThemeUndetected {
645 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
646 let mut sources = self.0.iter();
647 if let Some(first) = sources.next() {
648 write!(f, "{first}")?;
649 }
650 for failure in sources {
651 write!(f, "; {failure}")?;
652 }
653 Ok(())
654 }
655}
656
657impl std::error::Error for IconThemeUndetected {}
658
659#[derive(Debug, Clone, PartialEq, Eq)]
663pub(crate) enum IconThemeFailure {
664 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
666 Undetected(IconThemeUndetected),
667 #[cfg_attr(
669 any(
670 target_os = "linux",
671 target_os = "windows",
672 target_os = "macos",
673 target_os = "ios"
674 ),
675 allow(dead_code)
676 )]
677 Unsupported(&'static str),
678}
679
680impl IconThemeFailure {
681 pub(crate) fn to_error(&self) -> crate::Error {
683 match self {
684 Self::Undetected(undetected) => crate::Error::ReaderFailed {
685 reader: "icon-theme",
686 source: Box::new(undetected.clone()),
687 },
688 Self::Unsupported(platform) => crate::Error::PlatformUnsupported { platform },
689 }
690 }
691
692 #[cfg(test)]
695 pub(crate) fn for_tests(reason: &str) -> Self {
696 Self::Undetected(IconThemeUndetected(vec![SourceFailure {
697 source: "test".to_string(),
698 reason: reason.to_string(),
699 }]))
700 }
701}
702
703#[cfg(target_os = "linux")]
706const GNOME_INTERFACE_SCHEMA: &str = "org.gnome.desktop.interface";
707
708#[cfg(target_os = "linux")]
710fn detect_linux_icon_theme() -> Result<String, IconThemeFailure> {
711 use crate::detect::LinuxDesktop;
712
713 let found = match crate::detect::detect_linux_desktop() {
714 LinuxDesktop::Kde => detect_kde_icon_theme(),
715 LinuxDesktop::Gnome
716 | LinuxDesktop::Budgie
717 | LinuxDesktop::Hyprland
718 | LinuxDesktop::Sway
719 | LinuxDesktop::River
720 | LinuxDesktop::Niri
721 | LinuxDesktop::Wayfire
722 | LinuxDesktop::CosmicDe => gsettings_icon_theme(GNOME_INTERFACE_SCHEMA),
723 LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
724 LinuxDesktop::Xfce => detect_xfce_icon_theme(),
725 LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
726 LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
727 LinuxDesktop::Unknown => {
728 return unknown_desktop_icon_theme(detect_kde_icon_theme, || {
729 gsettings_icon_theme(GNOME_INTERFACE_SCHEMA)
730 });
731 }
732 };
733 found.map_err(|failure| IconThemeFailure::Undetected(IconThemeUndetected(vec![failure])))
734}
735
736#[cfg(target_os = "linux")]
739fn unknown_desktop_icon_theme(
740 kde: impl FnOnce() -> Result<String, SourceFailure>,
741 gnome: impl FnOnce() -> Result<String, SourceFailure>,
742) -> Result<String, IconThemeFailure> {
743 let kde_failure = match kde() {
744 Ok(theme) => return Ok(theme),
745 Err(failure) => failure,
746 };
747 let gnome_failure = match gnome() {
748 Ok(theme) => return Ok(theme),
749 Err(failure) => failure,
750 };
751 Err(IconThemeFailure::Undetected(IconThemeUndetected(vec![
752 kde_failure,
753 gnome_failure,
754 ])))
755}
756
757#[cfg(target_os = "linux")]
759fn config_dir_unresolved(source: &str) -> SourceFailure {
760 SourceFailure {
761 source: source.to_string(),
762 reason: "the config directory is unresolved: $XDG_CONFIG_HOME and $HOME are both \
763 unset or empty"
764 .to_string(),
765 }
766}
767
768#[cfg(target_os = "linux")]
770fn detect_kde_icon_theme() -> Result<String, SourceFailure> {
771 kde_icon_theme(xdg_config_dir().as_deref(), |path| {
772 std::fs::read_to_string(path)
773 })
774}
775
776#[cfg(target_os = "linux")]
783fn kde_icon_theme(
784 config_dir: Option<&std::path::Path>,
785 read: impl Fn(&std::path::Path) -> std::io::Result<String>,
786) -> Result<String, SourceFailure> {
787 const SOURCE: &str = "KDE kdeglobals";
788 let config_dir = config_dir.ok_or_else(|| config_dir_unresolved(SOURCE))?;
789 let paths = [
790 config_dir.join("kdeglobals"),
791 config_dir.join("kdedefaults").join("kdeglobals"),
792 ];
793
794 let mut reasons = Vec::new();
795 for path in &paths {
796 match read(path) {
797 Ok(content) => match ini_value(&content, "Icons", "Theme") {
798 Some(theme) => return Ok(theme),
799 None => reasons.push(format!(
800 "{}: `[Icons] Theme` is absent or empty",
801 path.display()
802 )),
803 },
804 Err(e) => reasons.push(format!("{}: unreadable ({e})", path.display())),
805 }
806 }
807 Err(SourceFailure {
808 source: SOURCE.to_string(),
809 reason: reasons.join(", "),
810 })
811}
812
813#[cfg(target_os = "linux")]
815fn gsettings_icon_theme(schema: &str) -> Result<String, SourceFailure> {
816 gsettings_icon_theme_from(
817 schema,
818 std::process::Command::new("gsettings")
819 .args(["get", schema, "icon-theme"])
820 .output(),
821 )
822}
823
824#[cfg(target_os = "linux")]
827fn gsettings_icon_theme_from(
828 schema: &str,
829 output: std::io::Result<std::process::Output>,
830) -> Result<String, SourceFailure> {
831 command_icon_theme(
832 format!("`gsettings get {schema} icon-theme`"),
833 output,
834 |printed| printed.trim().trim_matches('\''),
835 )
836}
837
838#[cfg(target_os = "linux")]
840const XFCONF_ICON_THEME_ARGS: [&str; 4] = ["-c", "xsettings", "-p", "/Net/IconThemeName"];
841
842#[cfg(target_os = "linux")]
844fn detect_xfce_icon_theme() -> Result<String, SourceFailure> {
845 xfce_icon_theme_from(
846 std::process::Command::new("xfconf-query")
847 .args(XFCONF_ICON_THEME_ARGS)
848 .output(),
849 )
850}
851
852#[cfg(target_os = "linux")]
855fn xfce_icon_theme_from(
856 output: std::io::Result<std::process::Output>,
857) -> Result<String, SourceFailure> {
858 command_icon_theme(
859 format!("`xfconf-query {}`", XFCONF_ICON_THEME_ARGS.join(" ")),
860 output,
861 str::trim,
862 )
863}
864
865#[cfg(target_os = "linux")]
868fn command_icon_theme(
869 source: String,
870 output: std::io::Result<std::process::Output>,
871 parse: fn(&str) -> &str,
872) -> Result<String, SourceFailure> {
873 let reason = match output {
874 Err(e) if e.kind() == std::io::ErrorKind::NotFound => "command not found".to_string(),
875 Err(e) => format!("could not run ({e})"),
876 Ok(output) if !output.status.success() => {
877 let stderr = String::from_utf8_lossy(&output.stderr);
878 match stderr.trim() {
879 "" => format!("unsuccessful ({})", output.status),
880 stderr => format!("unsuccessful ({}): {stderr}", output.status),
881 }
882 }
883 Ok(output) => match String::from_utf8(output.stdout) {
884 Err(_) => "printed output that is not UTF-8".to_string(),
885 Ok(printed) => match parse(&printed) {
886 "" => "printed no theme name".to_string(),
887 theme => return Ok(theme.to_string()),
888 },
889 },
890 };
891 Err(SourceFailure { source, reason })
892}
893
894#[cfg(target_os = "linux")]
896fn detect_lxqt_icon_theme() -> Result<String, SourceFailure> {
897 lxqt_icon_theme(xdg_config_dir().as_deref(), |path| {
898 std::fs::read_to_string(path)
899 })
900}
901
902#[cfg(target_os = "linux")]
907fn lxqt_icon_theme(
908 config_dir: Option<&std::path::Path>,
909 read: impl Fn(&std::path::Path) -> std::io::Result<String>,
910) -> Result<String, SourceFailure> {
911 const SOURCE: &str = "LXQt lxqt.conf";
912 let config_dir = config_dir.ok_or_else(|| config_dir_unresolved(SOURCE))?;
913 let path = config_dir.join("lxqt").join("lxqt.conf");
914 let failure = |reason: String| SourceFailure {
915 source: SOURCE.to_string(),
916 reason: format!("{}: {reason}", path.display()),
917 };
918
919 let content = read(&path).map_err(|e| failure(format!("unreadable ({e})")))?;
920 content
921 .lines()
922 .filter_map(|line| line.trim().strip_prefix("icon_theme="))
923 .map(str::trim)
924 .find(|value| !value.is_empty())
925 .map(str::to_string)
926 .ok_or_else(|| failure("`icon_theme` is absent or empty".to_string()))
927}
928
929#[cfg(target_os = "linux")]
934fn xdg_config_dir() -> Option<std::path::PathBuf> {
935 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
936 && !config_home.is_empty()
937 {
938 return Some(std::path::PathBuf::from(config_home));
939 }
940 std::env::var("HOME")
941 .ok()
942 .filter(|h| !h.is_empty())
943 .map(|h| std::path::PathBuf::from(h).join(".config"))
944}
945
946#[cfg(target_os = "linux")]
952fn ini_value(content: &str, section: &str, key: &str) -> Option<String> {
953 let target_section = format!("[{}]", section);
954 let mut in_section = false;
955
956 for line in content.lines() {
957 let trimmed = line.trim();
958 if trimmed.starts_with('[') {
959 in_section = trimmed == target_section;
960 continue;
961 }
962 if in_section && let Some(value) = trimmed.strip_prefix(key) {
963 let value = value.trim_start();
964 if let Some(value) = value.strip_prefix('=') {
965 let value = value.trim();
966 if !value.is_empty() {
967 return Some(value.to_string());
968 }
969 }
970 }
971 }
972 None
973}
974
975#[allow(unreachable_patterns)]
978fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
979 Some(match role {
980 IconRole::DialogWarning => "exclamationmark.triangle.fill",
982 IconRole::DialogError => "xmark.circle.fill",
983 IconRole::DialogInfo => "info.circle.fill",
984 IconRole::DialogQuestion => "questionmark.circle.fill",
985 IconRole::DialogSuccess => "checkmark.circle.fill",
986 IconRole::Shield => "shield.fill",
987
988 IconRole::WindowClose => "xmark",
990 IconRole::WindowMinimize => "minus",
991 IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
992 IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
993
994 IconRole::ActionSave => "square.and.arrow.down",
996 IconRole::ActionDelete => "trash",
997 IconRole::ActionCopy => "doc.on.doc",
998 IconRole::ActionPaste => "doc.on.clipboard",
999 IconRole::ActionCut => "scissors",
1000 IconRole::ActionUndo => "arrow.uturn.backward",
1001 IconRole::ActionRedo => "arrow.uturn.forward",
1002 IconRole::ActionSearch => "magnifyingglass",
1003 IconRole::ActionSettings => "gearshape",
1004 IconRole::ActionEdit => "pencil",
1005 IconRole::ActionAdd => "plus",
1006 IconRole::ActionRemove => "minus",
1007 IconRole::ActionRefresh => "arrow.clockwise",
1008 IconRole::ActionPrint => "printer",
1009
1010 IconRole::NavBack => "chevron.backward",
1012 IconRole::NavForward => "chevron.forward",
1013 IconRole::NavUp => "chevron.up",
1014 IconRole::NavDown => "chevron.down",
1015 IconRole::NavHome => "house",
1016 IconRole::NavMenu => "line.horizontal.3",
1017
1018 IconRole::FileGeneric => "doc",
1020 IconRole::FolderClosed => "folder",
1021 IconRole::FolderOpen => return None,
1023 IconRole::TrashEmpty => "trash",
1024 IconRole::TrashFull => "trash.fill",
1025
1026 IconRole::StatusBusy => return None,
1029 IconRole::StatusCheck => "checkmark",
1030 IconRole::StatusError => "xmark.circle.fill",
1031
1032 IconRole::UserAccount => "person.fill",
1034 IconRole::Notification => "bell.fill",
1035 IconRole::Help => "questionmark.circle",
1036 IconRole::Lock => "lock.fill",
1037
1038 _ => return None,
1039 })
1040}
1041
1042#[allow(unreachable_patterns)]
1043fn segoe_name(role: IconRole) -> Option<&'static str> {
1044 Some(match role {
1045 IconRole::DialogWarning => "SIID_WARNING",
1047 IconRole::DialogError => "SIID_ERROR",
1048 IconRole::DialogInfo => "SIID_INFO",
1049 IconRole::DialogQuestion => "IDI_QUESTION",
1050 IconRole::DialogSuccess => "CheckMark",
1051 IconRole::Shield => "SIID_SHIELD",
1052
1053 IconRole::WindowClose => "ChromeClose",
1055 IconRole::WindowMinimize => "ChromeMinimize",
1056 IconRole::WindowMaximize => "ChromeMaximize",
1057 IconRole::WindowRestore => "ChromeRestore",
1058
1059 IconRole::ActionSave => "Save",
1061 IconRole::ActionDelete => "Delete",
1062 IconRole::ActionCopy => "Copy",
1063 IconRole::ActionPaste => "Paste",
1064 IconRole::ActionCut => "Cut",
1065 IconRole::ActionUndo => "Undo",
1066 IconRole::ActionRedo => "Redo",
1067 IconRole::ActionSearch => "Search",
1068 IconRole::ActionSettings => "Settings",
1069 IconRole::ActionEdit => "Edit",
1070 IconRole::ActionAdd => "Add",
1071 IconRole::ActionRemove => "Remove",
1072 IconRole::ActionRefresh => "Refresh",
1073 IconRole::ActionPrint => "Print",
1074
1075 IconRole::NavBack => "Back",
1077 IconRole::NavForward => "Forward",
1078 IconRole::NavUp => "Up",
1079 IconRole::NavDown => "Down",
1080 IconRole::NavHome => "Home",
1081 IconRole::NavMenu => "GlobalNavigationButton",
1082
1083 IconRole::FileGeneric => "SIID_DOCNOASSOC",
1085 IconRole::FolderClosed => "SIID_FOLDER",
1086 IconRole::FolderOpen => "SIID_FOLDEROPEN",
1087 IconRole::TrashEmpty => "SIID_RECYCLER",
1088 IconRole::TrashFull => "SIID_RECYCLERFULL",
1089
1090 IconRole::StatusBusy => return None,
1093 IconRole::StatusCheck => "CheckMark",
1094 IconRole::StatusError => "SIID_ERROR",
1095
1096 IconRole::UserAccount => "SIID_USERS",
1098 IconRole::Notification => "Ringer",
1099 IconRole::Help => "SIID_HELP",
1100 IconRole::Lock => "SIID_LOCK",
1101
1102 _ => return None,
1103 })
1104}
1105
1106#[allow(unreachable_patterns)]
1107fn freedesktop_name(role: IconRole) -> Option<&'static str> {
1108 Some(match role {
1109 IconRole::DialogWarning => "dialog-warning",
1111 IconRole::DialogError => "dialog-error",
1112 IconRole::DialogInfo => "dialog-information",
1113 IconRole::DialogQuestion => "dialog-question",
1114 IconRole::DialogSuccess => "object-select-symbolic",
1118 IconRole::Shield => "security-high",
1119
1120 IconRole::WindowClose => "window-close",
1122 IconRole::WindowMinimize => "window-minimize",
1123 IconRole::WindowMaximize => "window-maximize",
1124 IconRole::WindowRestore => "window-restore",
1125
1126 IconRole::ActionSave => "document-save",
1128 IconRole::ActionDelete => "edit-delete",
1129 IconRole::ActionCopy => "edit-copy",
1130 IconRole::ActionPaste => "edit-paste",
1131 IconRole::ActionCut => "edit-cut",
1132 IconRole::ActionUndo => "edit-undo",
1133 IconRole::ActionRedo => "edit-redo",
1134 IconRole::ActionSearch => "edit-find",
1135 IconRole::ActionSettings => "preferences-system",
1136 IconRole::ActionEdit => "document-edit",
1137 IconRole::ActionAdd => "list-add",
1138 IconRole::ActionRemove => "list-remove",
1139 IconRole::ActionRefresh => "view-refresh",
1140 IconRole::ActionPrint => "document-print",
1141
1142 IconRole::NavBack => "go-previous",
1144 IconRole::NavForward => "go-next",
1145 IconRole::NavUp => "go-up",
1146 IconRole::NavDown => "go-down",
1147 IconRole::NavHome => "go-home",
1148 IconRole::NavMenu => "open-menu",
1149
1150 IconRole::FileGeneric => "text-x-generic",
1152 IconRole::FolderClosed => "folder",
1153 IconRole::FolderOpen => "folder-open",
1154 IconRole::TrashEmpty => "user-trash",
1155 IconRole::TrashFull => "user-trash-full",
1156
1157 IconRole::StatusBusy => "content-loading-symbolic",
1164 IconRole::StatusCheck => "emblem-default",
1165 IconRole::StatusError => "dialog-error",
1166
1167 IconRole::UserAccount => "system-users",
1169 IconRole::Notification => "notification-active",
1171 IconRole::Help => "help-browser",
1172 IconRole::Lock => "system-lock-screen",
1173
1174 _ => return None,
1175 })
1176}
1177
1178#[allow(unreachable_patterns)]
1179fn material_name(role: IconRole) -> Option<&'static str> {
1180 Some(match role {
1181 IconRole::DialogWarning => "warning",
1183 IconRole::DialogError => "error",
1184 IconRole::DialogInfo => "info",
1185 IconRole::DialogQuestion => "help",
1186 IconRole::DialogSuccess => "check_circle",
1187 IconRole::Shield => "shield",
1188
1189 IconRole::WindowClose => "close",
1191 IconRole::WindowMinimize => "minimize",
1192 IconRole::WindowMaximize => "open_in_full",
1193 IconRole::WindowRestore => "close_fullscreen",
1194
1195 IconRole::ActionSave => "save",
1197 IconRole::ActionDelete => "delete",
1198 IconRole::ActionCopy => "content_copy",
1199 IconRole::ActionPaste => "content_paste",
1200 IconRole::ActionCut => "content_cut",
1201 IconRole::ActionUndo => "undo",
1202 IconRole::ActionRedo => "redo",
1203 IconRole::ActionSearch => "search",
1204 IconRole::ActionSettings => "settings",
1205 IconRole::ActionEdit => "edit",
1206 IconRole::ActionAdd => "add",
1207 IconRole::ActionRemove => "remove",
1208 IconRole::ActionRefresh => "refresh",
1209 IconRole::ActionPrint => "print",
1210
1211 IconRole::NavBack => "arrow_back",
1213 IconRole::NavForward => "arrow_forward",
1214 IconRole::NavUp => "arrow_upward",
1215 IconRole::NavDown => "arrow_downward",
1216 IconRole::NavHome => "home",
1217 IconRole::NavMenu => "menu",
1218
1219 IconRole::FileGeneric => "description",
1221 IconRole::FolderClosed => "folder",
1222 IconRole::FolderOpen => "folder_open",
1223 IconRole::TrashEmpty => "delete",
1224 IconRole::TrashFull => "delete",
1226
1227 IconRole::StatusBusy => "progress_activity",
1229 IconRole::StatusCheck => "check",
1230 IconRole::StatusError => "error",
1231
1232 IconRole::UserAccount => "person",
1234 IconRole::Notification => "notifications",
1235 IconRole::Help => "help",
1236 IconRole::Lock => "lock",
1237
1238 _ => return None,
1239 })
1240}
1241
1242#[allow(unreachable_patterns)]
1243fn lucide_name(role: IconRole) -> Option<&'static str> {
1244 Some(match role {
1245 IconRole::DialogWarning => "triangle-alert",
1247 IconRole::DialogError => "circle-x",
1248 IconRole::DialogInfo => "info",
1249 IconRole::DialogQuestion => "circle-question-mark",
1250 IconRole::DialogSuccess => "circle-check",
1251 IconRole::Shield => "shield",
1252
1253 IconRole::WindowClose => "x",
1255 IconRole::WindowMinimize => "minimize",
1256 IconRole::WindowMaximize => "maximize",
1257 IconRole::WindowRestore => "minimize-2",
1258
1259 IconRole::ActionSave => "save",
1261 IconRole::ActionDelete => "trash",
1262 IconRole::ActionCopy => "copy",
1263 IconRole::ActionPaste => "clipboard-paste",
1264 IconRole::ActionCut => "scissors",
1265 IconRole::ActionUndo => "undo-2",
1266 IconRole::ActionRedo => "redo-2",
1267 IconRole::ActionSearch => "search",
1268 IconRole::ActionSettings => "settings",
1269 IconRole::ActionEdit => "pencil",
1270 IconRole::ActionAdd => "plus",
1271 IconRole::ActionRemove => "minus",
1272 IconRole::ActionRefresh => "refresh-cw",
1273 IconRole::ActionPrint => "printer",
1274
1275 IconRole::NavBack => "chevron-left",
1277 IconRole::NavForward => "chevron-right",
1278 IconRole::NavUp => "chevron-up",
1279 IconRole::NavDown => "chevron-down",
1280 IconRole::NavHome => "house",
1281 IconRole::NavMenu => "menu",
1282
1283 IconRole::FileGeneric => "file",
1285 IconRole::FolderClosed => "folder-closed",
1286 IconRole::FolderOpen => "folder-open",
1287 IconRole::TrashEmpty => "trash",
1288 IconRole::TrashFull => "trash",
1290
1291 IconRole::StatusBusy => "loader",
1293 IconRole::StatusCheck => "check",
1294 IconRole::StatusError => "circle-x",
1295
1296 IconRole::UserAccount => "user",
1298 IconRole::Notification => "bell",
1299 IconRole::Help => "circle-question-mark",
1300 IconRole::Lock => "lock",
1301
1302 _ => return None,
1303 })
1304}
1305
1306#[cfg(test)]
1307#[allow(clippy::unwrap_used, clippy::expect_used)]
1308mod tests {
1309 use super::*;
1310
1311 #[test]
1314 fn icon_role_all_has_42_variants() {
1315 assert_eq!(IconRole::ALL.len(), 42);
1316 }
1317
1318 #[test]
1319 fn icon_role_all_contains_every_variant() {
1320 use std::collections::HashSet;
1324 let all_set: HashSet<IconRole> = IconRole::ALL.iter().copied().collect();
1325 let check = |role: IconRole| {
1326 assert!(
1327 all_set.contains(&role),
1328 "IconRole::{role:?} missing from ALL array"
1329 );
1330 };
1331
1332 #[deny(unreachable_patterns)]
1334 match IconRole::DialogWarning {
1335 IconRole::DialogWarning
1336 | IconRole::DialogError
1337 | IconRole::DialogInfo
1338 | IconRole::DialogQuestion
1339 | IconRole::DialogSuccess
1340 | IconRole::Shield
1341 | IconRole::WindowClose
1342 | IconRole::WindowMinimize
1343 | IconRole::WindowMaximize
1344 | IconRole::WindowRestore
1345 | IconRole::ActionSave
1346 | IconRole::ActionDelete
1347 | IconRole::ActionCopy
1348 | IconRole::ActionPaste
1349 | IconRole::ActionCut
1350 | IconRole::ActionUndo
1351 | IconRole::ActionRedo
1352 | IconRole::ActionSearch
1353 | IconRole::ActionSettings
1354 | IconRole::ActionEdit
1355 | IconRole::ActionAdd
1356 | IconRole::ActionRemove
1357 | IconRole::ActionRefresh
1358 | IconRole::ActionPrint
1359 | IconRole::NavBack
1360 | IconRole::NavForward
1361 | IconRole::NavUp
1362 | IconRole::NavDown
1363 | IconRole::NavHome
1364 | IconRole::NavMenu
1365 | IconRole::FileGeneric
1366 | IconRole::FolderClosed
1367 | IconRole::FolderOpen
1368 | IconRole::TrashEmpty
1369 | IconRole::TrashFull
1370 | IconRole::StatusBusy
1371 | IconRole::StatusCheck
1372 | IconRole::StatusError
1373 | IconRole::UserAccount
1374 | IconRole::Notification
1375 | IconRole::Help
1376 | IconRole::Lock => {}
1377 }
1378
1379 check(IconRole::DialogWarning);
1381 check(IconRole::DialogError);
1382 check(IconRole::DialogInfo);
1383 check(IconRole::DialogQuestion);
1384 check(IconRole::DialogSuccess);
1385 check(IconRole::Shield);
1386 check(IconRole::WindowClose);
1387 check(IconRole::WindowMinimize);
1388 check(IconRole::WindowMaximize);
1389 check(IconRole::WindowRestore);
1390 check(IconRole::ActionSave);
1391 check(IconRole::ActionDelete);
1392 check(IconRole::ActionCopy);
1393 check(IconRole::ActionPaste);
1394 check(IconRole::ActionCut);
1395 check(IconRole::ActionUndo);
1396 check(IconRole::ActionRedo);
1397 check(IconRole::ActionSearch);
1398 check(IconRole::ActionSettings);
1399 check(IconRole::ActionEdit);
1400 check(IconRole::ActionAdd);
1401 check(IconRole::ActionRemove);
1402 check(IconRole::ActionRefresh);
1403 check(IconRole::ActionPrint);
1404 check(IconRole::NavBack);
1405 check(IconRole::NavForward);
1406 check(IconRole::NavUp);
1407 check(IconRole::NavDown);
1408 check(IconRole::NavHome);
1409 check(IconRole::NavMenu);
1410 check(IconRole::FileGeneric);
1411 check(IconRole::FolderClosed);
1412 check(IconRole::FolderOpen);
1413 check(IconRole::TrashEmpty);
1414 check(IconRole::TrashFull);
1415 check(IconRole::StatusBusy);
1416 check(IconRole::StatusCheck);
1417 check(IconRole::StatusError);
1418 check(IconRole::UserAccount);
1419 check(IconRole::Notification);
1420 check(IconRole::Help);
1421 check(IconRole::Lock);
1422 }
1423
1424 #[test]
1425 fn icon_role_all_no_duplicates() {
1426 let all = &IconRole::ALL;
1427 for (i, role) in all.iter().enumerate() {
1428 for (j, other) in all.iter().enumerate() {
1429 if i != j {
1430 assert_ne!(role, other, "Duplicate at index {i} and {j}");
1431 }
1432 }
1433 }
1434 }
1435
1436 #[test]
1437 fn icon_role_derives_copy_clone() {
1438 let role = IconRole::ActionCopy;
1439 let copied1 = role;
1440 let copied2 = role;
1441 assert_eq!(role, copied1);
1442 assert_eq!(role, copied2);
1443 }
1444
1445 #[test]
1446 fn icon_role_derives_debug() {
1447 let s = format!("{:?}", IconRole::DialogWarning);
1448 assert!(s.contains("DialogWarning"));
1449 }
1450
1451 #[test]
1452 fn icon_role_derives_hash() {
1453 use std::collections::HashSet;
1454 let mut set = HashSet::new();
1455 set.insert(IconRole::ActionSave);
1456 set.insert(IconRole::ActionDelete);
1457 assert_eq!(set.len(), 2);
1458 assert!(set.contains(&IconRole::ActionSave));
1459 }
1460
1461 #[test]
1464 fn icon_data_svg_construct_and_match() {
1465 let data = IconData::Svg(Cow::Borrowed(b"<svg></svg>"));
1466 assert_eq!(data.bytes(), b"<svg></svg>");
1467 match data {
1468 IconData::Svg(ref cow) => assert_eq!(cow.as_ref(), b"<svg></svg>"),
1469 _ => panic!("Expected Svg variant"),
1470 }
1471 }
1472
1473 #[test]
1474 fn icon_data_rgba_construct_and_match() {
1475 let pixels = vec![255, 0, 0, 255]; let data = IconData::Rgba {
1477 width: 1,
1478 height: 1,
1479 data: pixels.clone(),
1480 };
1481 match data {
1482 IconData::Rgba {
1483 width,
1484 height,
1485 data,
1486 } => {
1487 assert_eq!(width, 1);
1488 assert_eq!(height, 1);
1489 assert_eq!(data, pixels);
1490 }
1491 _ => panic!("Expected Rgba variant"),
1492 }
1493 }
1494
1495 #[test]
1496 fn icon_data_derives_debug() {
1497 let data = IconData::Svg(Cow::Borrowed(b""));
1498 let s = format!("{:?}", data);
1499 assert!(s.contains("Svg"));
1500 }
1501
1502 #[test]
1503 fn icon_data_derives_clone() {
1504 let data = IconData::Rgba {
1505 width: 16,
1506 height: 16,
1507 data: vec![0; 16 * 16 * 4],
1508 };
1509 let cloned = data.clone();
1510 assert_eq!(data, cloned);
1511 }
1512
1513 #[test]
1514 fn icon_data_derives_eq() {
1515 let a = IconData::Svg(Cow::Borrowed(b"<svg/>"));
1516 let b = IconData::Svg(Cow::Owned(b"<svg/>".to_vec()));
1517 assert_eq!(a, b);
1518
1519 let c = IconData::Svg(Cow::Borrowed(b"<other/>"));
1520 assert_ne!(a, c);
1521 }
1522
1523 #[test]
1526 fn icon_set_from_name_sf_symbols() {
1527 assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1528 }
1529
1530 #[test]
1531 fn icon_set_from_name_segoe_fluent() {
1532 assert_eq!(
1533 IconSet::from_name("segoe-fluent"),
1534 Some(IconSet::SegoeIcons)
1535 );
1536 }
1537
1538 #[test]
1539 fn icon_set_from_name_freedesktop() {
1540 assert_eq!(
1541 IconSet::from_name("freedesktop"),
1542 Some(IconSet::Freedesktop)
1543 );
1544 }
1545
1546 #[test]
1547 fn icon_set_from_name_material() {
1548 assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1549 }
1550
1551 #[test]
1552 fn icon_set_from_name_lucide() {
1553 assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1554 }
1555
1556 #[test]
1557 fn icon_set_from_name_unknown() {
1558 assert_eq!(IconSet::from_name("unknown"), None);
1559 }
1560
1561 #[test]
1562 fn icon_set_name_sf_symbols() {
1563 assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1564 }
1565
1566 #[test]
1567 fn icon_set_name_segoe_fluent() {
1568 assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1569 }
1570
1571 #[test]
1572 fn icon_set_name_freedesktop() {
1573 assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1574 }
1575
1576 #[test]
1577 fn icon_set_name_material() {
1578 assert_eq!(IconSet::Material.name(), "material");
1579 }
1580
1581 #[test]
1582 fn icon_set_name_lucide() {
1583 assert_eq!(IconSet::Lucide.name(), "lucide");
1584 }
1585
1586 #[test]
1587 fn icon_set_from_name_name_round_trip() {
1588 let sets = [
1589 IconSet::SfSymbols,
1590 IconSet::SegoeIcons,
1591 IconSet::Freedesktop,
1592 IconSet::Material,
1593 IconSet::Lucide,
1594 ];
1595 for set in &sets {
1596 let name = set.name();
1597 let parsed = IconSet::from_name(name);
1598 assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1599 }
1600 }
1601
1602 #[test]
1603 fn icon_set_derives_copy_clone() {
1604 let set = IconSet::Material;
1605 let copied1 = set;
1606 let copied2 = set;
1607 assert_eq!(set, copied1);
1608 assert_eq!(set, copied2);
1609 }
1610
1611 #[test]
1612 fn icon_set_derives_hash() {
1613 use std::collections::HashSet;
1614 let mut map = HashSet::new();
1615 map.insert(IconSet::SfSymbols);
1616 map.insert(IconSet::Lucide);
1617 assert_eq!(map.len(), 2);
1618 }
1619
1620 #[test]
1621 fn icon_set_derives_debug() {
1622 let s = format!("{:?}", IconSet::Freedesktop);
1623 assert!(s.contains("Freedesktop"));
1624 }
1625
1626 #[test]
1627 fn icon_set_serde_round_trip() {
1628 let set = IconSet::SfSymbols;
1629 let json = serde_json::to_string(&set).unwrap();
1630 let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1631 assert_eq!(set, deserialized);
1632 }
1633
1634 #[test]
1637 fn icon_name_sf_symbols_action_copy() {
1638 assert_eq!(
1639 icon_name(IconRole::ActionCopy, IconSet::SfSymbols),
1640 Some("doc.on.doc")
1641 );
1642 }
1643
1644 #[test]
1645 fn icon_name_segoe_action_copy() {
1646 assert_eq!(
1647 icon_name(IconRole::ActionCopy, IconSet::SegoeIcons),
1648 Some("Copy")
1649 );
1650 }
1651
1652 #[test]
1653 fn icon_name_freedesktop_action_copy() {
1654 assert_eq!(
1655 icon_name(IconRole::ActionCopy, IconSet::Freedesktop),
1656 Some("edit-copy")
1657 );
1658 }
1659
1660 #[test]
1661 fn icon_name_material_action_copy() {
1662 assert_eq!(
1663 icon_name(IconRole::ActionCopy, IconSet::Material),
1664 Some("content_copy")
1665 );
1666 }
1667
1668 #[test]
1669 fn icon_name_lucide_action_copy() {
1670 assert_eq!(
1671 icon_name(IconRole::ActionCopy, IconSet::Lucide),
1672 Some("copy")
1673 );
1674 }
1675
1676 #[test]
1677 fn icon_name_sf_symbols_dialog_warning() {
1678 assert_eq!(
1679 icon_name(IconRole::DialogWarning, IconSet::SfSymbols),
1680 Some("exclamationmark.triangle.fill")
1681 );
1682 }
1683
1684 #[test]
1686 fn icon_name_sf_symbols_folder_open_is_none() {
1687 assert_eq!(icon_name(IconRole::FolderOpen, IconSet::SfSymbols), None);
1688 }
1689
1690 #[test]
1691 fn icon_name_sf_symbols_trash_full() {
1692 assert_eq!(
1693 icon_name(IconRole::TrashFull, IconSet::SfSymbols),
1694 Some("trash.fill")
1695 );
1696 }
1697
1698 #[test]
1699 fn icon_name_sf_symbols_status_busy_is_none() {
1700 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SfSymbols), None);
1701 }
1702
1703 #[test]
1704 fn icon_name_sf_symbols_window_restore() {
1705 assert_eq!(
1706 icon_name(IconRole::WindowRestore, IconSet::SfSymbols),
1707 Some("arrow.down.right.and.arrow.up.left")
1708 );
1709 }
1710
1711 #[test]
1712 fn icon_name_segoe_dialog_success() {
1713 assert_eq!(
1714 icon_name(IconRole::DialogSuccess, IconSet::SegoeIcons),
1715 Some("CheckMark")
1716 );
1717 }
1718
1719 #[test]
1720 fn icon_name_segoe_status_busy_is_none() {
1721 assert_eq!(icon_name(IconRole::StatusBusy, IconSet::SegoeIcons), None);
1722 }
1723
1724 #[test]
1725 fn icon_name_freedesktop_notification() {
1726 assert_eq!(
1727 icon_name(IconRole::Notification, IconSet::Freedesktop),
1728 Some("notification-active")
1729 );
1730 }
1731
1732 #[test]
1733 fn icon_name_freedesktop_dialog_success() {
1734 assert_eq!(
1738 icon_name(IconRole::DialogSuccess, IconSet::Freedesktop),
1739 Some("object-select-symbolic")
1740 );
1741 }
1742
1743 #[test]
1744 fn icon_name_freedesktop_status_busy() {
1745 assert_eq!(
1749 icon_name(IconRole::StatusBusy, IconSet::Freedesktop),
1750 Some("content-loading-symbolic")
1751 );
1752 }
1753
1754 #[test]
1755 fn icon_name_material_trash_full() {
1756 assert_eq!(
1757 icon_name(IconRole::TrashFull, IconSet::Material),
1758 Some("delete")
1759 );
1760 }
1761
1762 #[test]
1763 fn icon_name_lucide_trash_full() {
1764 assert_eq!(
1765 icon_name(IconRole::TrashFull, IconSet::Lucide),
1766 Some("trash")
1767 );
1768 }
1769
1770 #[test]
1772 fn icon_name_spot_check_dialog_error() {
1773 assert_eq!(
1774 icon_name(IconRole::DialogError, IconSet::SfSymbols),
1775 Some("xmark.circle.fill")
1776 );
1777 assert_eq!(
1778 icon_name(IconRole::DialogError, IconSet::SegoeIcons),
1779 Some("SIID_ERROR")
1780 );
1781 assert_eq!(
1782 icon_name(IconRole::DialogError, IconSet::Freedesktop),
1783 Some("dialog-error")
1784 );
1785 assert_eq!(
1786 icon_name(IconRole::DialogError, IconSet::Material),
1787 Some("error")
1788 );
1789 assert_eq!(
1790 icon_name(IconRole::DialogError, IconSet::Lucide),
1791 Some("circle-x")
1792 );
1793 }
1794
1795 #[test]
1796 fn icon_name_spot_check_nav_home() {
1797 assert_eq!(
1798 icon_name(IconRole::NavHome, IconSet::SfSymbols),
1799 Some("house")
1800 );
1801 assert_eq!(
1802 icon_name(IconRole::NavHome, IconSet::SegoeIcons),
1803 Some("Home")
1804 );
1805 assert_eq!(
1806 icon_name(IconRole::NavHome, IconSet::Freedesktop),
1807 Some("go-home")
1808 );
1809 assert_eq!(
1810 icon_name(IconRole::NavHome, IconSet::Material),
1811 Some("home")
1812 );
1813 assert_eq!(icon_name(IconRole::NavHome, IconSet::Lucide), Some("house"));
1814 }
1815
1816 #[test]
1818 fn icon_name_sf_symbols_expected_count() {
1819 let some_count = IconRole::ALL
1821 .iter()
1822 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
1823 .count();
1824 assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1825 }
1826
1827 #[test]
1828 fn icon_name_segoe_expected_count() {
1829 let some_count = IconRole::ALL
1831 .iter()
1832 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
1833 .count();
1834 assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1835 }
1836
1837 #[test]
1838 fn icon_name_freedesktop_expected_count() {
1839 let some_count = IconRole::ALL
1841 .iter()
1842 .filter(|r| icon_name(**r, IconSet::Freedesktop).is_some())
1843 .count();
1844 assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1845 }
1846
1847 #[test]
1848 fn icon_name_material_expected_count() {
1849 let some_count = IconRole::ALL
1851 .iter()
1852 .filter(|r| icon_name(**r, IconSet::Material).is_some())
1853 .count();
1854 assert_eq!(some_count, 42, "Material should have 42 mappings");
1855 }
1856
1857 #[test]
1858 fn icon_name_lucide_expected_count() {
1859 let some_count = IconRole::ALL
1861 .iter()
1862 .filter(|r| icon_name(**r, IconSet::Lucide).is_some())
1863 .count();
1864 assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1865 }
1866
1867 #[test]
1870 #[cfg(target_os = "linux")]
1871 fn system_icon_set_returns_freedesktop_on_linux() {
1872 assert_eq!(system_icon_set(), IconSet::Freedesktop);
1873 }
1874
1875 #[test]
1878 fn icon_provider_is_object_safe() {
1879 let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1881 let debug_str = format!("{:?}", provider);
1882 assert!(
1883 debug_str.contains("ActionCopy"),
1884 "Debug should print variant name"
1885 );
1886 }
1887
1888 #[test]
1889 fn icon_role_provider_icon_name() {
1890 let role = IconRole::ActionCopy;
1892 let name = IconProvider::icon_name(&role, IconSet::Material);
1893 assert_eq!(name, Some("content_copy"));
1894 }
1895
1896 #[test]
1897 fn icon_role_provider_icon_name_sf_symbols() {
1898 let role = IconRole::ActionCopy;
1899 let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1900 assert_eq!(name, Some("doc.on.doc"));
1901 }
1902
1903 #[test]
1904 #[cfg(feature = "material-icons")]
1905 fn icon_role_provider_icon_svg_material() {
1906 let role = IconRole::ActionCopy;
1907 let svg = IconProvider::icon_svg(&role, IconSet::Material);
1908 assert!(svg.is_some(), "Material SVG should be Some");
1909 let cow = svg.unwrap();
1910 let content = std::str::from_utf8(&cow).expect("valid UTF-8");
1911 assert!(content.contains("<svg"), "should contain <svg tag");
1912 }
1913
1914 #[test]
1915 fn icon_role_provider_icon_svg_non_bundled() {
1916 let role = IconRole::ActionCopy;
1918 let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1919 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1920 }
1921
1922 #[test]
1923 fn icon_role_provider_all_roles() {
1924 for role in IconRole::ALL {
1926 let _name = IconProvider::icon_name(&role, IconSet::Material);
1928 }
1930 }
1931
1932 #[test]
1933 fn icon_provider_dyn_dispatch() {
1934 let role = IconRole::ActionCopy;
1936 let provider: &dyn IconProvider = &role;
1937 let name = provider.icon_name(IconSet::Material);
1938 assert_eq!(name, Some("content_copy"));
1939 let svg = provider.icon_svg(IconSet::SfSymbols);
1940 assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1941 }
1942
1943 fn known_gaps() -> &'static [(IconSet, IconRole)] {
1946 &[
1947 (IconSet::SfSymbols, IconRole::FolderOpen),
1948 (IconSet::SfSymbols, IconRole::StatusBusy),
1949 (IconSet::SegoeIcons, IconRole::StatusBusy),
1950 ]
1951 }
1952
1953 #[test]
1954 fn no_unexpected_icon_gaps() {
1955 let gaps = known_gaps();
1956 let system_sets = [
1957 IconSet::SfSymbols,
1958 IconSet::SegoeIcons,
1959 IconSet::Freedesktop,
1960 ];
1961 for &set in &system_sets {
1962 for role in IconRole::ALL {
1963 let is_known_gap = gaps.contains(&(set, role));
1964 let is_mapped = icon_name(role, set).is_some();
1965 if !is_known_gap {
1966 assert!(
1967 is_mapped,
1968 "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1969 );
1970 }
1971 }
1972 }
1973 }
1974
1975 #[test]
1976 #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1977 fn all_roles_have_bundled_svg() {
1978 use crate::bundled_icon_svg;
1979 for set in [IconSet::Material, IconSet::Lucide] {
1980 for role in IconRole::ALL {
1981 assert!(
1982 bundled_icon_svg(role, set).is_some(),
1983 "{role:?} has no bundled SVG for {set:?}"
1984 );
1985 }
1986 }
1987 }
1988
1989 #[test]
1992 fn icon_name_exhaustive_all_sets() {
1993 for role in IconRole::ALL {
1995 assert!(
1996 icon_name(role, IconSet::Material).is_some(),
1997 "Material must map {role:?} to Some"
1998 );
1999 }
2000 for role in IconRole::ALL {
2002 assert!(
2003 icon_name(role, IconSet::Lucide).is_some(),
2004 "Lucide must map {role:?} to Some"
2005 );
2006 }
2007 for role in IconRole::ALL {
2009 assert!(
2010 icon_name(role, IconSet::Freedesktop).is_some(),
2011 "Freedesktop must map {role:?} to Some"
2012 );
2013 }
2014 let segoe_count = IconRole::ALL
2016 .iter()
2017 .filter(|r| icon_name(**r, IconSet::SegoeIcons).is_some())
2018 .count();
2019 assert!(
2020 segoe_count >= 41,
2021 "Segoe should map at least 41 roles, got {segoe_count}"
2022 );
2023 let sf_count = IconRole::ALL
2025 .iter()
2026 .filter(|r| icon_name(**r, IconSet::SfSymbols).is_some())
2027 .count();
2028 assert!(
2029 sf_count >= 40,
2030 "SfSymbols should map at least 40 roles, got {sf_count}"
2031 );
2032 }
2033
2034 #[test]
2036 fn icon_name_returns_nonempty_strings() {
2037 let all_sets = [
2038 IconSet::SfSymbols,
2039 IconSet::SegoeIcons,
2040 IconSet::Freedesktop,
2041 IconSet::Material,
2042 IconSet::Lucide,
2043 ];
2044 for set in all_sets {
2045 for role in IconRole::ALL {
2046 if let Some(name) = icon_name(role, set) {
2047 assert!(
2048 !name.is_empty(),
2049 "icon_name({role:?}, {set:?}) returned empty string"
2050 );
2051 }
2052 }
2053 }
2054 }
2055
2056 #[test]
2059 fn icon_set_name_round_trip() {
2060 let all_sets = [
2063 IconSet::SfSymbols,
2064 IconSet::SegoeIcons,
2065 IconSet::Freedesktop,
2066 IconSet::Material,
2067 IconSet::Lucide,
2068 ];
2069 for set in all_sets {
2070 assert_eq!(
2071 IconSet::from_name(set.name()),
2072 Some(set),
2073 "IconSet::{:?}.name() = {:?} did not round-trip through from_name()",
2074 set,
2075 set.name()
2076 );
2077 }
2078 }
2079
2080 #[test]
2083 fn icon_role_name_returns_kebab_case() {
2084 assert_eq!(IconRole::ActionSave.name(), "action-save");
2085 assert_eq!(IconRole::DialogWarning.name(), "dialog-warning");
2086 assert_eq!(IconRole::WindowClose.name(), "window-close");
2087 assert_eq!(IconRole::NavBack.name(), "nav-back");
2088 assert_eq!(IconRole::FileGeneric.name(), "file-generic");
2089 assert_eq!(IconRole::StatusBusy.name(), "status-busy");
2090 assert_eq!(IconRole::UserAccount.name(), "user-account");
2091 assert_eq!(IconRole::Shield.name(), "shield");
2092 assert_eq!(IconRole::Notification.name(), "notification");
2093 assert_eq!(IconRole::Help.name(), "help");
2094 assert_eq!(IconRole::Lock.name(), "lock");
2095 }
2096
2097 #[test]
2098 fn icon_role_display_delegates_to_name() {
2099 for role in IconRole::ALL {
2100 assert_eq!(
2101 format!("{role}"),
2102 role.name(),
2103 "Display for IconRole::{:?} should delegate to name()",
2104 role
2105 );
2106 }
2107 }
2108
2109 #[test]
2112 fn icon_set_serde_matches_name() {
2113 let all_sets = [
2114 IconSet::SfSymbols,
2115 IconSet::SegoeIcons,
2116 IconSet::Freedesktop,
2117 IconSet::Material,
2118 IconSet::Lucide,
2119 ];
2120 for set in all_sets {
2121 let serialized = serde_json::to_string(&set)
2122 .unwrap_or_else(|e| panic!("failed to serialize {set:?}: {e}"));
2123 let trimmed = serialized.trim_matches('"');
2124 assert_eq!(
2125 trimmed,
2126 set.name(),
2127 "serde serialization of {set:?} ({trimmed:?}) does not match name() ({:?})",
2128 set.name()
2129 );
2130 }
2131 }
2132
2133 #[test]
2136 fn icon_data_bytes_accessor() {
2137 let svg = IconData::Svg(Cow::Borrowed(b"<svg/>"));
2138 assert_eq!(svg.bytes(), b"<svg/>");
2139
2140 let rgba = IconData::Rgba {
2141 width: 2,
2142 height: 2,
2143 data: vec![1, 2, 3, 4],
2144 };
2145 assert_eq!(rgba.bytes(), &[1, 2, 3, 4]);
2146 }
2147}
2148
2149#[cfg(test)]
2150#[cfg(target_os = "linux")]
2151#[allow(clippy::unwrap_used, clippy::expect_used)]
2152mod icon_theme_detection_tests {
2153 use super::*;
2154 use std::os::unix::process::ExitStatusExt;
2155 use std::path::{Path, PathBuf};
2156 use std::process::{ExitStatus, Output};
2157
2158 fn missing(_: &Path) -> std::io::Result<String> {
2159 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
2160 }
2161
2162 fn printed(stdout: &str) -> std::io::Result<Output> {
2163 Ok(Output {
2164 status: ExitStatus::from_raw(0),
2165 stdout: stdout.as_bytes().to_vec(),
2166 stderr: Vec::new(),
2167 })
2168 }
2169
2170 fn exited_1(stderr: &str) -> std::io::Result<Output> {
2171 Ok(Output {
2172 status: ExitStatus::from_raw(1 << 8),
2173 stdout: Vec::new(),
2174 stderr: stderr.as_bytes().to_vec(),
2175 })
2176 }
2177
2178 fn not_found() -> std::io::Result<Output> {
2179 Err(std::io::Error::from(std::io::ErrorKind::NotFound))
2180 }
2181
2182 #[test]
2185 fn kde_without_a_config_dir_names_kdeglobals_and_the_config_dir() {
2186 let failure = kde_icon_theme(None, missing).unwrap_err().to_string();
2187 assert!(failure.contains("kdeglobals"), "got: {failure}");
2188 assert!(failure.contains("config directory"), "got: {failure}");
2189 assert!(failure.contains("XDG_CONFIG_HOME"), "got: {failure}");
2190 }
2191
2192 #[test]
2193 fn kde_with_no_readable_file_names_both_files() {
2194 let dir = PathBuf::from("/cfg");
2195 let failure = kde_icon_theme(Some(&dir), missing).unwrap_err().to_string();
2196 assert!(failure.contains("/cfg/kdeglobals"), "got: {failure}");
2197 assert!(
2198 failure.contains("/cfg/kdedefaults/kdeglobals"),
2199 "got: {failure}"
2200 );
2201 assert!(failure.contains("unreadable"), "got: {failure}");
2202 }
2203
2204 #[test]
2205 fn kde_with_no_icons_theme_key_says_the_key_is_absent() {
2206 let dir = PathBuf::from("/cfg");
2207 let failure = kde_icon_theme(Some(&dir), |_: &Path| {
2208 Ok("[General]\nTheme=not-this-one\n".to_string())
2209 })
2210 .unwrap_err()
2211 .to_string();
2212 assert!(failure.contains("kdeglobals"), "got: {failure}");
2213 assert!(
2214 failure.contains("`[Icons] Theme` is absent or empty"),
2215 "got: {failure}"
2216 );
2217 }
2218
2219 #[test]
2220 fn kde_takes_the_defaults_file_where_kdeglobals_lacks_the_key() {
2221 let dir = PathBuf::from("/cfg");
2222 let theme = kde_icon_theme(Some(&dir), |path: &Path| {
2223 if path.ends_with("kdedefaults/kdeglobals") {
2224 Ok("[Icons]\nTheme=breeze-dark\n".to_string())
2225 } else {
2226 Ok("[General]\n".to_string())
2227 }
2228 });
2229 assert_eq!(theme, Ok("breeze-dark".to_string()));
2230 }
2231
2232 #[test]
2235 fn gsettings_not_found_names_the_command() {
2236 let failure = gsettings_icon_theme_from("org.gnome.desktop.interface", not_found())
2237 .unwrap_err()
2238 .to_string();
2239 assert!(
2240 failure.contains("gsettings get org.gnome.desktop.interface icon-theme"),
2241 "got: {failure}"
2242 );
2243 assert!(failure.contains("command not found"), "got: {failure}");
2244 }
2245
2246 #[test]
2247 fn gsettings_nonzero_exit_gives_the_status_and_stderr() {
2248 let failure = gsettings_icon_theme_from(
2249 "org.mate.interface",
2250 exited_1("No such schema “org.mate.interface”\n"),
2251 )
2252 .unwrap_err()
2253 .to_string();
2254 assert!(
2255 failure.contains("gsettings get org.mate.interface icon-theme"),
2256 "got: {failure}"
2257 );
2258 assert!(
2259 failure.contains("unsuccessful (exit status: 1)"),
2260 "got: {failure}"
2261 );
2262 assert!(failure.contains("No such schema"), "got: {failure}");
2263 }
2264
2265 #[test]
2266 fn gsettings_empty_output_says_it_printed_no_name() {
2267 let failure = gsettings_icon_theme_from("org.cinnamon.desktop.interface", printed("''\n"))
2268 .unwrap_err()
2269 .to_string();
2270 assert!(
2271 failure.contains("gsettings get org.cinnamon.desktop.interface icon-theme"),
2272 "got: {failure}"
2273 );
2274 assert!(failure.contains("printed no theme name"), "got: {failure}");
2275 }
2276
2277 #[test]
2278 fn gsettings_output_is_unquoted() {
2279 assert_eq!(
2280 gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'\n")),
2281 Ok("Adwaita".to_string())
2282 );
2283 }
2284
2285 #[test]
2288 fn xfconf_not_found_names_the_command() {
2289 let failure = xfce_icon_theme_from(not_found()).unwrap_err().to_string();
2290 assert!(
2291 failure.contains("xfconf-query -c xsettings -p /Net/IconThemeName"),
2292 "got: {failure}"
2293 );
2294 assert!(failure.contains("command not found"), "got: {failure}");
2295 }
2296
2297 #[test]
2298 fn xfconf_nonzero_exit_gives_the_status() {
2299 let failure = xfce_icon_theme_from(exited_1("")).unwrap_err().to_string();
2300 assert!(failure.contains("xfconf-query"), "got: {failure}");
2301 assert!(
2302 failure.contains("unsuccessful (exit status: 1)"),
2303 "got: {failure}"
2304 );
2305 }
2306
2307 #[test]
2308 fn xfconf_empty_output_says_it_printed_no_name() {
2309 let failure = xfce_icon_theme_from(printed("\n")).unwrap_err().to_string();
2310 assert!(failure.contains("xfconf-query"), "got: {failure}");
2311 assert!(failure.contains("printed no theme name"), "got: {failure}");
2312 }
2313
2314 #[test]
2315 fn xfconf_output_is_trimmed() {
2316 assert_eq!(
2317 xfce_icon_theme_from(printed("elementary\n")),
2318 Ok("elementary".to_string())
2319 );
2320 }
2321
2322 #[test]
2325 fn lxqt_without_a_config_dir_names_lxqt_conf_and_the_config_dir() {
2326 let failure = lxqt_icon_theme(None, missing).unwrap_err().to_string();
2327 assert!(failure.contains("lxqt.conf"), "got: {failure}");
2328 assert!(failure.contains("config directory"), "got: {failure}");
2329 }
2330
2331 #[test]
2332 fn lxqt_unreadable_file_names_the_file() {
2333 let dir = PathBuf::from("/cfg");
2334 let failure = lxqt_icon_theme(Some(&dir), missing)
2335 .unwrap_err()
2336 .to_string();
2337 assert!(failure.contains("/cfg/lxqt/lxqt.conf"), "got: {failure}");
2338 assert!(failure.contains("unreadable"), "got: {failure}");
2339 }
2340
2341 #[test]
2342 fn lxqt_without_the_key_says_the_key_is_absent() {
2343 let dir = PathBuf::from("/cfg");
2344 let failure = lxqt_icon_theme(Some(&dir), |_: &Path| Ok("theme=frost\n".to_string()))
2345 .unwrap_err()
2346 .to_string();
2347 assert!(failure.contains("/cfg/lxqt/lxqt.conf"), "got: {failure}");
2348 assert!(
2349 failure.contains("`icon_theme` is absent or empty"),
2350 "got: {failure}"
2351 );
2352 }
2353
2354 #[test]
2355 fn lxqt_reads_the_icon_theme_key() {
2356 let dir = PathBuf::from("/cfg");
2357 assert_eq!(
2358 lxqt_icon_theme(Some(&dir), |_: &Path| Ok(
2359 "[General]\nicon_theme=Papirus\n".to_string()
2360 )),
2361 Ok("Papirus".to_string())
2362 );
2363 }
2364
2365 #[test]
2368 fn unknown_desktop_error_names_both_attempts() {
2369 let error = unknown_desktop_icon_theme(
2370 || kde_icon_theme(None, missing),
2371 || gsettings_icon_theme_from("org.gnome.desktop.interface", not_found()),
2372 )
2373 .unwrap_err()
2374 .to_error();
2375 let message = error.to_string();
2376 assert!(message.contains("kdeglobals"), "got: {message}");
2377 assert!(
2378 message.contains("gsettings get org.gnome.desktop.interface icon-theme"),
2379 "got: {message}"
2380 );
2381 assert!(
2382 matches!(
2383 error,
2384 crate::Error::ReaderFailed {
2385 reader: "icon-theme",
2386 ..
2387 }
2388 ),
2389 "got: {error:?}"
2390 );
2391 }
2392
2393 #[test]
2394 fn unknown_desktop_takes_kde_first() {
2395 let dir = PathBuf::from("/cfg");
2396 let theme = unknown_desktop_icon_theme(
2397 || {
2398 kde_icon_theme(Some(&dir), |_: &Path| {
2399 Ok("[Icons]\nTheme=breeze\n".to_string())
2400 })
2401 },
2402 || gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'")),
2403 );
2404 assert_eq!(theme, Ok("breeze".to_string()));
2405 }
2406
2407 #[test]
2408 fn unknown_desktop_takes_gnome_where_kde_fails() {
2409 let theme = unknown_desktop_icon_theme(
2410 || kde_icon_theme(None, missing),
2411 || gsettings_icon_theme_from("org.gnome.desktop.interface", printed("'Adwaita'")),
2412 );
2413 assert_eq!(theme, Ok("Adwaita".to_string()));
2414 }
2415
2416 #[test]
2419 fn system_icon_theme_is_a_name_or_the_reason_for_none() {
2420 match system_icon_theme() {
2421 Ok(theme) => assert!(!theme.is_empty(), "detection named an empty theme"),
2422 Err(e) => assert!(!e.to_string().is_empty(), "a failure gave no reason"),
2423 }
2424 }
2425}