Skip to main content

native_theme/model/
icons.rs

1// Icon type definitions: IconRole, IconData, IconSet
2//
3// These are the core icon types for the native-theme icon system.
4
5use std::sync::OnceLock;
6
7use serde::{Deserialize, Serialize};
8
9/// Semantic icon roles for cross-platform icon resolution.
10///
11/// Each variant represents a conceptual icon role (not a specific icon image).
12/// Platform-specific icon identifiers are resolved via
13/// [`icon_name()`](crate::icon_name) using an [`IconSet`].
14///
15/// # Categories
16///
17/// Variants are grouped by prefix into 7 categories:
18/// - **Dialog** (6): Alerts and dialog indicators
19/// - **Window** (4): Window control buttons
20/// - **Action** (14): Common user actions
21/// - **Navigation** (6): Directional and structural navigation
22/// - **Files** (5): File and folder representations
23/// - **Status** (3): State indicators
24/// - **System** (4): System-level UI elements
25///
26/// # Examples
27///
28/// ```
29/// use native_theme::IconRole;
30///
31/// let role = IconRole::ActionSave;
32/// match role {
33///     IconRole::ActionSave => println!("save icon"),
34///     _ => println!("other icon"),
35/// }
36///
37/// // Iterate all roles
38/// assert_eq!(IconRole::ALL.len(), 42);
39/// ```
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41#[non_exhaustive]
42pub enum IconRole {
43    // Dialog / Alert (6)
44    /// Warning indicator for dialogs
45    DialogWarning,
46    /// Error indicator for dialogs
47    DialogError,
48    /// Informational indicator for dialogs
49    DialogInfo,
50    /// Question indicator for dialogs
51    DialogQuestion,
52    /// Success/confirmation indicator for dialogs
53    DialogSuccess,
54    /// Security/shield indicator
55    Shield,
56
57    // Window Controls (4)
58    /// Close window button
59    WindowClose,
60    /// Minimize window button
61    WindowMinimize,
62    /// Maximize window button
63    WindowMaximize,
64    /// Restore window button (from maximized state)
65    WindowRestore,
66
67    // Common Actions (14)
68    /// Save action
69    ActionSave,
70    /// Delete action
71    ActionDelete,
72    /// Copy to clipboard
73    ActionCopy,
74    /// Paste from clipboard
75    ActionPaste,
76    /// Cut to clipboard
77    ActionCut,
78    /// Undo last action
79    ActionUndo,
80    /// Redo last undone action
81    ActionRedo,
82    /// Search / find
83    ActionSearch,
84    /// Settings / preferences
85    ActionSettings,
86    /// Edit / modify
87    ActionEdit,
88    /// Add / create new item
89    ActionAdd,
90    /// Remove item
91    ActionRemove,
92    /// Refresh / reload
93    ActionRefresh,
94    /// Print
95    ActionPrint,
96
97    // Navigation (6)
98    /// Navigate backward
99    NavBack,
100    /// Navigate forward
101    NavForward,
102    /// Navigate up in hierarchy
103    NavUp,
104    /// Navigate down in hierarchy
105    NavDown,
106    /// Navigate to home / root
107    NavHome,
108    /// Open menu / hamburger
109    NavMenu,
110
111    // Files / Places (5)
112    /// Generic file icon
113    FileGeneric,
114    /// Closed folder
115    FolderClosed,
116    /// Open folder
117    FolderOpen,
118    /// Empty trash / recycle bin
119    TrashEmpty,
120    /// Full trash / recycle bin
121    TrashFull,
122
123    // Status (3)
124    /// Loading / in-progress indicator
125    StatusLoading,
126    /// Check / success indicator
127    StatusCheck,
128    /// Error state indicator
129    StatusError,
130
131    // System (4)
132    /// User account / profile
133    UserAccount,
134    /// Notification / bell
135    Notification,
136    /// Help / question mark
137    Help,
138    /// Lock / security
139    Lock,
140}
141
142impl IconRole {
143    /// All icon role variants, useful for iteration and exhaustive testing.
144    ///
145    /// Contains exactly 42 variants, one for each role, in declaration order.
146    pub const ALL: [IconRole; 42] = [
147        // Dialog (6)
148        Self::DialogWarning,
149        Self::DialogError,
150        Self::DialogInfo,
151        Self::DialogQuestion,
152        Self::DialogSuccess,
153        Self::Shield,
154        // Window (4)
155        Self::WindowClose,
156        Self::WindowMinimize,
157        Self::WindowMaximize,
158        Self::WindowRestore,
159        // Action (14)
160        Self::ActionSave,
161        Self::ActionDelete,
162        Self::ActionCopy,
163        Self::ActionPaste,
164        Self::ActionCut,
165        Self::ActionUndo,
166        Self::ActionRedo,
167        Self::ActionSearch,
168        Self::ActionSettings,
169        Self::ActionEdit,
170        Self::ActionAdd,
171        Self::ActionRemove,
172        Self::ActionRefresh,
173        Self::ActionPrint,
174        // Navigation (6)
175        Self::NavBack,
176        Self::NavForward,
177        Self::NavUp,
178        Self::NavDown,
179        Self::NavHome,
180        Self::NavMenu,
181        // Files (5)
182        Self::FileGeneric,
183        Self::FolderClosed,
184        Self::FolderOpen,
185        Self::TrashEmpty,
186        Self::TrashFull,
187        // Status (3)
188        Self::StatusLoading,
189        Self::StatusCheck,
190        Self::StatusError,
191        // System (4)
192        Self::UserAccount,
193        Self::Notification,
194        Self::Help,
195        Self::Lock,
196    ];
197}
198
199/// Icon data returned by loading functions.
200///
201/// Represents the actual pixel or vector data for an icon. This type is
202/// produced by platform icon loaders and bundled icon accessors.
203///
204/// # Examples
205///
206/// ```
207/// use native_theme::IconData;
208///
209/// let svg = IconData::Svg(b"<svg></svg>".to_vec());
210/// match svg {
211///     IconData::Svg(bytes) => assert!(!bytes.is_empty()),
212///     _ => unreachable!(),
213/// }
214///
215/// let rgba = IconData::Rgba { width: 16, height: 16, data: vec![0; 16*16*4] };
216/// match rgba {
217///     IconData::Rgba { width, height, .. } => {
218///         assert_eq!(width, 16);
219///         assert_eq!(height, 16);
220///     }
221///     _ => unreachable!(),
222/// }
223/// ```
224#[derive(Debug, Clone, PartialEq, Eq)]
225#[non_exhaustive]
226#[must_use = "loading icon data without using it is likely a bug"]
227pub enum IconData {
228    /// SVG content as raw bytes (from freedesktop themes, bundled icon sets).
229    Svg(Vec<u8>),
230
231    /// Rasterized RGBA pixels (from macOS/Windows system APIs).
232    Rgba {
233        /// Image width in pixels.
234        width: u32,
235        /// Image height in pixels.
236        height: u32,
237        /// Raw RGBA pixel data (4 bytes per pixel, row-major).
238        data: Vec<u8>,
239    },
240}
241
242/// Known icon sets that provide platform-specific icon identifiers.
243///
244/// Each variant corresponds to a well-known icon naming system.
245/// Use [`from_name`](IconSet::from_name) to parse from TOML strings
246/// and [`name`](IconSet::name) to serialize back to kebab-case.
247///
248/// # Examples
249///
250/// ```
251/// use native_theme::IconSet;
252///
253/// let set = IconSet::from_name("sf-symbols").unwrap();
254/// assert_eq!(set, IconSet::SfSymbols);
255/// assert_eq!(set.name(), "sf-symbols");
256///
257/// // Round-trip
258/// let name = IconSet::Material.name();
259/// assert_eq!(IconSet::from_name(name), Some(IconSet::Material));
260///
261/// // Unknown names return None
262/// assert_eq!(IconSet::from_name("unknown"), None);
263/// ```
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
265#[non_exhaustive]
266pub enum IconSet {
267    /// Apple SF Symbols (macOS, iOS).
268    SfSymbols,
269    /// Microsoft Segoe Fluent Icons (Windows).
270    SegoeIcons,
271    /// freedesktop Icon Naming Specification (Linux).
272    Freedesktop,
273    /// Google Material Symbols.
274    Material,
275    /// Lucide Icons (fork of Feather).
276    Lucide,
277}
278
279impl IconSet {
280    /// Parse an icon set from its kebab-case string identifier.
281    ///
282    /// Accepts the names used in TOML configuration:
283    /// `"sf-symbols"`, `"segoe-fluent"`, `"freedesktop"`, `"material"`, `"lucide"`.
284    ///
285    /// Returns `None` for unrecognized names.
286    pub fn from_name(name: &str) -> Option<Self> {
287        match name {
288            "sf-symbols" => Some(Self::SfSymbols),
289            "segoe-fluent" => Some(Self::SegoeIcons),
290            "freedesktop" => Some(Self::Freedesktop),
291            "material" => Some(Self::Material),
292            "lucide" => Some(Self::Lucide),
293            _ => None,
294        }
295    }
296
297    /// The kebab-case string identifier for this icon set, as used in TOML.
298    pub fn name(&self) -> &'static str {
299        match self {
300            Self::SfSymbols => "sf-symbols",
301            Self::SegoeIcons => "segoe-fluent",
302            Self::Freedesktop => "freedesktop",
303            Self::Material => "material",
304            Self::Lucide => "lucide",
305        }
306    }
307}
308
309/// Look up the platform-specific icon identifier for a given icon set and role.
310///
311/// Returns `Some(name)` if the icon set has a standard icon for the role,
312/// or `None` if no standard icon exists (e.g., SF Symbols has no open-folder
313/// variant).
314///
315/// # Examples
316///
317/// ```
318/// use native_theme::{IconSet, IconRole, icon_name};
319///
320/// assert_eq!(icon_name(IconSet::SfSymbols, IconRole::ActionCopy), Some("doc.on.doc"));
321/// assert_eq!(icon_name(IconSet::Freedesktop, IconRole::ActionCopy), Some("edit-copy"));
322/// assert_eq!(icon_name(IconSet::SfSymbols, IconRole::FolderOpen), None);
323/// ```
324#[allow(unreachable_patterns)] // wildcard arm kept for #[non_exhaustive] forward compat
325pub fn icon_name(set: IconSet, role: IconRole) -> Option<&'static str> {
326    match set {
327        IconSet::SfSymbols => sf_symbols_name(role),
328        IconSet::SegoeIcons => segoe_name(role),
329        IconSet::Freedesktop => freedesktop_name(role),
330        IconSet::Material => material_name(role),
331        IconSet::Lucide => lucide_name(role),
332        _ => None,
333    }
334}
335
336/// Detect the native icon set for the current operating system.
337///
338/// Returns the platform-appropriate icon set at runtime using `cfg!()` macros:
339/// - macOS / iOS: [`IconSet::SfSymbols`]
340/// - Windows: [`IconSet::SegoeIcons`]
341/// - Linux: [`IconSet::Freedesktop`]
342/// - Other: [`IconSet::Material`] (safe cross-platform fallback)
343///
344/// # Examples
345///
346/// ```
347/// use native_theme::{IconSet, system_icon_set};
348///
349/// let set = system_icon_set();
350/// // On Linux, this returns Freedesktop
351/// ```
352#[must_use = "this returns the current icon set for the platform"]
353pub fn system_icon_set() -> IconSet {
354    if cfg!(any(target_os = "macos", target_os = "ios")) {
355        IconSet::SfSymbols
356    } else if cfg!(target_os = "windows") {
357        IconSet::SegoeIcons
358    } else if cfg!(target_os = "linux") {
359        IconSet::Freedesktop
360    } else {
361        IconSet::Material
362    }
363}
364
365/// Detect the icon theme name for the current platform.
366///
367/// Returns the name of the icon theme that provides the actual icon files:
368/// - **macOS / iOS:** `"sf-symbols"` (no user-configurable icon theme)
369/// - **Windows:** `"segoe-fluent"` (no user-configurable icon theme)
370/// - **Linux:** DE-specific detection (e.g., `"breeze-dark"`, `"Adwaita"`)
371/// - **Other:** `"material"` (bundled fallback)
372///
373/// On Linux, the detection method depends on the desktop environment:
374/// - KDE: reads `[Icons] Theme` from `kdeglobals`
375/// - GNOME/Budgie: `gsettings get org.gnome.desktop.interface icon-theme`
376/// - Cinnamon: `gsettings get org.cinnamon.desktop.interface icon-theme`
377/// - XFCE: `xfconf-query -c xsettings -p /Net/IconThemeName`
378/// - MATE: `gsettings get org.mate.interface icon-theme`
379/// - LXQt: reads `icon_theme` from `~/.config/lxqt/lxqt.conf`
380/// - Unknown: tries KDE, then GNOME gsettings, then `"hicolor"`
381///
382/// # Examples
383///
384/// ```
385/// use native_theme::system_icon_theme;
386///
387/// let theme = system_icon_theme();
388/// // On a KDE system with Breeze Dark: "breeze-dark"
389/// // On macOS: "sf-symbols"
390/// ```
391#[must_use = "this returns the current icon theme name"]
392pub fn system_icon_theme() -> String {
393    #[cfg(target_os = "linux")]
394    static CACHED_ICON_THEME: OnceLock<String> = OnceLock::new();
395
396    #[cfg(any(target_os = "macos", target_os = "ios"))]
397    {
398        return "sf-symbols".to_string();
399    }
400
401    #[cfg(target_os = "windows")]
402    {
403        return "segoe-fluent".to_string();
404    }
405
406    #[cfg(target_os = "linux")]
407    {
408        CACHED_ICON_THEME
409            .get_or_init(detect_linux_icon_theme)
410            .clone()
411    }
412
413    #[cfg(not(any(
414        target_os = "linux",
415        target_os = "windows",
416        target_os = "macos",
417        target_os = "ios"
418    )))]
419    {
420        "material".to_string()
421    }
422}
423
424/// Linux icon theme detection, dispatched by desktop environment.
425#[cfg(target_os = "linux")]
426fn detect_linux_icon_theme() -> String {
427    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
428    let de = crate::detect_linux_de(&desktop);
429
430    match de {
431        crate::LinuxDesktop::Kde => detect_kde_icon_theme(),
432        crate::LinuxDesktop::Gnome | crate::LinuxDesktop::Budgie => {
433            gsettings_icon_theme("org.gnome.desktop.interface")
434        }
435        crate::LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
436        crate::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
437        crate::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
438        crate::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
439        crate::LinuxDesktop::Unknown => {
440            let kde = detect_kde_icon_theme();
441            if kde != "hicolor" {
442                return kde;
443            }
444            let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
445            if gnome != "hicolor" {
446                return gnome;
447            }
448            "hicolor".to_string()
449        }
450    }
451}
452
453/// Read icon theme from KDE's kdeglobals INI file.
454///
455/// Checks `~/.config/kdeglobals` first, then `~/.config/kdedefaults/kdeglobals`
456/// (Plasma 6 stores distro defaults there, including the icon theme).
457///
458/// Uses simple line parsing — no `configparser` dependency required — so this
459/// works without the `kde` feature enabled.
460#[cfg(target_os = "linux")]
461fn detect_kde_icon_theme() -> String {
462    let config_dir = xdg_config_dir();
463    let paths = [
464        config_dir.join("kdeglobals"),
465        config_dir.join("kdedefaults").join("kdeglobals"),
466    ];
467
468    for path in &paths {
469        if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
470            return theme;
471        }
472    }
473    "hicolor".to_string()
474}
475
476/// Query gsettings for icon-theme with the given schema.
477#[cfg(target_os = "linux")]
478fn gsettings_icon_theme(schema: &str) -> String {
479    std::process::Command::new("gsettings")
480        .args(["get", schema, "icon-theme"])
481        .output()
482        .ok()
483        .filter(|o| o.status.success())
484        .and_then(|o| String::from_utf8(o.stdout).ok())
485        .map(|s| s.trim().trim_matches('\'').to_string())
486        .filter(|s| !s.is_empty())
487        .unwrap_or_else(|| "hicolor".to_string())
488}
489
490/// Read icon theme from XFCE's xfconf-query.
491#[cfg(target_os = "linux")]
492fn detect_xfce_icon_theme() -> String {
493    std::process::Command::new("xfconf-query")
494        .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
495        .output()
496        .ok()
497        .filter(|o| o.status.success())
498        .and_then(|o| String::from_utf8(o.stdout).ok())
499        .map(|s| s.trim().to_string())
500        .filter(|s| !s.is_empty())
501        .unwrap_or_else(|| "hicolor".to_string())
502}
503
504/// Read icon theme from LXQt's config file.
505///
506/// LXQt uses a flat `key=value` format (no section headers for the icon_theme
507/// key), so we scan for the bare `icon_theme=` prefix.
508#[cfg(target_os = "linux")]
509fn detect_lxqt_icon_theme() -> String {
510    let path = xdg_config_dir().join("lxqt").join("lxqt.conf");
511
512    if let Ok(content) = std::fs::read_to_string(&path) {
513        for line in content.lines() {
514            let trimmed = line.trim();
515            if let Some(value) = trimmed.strip_prefix("icon_theme=") {
516                let value = value.trim();
517                if !value.is_empty() {
518                    return value.to_string();
519                }
520            }
521        }
522    }
523    "hicolor".to_string()
524}
525
526/// Resolve `$XDG_CONFIG_HOME`, falling back to `$HOME/.config`.
527#[cfg(target_os = "linux")]
528fn xdg_config_dir() -> std::path::PathBuf {
529    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
530        && !config_home.is_empty()
531    {
532        return std::path::PathBuf::from(config_home);
533    }
534    std::env::var("HOME")
535        .map(std::path::PathBuf::from)
536        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp"))
537        .join(".config")
538}
539
540/// Read a value from an INI file by section and key.
541///
542/// Simple line-based parser — no external crate needed. Handles `[Section]`
543/// headers and `Key=Value` lines. Returns `None` if the file doesn't exist,
544/// the section/key is missing, or the value is empty.
545#[cfg(target_os = "linux")]
546fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
547    let content = std::fs::read_to_string(path).ok()?;
548    let target_section = format!("[{}]", section);
549    let mut in_section = false;
550
551    for line in content.lines() {
552        let trimmed = line.trim();
553        if trimmed.starts_with('[') {
554            in_section = trimmed == target_section;
555            continue;
556        }
557        if in_section && let Some(value) = trimmed.strip_prefix(key) {
558            let value = value.trim_start();
559            if let Some(value) = value.strip_prefix('=') {
560                let value = value.trim();
561                if !value.is_empty() {
562                    return Some(value.to_string());
563                }
564            }
565        }
566    }
567    None
568}
569
570// --- Private mapping functions ---
571
572#[allow(unreachable_patterns)]
573fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
574    Some(match role {
575        // Dialog / Alert
576        IconRole::DialogWarning => "exclamationmark.triangle.fill",
577        IconRole::DialogError => "xmark.circle.fill",
578        IconRole::DialogInfo => "info.circle.fill",
579        IconRole::DialogQuestion => "questionmark.circle.fill",
580        IconRole::DialogSuccess => "checkmark.circle.fill",
581        IconRole::Shield => "shield.fill",
582
583        // Window Controls
584        IconRole::WindowClose => "xmark",
585        IconRole::WindowMinimize => "minus",
586        IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
587        // WindowRestore: no SF Symbol equivalent
588        IconRole::WindowRestore => return None,
589
590        // Common Actions
591        IconRole::ActionSave => "square.and.arrow.down",
592        IconRole::ActionDelete => "trash",
593        IconRole::ActionCopy => "doc.on.doc",
594        IconRole::ActionPaste => "doc.on.clipboard",
595        IconRole::ActionCut => "scissors",
596        IconRole::ActionUndo => "arrow.uturn.backward",
597        IconRole::ActionRedo => "arrow.uturn.forward",
598        IconRole::ActionSearch => "magnifyingglass",
599        IconRole::ActionSettings => "gearshape",
600        IconRole::ActionEdit => "pencil",
601        IconRole::ActionAdd => "plus",
602        IconRole::ActionRemove => "minus",
603        IconRole::ActionRefresh => "arrow.clockwise",
604        IconRole::ActionPrint => "printer",
605
606        // Navigation
607        IconRole::NavBack => "chevron.backward",
608        IconRole::NavForward => "chevron.forward",
609        IconRole::NavUp => "chevron.up",
610        IconRole::NavDown => "chevron.down",
611        IconRole::NavHome => "house",
612        IconRole::NavMenu => "line.horizontal.3",
613
614        // Files / Places
615        IconRole::FileGeneric => "doc",
616        IconRole::FolderClosed => "folder",
617        // FolderOpen: no SF Symbol equivalent
618        IconRole::FolderOpen => return None,
619        IconRole::TrashEmpty => "trash",
620        // TrashFull: no SF Symbol equivalent
621        IconRole::TrashFull => return None,
622
623        // Status
624        // StatusLoading: no static SF Symbol (loading is animated)
625        IconRole::StatusLoading => return None,
626        IconRole::StatusCheck => "checkmark",
627        IconRole::StatusError => "xmark.circle.fill",
628
629        // System
630        IconRole::UserAccount => "person.fill",
631        IconRole::Notification => "bell.fill",
632        IconRole::Help => "questionmark.circle",
633        IconRole::Lock => "lock.fill",
634
635        _ => return None,
636    })
637}
638
639#[allow(unreachable_patterns)]
640fn segoe_name(role: IconRole) -> Option<&'static str> {
641    Some(match role {
642        // Dialog / Alert (SHSTOCKICONID constants)
643        IconRole::DialogWarning => "SIID_WARNING",
644        IconRole::DialogError => "SIID_ERROR",
645        IconRole::DialogInfo => "SIID_INFO",
646        IconRole::DialogQuestion => "IDI_QUESTION",
647        // DialogSuccess: no Windows stock icon
648        IconRole::DialogSuccess => return None,
649        IconRole::Shield => "SIID_SHIELD",
650
651        // Window Controls (Segoe Fluent Icons glyphs)
652        IconRole::WindowClose => "ChromeClose",
653        IconRole::WindowMinimize => "ChromeMinimize",
654        IconRole::WindowMaximize => "ChromeMaximize",
655        IconRole::WindowRestore => "ChromeRestore",
656
657        // Common Actions (mix of SHSTOCKICONID and Segoe Fluent)
658        IconRole::ActionSave => "Save",
659        IconRole::ActionDelete => "SIID_DELETE",
660        IconRole::ActionCopy => "Copy",
661        IconRole::ActionPaste => "Paste",
662        IconRole::ActionCut => "Cut",
663        IconRole::ActionUndo => "Undo",
664        IconRole::ActionRedo => "Redo",
665        IconRole::ActionSearch => "SIID_FIND",
666        IconRole::ActionSettings => "SIID_SETTINGS",
667        IconRole::ActionEdit => "Edit",
668        IconRole::ActionAdd => "Add",
669        IconRole::ActionRemove => "Remove",
670        IconRole::ActionRefresh => "Refresh",
671        IconRole::ActionPrint => "SIID_PRINTER",
672
673        // Navigation (Segoe Fluent Icons)
674        IconRole::NavBack => "Back",
675        IconRole::NavForward => "Forward",
676        IconRole::NavUp => "Up",
677        IconRole::NavDown => "Down",
678        IconRole::NavHome => "Home",
679        IconRole::NavMenu => "GlobalNavigationButton",
680
681        // Files / Places (SHSTOCKICONID)
682        IconRole::FileGeneric => "SIID_DOCNOASSOC",
683        IconRole::FolderClosed => "SIID_FOLDER",
684        IconRole::FolderOpen => "SIID_FOLDEROPEN",
685        IconRole::TrashEmpty => "SIID_RECYCLER",
686        IconRole::TrashFull => "SIID_RECYCLERFULL",
687
688        // Status
689        // StatusLoading: no static Windows icon (progress ring is animated)
690        IconRole::StatusLoading => return None,
691        IconRole::StatusCheck => "CheckMark",
692        IconRole::StatusError => "SIID_ERROR",
693
694        // System
695        IconRole::UserAccount => "SIID_USERS",
696        IconRole::Notification => "Ringer",
697        IconRole::Help => "SIID_HELP",
698        IconRole::Lock => "SIID_LOCK",
699
700        _ => return None,
701    })
702}
703
704#[allow(unreachable_patterns)]
705fn freedesktop_name(role: IconRole) -> Option<&'static str> {
706    Some(match role {
707        // Dialog / Alert
708        IconRole::DialogWarning => "dialog-warning",
709        IconRole::DialogError => "dialog-error",
710        IconRole::DialogInfo => "dialog-information",
711        IconRole::DialogQuestion => "dialog-question",
712        IconRole::DialogSuccess => "emblem-ok-symbolic",
713        IconRole::Shield => "security-high",
714
715        // Window Controls
716        IconRole::WindowClose => "window-close",
717        IconRole::WindowMinimize => "window-minimize",
718        IconRole::WindowMaximize => "window-maximize",
719        IconRole::WindowRestore => "window-restore",
720
721        // Common Actions
722        IconRole::ActionSave => "document-save",
723        IconRole::ActionDelete => "edit-delete",
724        IconRole::ActionCopy => "edit-copy",
725        IconRole::ActionPaste => "edit-paste",
726        IconRole::ActionCut => "edit-cut",
727        IconRole::ActionUndo => "edit-undo",
728        IconRole::ActionRedo => "edit-redo",
729        IconRole::ActionSearch => "edit-find",
730        IconRole::ActionSettings => "preferences-system",
731        IconRole::ActionEdit => "document-edit",
732        IconRole::ActionAdd => "list-add",
733        IconRole::ActionRemove => "list-remove",
734        IconRole::ActionRefresh => "view-refresh",
735        IconRole::ActionPrint => "document-print",
736
737        // Navigation
738        IconRole::NavBack => "go-previous",
739        IconRole::NavForward => "go-next",
740        IconRole::NavUp => "go-up",
741        IconRole::NavDown => "go-down",
742        IconRole::NavHome => "go-home",
743        IconRole::NavMenu => "open-menu",
744
745        // Files / Places
746        IconRole::FileGeneric => "text-x-generic",
747        IconRole::FolderClosed => "folder",
748        IconRole::FolderOpen => "folder-open",
749        IconRole::TrashEmpty => "user-trash",
750        IconRole::TrashFull => "user-trash-full",
751
752        // Status
753        IconRole::StatusLoading => "process-working",
754        IconRole::StatusCheck => "emblem-default",
755        IconRole::StatusError => "dialog-error",
756
757        // System
758        IconRole::UserAccount => "system-users",
759        // Notification: no freedesktop standard notification bell icon
760        IconRole::Notification => return None,
761        IconRole::Help => "help-browser",
762        IconRole::Lock => "system-lock-screen",
763
764        _ => return None,
765    })
766}
767
768#[allow(unreachable_patterns)]
769fn material_name(role: IconRole) -> Option<&'static str> {
770    Some(match role {
771        // Dialog / Alert
772        IconRole::DialogWarning => "warning",
773        IconRole::DialogError => "error",
774        IconRole::DialogInfo => "info",
775        IconRole::DialogQuestion => "help",
776        IconRole::DialogSuccess => "check_circle",
777        IconRole::Shield => "shield",
778
779        // Window Controls
780        IconRole::WindowClose => "close",
781        IconRole::WindowMinimize => "minimize",
782        IconRole::WindowMaximize => "open_in_full",
783        IconRole::WindowRestore => "close_fullscreen",
784
785        // Common Actions
786        IconRole::ActionSave => "save",
787        IconRole::ActionDelete => "delete",
788        IconRole::ActionCopy => "content_copy",
789        IconRole::ActionPaste => "content_paste",
790        IconRole::ActionCut => "content_cut",
791        IconRole::ActionUndo => "undo",
792        IconRole::ActionRedo => "redo",
793        IconRole::ActionSearch => "search",
794        IconRole::ActionSettings => "settings",
795        IconRole::ActionEdit => "edit",
796        IconRole::ActionAdd => "add",
797        IconRole::ActionRemove => "remove",
798        IconRole::ActionRefresh => "refresh",
799        IconRole::ActionPrint => "print",
800
801        // Navigation
802        IconRole::NavBack => "arrow_back",
803        IconRole::NavForward => "arrow_forward",
804        IconRole::NavUp => "arrow_upward",
805        IconRole::NavDown => "arrow_downward",
806        IconRole::NavHome => "home",
807        IconRole::NavMenu => "menu",
808
809        // Files / Places
810        IconRole::FileGeneric => "description",
811        IconRole::FolderClosed => "folder",
812        IconRole::FolderOpen => "folder_open",
813        IconRole::TrashEmpty => "delete",
814        // TrashFull: Material has no separate full-trash icon
815        IconRole::TrashFull => return None,
816
817        // Status
818        IconRole::StatusLoading => "progress_activity",
819        IconRole::StatusCheck => "check",
820        IconRole::StatusError => "error",
821
822        // System
823        IconRole::UserAccount => "person",
824        IconRole::Notification => "notifications",
825        IconRole::Help => "help",
826        IconRole::Lock => "lock",
827
828        _ => return None,
829    })
830}
831
832#[allow(unreachable_patterns)]
833fn lucide_name(role: IconRole) -> Option<&'static str> {
834    Some(match role {
835        // Dialog / Alert
836        IconRole::DialogWarning => "triangle-alert",
837        IconRole::DialogError => "circle-x",
838        IconRole::DialogInfo => "info",
839        IconRole::DialogQuestion => "circle-question-mark",
840        IconRole::DialogSuccess => "circle-check",
841        IconRole::Shield => "shield",
842
843        // Window Controls
844        IconRole::WindowClose => "x",
845        IconRole::WindowMinimize => "minimize",
846        IconRole::WindowMaximize => "maximize",
847        IconRole::WindowRestore => "minimize-2",
848
849        // Common Actions
850        IconRole::ActionSave => "save",
851        IconRole::ActionDelete => "trash-2",
852        IconRole::ActionCopy => "copy",
853        IconRole::ActionPaste => "clipboard-paste",
854        IconRole::ActionCut => "scissors",
855        IconRole::ActionUndo => "undo-2",
856        IconRole::ActionRedo => "redo-2",
857        IconRole::ActionSearch => "search",
858        IconRole::ActionSettings => "settings",
859        IconRole::ActionEdit => "pencil",
860        IconRole::ActionAdd => "plus",
861        IconRole::ActionRemove => "minus",
862        IconRole::ActionRefresh => "refresh-cw",
863        IconRole::ActionPrint => "printer",
864
865        // Navigation
866        IconRole::NavBack => "chevron-left",
867        IconRole::NavForward => "chevron-right",
868        IconRole::NavUp => "chevron-up",
869        IconRole::NavDown => "chevron-down",
870        IconRole::NavHome => "house",
871        IconRole::NavMenu => "menu",
872
873        // Files / Places
874        IconRole::FileGeneric => "file",
875        IconRole::FolderClosed => "folder-closed",
876        IconRole::FolderOpen => "folder-open",
877        IconRole::TrashEmpty => "trash-2",
878        // TrashFull: Lucide has no separate full-trash icon
879        IconRole::TrashFull => return None,
880
881        // Status
882        IconRole::StatusLoading => "loader",
883        IconRole::StatusCheck => "check",
884        IconRole::StatusError => "circle-x",
885
886        // System
887        IconRole::UserAccount => "user",
888        IconRole::Notification => "bell",
889        IconRole::Help => "circle-question-mark",
890        IconRole::Lock => "lock",
891
892        _ => return None,
893    })
894}
895
896#[cfg(test)]
897mod tests {
898    use super::*;
899
900    // === IconRole tests ===
901
902    #[test]
903    fn icon_role_all_has_42_variants() {
904        assert_eq!(IconRole::ALL.len(), 42);
905    }
906
907    #[test]
908    fn icon_role_all_contains_every_variant() {
909        // Verify specific variants from each category are present
910        let all = &IconRole::ALL;
911
912        // Dialog (6)
913        assert!(all.contains(&IconRole::DialogWarning));
914        assert!(all.contains(&IconRole::DialogError));
915        assert!(all.contains(&IconRole::DialogInfo));
916        assert!(all.contains(&IconRole::DialogQuestion));
917        assert!(all.contains(&IconRole::DialogSuccess));
918        assert!(all.contains(&IconRole::Shield));
919
920        // Window (4)
921        assert!(all.contains(&IconRole::WindowClose));
922        assert!(all.contains(&IconRole::WindowMinimize));
923        assert!(all.contains(&IconRole::WindowMaximize));
924        assert!(all.contains(&IconRole::WindowRestore));
925
926        // Action (14)
927        assert!(all.contains(&IconRole::ActionSave));
928        assert!(all.contains(&IconRole::ActionDelete));
929        assert!(all.contains(&IconRole::ActionCopy));
930        assert!(all.contains(&IconRole::ActionPaste));
931        assert!(all.contains(&IconRole::ActionCut));
932        assert!(all.contains(&IconRole::ActionUndo));
933        assert!(all.contains(&IconRole::ActionRedo));
934        assert!(all.contains(&IconRole::ActionSearch));
935        assert!(all.contains(&IconRole::ActionSettings));
936        assert!(all.contains(&IconRole::ActionEdit));
937        assert!(all.contains(&IconRole::ActionAdd));
938        assert!(all.contains(&IconRole::ActionRemove));
939        assert!(all.contains(&IconRole::ActionRefresh));
940        assert!(all.contains(&IconRole::ActionPrint));
941
942        // Navigation (6)
943        assert!(all.contains(&IconRole::NavBack));
944        assert!(all.contains(&IconRole::NavForward));
945        assert!(all.contains(&IconRole::NavUp));
946        assert!(all.contains(&IconRole::NavDown));
947        assert!(all.contains(&IconRole::NavHome));
948        assert!(all.contains(&IconRole::NavMenu));
949
950        // Files (5)
951        assert!(all.contains(&IconRole::FileGeneric));
952        assert!(all.contains(&IconRole::FolderClosed));
953        assert!(all.contains(&IconRole::FolderOpen));
954        assert!(all.contains(&IconRole::TrashEmpty));
955        assert!(all.contains(&IconRole::TrashFull));
956
957        // Status (3)
958        assert!(all.contains(&IconRole::StatusLoading));
959        assert!(all.contains(&IconRole::StatusCheck));
960        assert!(all.contains(&IconRole::StatusError));
961
962        // System (4)
963        assert!(all.contains(&IconRole::UserAccount));
964        assert!(all.contains(&IconRole::Notification));
965        assert!(all.contains(&IconRole::Help));
966        assert!(all.contains(&IconRole::Lock));
967    }
968
969    #[test]
970    fn icon_role_all_no_duplicates() {
971        let all = &IconRole::ALL;
972        for (i, role) in all.iter().enumerate() {
973            for (j, other) in all.iter().enumerate() {
974                if i != j {
975                    assert_ne!(role, other, "Duplicate at index {i} and {j}");
976                }
977            }
978        }
979    }
980
981    #[test]
982    fn icon_role_derives_copy_clone() {
983        let role = IconRole::ActionCopy;
984        let copied1 = role;
985        let copied2 = role;
986        assert_eq!(role, copied1);
987        assert_eq!(role, copied2);
988    }
989
990    #[test]
991    fn icon_role_derives_debug() {
992        let s = format!("{:?}", IconRole::DialogWarning);
993        assert!(s.contains("DialogWarning"));
994    }
995
996    #[test]
997    fn icon_role_derives_hash() {
998        use std::collections::HashSet;
999        let mut set = HashSet::new();
1000        set.insert(IconRole::ActionSave);
1001        set.insert(IconRole::ActionDelete);
1002        assert_eq!(set.len(), 2);
1003        assert!(set.contains(&IconRole::ActionSave));
1004    }
1005
1006    // === IconData tests ===
1007
1008    #[test]
1009    fn icon_data_svg_construct_and_match() {
1010        let svg_bytes = b"<svg></svg>".to_vec();
1011        let data = IconData::Svg(svg_bytes.clone());
1012        match data {
1013            IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1014            _ => panic!("Expected Svg variant"),
1015        }
1016    }
1017
1018    #[test]
1019    fn icon_data_rgba_construct_and_match() {
1020        let pixels = vec![255, 0, 0, 255]; // 1 red pixel
1021        let data = IconData::Rgba {
1022            width: 1,
1023            height: 1,
1024            data: pixels.clone(),
1025        };
1026        match data {
1027            IconData::Rgba {
1028                width,
1029                height,
1030                data,
1031            } => {
1032                assert_eq!(width, 1);
1033                assert_eq!(height, 1);
1034                assert_eq!(data, pixels);
1035            }
1036            _ => panic!("Expected Rgba variant"),
1037        }
1038    }
1039
1040    #[test]
1041    fn icon_data_derives_debug() {
1042        let data = IconData::Svg(vec![]);
1043        let s = format!("{:?}", data);
1044        assert!(s.contains("Svg"));
1045    }
1046
1047    #[test]
1048    fn icon_data_derives_clone() {
1049        let data = IconData::Rgba {
1050            width: 16,
1051            height: 16,
1052            data: vec![0; 16 * 16 * 4],
1053        };
1054        let cloned = data.clone();
1055        assert_eq!(data, cloned);
1056    }
1057
1058    #[test]
1059    fn icon_data_derives_eq() {
1060        let a = IconData::Svg(b"<svg/>".to_vec());
1061        let b = IconData::Svg(b"<svg/>".to_vec());
1062        assert_eq!(a, b);
1063
1064        let c = IconData::Svg(b"<other/>".to_vec());
1065        assert_ne!(a, c);
1066    }
1067
1068    // === IconSet tests ===
1069
1070    #[test]
1071    fn icon_set_from_name_sf_symbols() {
1072        assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1073    }
1074
1075    #[test]
1076    fn icon_set_from_name_segoe_fluent() {
1077        assert_eq!(
1078            IconSet::from_name("segoe-fluent"),
1079            Some(IconSet::SegoeIcons)
1080        );
1081    }
1082
1083    #[test]
1084    fn icon_set_from_name_freedesktop() {
1085        assert_eq!(
1086            IconSet::from_name("freedesktop"),
1087            Some(IconSet::Freedesktop)
1088        );
1089    }
1090
1091    #[test]
1092    fn icon_set_from_name_material() {
1093        assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1094    }
1095
1096    #[test]
1097    fn icon_set_from_name_lucide() {
1098        assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1099    }
1100
1101    #[test]
1102    fn icon_set_from_name_unknown() {
1103        assert_eq!(IconSet::from_name("unknown"), None);
1104    }
1105
1106    #[test]
1107    fn icon_set_name_sf_symbols() {
1108        assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1109    }
1110
1111    #[test]
1112    fn icon_set_name_segoe_fluent() {
1113        assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1114    }
1115
1116    #[test]
1117    fn icon_set_name_freedesktop() {
1118        assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1119    }
1120
1121    #[test]
1122    fn icon_set_name_material() {
1123        assert_eq!(IconSet::Material.name(), "material");
1124    }
1125
1126    #[test]
1127    fn icon_set_name_lucide() {
1128        assert_eq!(IconSet::Lucide.name(), "lucide");
1129    }
1130
1131    #[test]
1132    fn icon_set_from_name_name_round_trip() {
1133        let sets = [
1134            IconSet::SfSymbols,
1135            IconSet::SegoeIcons,
1136            IconSet::Freedesktop,
1137            IconSet::Material,
1138            IconSet::Lucide,
1139        ];
1140        for set in &sets {
1141            let name = set.name();
1142            let parsed = IconSet::from_name(name);
1143            assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1144        }
1145    }
1146
1147    #[test]
1148    fn icon_set_derives_copy_clone() {
1149        let set = IconSet::Material;
1150        let copied1 = set;
1151        let copied2 = set;
1152        assert_eq!(set, copied1);
1153        assert_eq!(set, copied2);
1154    }
1155
1156    #[test]
1157    fn icon_set_derives_hash() {
1158        use std::collections::HashSet;
1159        let mut map = HashSet::new();
1160        map.insert(IconSet::SfSymbols);
1161        map.insert(IconSet::Lucide);
1162        assert_eq!(map.len(), 2);
1163    }
1164
1165    #[test]
1166    fn icon_set_derives_debug() {
1167        let s = format!("{:?}", IconSet::Freedesktop);
1168        assert!(s.contains("Freedesktop"));
1169    }
1170
1171    #[test]
1172    fn icon_set_serde_round_trip() {
1173        let set = IconSet::SfSymbols;
1174        let json = serde_json::to_string(&set).unwrap();
1175        let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1176        assert_eq!(set, deserialized);
1177    }
1178
1179    // === icon_name() tests ===
1180
1181    #[test]
1182    fn icon_name_sf_symbols_action_copy() {
1183        assert_eq!(
1184            icon_name(IconSet::SfSymbols, IconRole::ActionCopy),
1185            Some("doc.on.doc")
1186        );
1187    }
1188
1189    #[test]
1190    fn icon_name_segoe_action_copy() {
1191        assert_eq!(
1192            icon_name(IconSet::SegoeIcons, IconRole::ActionCopy),
1193            Some("Copy")
1194        );
1195    }
1196
1197    #[test]
1198    fn icon_name_freedesktop_action_copy() {
1199        assert_eq!(
1200            icon_name(IconSet::Freedesktop, IconRole::ActionCopy),
1201            Some("edit-copy")
1202        );
1203    }
1204
1205    #[test]
1206    fn icon_name_material_action_copy() {
1207        assert_eq!(
1208            icon_name(IconSet::Material, IconRole::ActionCopy),
1209            Some("content_copy")
1210        );
1211    }
1212
1213    #[test]
1214    fn icon_name_lucide_action_copy() {
1215        assert_eq!(
1216            icon_name(IconSet::Lucide, IconRole::ActionCopy),
1217            Some("copy")
1218        );
1219    }
1220
1221    #[test]
1222    fn icon_name_sf_symbols_dialog_warning() {
1223        assert_eq!(
1224            icon_name(IconSet::SfSymbols, IconRole::DialogWarning),
1225            Some("exclamationmark.triangle.fill")
1226        );
1227    }
1228
1229    // None cases for known gaps
1230    #[test]
1231    fn icon_name_sf_symbols_folder_open_is_none() {
1232        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::FolderOpen), None);
1233    }
1234
1235    #[test]
1236    fn icon_name_sf_symbols_trash_full_is_none() {
1237        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::TrashFull), None);
1238    }
1239
1240    #[test]
1241    fn icon_name_sf_symbols_status_loading_is_none() {
1242        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::StatusLoading), None);
1243    }
1244
1245    #[test]
1246    fn icon_name_sf_symbols_window_restore_is_none() {
1247        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::WindowRestore), None);
1248    }
1249
1250    #[test]
1251    fn icon_name_segoe_dialog_success_is_none() {
1252        assert_eq!(
1253            icon_name(IconSet::SegoeIcons, IconRole::DialogSuccess),
1254            None
1255        );
1256    }
1257
1258    #[test]
1259    fn icon_name_segoe_status_loading_is_none() {
1260        assert_eq!(
1261            icon_name(IconSet::SegoeIcons, IconRole::StatusLoading),
1262            None
1263        );
1264    }
1265
1266    #[test]
1267    fn icon_name_freedesktop_notification_is_none() {
1268        assert_eq!(
1269            icon_name(IconSet::Freedesktop, IconRole::Notification),
1270            None
1271        );
1272    }
1273
1274    #[test]
1275    fn icon_name_material_trash_full_is_none() {
1276        assert_eq!(icon_name(IconSet::Material, IconRole::TrashFull), None);
1277    }
1278
1279    #[test]
1280    fn icon_name_lucide_trash_full_is_none() {
1281        assert_eq!(icon_name(IconSet::Lucide, IconRole::TrashFull), None);
1282    }
1283
1284    // Spot-check across all 5 icon sets for multiple roles
1285    #[test]
1286    fn icon_name_spot_check_dialog_error() {
1287        assert_eq!(
1288            icon_name(IconSet::SfSymbols, IconRole::DialogError),
1289            Some("xmark.circle.fill")
1290        );
1291        assert_eq!(
1292            icon_name(IconSet::SegoeIcons, IconRole::DialogError),
1293            Some("SIID_ERROR")
1294        );
1295        assert_eq!(
1296            icon_name(IconSet::Freedesktop, IconRole::DialogError),
1297            Some("dialog-error")
1298        );
1299        assert_eq!(
1300            icon_name(IconSet::Material, IconRole::DialogError),
1301            Some("error")
1302        );
1303        assert_eq!(
1304            icon_name(IconSet::Lucide, IconRole::DialogError),
1305            Some("circle-x")
1306        );
1307    }
1308
1309    #[test]
1310    fn icon_name_spot_check_nav_home() {
1311        assert_eq!(
1312            icon_name(IconSet::SfSymbols, IconRole::NavHome),
1313            Some("house")
1314        );
1315        assert_eq!(
1316            icon_name(IconSet::SegoeIcons, IconRole::NavHome),
1317            Some("Home")
1318        );
1319        assert_eq!(
1320            icon_name(IconSet::Freedesktop, IconRole::NavHome),
1321            Some("go-home")
1322        );
1323        assert_eq!(
1324            icon_name(IconSet::Material, IconRole::NavHome),
1325            Some("home")
1326        );
1327        assert_eq!(icon_name(IconSet::Lucide, IconRole::NavHome), Some("house"));
1328    }
1329
1330    // Count test: verify expected Some/None count for each icon set
1331    #[test]
1332    fn icon_name_sf_symbols_expected_count() {
1333        // SF Symbols: 42 - 4 None (FolderOpen, TrashFull, StatusLoading, WindowRestore) = 38 Some
1334        let some_count = IconRole::ALL
1335            .iter()
1336            .filter(|r| icon_name(IconSet::SfSymbols, **r).is_some())
1337            .count();
1338        assert_eq!(some_count, 38, "SF Symbols should have 38 mappings");
1339    }
1340
1341    #[test]
1342    fn icon_name_segoe_expected_count() {
1343        // Segoe: 42 - 2 None (DialogSuccess, StatusLoading) = 40 Some
1344        let some_count = IconRole::ALL
1345            .iter()
1346            .filter(|r| icon_name(IconSet::SegoeIcons, **r).is_some())
1347            .count();
1348        assert_eq!(some_count, 40, "Segoe Icons should have 40 mappings");
1349    }
1350
1351    #[test]
1352    fn icon_name_freedesktop_expected_count() {
1353        // Freedesktop: 42 - 1 None (Notification) = 41 Some
1354        let some_count = IconRole::ALL
1355            .iter()
1356            .filter(|r| icon_name(IconSet::Freedesktop, **r).is_some())
1357            .count();
1358        assert_eq!(some_count, 41, "Freedesktop should have 41 mappings");
1359    }
1360
1361    #[test]
1362    fn icon_name_material_expected_count() {
1363        // Material: 42 - 1 None (TrashFull) = 41 Some
1364        let some_count = IconRole::ALL
1365            .iter()
1366            .filter(|r| icon_name(IconSet::Material, **r).is_some())
1367            .count();
1368        assert_eq!(some_count, 41, "Material should have 41 mappings");
1369    }
1370
1371    #[test]
1372    fn icon_name_lucide_expected_count() {
1373        // Lucide: 42 - 1 None (TrashFull) = 41 Some
1374        let some_count = IconRole::ALL
1375            .iter()
1376            .filter(|r| icon_name(IconSet::Lucide, **r).is_some())
1377            .count();
1378        assert_eq!(some_count, 41, "Lucide should have 41 mappings");
1379    }
1380
1381    // === system_icon_set() tests ===
1382
1383    #[test]
1384    fn system_icon_set_returns_freedesktop_on_linux() {
1385        // This test is only meaningful on Linux (our CI/test platform)
1386        assert_eq!(system_icon_set(), IconSet::Freedesktop);
1387    }
1388
1389    #[test]
1390    fn system_icon_theme_returns_non_empty() {
1391        let theme = system_icon_theme();
1392        assert!(
1393            !theme.is_empty(),
1394            "system_icon_theme() should return a non-empty string"
1395        );
1396    }
1397}