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