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    /// Busy / working state indicator
125    StatusBusy,
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::StatusBusy,
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/// Trait for types that map icon identifiers to platform-specific names and SVG data.
310///
311/// Implement this trait on an enum to make its variants loadable via
312/// [`load_custom_icon()`](crate::load_custom_icon). The typical pattern is
313/// for each enum variant to represent an icon role, with `icon_name()` returning
314/// the platform-specific identifier and `icon_svg()` returning embedded SVG bytes.
315///
316/// The `native-theme-build` crate can auto-generate implementations from TOML
317/// definitions at build time, so manual implementation is only needed for
318/// special cases.
319///
320/// [`IconRole`] implements this trait, delegating to the built-in icon mappings.
321///
322/// # Object Safety
323///
324/// This trait is object-safe (only requires [`Debug`] as a supertrait).
325/// `Box<dyn IconProvider>` works for dynamic dispatch.
326///
327/// # Examples
328///
329/// ```
330/// use native_theme::{IconProvider, IconSet};
331///
332/// #[derive(Debug)]
333/// enum MyIcon { Play, Pause }
334///
335/// impl IconProvider for MyIcon {
336///     fn icon_name(&self, set: IconSet) -> Option<&str> {
337///         match (self, set) {
338///             (MyIcon::Play, IconSet::SfSymbols) => Some("play.fill"),
339///             (MyIcon::Play, IconSet::Material) => Some("play_arrow"),
340///             (MyIcon::Pause, IconSet::SfSymbols) => Some("pause.fill"),
341///             (MyIcon::Pause, IconSet::Material) => Some("pause"),
342///             _ => None,
343///         }
344///     }
345///     fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
346///         None // No bundled SVGs in this example
347///     }
348/// }
349/// ```
350pub trait IconProvider: std::fmt::Debug {
351    /// Return the platform/theme-specific icon name for this icon in the given set.
352    fn icon_name(&self, set: IconSet) -> Option<&str>;
353
354    /// Return bundled SVG bytes for this icon in the given set.
355    fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]>;
356}
357
358impl IconProvider for IconRole {
359    fn icon_name(&self, set: IconSet) -> Option<&str> {
360        icon_name(set, *self)
361    }
362
363    fn icon_svg(&self, set: IconSet) -> Option<&'static [u8]> {
364        crate::model::bundled::bundled_icon_svg(set, *self)
365    }
366}
367
368/// Look up the platform-specific icon identifier for a given icon set and role.
369///
370/// Returns `Some(name)` if the icon set has a standard icon for the role,
371/// or `None` if no standard icon exists (e.g., SF Symbols has no open-folder
372/// variant).
373///
374/// # Examples
375///
376/// ```
377/// use native_theme::{IconSet, IconRole, icon_name};
378///
379/// assert_eq!(icon_name(IconSet::SfSymbols, IconRole::ActionCopy), Some("doc.on.doc"));
380/// assert_eq!(icon_name(IconSet::Freedesktop, IconRole::ActionCopy), Some("edit-copy"));
381/// assert_eq!(icon_name(IconSet::SfSymbols, IconRole::FolderOpen), None);
382/// ```
383#[allow(unreachable_patterns)] // wildcard arm kept for #[non_exhaustive] forward compat
384pub fn icon_name(set: IconSet, role: IconRole) -> Option<&'static str> {
385    match set {
386        IconSet::SfSymbols => sf_symbols_name(role),
387        IconSet::SegoeIcons => segoe_name(role),
388        IconSet::Freedesktop => freedesktop_name(role),
389        IconSet::Material => material_name(role),
390        IconSet::Lucide => lucide_name(role),
391        _ => None,
392    }
393}
394
395/// Detect the native icon set for the current operating system.
396///
397/// Returns the platform-appropriate icon set at runtime using `cfg!()` macros:
398/// - macOS / iOS: [`IconSet::SfSymbols`]
399/// - Windows: [`IconSet::SegoeIcons`]
400/// - Linux: [`IconSet::Freedesktop`]
401/// - Other: [`IconSet::Material`] (safe cross-platform fallback)
402///
403/// # Examples
404///
405/// ```
406/// use native_theme::{IconSet, system_icon_set};
407///
408/// let set = system_icon_set();
409/// // On Linux, this returns Freedesktop
410/// ```
411#[must_use = "this returns the current icon set for the platform"]
412pub fn system_icon_set() -> IconSet {
413    if cfg!(any(target_os = "macos", target_os = "ios")) {
414        IconSet::SfSymbols
415    } else if cfg!(target_os = "windows") {
416        IconSet::SegoeIcons
417    } else if cfg!(target_os = "linux") {
418        IconSet::Freedesktop
419    } else {
420        IconSet::Material
421    }
422}
423
424/// Detect the icon theme name for the current platform.
425///
426/// Returns the name of the icon theme that provides the actual icon files:
427/// - **macOS / iOS:** `"sf-symbols"` (no user-configurable icon theme)
428/// - **Windows:** `"segoe-fluent"` (no user-configurable icon theme)
429/// - **Linux:** DE-specific detection (e.g., `"breeze-dark"`, `"Adwaita"`)
430/// - **Other:** `"material"` (bundled fallback)
431///
432/// On Linux, the detection method depends on the desktop environment:
433/// - KDE: reads `[Icons] Theme` from `kdeglobals`
434/// - GNOME/Budgie: `gsettings get org.gnome.desktop.interface icon-theme`
435/// - Cinnamon: `gsettings get org.cinnamon.desktop.interface icon-theme`
436/// - XFCE: `xfconf-query -c xsettings -p /Net/IconThemeName`
437/// - MATE: `gsettings get org.mate.interface icon-theme`
438/// - LXQt: reads `icon_theme` from `~/.config/lxqt/lxqt.conf`
439/// - Unknown: tries KDE, then GNOME gsettings, then `"hicolor"`
440///
441/// # Examples
442///
443/// ```
444/// use native_theme::system_icon_theme;
445///
446/// let theme = system_icon_theme();
447/// // On a KDE system with Breeze Dark: "breeze-dark"
448/// // On macOS: "sf-symbols"
449/// ```
450#[must_use = "this returns the current icon theme name"]
451pub fn system_icon_theme() -> String {
452    #[cfg(target_os = "linux")]
453    static CACHED_ICON_THEME: OnceLock<String> = OnceLock::new();
454
455    #[cfg(any(target_os = "macos", target_os = "ios"))]
456    {
457        return "sf-symbols".to_string();
458    }
459
460    #[cfg(target_os = "windows")]
461    {
462        return "segoe-fluent".to_string();
463    }
464
465    #[cfg(target_os = "linux")]
466    {
467        CACHED_ICON_THEME
468            .get_or_init(detect_linux_icon_theme)
469            .clone()
470    }
471
472    #[cfg(not(any(
473        target_os = "linux",
474        target_os = "windows",
475        target_os = "macos",
476        target_os = "ios"
477    )))]
478    {
479        "material".to_string()
480    }
481}
482
483/// Linux icon theme detection, dispatched by desktop environment.
484#[cfg(target_os = "linux")]
485fn detect_linux_icon_theme() -> String {
486    let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
487    let de = crate::detect_linux_de(&desktop);
488
489    match de {
490        crate::LinuxDesktop::Kde => detect_kde_icon_theme(),
491        crate::LinuxDesktop::Gnome | crate::LinuxDesktop::Budgie => {
492            gsettings_icon_theme("org.gnome.desktop.interface")
493        }
494        crate::LinuxDesktop::Cinnamon => gsettings_icon_theme("org.cinnamon.desktop.interface"),
495        crate::LinuxDesktop::Xfce => detect_xfce_icon_theme(),
496        crate::LinuxDesktop::Mate => gsettings_icon_theme("org.mate.interface"),
497        crate::LinuxDesktop::LxQt => detect_lxqt_icon_theme(),
498        crate::LinuxDesktop::Unknown => {
499            let kde = detect_kde_icon_theme();
500            if kde != "hicolor" {
501                return kde;
502            }
503            let gnome = gsettings_icon_theme("org.gnome.desktop.interface");
504            if gnome != "hicolor" {
505                return gnome;
506            }
507            "hicolor".to_string()
508        }
509    }
510}
511
512/// Read icon theme from KDE's kdeglobals INI file.
513///
514/// Checks `~/.config/kdeglobals` first, then `~/.config/kdedefaults/kdeglobals`
515/// (Plasma 6 stores distro defaults there, including the icon theme).
516///
517/// Uses simple line parsing — no `configparser` dependency required — so this
518/// works without the `kde` feature enabled.
519#[cfg(target_os = "linux")]
520fn detect_kde_icon_theme() -> String {
521    let config_dir = xdg_config_dir();
522    let paths = [
523        config_dir.join("kdeglobals"),
524        config_dir.join("kdedefaults").join("kdeglobals"),
525    ];
526
527    for path in &paths {
528        if let Some(theme) = read_ini_value(path, "Icons", "Theme") {
529            return theme;
530        }
531    }
532    "hicolor".to_string()
533}
534
535/// Query gsettings for icon-theme with the given schema.
536#[cfg(target_os = "linux")]
537fn gsettings_icon_theme(schema: &str) -> String {
538    std::process::Command::new("gsettings")
539        .args(["get", schema, "icon-theme"])
540        .output()
541        .ok()
542        .filter(|o| o.status.success())
543        .and_then(|o| String::from_utf8(o.stdout).ok())
544        .map(|s| s.trim().trim_matches('\'').to_string())
545        .filter(|s| !s.is_empty())
546        .unwrap_or_else(|| "hicolor".to_string())
547}
548
549/// Read icon theme from XFCE's xfconf-query.
550#[cfg(target_os = "linux")]
551fn detect_xfce_icon_theme() -> String {
552    std::process::Command::new("xfconf-query")
553        .args(["-c", "xsettings", "-p", "/Net/IconThemeName"])
554        .output()
555        .ok()
556        .filter(|o| o.status.success())
557        .and_then(|o| String::from_utf8(o.stdout).ok())
558        .map(|s| s.trim().to_string())
559        .filter(|s| !s.is_empty())
560        .unwrap_or_else(|| "hicolor".to_string())
561}
562
563/// Read icon theme from LXQt's config file.
564///
565/// LXQt uses a flat `key=value` format (no section headers for the icon_theme
566/// key), so we scan for the bare `icon_theme=` prefix.
567#[cfg(target_os = "linux")]
568fn detect_lxqt_icon_theme() -> String {
569    let path = xdg_config_dir().join("lxqt").join("lxqt.conf");
570
571    if let Ok(content) = std::fs::read_to_string(&path) {
572        for line in content.lines() {
573            let trimmed = line.trim();
574            if let Some(value) = trimmed.strip_prefix("icon_theme=") {
575                let value = value.trim();
576                if !value.is_empty() {
577                    return value.to_string();
578                }
579            }
580        }
581    }
582    "hicolor".to_string()
583}
584
585/// Resolve `$XDG_CONFIG_HOME`, falling back to `$HOME/.config`.
586#[cfg(target_os = "linux")]
587fn xdg_config_dir() -> std::path::PathBuf {
588    if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME")
589        && !config_home.is_empty()
590    {
591        return std::path::PathBuf::from(config_home);
592    }
593    std::env::var("HOME")
594        .map(std::path::PathBuf::from)
595        .unwrap_or_else(|_| std::path::PathBuf::from("/tmp"))
596        .join(".config")
597}
598
599/// Read a value from an INI file by section and key.
600///
601/// Simple line-based parser — no external crate needed. Handles `[Section]`
602/// headers and `Key=Value` lines. Returns `None` if the file doesn't exist,
603/// the section/key is missing, or the value is empty.
604#[cfg(target_os = "linux")]
605fn read_ini_value(path: &std::path::Path, section: &str, key: &str) -> Option<String> {
606    let content = std::fs::read_to_string(path).ok()?;
607    let target_section = format!("[{}]", section);
608    let mut in_section = false;
609
610    for line in content.lines() {
611        let trimmed = line.trim();
612        if trimmed.starts_with('[') {
613            in_section = trimmed == target_section;
614            continue;
615        }
616        if in_section && let Some(value) = trimmed.strip_prefix(key) {
617            let value = value.trim_start();
618            if let Some(value) = value.strip_prefix('=') {
619                let value = value.trim();
620                if !value.is_empty() {
621                    return Some(value.to_string());
622                }
623            }
624        }
625    }
626    None
627}
628
629// --- Private mapping functions ---
630
631#[allow(unreachable_patterns)]
632fn sf_symbols_name(role: IconRole) -> Option<&'static str> {
633    Some(match role {
634        // Dialog / Alert
635        IconRole::DialogWarning => "exclamationmark.triangle.fill",
636        IconRole::DialogError => "xmark.circle.fill",
637        IconRole::DialogInfo => "info.circle.fill",
638        IconRole::DialogQuestion => "questionmark.circle.fill",
639        IconRole::DialogSuccess => "checkmark.circle.fill",
640        IconRole::Shield => "shield.fill",
641
642        // Window Controls
643        IconRole::WindowClose => "xmark",
644        IconRole::WindowMinimize => "minus",
645        IconRole::WindowMaximize => "arrow.up.left.and.arrow.down.right",
646        IconRole::WindowRestore => "arrow.down.right.and.arrow.up.left",
647
648        // Common Actions
649        IconRole::ActionSave => "square.and.arrow.down",
650        IconRole::ActionDelete => "trash",
651        IconRole::ActionCopy => "doc.on.doc",
652        IconRole::ActionPaste => "doc.on.clipboard",
653        IconRole::ActionCut => "scissors",
654        IconRole::ActionUndo => "arrow.uturn.backward",
655        IconRole::ActionRedo => "arrow.uturn.forward",
656        IconRole::ActionSearch => "magnifyingglass",
657        IconRole::ActionSettings => "gearshape",
658        IconRole::ActionEdit => "pencil",
659        IconRole::ActionAdd => "plus",
660        IconRole::ActionRemove => "minus",
661        IconRole::ActionRefresh => "arrow.clockwise",
662        IconRole::ActionPrint => "printer",
663
664        // Navigation
665        IconRole::NavBack => "chevron.backward",
666        IconRole::NavForward => "chevron.forward",
667        IconRole::NavUp => "chevron.up",
668        IconRole::NavDown => "chevron.down",
669        IconRole::NavHome => "house",
670        IconRole::NavMenu => "line.horizontal.3",
671
672        // Files / Places
673        IconRole::FileGeneric => "doc",
674        IconRole::FolderClosed => "folder",
675        // FolderOpen: no SF Symbol equivalent
676        IconRole::FolderOpen => return None,
677        IconRole::TrashEmpty => "trash",
678        IconRole::TrashFull => "trash.fill",
679
680        // Status
681        // StatusBusy: no static SF Symbol (no static busy equivalent)
682        IconRole::StatusBusy => return None,
683        IconRole::StatusCheck => "checkmark",
684        IconRole::StatusError => "xmark.circle.fill",
685
686        // System
687        IconRole::UserAccount => "person.fill",
688        IconRole::Notification => "bell.fill",
689        IconRole::Help => "questionmark.circle",
690        IconRole::Lock => "lock.fill",
691
692        _ => return None,
693    })
694}
695
696#[allow(unreachable_patterns)]
697fn segoe_name(role: IconRole) -> Option<&'static str> {
698    Some(match role {
699        // Dialog / Alert (SHSTOCKICONID constants)
700        IconRole::DialogWarning => "SIID_WARNING",
701        IconRole::DialogError => "SIID_ERROR",
702        IconRole::DialogInfo => "SIID_INFO",
703        IconRole::DialogQuestion => "IDI_QUESTION",
704        IconRole::DialogSuccess => "CheckMark",
705        IconRole::Shield => "SIID_SHIELD",
706
707        // Window Controls (Segoe Fluent Icons glyphs)
708        IconRole::WindowClose => "ChromeClose",
709        IconRole::WindowMinimize => "ChromeMinimize",
710        IconRole::WindowMaximize => "ChromeMaximize",
711        IconRole::WindowRestore => "ChromeRestore",
712
713        // Common Actions (mix of SHSTOCKICONID and Segoe Fluent)
714        IconRole::ActionSave => "Save",
715        IconRole::ActionDelete => "SIID_DELETE",
716        IconRole::ActionCopy => "Copy",
717        IconRole::ActionPaste => "Paste",
718        IconRole::ActionCut => "Cut",
719        IconRole::ActionUndo => "Undo",
720        IconRole::ActionRedo => "Redo",
721        IconRole::ActionSearch => "SIID_FIND",
722        IconRole::ActionSettings => "SIID_SETTINGS",
723        IconRole::ActionEdit => "Edit",
724        IconRole::ActionAdd => "Add",
725        IconRole::ActionRemove => "Remove",
726        IconRole::ActionRefresh => "Refresh",
727        IconRole::ActionPrint => "SIID_PRINTER",
728
729        // Navigation (Segoe Fluent Icons)
730        IconRole::NavBack => "Back",
731        IconRole::NavForward => "Forward",
732        IconRole::NavUp => "Up",
733        IconRole::NavDown => "Down",
734        IconRole::NavHome => "Home",
735        IconRole::NavMenu => "GlobalNavigationButton",
736
737        // Files / Places (SHSTOCKICONID)
738        IconRole::FileGeneric => "SIID_DOCNOASSOC",
739        IconRole::FolderClosed => "SIID_FOLDER",
740        IconRole::FolderOpen => "SIID_FOLDEROPEN",
741        IconRole::TrashEmpty => "SIID_RECYCLER",
742        IconRole::TrashFull => "SIID_RECYCLERFULL",
743
744        // Status
745        // StatusBusy: no static Windows icon (no static busy equivalent)
746        IconRole::StatusBusy => return None,
747        IconRole::StatusCheck => "CheckMark",
748        IconRole::StatusError => "SIID_ERROR",
749
750        // System
751        IconRole::UserAccount => "SIID_USERS",
752        IconRole::Notification => "Ringer",
753        IconRole::Help => "SIID_HELP",
754        IconRole::Lock => "SIID_LOCK",
755
756        _ => return None,
757    })
758}
759
760#[allow(unreachable_patterns)]
761fn freedesktop_name(role: IconRole) -> Option<&'static str> {
762    Some(match role {
763        // Dialog / Alert
764        IconRole::DialogWarning => "dialog-warning",
765        IconRole::DialogError => "dialog-error",
766        IconRole::DialogInfo => "dialog-information",
767        IconRole::DialogQuestion => "dialog-question",
768        IconRole::DialogSuccess => "emblem-ok-symbolic",
769        IconRole::Shield => "security-high",
770
771        // Window Controls
772        IconRole::WindowClose => "window-close",
773        IconRole::WindowMinimize => "window-minimize",
774        IconRole::WindowMaximize => "window-maximize",
775        IconRole::WindowRestore => "window-restore",
776
777        // Common Actions
778        IconRole::ActionSave => "document-save",
779        IconRole::ActionDelete => "edit-delete",
780        IconRole::ActionCopy => "edit-copy",
781        IconRole::ActionPaste => "edit-paste",
782        IconRole::ActionCut => "edit-cut",
783        IconRole::ActionUndo => "edit-undo",
784        IconRole::ActionRedo => "edit-redo",
785        IconRole::ActionSearch => "edit-find",
786        IconRole::ActionSettings => "preferences-system",
787        IconRole::ActionEdit => "document-edit",
788        IconRole::ActionAdd => "list-add",
789        IconRole::ActionRemove => "list-remove",
790        IconRole::ActionRefresh => "view-refresh",
791        IconRole::ActionPrint => "document-print",
792
793        // Navigation
794        IconRole::NavBack => "go-previous",
795        IconRole::NavForward => "go-next",
796        IconRole::NavUp => "go-up",
797        IconRole::NavDown => "go-down",
798        IconRole::NavHome => "go-home",
799        IconRole::NavMenu => "open-menu",
800
801        // Files / Places
802        IconRole::FileGeneric => "text-x-generic",
803        IconRole::FolderClosed => "folder",
804        IconRole::FolderOpen => "folder-open",
805        IconRole::TrashEmpty => "user-trash",
806        IconRole::TrashFull => "user-trash-full",
807
808        // Status
809        IconRole::StatusBusy => "process-working",
810        IconRole::StatusCheck => "emblem-default",
811        IconRole::StatusError => "dialog-error",
812
813        // System
814        IconRole::UserAccount => "system-users",
815        // KDE convention (Breeze, Oxygen); GNOME themes return None from lookup
816        IconRole::Notification => "notification-active",
817        IconRole::Help => "help-browser",
818        IconRole::Lock => "system-lock-screen",
819
820        _ => return None,
821    })
822}
823
824#[allow(unreachable_patterns)]
825fn material_name(role: IconRole) -> Option<&'static str> {
826    Some(match role {
827        // Dialog / Alert
828        IconRole::DialogWarning => "warning",
829        IconRole::DialogError => "error",
830        IconRole::DialogInfo => "info",
831        IconRole::DialogQuestion => "help",
832        IconRole::DialogSuccess => "check_circle",
833        IconRole::Shield => "shield",
834
835        // Window Controls
836        IconRole::WindowClose => "close",
837        IconRole::WindowMinimize => "minimize",
838        IconRole::WindowMaximize => "open_in_full",
839        IconRole::WindowRestore => "close_fullscreen",
840
841        // Common Actions
842        IconRole::ActionSave => "save",
843        IconRole::ActionDelete => "delete",
844        IconRole::ActionCopy => "content_copy",
845        IconRole::ActionPaste => "content_paste",
846        IconRole::ActionCut => "content_cut",
847        IconRole::ActionUndo => "undo",
848        IconRole::ActionRedo => "redo",
849        IconRole::ActionSearch => "search",
850        IconRole::ActionSettings => "settings",
851        IconRole::ActionEdit => "edit",
852        IconRole::ActionAdd => "add",
853        IconRole::ActionRemove => "remove",
854        IconRole::ActionRefresh => "refresh",
855        IconRole::ActionPrint => "print",
856
857        // Navigation
858        IconRole::NavBack => "arrow_back",
859        IconRole::NavForward => "arrow_forward",
860        IconRole::NavUp => "arrow_upward",
861        IconRole::NavDown => "arrow_downward",
862        IconRole::NavHome => "home",
863        IconRole::NavMenu => "menu",
864
865        // Files / Places
866        IconRole::FileGeneric => "description",
867        IconRole::FolderClosed => "folder",
868        IconRole::FolderOpen => "folder_open",
869        IconRole::TrashEmpty => "delete",
870        // same as TrashEmpty -- Material has no full-trash variant
871        IconRole::TrashFull => "delete",
872
873        // Status
874        IconRole::StatusBusy => "progress_activity",
875        IconRole::StatusCheck => "check",
876        IconRole::StatusError => "error",
877
878        // System
879        IconRole::UserAccount => "person",
880        IconRole::Notification => "notifications",
881        IconRole::Help => "help",
882        IconRole::Lock => "lock",
883
884        _ => return None,
885    })
886}
887
888#[allow(unreachable_patterns)]
889fn lucide_name(role: IconRole) -> Option<&'static str> {
890    Some(match role {
891        // Dialog / Alert
892        IconRole::DialogWarning => "triangle-alert",
893        IconRole::DialogError => "circle-x",
894        IconRole::DialogInfo => "info",
895        IconRole::DialogQuestion => "circle-question-mark",
896        IconRole::DialogSuccess => "circle-check",
897        IconRole::Shield => "shield",
898
899        // Window Controls
900        IconRole::WindowClose => "x",
901        IconRole::WindowMinimize => "minimize",
902        IconRole::WindowMaximize => "maximize",
903        IconRole::WindowRestore => "minimize-2",
904
905        // Common Actions
906        IconRole::ActionSave => "save",
907        IconRole::ActionDelete => "trash-2",
908        IconRole::ActionCopy => "copy",
909        IconRole::ActionPaste => "clipboard-paste",
910        IconRole::ActionCut => "scissors",
911        IconRole::ActionUndo => "undo-2",
912        IconRole::ActionRedo => "redo-2",
913        IconRole::ActionSearch => "search",
914        IconRole::ActionSettings => "settings",
915        IconRole::ActionEdit => "pencil",
916        IconRole::ActionAdd => "plus",
917        IconRole::ActionRemove => "minus",
918        IconRole::ActionRefresh => "refresh-cw",
919        IconRole::ActionPrint => "printer",
920
921        // Navigation
922        IconRole::NavBack => "chevron-left",
923        IconRole::NavForward => "chevron-right",
924        IconRole::NavUp => "chevron-up",
925        IconRole::NavDown => "chevron-down",
926        IconRole::NavHome => "house",
927        IconRole::NavMenu => "menu",
928
929        // Files / Places
930        IconRole::FileGeneric => "file",
931        IconRole::FolderClosed => "folder-closed",
932        IconRole::FolderOpen => "folder-open",
933        IconRole::TrashEmpty => "trash-2",
934        // same as TrashEmpty -- Lucide has no full-trash variant
935        IconRole::TrashFull => "trash-2",
936
937        // Status
938        IconRole::StatusBusy => "loader",
939        IconRole::StatusCheck => "check",
940        IconRole::StatusError => "circle-x",
941
942        // System
943        IconRole::UserAccount => "user",
944        IconRole::Notification => "bell",
945        IconRole::Help => "circle-question-mark",
946        IconRole::Lock => "lock",
947
948        _ => return None,
949    })
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    // === IconRole tests ===
957
958    #[test]
959    fn icon_role_all_has_42_variants() {
960        assert_eq!(IconRole::ALL.len(), 42);
961    }
962
963    #[test]
964    fn icon_role_all_contains_every_variant() {
965        // Verify specific variants from each category are present
966        let all = &IconRole::ALL;
967
968        // Dialog (6)
969        assert!(all.contains(&IconRole::DialogWarning));
970        assert!(all.contains(&IconRole::DialogError));
971        assert!(all.contains(&IconRole::DialogInfo));
972        assert!(all.contains(&IconRole::DialogQuestion));
973        assert!(all.contains(&IconRole::DialogSuccess));
974        assert!(all.contains(&IconRole::Shield));
975
976        // Window (4)
977        assert!(all.contains(&IconRole::WindowClose));
978        assert!(all.contains(&IconRole::WindowMinimize));
979        assert!(all.contains(&IconRole::WindowMaximize));
980        assert!(all.contains(&IconRole::WindowRestore));
981
982        // Action (14)
983        assert!(all.contains(&IconRole::ActionSave));
984        assert!(all.contains(&IconRole::ActionDelete));
985        assert!(all.contains(&IconRole::ActionCopy));
986        assert!(all.contains(&IconRole::ActionPaste));
987        assert!(all.contains(&IconRole::ActionCut));
988        assert!(all.contains(&IconRole::ActionUndo));
989        assert!(all.contains(&IconRole::ActionRedo));
990        assert!(all.contains(&IconRole::ActionSearch));
991        assert!(all.contains(&IconRole::ActionSettings));
992        assert!(all.contains(&IconRole::ActionEdit));
993        assert!(all.contains(&IconRole::ActionAdd));
994        assert!(all.contains(&IconRole::ActionRemove));
995        assert!(all.contains(&IconRole::ActionRefresh));
996        assert!(all.contains(&IconRole::ActionPrint));
997
998        // Navigation (6)
999        assert!(all.contains(&IconRole::NavBack));
1000        assert!(all.contains(&IconRole::NavForward));
1001        assert!(all.contains(&IconRole::NavUp));
1002        assert!(all.contains(&IconRole::NavDown));
1003        assert!(all.contains(&IconRole::NavHome));
1004        assert!(all.contains(&IconRole::NavMenu));
1005
1006        // Files (5)
1007        assert!(all.contains(&IconRole::FileGeneric));
1008        assert!(all.contains(&IconRole::FolderClosed));
1009        assert!(all.contains(&IconRole::FolderOpen));
1010        assert!(all.contains(&IconRole::TrashEmpty));
1011        assert!(all.contains(&IconRole::TrashFull));
1012
1013        // Status (3)
1014        assert!(all.contains(&IconRole::StatusBusy));
1015        assert!(all.contains(&IconRole::StatusCheck));
1016        assert!(all.contains(&IconRole::StatusError));
1017
1018        // System (4)
1019        assert!(all.contains(&IconRole::UserAccount));
1020        assert!(all.contains(&IconRole::Notification));
1021        assert!(all.contains(&IconRole::Help));
1022        assert!(all.contains(&IconRole::Lock));
1023    }
1024
1025    #[test]
1026    fn icon_role_all_no_duplicates() {
1027        let all = &IconRole::ALL;
1028        for (i, role) in all.iter().enumerate() {
1029            for (j, other) in all.iter().enumerate() {
1030                if i != j {
1031                    assert_ne!(role, other, "Duplicate at index {i} and {j}");
1032                }
1033            }
1034        }
1035    }
1036
1037    #[test]
1038    fn icon_role_derives_copy_clone() {
1039        let role = IconRole::ActionCopy;
1040        let copied1 = role;
1041        let copied2 = role;
1042        assert_eq!(role, copied1);
1043        assert_eq!(role, copied2);
1044    }
1045
1046    #[test]
1047    fn icon_role_derives_debug() {
1048        let s = format!("{:?}", IconRole::DialogWarning);
1049        assert!(s.contains("DialogWarning"));
1050    }
1051
1052    #[test]
1053    fn icon_role_derives_hash() {
1054        use std::collections::HashSet;
1055        let mut set = HashSet::new();
1056        set.insert(IconRole::ActionSave);
1057        set.insert(IconRole::ActionDelete);
1058        assert_eq!(set.len(), 2);
1059        assert!(set.contains(&IconRole::ActionSave));
1060    }
1061
1062    // === IconData tests ===
1063
1064    #[test]
1065    fn icon_data_svg_construct_and_match() {
1066        let svg_bytes = b"<svg></svg>".to_vec();
1067        let data = IconData::Svg(svg_bytes.clone());
1068        match data {
1069            IconData::Svg(bytes) => assert_eq!(bytes, svg_bytes),
1070            _ => panic!("Expected Svg variant"),
1071        }
1072    }
1073
1074    #[test]
1075    fn icon_data_rgba_construct_and_match() {
1076        let pixels = vec![255, 0, 0, 255]; // 1 red pixel
1077        let data = IconData::Rgba {
1078            width: 1,
1079            height: 1,
1080            data: pixels.clone(),
1081        };
1082        match data {
1083            IconData::Rgba {
1084                width,
1085                height,
1086                data,
1087            } => {
1088                assert_eq!(width, 1);
1089                assert_eq!(height, 1);
1090                assert_eq!(data, pixels);
1091            }
1092            _ => panic!("Expected Rgba variant"),
1093        }
1094    }
1095
1096    #[test]
1097    fn icon_data_derives_debug() {
1098        let data = IconData::Svg(vec![]);
1099        let s = format!("{:?}", data);
1100        assert!(s.contains("Svg"));
1101    }
1102
1103    #[test]
1104    fn icon_data_derives_clone() {
1105        let data = IconData::Rgba {
1106            width: 16,
1107            height: 16,
1108            data: vec![0; 16 * 16 * 4],
1109        };
1110        let cloned = data.clone();
1111        assert_eq!(data, cloned);
1112    }
1113
1114    #[test]
1115    fn icon_data_derives_eq() {
1116        let a = IconData::Svg(b"<svg/>".to_vec());
1117        let b = IconData::Svg(b"<svg/>".to_vec());
1118        assert_eq!(a, b);
1119
1120        let c = IconData::Svg(b"<other/>".to_vec());
1121        assert_ne!(a, c);
1122    }
1123
1124    // === IconSet tests ===
1125
1126    #[test]
1127    fn icon_set_from_name_sf_symbols() {
1128        assert_eq!(IconSet::from_name("sf-symbols"), Some(IconSet::SfSymbols));
1129    }
1130
1131    #[test]
1132    fn icon_set_from_name_segoe_fluent() {
1133        assert_eq!(
1134            IconSet::from_name("segoe-fluent"),
1135            Some(IconSet::SegoeIcons)
1136        );
1137    }
1138
1139    #[test]
1140    fn icon_set_from_name_freedesktop() {
1141        assert_eq!(
1142            IconSet::from_name("freedesktop"),
1143            Some(IconSet::Freedesktop)
1144        );
1145    }
1146
1147    #[test]
1148    fn icon_set_from_name_material() {
1149        assert_eq!(IconSet::from_name("material"), Some(IconSet::Material));
1150    }
1151
1152    #[test]
1153    fn icon_set_from_name_lucide() {
1154        assert_eq!(IconSet::from_name("lucide"), Some(IconSet::Lucide));
1155    }
1156
1157    #[test]
1158    fn icon_set_from_name_unknown() {
1159        assert_eq!(IconSet::from_name("unknown"), None);
1160    }
1161
1162    #[test]
1163    fn icon_set_name_sf_symbols() {
1164        assert_eq!(IconSet::SfSymbols.name(), "sf-symbols");
1165    }
1166
1167    #[test]
1168    fn icon_set_name_segoe_fluent() {
1169        assert_eq!(IconSet::SegoeIcons.name(), "segoe-fluent");
1170    }
1171
1172    #[test]
1173    fn icon_set_name_freedesktop() {
1174        assert_eq!(IconSet::Freedesktop.name(), "freedesktop");
1175    }
1176
1177    #[test]
1178    fn icon_set_name_material() {
1179        assert_eq!(IconSet::Material.name(), "material");
1180    }
1181
1182    #[test]
1183    fn icon_set_name_lucide() {
1184        assert_eq!(IconSet::Lucide.name(), "lucide");
1185    }
1186
1187    #[test]
1188    fn icon_set_from_name_name_round_trip() {
1189        let sets = [
1190            IconSet::SfSymbols,
1191            IconSet::SegoeIcons,
1192            IconSet::Freedesktop,
1193            IconSet::Material,
1194            IconSet::Lucide,
1195        ];
1196        for set in &sets {
1197            let name = set.name();
1198            let parsed = IconSet::from_name(name);
1199            assert_eq!(parsed, Some(*set), "Round-trip failed for {:?}", set);
1200        }
1201    }
1202
1203    #[test]
1204    fn icon_set_derives_copy_clone() {
1205        let set = IconSet::Material;
1206        let copied1 = set;
1207        let copied2 = set;
1208        assert_eq!(set, copied1);
1209        assert_eq!(set, copied2);
1210    }
1211
1212    #[test]
1213    fn icon_set_derives_hash() {
1214        use std::collections::HashSet;
1215        let mut map = HashSet::new();
1216        map.insert(IconSet::SfSymbols);
1217        map.insert(IconSet::Lucide);
1218        assert_eq!(map.len(), 2);
1219    }
1220
1221    #[test]
1222    fn icon_set_derives_debug() {
1223        let s = format!("{:?}", IconSet::Freedesktop);
1224        assert!(s.contains("Freedesktop"));
1225    }
1226
1227    #[test]
1228    fn icon_set_serde_round_trip() {
1229        let set = IconSet::SfSymbols;
1230        let json = serde_json::to_string(&set).unwrap();
1231        let deserialized: IconSet = serde_json::from_str(&json).unwrap();
1232        assert_eq!(set, deserialized);
1233    }
1234
1235    // === icon_name() tests ===
1236
1237    #[test]
1238    fn icon_name_sf_symbols_action_copy() {
1239        assert_eq!(
1240            icon_name(IconSet::SfSymbols, IconRole::ActionCopy),
1241            Some("doc.on.doc")
1242        );
1243    }
1244
1245    #[test]
1246    fn icon_name_segoe_action_copy() {
1247        assert_eq!(
1248            icon_name(IconSet::SegoeIcons, IconRole::ActionCopy),
1249            Some("Copy")
1250        );
1251    }
1252
1253    #[test]
1254    fn icon_name_freedesktop_action_copy() {
1255        assert_eq!(
1256            icon_name(IconSet::Freedesktop, IconRole::ActionCopy),
1257            Some("edit-copy")
1258        );
1259    }
1260
1261    #[test]
1262    fn icon_name_material_action_copy() {
1263        assert_eq!(
1264            icon_name(IconSet::Material, IconRole::ActionCopy),
1265            Some("content_copy")
1266        );
1267    }
1268
1269    #[test]
1270    fn icon_name_lucide_action_copy() {
1271        assert_eq!(
1272            icon_name(IconSet::Lucide, IconRole::ActionCopy),
1273            Some("copy")
1274        );
1275    }
1276
1277    #[test]
1278    fn icon_name_sf_symbols_dialog_warning() {
1279        assert_eq!(
1280            icon_name(IconSet::SfSymbols, IconRole::DialogWarning),
1281            Some("exclamationmark.triangle.fill")
1282        );
1283    }
1284
1285    // None cases for known gaps
1286    #[test]
1287    fn icon_name_sf_symbols_folder_open_is_none() {
1288        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::FolderOpen), None);
1289    }
1290
1291    #[test]
1292    fn icon_name_sf_symbols_trash_full() {
1293        assert_eq!(
1294            icon_name(IconSet::SfSymbols, IconRole::TrashFull),
1295            Some("trash.fill")
1296        );
1297    }
1298
1299    #[test]
1300    fn icon_name_sf_symbols_status_busy_is_none() {
1301        assert_eq!(icon_name(IconSet::SfSymbols, IconRole::StatusBusy), None);
1302    }
1303
1304    #[test]
1305    fn icon_name_sf_symbols_window_restore() {
1306        assert_eq!(
1307            icon_name(IconSet::SfSymbols, IconRole::WindowRestore),
1308            Some("arrow.down.right.and.arrow.up.left")
1309        );
1310    }
1311
1312    #[test]
1313    fn icon_name_segoe_dialog_success() {
1314        assert_eq!(
1315            icon_name(IconSet::SegoeIcons, IconRole::DialogSuccess),
1316            Some("CheckMark")
1317        );
1318    }
1319
1320    #[test]
1321    fn icon_name_segoe_status_busy_is_none() {
1322        assert_eq!(icon_name(IconSet::SegoeIcons, IconRole::StatusBusy), None);
1323    }
1324
1325    #[test]
1326    fn icon_name_freedesktop_notification() {
1327        assert_eq!(
1328            icon_name(IconSet::Freedesktop, IconRole::Notification),
1329            Some("notification-active")
1330        );
1331    }
1332
1333    #[test]
1334    fn icon_name_material_trash_full() {
1335        assert_eq!(
1336            icon_name(IconSet::Material, IconRole::TrashFull),
1337            Some("delete")
1338        );
1339    }
1340
1341    #[test]
1342    fn icon_name_lucide_trash_full() {
1343        assert_eq!(
1344            icon_name(IconSet::Lucide, IconRole::TrashFull),
1345            Some("trash-2")
1346        );
1347    }
1348
1349    // Spot-check across all 5 icon sets for multiple roles
1350    #[test]
1351    fn icon_name_spot_check_dialog_error() {
1352        assert_eq!(
1353            icon_name(IconSet::SfSymbols, IconRole::DialogError),
1354            Some("xmark.circle.fill")
1355        );
1356        assert_eq!(
1357            icon_name(IconSet::SegoeIcons, IconRole::DialogError),
1358            Some("SIID_ERROR")
1359        );
1360        assert_eq!(
1361            icon_name(IconSet::Freedesktop, IconRole::DialogError),
1362            Some("dialog-error")
1363        );
1364        assert_eq!(
1365            icon_name(IconSet::Material, IconRole::DialogError),
1366            Some("error")
1367        );
1368        assert_eq!(
1369            icon_name(IconSet::Lucide, IconRole::DialogError),
1370            Some("circle-x")
1371        );
1372    }
1373
1374    #[test]
1375    fn icon_name_spot_check_nav_home() {
1376        assert_eq!(
1377            icon_name(IconSet::SfSymbols, IconRole::NavHome),
1378            Some("house")
1379        );
1380        assert_eq!(
1381            icon_name(IconSet::SegoeIcons, IconRole::NavHome),
1382            Some("Home")
1383        );
1384        assert_eq!(
1385            icon_name(IconSet::Freedesktop, IconRole::NavHome),
1386            Some("go-home")
1387        );
1388        assert_eq!(
1389            icon_name(IconSet::Material, IconRole::NavHome),
1390            Some("home")
1391        );
1392        assert_eq!(icon_name(IconSet::Lucide, IconRole::NavHome), Some("house"));
1393    }
1394
1395    // Count test: verify expected Some/None count for each icon set
1396    #[test]
1397    fn icon_name_sf_symbols_expected_count() {
1398        // SF Symbols: 42 - 2 None (FolderOpen, StatusBusy) = 40 Some
1399        let some_count = IconRole::ALL
1400            .iter()
1401            .filter(|r| icon_name(IconSet::SfSymbols, **r).is_some())
1402            .count();
1403        assert_eq!(some_count, 40, "SF Symbols should have 40 mappings");
1404    }
1405
1406    #[test]
1407    fn icon_name_segoe_expected_count() {
1408        // Segoe: 42 - 1 None (StatusBusy) = 41 Some
1409        let some_count = IconRole::ALL
1410            .iter()
1411            .filter(|r| icon_name(IconSet::SegoeIcons, **r).is_some())
1412            .count();
1413        assert_eq!(some_count, 41, "Segoe Icons should have 41 mappings");
1414    }
1415
1416    #[test]
1417    fn icon_name_freedesktop_expected_count() {
1418        // Freedesktop: all 42 roles mapped
1419        let some_count = IconRole::ALL
1420            .iter()
1421            .filter(|r| icon_name(IconSet::Freedesktop, **r).is_some())
1422            .count();
1423        assert_eq!(some_count, 42, "Freedesktop should have 42 mappings");
1424    }
1425
1426    #[test]
1427    fn icon_name_material_expected_count() {
1428        // Material: all 42 roles mapped
1429        let some_count = IconRole::ALL
1430            .iter()
1431            .filter(|r| icon_name(IconSet::Material, **r).is_some())
1432            .count();
1433        assert_eq!(some_count, 42, "Material should have 42 mappings");
1434    }
1435
1436    #[test]
1437    fn icon_name_lucide_expected_count() {
1438        // Lucide: all 42 roles mapped
1439        let some_count = IconRole::ALL
1440            .iter()
1441            .filter(|r| icon_name(IconSet::Lucide, **r).is_some())
1442            .count();
1443        assert_eq!(some_count, 42, "Lucide should have 42 mappings");
1444    }
1445
1446    // === system_icon_set() tests ===
1447
1448    #[test]
1449    #[cfg(target_os = "linux")]
1450    fn system_icon_set_returns_freedesktop_on_linux() {
1451        assert_eq!(system_icon_set(), IconSet::Freedesktop);
1452    }
1453
1454    #[test]
1455    fn system_icon_theme_returns_non_empty() {
1456        let theme = system_icon_theme();
1457        assert!(
1458            !theme.is_empty(),
1459            "system_icon_theme() should return a non-empty string"
1460        );
1461    }
1462
1463    // === IconProvider trait tests ===
1464
1465    #[test]
1466    fn icon_provider_is_object_safe() {
1467        // Box<dyn IconProvider> must compile and be usable
1468        let provider: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1469        let debug_str = format!("{:?}", provider);
1470        assert!(
1471            debug_str.contains("ActionCopy"),
1472            "Debug should print variant name"
1473        );
1474    }
1475
1476    #[test]
1477    fn icon_role_provider_icon_name() {
1478        // IconRole::ActionCopy should return "content_copy" for Material via IconProvider
1479        let role = IconRole::ActionCopy;
1480        let name = IconProvider::icon_name(&role, IconSet::Material);
1481        assert_eq!(name, Some("content_copy"));
1482    }
1483
1484    #[test]
1485    fn icon_role_provider_icon_name_sf_symbols() {
1486        let role = IconRole::ActionCopy;
1487        let name = IconProvider::icon_name(&role, IconSet::SfSymbols);
1488        assert_eq!(name, Some("doc.on.doc"));
1489    }
1490
1491    #[test]
1492    #[cfg(feature = "material-icons")]
1493    fn icon_role_provider_icon_svg_material() {
1494        let role = IconRole::ActionCopy;
1495        let svg = IconProvider::icon_svg(&role, IconSet::Material);
1496        assert!(svg.is_some(), "Material SVG should be Some");
1497        let content = std::str::from_utf8(svg.unwrap()).expect("valid UTF-8");
1498        assert!(content.contains("<svg"), "should contain <svg tag");
1499    }
1500
1501    #[test]
1502    fn icon_role_provider_icon_svg_non_bundled() {
1503        // SfSymbols is not a bundled set, so icon_svg should return None
1504        let role = IconRole::ActionCopy;
1505        let svg = IconProvider::icon_svg(&role, IconSet::SfSymbols);
1506        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1507    }
1508
1509    #[test]
1510    fn icon_role_provider_all_roles() {
1511        // All 42 IconRole variants implement IconProvider -- iterate and call icon_name
1512        for role in IconRole::ALL {
1513            // All 42 roles are mapped for Material
1514            let _name = IconProvider::icon_name(&role, IconSet::Material);
1515            // Just verifying it doesn't panic
1516        }
1517    }
1518
1519    #[test]
1520    fn icon_provider_dyn_dispatch() {
1521        // Call icon_name and icon_svg through &dyn IconProvider
1522        let role = IconRole::ActionCopy;
1523        let provider: &dyn IconProvider = &role;
1524        let name = provider.icon_name(IconSet::Material);
1525        assert_eq!(name, Some("content_copy"));
1526        let svg = provider.icon_svg(IconSet::SfSymbols);
1527        assert!(svg.is_none(), "SfSymbols should not have bundled SVGs");
1528    }
1529
1530    // === Coverage tests ===
1531
1532    fn known_gaps() -> &'static [(IconSet, IconRole)] {
1533        &[
1534            (IconSet::SfSymbols, IconRole::FolderOpen),
1535            (IconSet::SfSymbols, IconRole::StatusBusy),
1536            (IconSet::SegoeIcons, IconRole::StatusBusy),
1537        ]
1538    }
1539
1540    #[test]
1541    fn no_unexpected_icon_gaps() {
1542        let gaps = known_gaps();
1543        let system_sets = [
1544            IconSet::SfSymbols,
1545            IconSet::SegoeIcons,
1546            IconSet::Freedesktop,
1547        ];
1548        for &set in &system_sets {
1549            for role in IconRole::ALL {
1550                let is_known_gap = gaps.contains(&(set, role));
1551                let is_mapped = icon_name(set, role).is_some();
1552                if !is_known_gap {
1553                    assert!(
1554                        is_mapped,
1555                        "{role:?} has no mapping for {set:?} and is not in known_gaps()"
1556                    );
1557                }
1558            }
1559        }
1560    }
1561
1562    #[test]
1563    #[cfg(all(feature = "material-icons", feature = "lucide-icons"))]
1564    fn all_roles_have_bundled_svg() {
1565        use crate::bundled_icon_svg;
1566        for set in [IconSet::Material, IconSet::Lucide] {
1567            for role in IconRole::ALL {
1568                assert!(
1569                    bundled_icon_svg(set, role).is_some(),
1570                    "{role:?} has no bundled SVG for {set:?}"
1571                );
1572            }
1573        }
1574    }
1575}