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