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