Skip to main content

native_theme/
icons.rs

1//! Icon loading and dispatch.
2
3use std::fmt;
4
5#[allow(unused_imports)]
6use std::borrow::Cow;
7
8#[allow(unused_imports)]
9use crate::model::icons::{icon_name, system_icon_set, system_icon_theme};
10use crate::model::{AnimatedIcon, IconData, IconProvider, IconRole, IconSet};
11#[allow(unused_imports)]
12use crate::model::{bundled_icon_by_name, bundled_icon_svg};
13
14/// Identifies what icon to load: a semantic role, a platform-specific name,
15/// or a custom provider.
16///
17/// Constructed via [`From`] impls -- users rarely reference this type directly.
18/// Pass an [`IconRole`], `&str`, or `&dyn IconProvider` to a per-set loader's
19/// `new()` constructor (e.g. [`FreedesktopLoader::new`] or [`MaterialLoader::new`]).
20///
21/// # Examples
22///
23/// ```
24/// use native_theme::icons::IconId;
25/// use native_theme::theme::IconRole;
26///
27/// // All of these work via Into<IconId>:
28/// let _: IconId = IconRole::ActionSave.into();
29/// let _: IconId = "edit-copy".into();
30/// ```
31#[derive(Debug)]
32#[non_exhaustive]
33pub enum IconId<'a> {
34    /// Load by semantic role (most common).
35    Role(IconRole),
36    /// Load by platform-specific name string.
37    Name(&'a str),
38    /// Load via a custom [`IconProvider`] implementation.
39    Custom(&'a dyn IconProvider),
40}
41
42impl From<IconRole> for IconId<'_> {
43    fn from(role: IconRole) -> Self {
44        IconId::Role(role)
45    }
46}
47
48impl<'a> From<&'a str> for IconId<'a> {
49    fn from(name: &'a str) -> Self {
50        IconId::Name(name)
51    }
52}
53
54impl<'a> From<&'a dyn IconProvider> for IconId<'a> {
55    fn from(provider: &'a dyn IconProvider) -> Self {
56        IconId::Custom(provider)
57    }
58}
59
60/// A provider by reference, such as a variant of the icon enum
61/// `native-theme-build` generates: `MaterialLoader::new(&AppIcon::PlayPause)`.
62impl<'a, T: IconProvider> From<&'a T> for IconId<'a> {
63    fn from(provider: &'a T) -> Self {
64        IconId::Custom(provider)
65    }
66}
67
68// =============================================================================
69// Typed per-set icon loaders
70// =============================================================================
71//
72// Each set has its own loader struct exposing only the methods meaningful
73// for that set. This eliminates the silent-ignore bug class where a method
74// like `.theme()` could be called on a non-freedesktop loader and have no
75// effect. Calling a set-specific method on the wrong loader is now a
76// compile error.
77//
78// For runtime-set dispatch (common in connector code), use the free
79// functions `load_icon(id, set)` and `load_icon_indicator(set)` at the
80// bottom of this section.
81
82/// Loader for freedesktop.org icon themes (GTK / Linux).
83///
84/// Honors `.theme()` for BOTH role-based and name-based lookups โ€” unlike
85/// the previous single-struct design, no dispatch layer can silently
86/// drop the theme override.
87///
88/// An icon comes only from the theme or from a theme in the `Inherits=`
89/// chain its `index.theme` declares, searched depth-first in declared
90/// order as the Icon Theme Specification's lookup does, never from
91/// `hicolor` unless `hicolor` is the theme asked for, nor from a loose
92/// file in an icon directory or `/usr/share/pixmaps`. An icon the theme
93/// and its parents lack is `None`, and so is every icon of a theme that
94/// is not installed.
95///
96/// The installed themes are read once, at the process's first freedesktop
97/// lookup (freedesktop-icons caches them the same way), and a theme's
98/// `Inherits=` chain at its own first lookup, so a theme installed after
99/// that is not searched, and a changed `Inherits=` is not seen, until the
100/// process restarts.
101///
102/// ```
103/// # #[cfg(all(target_os = "linux", feature = "system-icons"))]
104/// # {
105/// use native_theme::icons::FreedesktopLoader;
106/// use native_theme::theme::IconRole;
107///
108/// // By role, system theme:
109/// let _icon = FreedesktopLoader::new(IconRole::ActionCopy).load();
110///
111/// // By name, explicit theme + size + symbolic fg color:
112/// let _icon = FreedesktopLoader::new("edit-copy")
113///     .theme("Adwaita")
114///     .size(48)
115///     .color([0, 0, 0])
116///     .load();
117/// # }
118/// ```
119#[derive(Debug)]
120#[must_use]
121pub struct FreedesktopLoader<'a> {
122    // Fields read only in the Linux + system-icons cfg branch of `load`.
123    #[allow(dead_code)]
124    id: IconId<'a>,
125    #[allow(dead_code)]
126    size: u16,
127    #[allow(dead_code)]
128    fg_color: Option<[u8; 3]>,
129    #[allow(dead_code)]
130    theme: Option<&'a str>,
131}
132
133impl<'a> FreedesktopLoader<'a> {
134    /// Create a freedesktop loader. Defaults: size 24, no fg_color, system theme.
135    pub fn new(id: impl Into<IconId<'a>>) -> Self {
136        Self {
137            id: id.into(),
138            size: 24,
139            fg_color: None,
140            theme: None,
141        }
142    }
143
144    /// Set the requested icon size in pixels (default 24).
145    pub fn size(mut self, size: u16) -> Self {
146        self.size = size;
147        self
148    }
149
150    /// Set the foreground color for GTK symbolic icon colorization.
151    pub fn color(mut self, rgb: [u8; 3]) -> Self {
152        self.fg_color = Some(rgb);
153        self
154    }
155
156    /// Set the foreground color if `Some`, leave default if `None`.
157    pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
158        self.fg_color = rgb;
159        self
160    }
161
162    /// Override the freedesktop icon theme (e.g. "Adwaita", "breeze").
163    /// When unset, uses the system-detected theme, and searches no theme
164    /// where detection fails (see [`Self::load`]).
165    pub fn theme(mut self, theme: &'a str) -> Self {
166        self.theme = Some(theme);
167        self
168    }
169
170    /// Load the icon, returning its data.
171    ///
172    /// `None` when neither the theme nor a theme it inherits from has the
173    /// icon (see [`FreedesktopLoader`]).
174    ///
175    /// With no [`theme`](Self::theme) set, the icon comes from the system's
176    /// icon theme, and where that cannot be detected no theme is searched,
177    /// as none stands in for the undetected one: a role or a name gives
178    /// `None`, and a custom [`IconProvider`] gives only its own freedesktop
179    /// SVG ([`IconProvider::icon_svg`]), which depends on no theme, where it
180    /// has one. [`system_icon_theme()`](crate::theme::system_icon_theme)
181    /// gives the reason.
182    ///
183    /// Requires the `system-icons` feature, and Linux; `None` otherwise.
184    #[must_use]
185    pub fn load(self) -> Option<IconData> {
186        self.load_with(system_icon_theme)
187    }
188
189    /// [`Self::load`], with `detect` giving the system's icon theme where no
190    /// theme is set.
191    #[allow(unused_variables)]
192    fn load_with(self, detect: impl FnOnce() -> crate::Result<String>) -> Option<IconData> {
193        #[cfg(all(target_os = "linux", feature = "system-icons"))]
194        {
195            let detected;
196            let theme: Option<&str> = match self.theme {
197                Some(t) => Some(t),
198                None => {
199                    detected = detect().ok();
200                    detected.as_deref()
201                }
202            };
203            match self.id {
204                IconId::Role(role) => {
205                    let name = icon_name(role, IconSet::Freedesktop)?;
206                    crate::freedesktop::load_freedesktop_icon_by_name(
207                        name,
208                        theme?,
209                        self.size,
210                        self.fg_color,
211                    )
212                }
213                IconId::Name(name) => crate::freedesktop::load_freedesktop_icon_by_name(
214                    name,
215                    theme?,
216                    self.size,
217                    self.fg_color,
218                ),
219                IconId::Custom(provider) => {
220                    if let Some(theme) = theme
221                        && let Some(name) = provider.icon_name(IconSet::Freedesktop)
222                        && let Some(data) = crate::freedesktop::load_freedesktop_icon_by_name(
223                            name,
224                            theme,
225                            self.size,
226                            self.fg_color,
227                        )
228                    {
229                        return Some(data);
230                    }
231                    provider.icon_svg(IconSet::Freedesktop).map(IconData::Svg)
232                }
233            }
234        }
235        #[cfg(not(all(target_os = "linux", feature = "system-icons")))]
236        {
237            None
238        }
239    }
240
241    /// Load the theme's animated process-working spinner.
242    ///
243    /// `theme` of `None` uses the system-detected theme, and gives `None`
244    /// where detection fails โ€” no theme stands in for the undetected one;
245    /// `Some(t)` overrides.
246    /// Associated function (no `self`) โ€” the spinner is a property of the
247    /// theme, not of any particular icon id. Found only where
248    /// [`Self::load`] would find an icon: in the theme or a theme it
249    /// inherits from, never in `hicolor` unless that is the theme.
250    ///
251    /// Requires the `system-icons` feature, and Linux; `None` otherwise.
252    #[must_use]
253    #[allow(unused_variables)]
254    pub fn load_indicator(theme: Option<&str>) -> Option<AnimatedIcon> {
255        #[cfg(all(target_os = "linux", feature = "system-icons"))]
256        {
257            crate::freedesktop::load_freedesktop_spinner(theme)
258        }
259        #[cfg(not(all(target_os = "linux", feature = "system-icons")))]
260        {
261            None
262        }
263    }
264}
265
266/// Loader for Apple SF Symbols (macOS only).
267///
268/// SF Symbols has no concept of themes. A symbol is a monochrome template,
269/// drawn black unless [`Self::color`] gives it a foreground colour.
270#[derive(Debug)]
271#[must_use]
272pub struct SfSymbolsLoader<'a> {
273    #[allow(dead_code)] // read only in the macOS cfg branch of `load`
274    id: IconId<'a>,
275    #[allow(dead_code)] // read only in the macOS cfg branch of `load`
276    fg_color: Option<[u8; 3]>,
277}
278
279impl<'a> SfSymbolsLoader<'a> {
280    /// Construct a new SF Symbols loader for the given icon id. Defaults:
281    /// no fg_color.
282    pub fn new(id: impl Into<IconId<'a>>) -> Self {
283        Self {
284            id: id.into(),
285            fg_color: None,
286        }
287    }
288
289    /// Set the foreground color the monochrome symbol is drawn in; its
290    /// alpha is kept.
291    pub fn color(mut self, rgb: [u8; 3]) -> Self {
292        self.fg_color = Some(rgb);
293        self
294    }
295
296    /// Set the foreground color if `Some`, leave default if `None`.
297    pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
298        self.fg_color = rgb;
299        self
300    }
301
302    /// Load the icon, returning its data.
303    ///
304    /// Requires the `system-icons` feature, and macOS; `None` otherwise.
305    #[must_use]
306    #[allow(unused_variables)]
307    pub fn load(self) -> Option<IconData> {
308        #[cfg(all(target_os = "macos", feature = "system-icons"))]
309        {
310            match self.id {
311                IconId::Role(role) => {
312                    let name = icon_name(role, IconSet::SfSymbols)?;
313                    crate::sficons::load_sf_icon_by_name(name, self.fg_color)
314                }
315                IconId::Name(n) => crate::sficons::load_sf_icon_by_name(n, self.fg_color),
316                IconId::Custom(p) => {
317                    if let Some(n) = p.icon_name(IconSet::SfSymbols)
318                        && let Some(data) = crate::sficons::load_sf_icon_by_name(n, self.fg_color)
319                    {
320                        return Some(data);
321                    }
322                    p.icon_svg(IconSet::SfSymbols).map(IconData::Svg)
323                }
324            }
325        }
326        #[cfg(not(all(target_os = "macos", feature = "system-icons")))]
327        {
328            None
329        }
330    }
331    // No load_indicator: SF Symbols has no animated spinner primitive.
332}
333
334/// Loader for Windows Segoe Fluent Icons (Windows only).
335///
336/// Segoe icons have no themes. A Segoe Fluent glyph is monochrome, drawn
337/// white unless [`Self::color`] gives it a foreground colour; a stock shell
338/// icon (the `SIID_*` and `IDI_QUESTION` names) is full-colour and keeps its
339/// own colours.
340#[derive(Debug)]
341#[must_use]
342pub struct SegoeIconsLoader<'a> {
343    #[allow(dead_code)] // read only in the Windows cfg branch of `load`
344    id: IconId<'a>,
345    #[allow(dead_code)] // read only in the Windows cfg branch of `load`
346    fg_color: Option<[u8; 3]>,
347}
348
349impl<'a> SegoeIconsLoader<'a> {
350    /// Construct a new Segoe Fluent loader for the given icon id. Defaults:
351    /// no fg_color.
352    pub fn new(id: impl Into<IconId<'a>>) -> Self {
353        Self {
354            id: id.into(),
355            fg_color: None,
356        }
357    }
358
359    /// Set the foreground color a monochrome glyph is drawn in; its alpha
360    /// is kept. A full-colour stock icon is unchanged.
361    pub fn color(mut self, rgb: [u8; 3]) -> Self {
362        self.fg_color = Some(rgb);
363        self
364    }
365
366    /// Set the foreground color if `Some`, leave default if `None`.
367    pub fn color_opt(mut self, rgb: Option<[u8; 3]>) -> Self {
368        self.fg_color = rgb;
369        self
370    }
371
372    /// Load the icon, returning its data.
373    ///
374    /// Requires the `system-icons` feature, and Windows; `None` otherwise.
375    #[must_use]
376    #[allow(unused_variables)]
377    pub fn load(self) -> Option<IconData> {
378        #[cfg(all(target_os = "windows", feature = "system-icons"))]
379        {
380            match self.id {
381                IconId::Role(role) => {
382                    let name = icon_name(role, IconSet::SegoeIcons)?;
383                    crate::winicons::load_windows_icon_by_name(name, self.fg_color)
384                }
385                IconId::Name(n) => crate::winicons::load_windows_icon_by_name(n, self.fg_color),
386                IconId::Custom(p) => {
387                    if let Some(n) = p.icon_name(IconSet::SegoeIcons)
388                        && let Some(data) =
389                            crate::winicons::load_windows_icon_by_name(n, self.fg_color)
390                    {
391                        return Some(data);
392                    }
393                    p.icon_svg(IconSet::SegoeIcons).map(IconData::Svg)
394                }
395            }
396        }
397        #[cfg(not(all(target_os = "windows", feature = "system-icons")))]
398        {
399            None
400        }
401    }
402    // No load_indicator: Segoe has no animated spinner primitive.
403}
404
405/// Loader for Google Material Symbols (bundled SVG).
406///
407/// Material SVGs are scalable at render time, so no `size` method.
408#[derive(Debug)]
409#[must_use]
410pub struct MaterialLoader<'a> {
411    #[allow(dead_code)] // read only in the material-icons feature cfg branch of `load`
412    id: IconId<'a>,
413}
414
415impl<'a> MaterialLoader<'a> {
416    /// Construct a new Material Symbols loader for the given icon id.
417    pub fn new(id: impl Into<IconId<'a>>) -> Self {
418        Self { id: id.into() }
419    }
420
421    /// Load the icon, returning its data.
422    ///
423    /// Requires the `material-icons` feature; `None` otherwise.
424    #[must_use]
425    #[allow(unused_variables)]
426    pub fn load(self) -> Option<IconData> {
427        #[cfg(feature = "material-icons")]
428        {
429            match self.id {
430                IconId::Role(role) => bundled_icon_svg(role, IconSet::Material)
431                    .map(|b| IconData::Svg(Cow::Borrowed(b))),
432                IconId::Name(n) => bundled_icon_by_name(n, IconSet::Material)
433                    .map(|b| IconData::Svg(Cow::Borrowed(b))),
434                IconId::Custom(p) => {
435                    if let Some(n) = p.icon_name(IconSet::Material)
436                        && let Some(b) = bundled_icon_by_name(n, IconSet::Material)
437                    {
438                        return Some(IconData::Svg(Cow::Borrowed(b)));
439                    }
440                    p.icon_svg(IconSet::Material).map(IconData::Svg)
441                }
442            }
443        }
444        #[cfg(not(feature = "material-icons"))]
445        {
446            None
447        }
448    }
449
450    /// Load the Material animated spinner. Associated function; no `self`.
451    ///
452    /// Requires the `material-icons` feature; `None` otherwise.
453    #[must_use]
454    pub fn load_indicator() -> Option<AnimatedIcon> {
455        #[cfg(feature = "material-icons")]
456        {
457            Some(crate::spinners::material_spinner())
458        }
459        #[cfg(not(feature = "material-icons"))]
460        {
461            None
462        }
463    }
464}
465
466/// Loader for Lucide Icons (bundled SVG).
467#[derive(Debug)]
468#[must_use]
469pub struct LucideLoader<'a> {
470    #[allow(dead_code)] // read only in the lucide-icons feature cfg branch of `load`
471    id: IconId<'a>,
472}
473
474impl<'a> LucideLoader<'a> {
475    /// Construct a new Lucide Icons loader for the given icon id.
476    pub fn new(id: impl Into<IconId<'a>>) -> Self {
477        Self { id: id.into() }
478    }
479
480    /// Load the icon, returning its data.
481    ///
482    /// Requires the `lucide-icons` feature; `None` otherwise.
483    #[must_use]
484    #[allow(unused_variables)]
485    pub fn load(self) -> Option<IconData> {
486        #[cfg(feature = "lucide-icons")]
487        {
488            match self.id {
489                IconId::Role(role) => {
490                    bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(Cow::Borrowed(b)))
491                }
492                IconId::Name(n) => bundled_icon_by_name(n, IconSet::Lucide)
493                    .map(|b| IconData::Svg(Cow::Borrowed(b))),
494                IconId::Custom(p) => {
495                    if let Some(n) = p.icon_name(IconSet::Lucide)
496                        && let Some(b) = bundled_icon_by_name(n, IconSet::Lucide)
497                    {
498                        return Some(IconData::Svg(Cow::Borrowed(b)));
499                    }
500                    p.icon_svg(IconSet::Lucide).map(IconData::Svg)
501                }
502            }
503        }
504        #[cfg(not(feature = "lucide-icons"))]
505        {
506            None
507        }
508    }
509
510    /// Load the Lucide animated spinner. Associated function; no `self`.
511    ///
512    /// Requires the `lucide-icons` feature; `None` otherwise.
513    #[must_use]
514    pub fn load_indicator() -> Option<AnimatedIcon> {
515        #[cfg(feature = "lucide-icons")]
516        {
517            Some(crate::spinners::lucide_spinner())
518        }
519        #[cfg(not(feature = "lucide-icons"))]
520        {
521            None
522        }
523    }
524}
525
526// =============================================================================
527// Runtime-set dispatch helpers
528// =============================================================================
529
530/// Load an icon using the given set with default per-set options.
531///
532/// For set-specific options (freedesktop theme, fg_color), construct the
533/// specific loader directly and chain the relevant methods.
534///
535/// Each set needs its loader's feature (see the loaders); `None` without it.
536#[must_use]
537pub fn load_icon<'a>(id: impl Into<IconId<'a>>, set: IconSet) -> Option<IconData> {
538    let id = id.into();
539    match set {
540        IconSet::Freedesktop => FreedesktopLoader::new(id).load(),
541        IconSet::SfSymbols => SfSymbolsLoader::new(id).load(),
542        IconSet::SegoeIcons => SegoeIconsLoader::new(id).load(),
543        IconSet::Material => MaterialLoader::new(id).load(),
544        IconSet::Lucide => LucideLoader::new(id).load(),
545    }
546}
547
548/// Load the animated loading indicator for the given set.
549///
550/// Returns `None` for sets without an animated spinner (SfSymbols, SegoeIcons).
551/// For a freedesktop spinner from a specific theme, use
552/// [`FreedesktopLoader::load_indicator`] directly.
553///
554/// Each set needs its loader's feature (see the loaders); `None` without it.
555#[must_use]
556pub fn load_icon_indicator(set: IconSet) -> Option<AnimatedIcon> {
557    match set {
558        IconSet::Freedesktop => FreedesktopLoader::load_indicator(None),
559        IconSet::Material => MaterialLoader::load_indicator(),
560        IconSet::Lucide => LucideLoader::load_indicator(),
561        IconSet::SfSymbols | IconSet::SegoeIcons => None,
562    }
563}
564
565/// Colour a monochrome SVG icon in its bytes.
566///
567/// A rasteriser given no `color` draws an implicit fill and an unresolved
568/// `currentColor` black, and a draw-time tint multiplies, which leaves black
569/// black โ€” so a Material or Lucide icon would draw black on a dark scheme.
570/// This writes `color` into the bytes instead, with gpui's algorithm:
571///
572/// 1. `currentColor` becomes the colour's `#rrggbb` (Lucide-style SVGs);
573/// 2. explicit black fills โ€” `fill="black"`, `fill="#000000"`, `fill="#000"` โ€”
574///    and explicit black strokes โ€” `stroke="black"`, `stroke="#000000"`,
575///    `stroke="#000"` โ€” become the hex (third-party SVGs with hardcoded black);
576/// 3. where none of those occurs, `fill="#rrggbb"` is injected into a root
577///    `<svg>` tag that has no `fill=` attribute (Material-style SVGs), before
578///    the `/` of a self-closing tag.
579///
580/// Bytes that are not UTF-8 come back unchanged (a lossy replacement would
581/// corrupt them). The colour's alpha is discarded: an SVG `fill` or `stroke`
582/// attribute takes opaque hex. Not handled, as in gpui: CSS inline styles
583/// (`style="fill:black"`), `fill="rgb(0,0,0)"`, and explicit black on a child
584/// element when the root tag has another fill. For monochrome icon sets; a
585/// full-colour SVG should not be passed through it.
586#[must_use]
587pub fn colorize_monochrome_svg(svg: &[u8], color: crate::color::Rgba) -> Vec<u8> {
588    let hex = format!("#{:02x}{:02x}{:02x}", color.r, color.g, color.b);
589
590    let Ok(svg_str) = std::str::from_utf8(svg) else {
591        return svg.to_vec();
592    };
593
594    // 1. currentColor
595    let replaced = if svg_str.contains("currentColor") {
596        svg_str.replace("currentColor", &hex)
597    } else {
598        svg_str.to_owned()
599    };
600
601    // 2. explicit black fills and strokes
602    let fill_hex = format!("fill=\"{hex}\"");
603    let replaced = replaced
604        .replace("fill=\"black\"", &fill_hex)
605        .replace("fill=\"#000000\"", &fill_hex)
606        .replace("fill=\"#000\"", &fill_hex);
607    let stroke_hex = format!("stroke=\"{hex}\"");
608    let replaced = replaced
609        .replace("stroke=\"black\"", &stroke_hex)
610        .replace("stroke=\"#000000\"", &stroke_hex)
611        .replace("stroke=\"#000\"", &stroke_hex);
612
613    if replaced != svg_str {
614        return replaced.into_bytes();
615    }
616
617    // 3. No currentColor and no explicit black: inject a fill into a root
618    // <svg> tag that has none (implicit black fill).
619    if let Some(pos) = svg_str.find("<svg")
620        && let Some(tail) = svg_str.get(pos..)
621        && let Some(close) = tail.find('>')
622    {
623        let tag_end = pos.saturating_add(close);
624        if let Some(tag) = svg_str.get(pos..tag_end)
625            && !tag.contains("fill=")
626        {
627            // A self-closing tag: inject before the '/' of '<svg .../>'.
628            let is_self_closing = tag_end > 0
629                && svg_str
630                    .as_bytes()
631                    .get(tag_end.saturating_sub(1))
632                    .is_some_and(|&b| b == b'/');
633            let inject_pos = if is_self_closing {
634                tag_end.saturating_sub(1)
635            } else {
636                tag_end
637            };
638            if let Some(before) = svg_str.get(..inject_pos)
639                && let Some(after) = svg_str.get(inject_pos..)
640            {
641                let mut result = String::with_capacity(svg_str.len().saturating_add(20));
642                result.push_str(before);
643                result.push_str(&format!(" fill=\"{hex}\""));
644                result.push_str(after);
645                return result.into_bytes();
646            }
647        }
648    }
649
650    // A non-black fill and no currentColor: unchanged.
651    svg.to_vec()
652}
653
654/// The icon base directories freedesktop-icons 0.4.0 searches for themes,
655/// in its order (`freedesktop-icons-0.4.0/src/theme/paths.rs:13-32`): each
656/// `$XDG_DATA_DIRS` entry's `icons`, `$XDG_DATA_HOME/icons`, then
657/// `~/.icons`, keeping those that exist. freedesktop-icons also searches
658/// the `pixmaps` directory beside each `icons` one; no UI icon may come
659/// from there, so it is left out.
660///
661/// The freedesktop icon lookup, [`is_freedesktop_theme_available`] and
662/// [`list_freedesktop_themes`] all read this one list, so a theme listed
663/// or available is one the lookup searches. Read once per process, as
664/// freedesktop-icons reads its own list once.
665#[cfg(target_os = "linux")]
666pub(crate) fn icon_base_dirs() -> &'static [std::path::PathBuf] {
667    static DIRS: std::sync::OnceLock<Vec<std::path::PathBuf>> = std::sync::OnceLock::new();
668    DIRS.get_or_init(xdg_icon_base_dirs)
669}
670
671/// Build [`icon_base_dirs`] from the environment the way freedesktop-icons'
672/// `xdg` 2.5.2 does (`xdg-2.5.2/src/base_directories.rs:268-309`): a
673/// relative or unset `$XDG_DATA_HOME` is `~/.local/share`, relative
674/// `$XDG_DATA_DIRS` entries are dropped, and none left is
675/// `/usr/local/share:/usr/share`. Without a home directory `xdg` gives no
676/// data dirs at all, and there is no `~/.icons` either.
677#[cfg(target_os = "linux")]
678fn xdg_icon_base_dirs() -> Vec<std::path::PathBuf> {
679    use std::path::PathBuf;
680
681    let Some(home) = std::env::home_dir() else {
682        return Vec::new();
683    };
684    let data_dirs: Vec<PathBuf> = std::env::var_os("XDG_DATA_DIRS")
685        .map(|dirs| {
686            std::env::split_paths(&dirs)
687                .filter(|dir| dir.is_absolute())
688                .collect::<Vec<_>>()
689        })
690        .filter(|dirs| !dirs.is_empty())
691        .unwrap_or_else(|| {
692            vec![
693                PathBuf::from("/usr/local/share"),
694                PathBuf::from("/usr/share"),
695            ]
696        });
697    let data_home = std::env::var_os("XDG_DATA_HOME")
698        .map(PathBuf::from)
699        .filter(|dir| dir.is_absolute())
700        .unwrap_or_else(|| home.join(".local/share"));
701    let mut dirs: Vec<PathBuf> = data_dirs.iter().map(|dir| dir.join("icons")).collect();
702    dirs.push(data_home.join("icons"));
703    dirs.push(home.join(".icons"));
704    dirs.retain(|dir| dir.exists());
705    dirs
706}
707
708/// Whether `name` can name a theme directory: one plain path component,
709/// so joining it to a base dir stays inside that base dir.
710#[cfg(target_os = "linux")]
711fn is_theme_name(name: &str) -> bool {
712    use std::path::{Component, Path};
713
714    let mut components = Path::new(name).components();
715    matches!(
716        (components.next(), components.next()),
717        (Some(Component::Normal(_)), None)
718    )
719}
720
721/// Whether `theme` is installed: a theme name with an `index.theme` in
722/// one of `bases`.
723#[cfg(target_os = "linux")]
724pub(crate) fn has_theme_index(theme: &str, bases: &[std::path::PathBuf]) -> bool {
725    is_theme_name(theme)
726        && bases
727            .iter()
728            .any(|base| base.join(theme).join("index.theme").exists())
729}
730
731/// Check whether a freedesktop icon theme is installed on this system.
732///
733/// Looks for the theme's `index.theme` file in the icon base directories
734/// the freedesktop icon lookup searches: `$XDG_DATA_DIRS/icons/<theme>/`,
735/// `$XDG_DATA_HOME/icons/<theme>/` and `~/.icons/<theme>/`.
736///
737/// Always returns `false` on non-Linux platforms.
738#[must_use]
739pub fn is_freedesktop_theme_available(theme: &str) -> bool {
740    #[cfg(target_os = "linux")]
741    {
742        has_theme_index(theme, icon_base_dirs())
743    }
744    #[cfg(not(target_os = "linux"))]
745    {
746        let _ = theme;
747        false
748    }
749}
750
751// =============================================================================
752// IconSetChoice: user's icon set selection intent
753// =============================================================================
754
755/// The user's icon set selection mode.
756///
757/// Represents the user's intent for which icons to display.  The key
758/// invariant: only [`Default`](Self::Default) is re-derived on theme changes.
759/// All other variants represent an explicit user choice that is preserved
760/// across theme re-applications.
761#[derive(Debug, Clone, PartialEq, Eq)]
762pub enum IconSetChoice {
763    /// Follow the theme preset's recommendation.
764    ///
765    /// The `String` is the preset's `icon_theme` name (e.g. "Adwaita"),
766    /// used for display ("default (Adwaita)") and for loading when the
767    /// icon set is `Freedesktop`.
768    ///
769    /// This is the ONLY variant that gets overwritten on theme change.
770    /// It is only constructed via [`default_icon_choice()`], which
771    /// guarantees the theme is available (bundled sets are always
772    /// available; freedesktop themes are checked via
773    /// [`is_freedesktop_theme_available`] before returning this variant).
774    Default(String),
775
776    /// Use the OS-configured icon theme.
777    ///
778    /// Resolved at load time via [`system_icon_set()`](crate::model::icons::system_icon_set).
779    /// The display label ("system (breeze-dark)") is computed dynamically
780    /// from [`system_icon_theme()`](crate::model::icons::system_icon_theme),
781    /// so it tracks runtime OS theme changes. Where detection fails the
782    /// label gives the reason, `system (unavailable: <reason>)`, and the
783    /// freedesktop loaders search no theme for this choice.
784    System,
785
786    /// User explicitly picked a specific installed freedesktop icon theme.
787    ///
788    /// The `String` is the theme directory name (e.g. "char-white",
789    /// "breeze", "Papirus").  Loaded via `IconSet::Freedesktop` with
790    /// `.theme(name)`.
791    Freedesktop(String),
792
793    /// Google Material Symbols (bundled).
794    Material,
795
796    /// Lucide Icons (bundled).
797    Lucide,
798}
799
800impl fmt::Display for IconSetChoice {
801    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
802        match self {
803            Self::Default(name) => write!(f, "default ({name})"),
804            Self::System => f.write_str(&system_choice_label(&system_icon_theme())),
805            Self::Freedesktop(name) => write!(f, "{name}"),
806            Self::Material => write!(f, "Material (bundled)"),
807            Self::Lucide => write!(f, "Lucide (bundled)"),
808        }
809    }
810}
811
812/// [`IconSetChoice::System`]'s label for the `detected` system icon theme:
813/// its name, or why there is none.
814fn system_choice_label(detected: &crate::Result<String>) -> String {
815    match detected {
816        Ok(name) => format!("system ({name})"),
817        Err(e) => format!("system (unavailable: {e})"),
818    }
819}
820
821impl IconSetChoice {
822    /// The effective [`IconSet`] loading mechanism for this choice.
823    ///
824    /// For [`Default`](Self::Default), returns the theme's icon set
825    /// (caller passes it in).
826    /// For [`Freedesktop`](Self::Freedesktop), always returns
827    /// [`IconSet::Freedesktop`].
828    /// For others, returns the corresponding bundled or system set.
829    #[must_use]
830    pub fn effective_icon_set(&self, theme_icon_set: IconSet) -> IconSet {
831        match self {
832            Self::Default(_) => theme_icon_set,
833            Self::System => system_icon_set(),
834            Self::Freedesktop(_) => IconSet::Freedesktop,
835            Self::Material => IconSet::Material,
836            Self::Lucide => IconSet::Lucide,
837        }
838    }
839
840    /// The freedesktop theme name to pass to [`FreedesktopLoader::theme`], if any.
841    ///
842    /// Returns `Some(name)` for [`Default`](Self::Default) and
843    /// [`Freedesktop`](Self::Freedesktop) variants.
844    /// Returns `None` for [`System`](Self::System), [`Material`](Self::Material),
845    /// [`Lucide`](Self::Lucide).
846    ///
847    /// The caller is responsible for only passing the result to
848    /// [`FreedesktopLoader::theme`] when the effective icon set is
849    /// [`IconSet::Freedesktop`].  For bundled sets (Material, Lucide),
850    /// the theme name is not used by the loader.
851    #[must_use]
852    pub fn freedesktop_theme(&self) -> Option<&str> {
853        match self {
854            Self::Default(name) | Self::Freedesktop(name) => Some(name),
855            Self::System | Self::Material | Self::Lucide => None,
856        }
857    }
858
859    /// Whether this choice should be re-derived when the theme changes.
860    ///
861    /// Only [`Default`](Self::Default) follows the preset.  All others
862    /// are explicit user choices that must be preserved.
863    #[must_use]
864    pub fn follows_preset(&self) -> bool {
865        matches!(self, Self::Default(_))
866    }
867}
868
869/// Determine the default icon set choice for a theme.
870///
871/// When the TOML specifies `icon_theme` (`Some`) and the theme is
872/// available (bundled sets are always available; freedesktop themes are
873/// checked via [`is_freedesktop_theme_available`]), returns
874/// [`IconSetChoice::Default(icon_theme)`](IconSetChoice::Default).
875///
876/// When the TOML does not specify `icon_theme` (`None`), or the
877/// specified freedesktop theme is not installed, returns
878/// [`IconSetChoice::System`].
879#[must_use]
880pub fn default_icon_choice(icon_set: IconSet, icon_theme: Option<&str>) -> IconSetChoice {
881    let Some(theme) = icon_theme else {
882        return IconSetChoice::System;
883    };
884    // All five IconSet variants listed explicitly -- no wildcard.
885    // Adding a new variant produces a compiler error, forcing the author
886    // to decide whether the new set's theme is "always available" or
887    // needs a runtime check.
888    let available = match icon_set {
889        IconSet::Material | IconSet::Lucide => true,
890        IconSet::Freedesktop => is_freedesktop_theme_available(theme),
891        IconSet::SfSymbols | IconSet::SegoeIcons => true,
892    };
893    if available {
894        IconSetChoice::Default(theme.to_string())
895    } else {
896        IconSetChoice::System
897    }
898}
899
900/// List installed freedesktop icon themes.
901///
902/// Scans the icon base directories the freedesktop icon lookup searches
903/// (`$XDG_DATA_DIRS/icons/`, `$XDG_DATA_HOME/icons/` and `~/.icons/`) for
904/// subdirectories containing an `index.theme` file with a `Directories=`
905/// line (per the freedesktop Icon Theme Specification).  This filters
906/// out cursor-only themes that lack application icons.
907///
908/// Excludes `hicolor` (the specification's shared theme for application
909/// icons, which native-theme's icon lookup never falls back to) and `default` (typically a
910/// symlink).  Returns a sorted, deduplicated list of theme directory
911/// names.
912///
913/// Silently skips entries on IO errors (e.g. permission denied).
914/// Returns an empty `Vec` on non-Linux platforms.
915///
916/// Note: themes installed via Flatpak or Snap may reside outside
917/// standard XDG paths and will not be discovered.
918#[must_use]
919pub fn list_freedesktop_themes() -> Vec<String> {
920    #[cfg(target_os = "linux")]
921    {
922        use std::collections::BTreeSet;
923        use std::io::BufRead;
924
925        let mut themes = BTreeSet::new();
926
927        for icon_dir in icon_base_dirs() {
928            let entries = match std::fs::read_dir(icon_dir) {
929                Ok(e) => e,
930                Err(_) => continue,
931            };
932            for entry in entries.filter_map(|e| e.ok()) {
933                let path = entry.path();
934                if !path.is_dir() {
935                    continue;
936                }
937                let name = match entry.file_name().into_string() {
938                    Ok(n) => n,
939                    Err(_) => continue,
940                };
941
942                // Exclude hicolor (application icons, never a UI set) and
943                // default (symlink).
944                if name == "hicolor" || name == "default" {
945                    continue;
946                }
947
948                let index_path = path.join("index.theme");
949                let file = match std::fs::File::open(&index_path) {
950                    Ok(f) => f,
951                    Err(_) => continue,
952                };
953
954                // Check if index.theme contains a Directories= line.
955                // Cursor-only themes omit this line.
956                let reader = std::io::BufReader::new(file);
957                let has_directories = reader
958                    .lines()
959                    .map_while(Result::ok)
960                    .any(|line| line.starts_with("Directories="));
961
962                if has_directories {
963                    themes.insert(name);
964                }
965            }
966        }
967
968        themes.into_iter().collect()
969    }
970    #[cfg(not(target_os = "linux"))]
971    {
972        Vec::new()
973    }
974}
975
976// =============================================================================
977// Tests
978// =============================================================================
979
980#[cfg(test)]
981#[allow(clippy::unwrap_used, clippy::expect_used)]
982mod load_icon_tests {
983    use super::*;
984
985    #[cfg(all(target_os = "linux", feature = "system-icons"))]
986    fn undetected() -> crate::Result<String> {
987        Err(crate::Error::PlatformUnsupported {
988            platform: "the test's failing detection",
989        })
990    }
991
992    /// With no theme set and no icon theme detected, nothing loads: no
993    /// theme stands in for the one that could not be detected.
994    #[test]
995    #[cfg(all(target_os = "linux", feature = "system-icons"))]
996    fn freedesktop_loader_without_a_detected_theme_loads_nothing() {
997        let custom: &dyn IconProvider = &IconRole::ActionCopy;
998        for id in [
999            IconId::Role(IconRole::ActionCopy),
1000            IconId::Name("edit-copy"),
1001            IconId::Custom(custom),
1002        ] {
1003            assert!(
1004                FreedesktopLoader::new(id).load_with(undetected).is_none(),
1005                "an icon loaded though no icon theme was detected"
1006            );
1007        }
1008    }
1009
1010    /// A custom provider's own freedesktop SVG depends on no theme, so it
1011    /// still loads where no icon theme was detected; only the lookup of its
1012    /// freedesktop name in a theme is skipped.
1013    #[test]
1014    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1015    fn freedesktop_loader_without_a_detected_theme_gives_a_providers_own_svg() {
1016        #[derive(Debug)]
1017        struct AppIcon;
1018        impl IconProvider for AppIcon {
1019            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1020                Some("edit-copy")
1021            }
1022            fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
1023                (set == IconSet::Freedesktop).then_some(Cow::Borrowed(b"<svg>app</svg>"))
1024            }
1025        }
1026
1027        let provider: &dyn IconProvider = &AppIcon;
1028        assert_eq!(
1029            FreedesktopLoader::new(provider).load_with(undetected),
1030            Some(IconData::Svg(Cow::Borrowed(b"<svg>app</svg>"))),
1031            "the provider's own SVG did not load without a detected icon theme"
1032        );
1033    }
1034
1035    /// A generated icon enum (native-theme-build) is handed to a loader by
1036    /// reference, as its README shows: `MaterialLoader::new(&AppIcon::X)`.
1037    #[test]
1038    #[cfg(feature = "material-icons")]
1039    fn a_provider_is_passed_to_a_loader_by_reference() {
1040        #[derive(Debug)]
1041        struct AppIcon;
1042        impl IconProvider for AppIcon {
1043            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1044                None
1045            }
1046            fn icon_svg(&self, set: IconSet) -> Option<Cow<'static, [u8]>> {
1047                (set == IconSet::Material).then_some(Cow::Borrowed(b"<svg>app</svg>"))
1048            }
1049        }
1050
1051        assert_eq!(
1052            MaterialLoader::new(&AppIcon).load(),
1053            Some(IconData::Svg(Cow::Borrowed(b"<svg>app</svg>"))),
1054            "a provider passed by reference did not load its own SVG"
1055        );
1056    }
1057
1058    /// An explicit theme needs no detection: the detector never runs.
1059    #[test]
1060    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1061    fn freedesktop_loader_with_a_theme_does_not_detect() {
1062        let runs = std::cell::Cell::new(0);
1063        let _ = FreedesktopLoader::new("edit-copy")
1064            .theme("Adwaita")
1065            .load_with(|| {
1066                runs.set(runs.get() + 1);
1067                undetected()
1068            });
1069        assert_eq!(runs.get(), 0, "an explicit theme ran the detection");
1070    }
1071
1072    #[test]
1073    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1074    fn freedesktop_loader_theme_override_honored_for_name_lookup() {
1075        // Regression pin for Phase 93-03 silent-ignore bug (Plan 93-09 fix).
1076        // The old IconLoader dispatched IconId::Name to a path that silently
1077        // dropped `.theme()`. FreedesktopLoader has a single `load()` method
1078        // that honors `.theme()` uniformly across Role/Name/Custom.
1079        //
1080        // Requires the Adwaita icon theme installed on the test host.
1081        let result = FreedesktopLoader::new("format-text-rich")
1082            .theme("Adwaita")
1083            .size(24)
1084            .load();
1085        assert!(
1086            result.is_some(),
1087            "theme('Adwaita') must resolve 'format-text-rich' in Adwaita, regardless of system theme"
1088        );
1089    }
1090
1091    #[test]
1092    #[cfg(feature = "material-icons")]
1093    fn load_icon_material_returns_svg() {
1094        let result = MaterialLoader::new(IconRole::ActionCopy).load();
1095        assert!(result.is_some(), "material ActionCopy should return Some");
1096        match result.unwrap() {
1097            IconData::Svg(ref cow) => {
1098                let s = String::from_utf8_lossy(cow);
1099                assert!(s.contains("<svg"), "should contain SVG data");
1100            }
1101            _ => panic!("expected IconData::Svg for bundled material icon"),
1102        }
1103    }
1104
1105    #[test]
1106    #[cfg(feature = "lucide-icons")]
1107    fn load_icon_lucide_returns_svg() {
1108        let result = LucideLoader::new(IconRole::ActionCopy).load();
1109        assert!(result.is_some(), "lucide ActionCopy should return Some");
1110        match result.unwrap() {
1111            IconData::Svg(ref cow) => {
1112                let s = String::from_utf8_lossy(cow);
1113                assert!(s.contains("<svg"), "should contain SVG data");
1114            }
1115            _ => panic!("expected IconData::Svg for bundled lucide icon"),
1116        }
1117    }
1118
1119    #[test]
1120    #[cfg(feature = "material-icons")]
1121    fn load_icon_unknown_theme_no_cross_set_fallback() {
1122        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
1123        // Without system-icons feature, Freedesktop falls through to wildcard -> None.
1124        // No cross-set Material fallback.
1125        let result = FreedesktopLoader::new(IconRole::ActionCopy).load();
1126        // Without system-icons, this falls to wildcard which returns None
1127        // With system-icons, this dispatches to load_freedesktop_icon which may return Some
1128        // Either way, no panic
1129        let _ = result;
1130    }
1131
1132    #[test]
1133    #[cfg(feature = "material-icons")]
1134    fn load_icon_all_roles_material() {
1135        // Material has 42 of 42 roles mapped, all return Some
1136        let mut some_count = 0;
1137        for role in IconRole::ALL {
1138            if MaterialLoader::new(role).load().is_some() {
1139                some_count += 1;
1140            }
1141        }
1142        // bundled_icon_svg covers all 42 roles for Material
1143        assert_eq!(
1144            some_count, 42,
1145            "Material should cover all 42 roles via bundled SVGs"
1146        );
1147    }
1148
1149    #[test]
1150    #[cfg(feature = "lucide-icons")]
1151    fn load_icon_all_roles_lucide() {
1152        let mut some_count = 0;
1153        for role in IconRole::ALL {
1154            if LucideLoader::new(role).load().is_some() {
1155                some_count += 1;
1156            }
1157        }
1158        // bundled_icon_svg covers all 42 roles for Lucide
1159        assert_eq!(
1160            some_count, 42,
1161            "Lucide should cover all 42 roles via bundled SVGs"
1162        );
1163    }
1164
1165    #[test]
1166    fn sf_symbols_loader_stores_the_colour() {
1167        let loader = SfSymbolsLoader::new(IconRole::ActionCopy).color([10, 20, 30]);
1168        assert_eq!(loader.fg_color, Some([10, 20, 30]));
1169        let loader = loader.color_opt(None);
1170        assert_eq!(loader.fg_color, None);
1171        let loader = loader.color_opt(Some([40, 50, 60]));
1172        assert_eq!(loader.fg_color, Some([40, 50, 60]));
1173        assert_eq!(SfSymbolsLoader::new(IconRole::ActionCopy).fg_color, None);
1174    }
1175
1176    #[test]
1177    fn segoe_icons_loader_stores_the_colour() {
1178        let loader = SegoeIconsLoader::new(IconRole::ActionCopy).color([10, 20, 30]);
1179        assert_eq!(loader.fg_color, Some([10, 20, 30]));
1180        let loader = loader.color_opt(None);
1181        assert_eq!(loader.fg_color, None);
1182        let loader = loader.color_opt(Some([40, 50, 60]));
1183        assert_eq!(loader.fg_color, Some([40, 50, 60]));
1184        assert_eq!(SegoeIconsLoader::new(IconRole::ActionCopy).fg_color, None);
1185    }
1186
1187    #[test]
1188    fn load_icon_unrecognized_set_no_features() {
1189        // SfSymbols on Linux without system-icons: falls through to wildcard -> None
1190        let _result = SfSymbolsLoader::new(IconRole::ActionCopy).load();
1191        // Just verifying it doesn't panic
1192    }
1193
1194    #[test]
1195    #[cfg(feature = "material-icons")]
1196    fn bundled_icon_load_produces_cow_borrowed() {
1197        let result = MaterialLoader::new(IconRole::ActionCopy).load();
1198        assert!(
1199            matches!(result, Some(IconData::Svg(Cow::Borrowed(_)))),
1200            "bundled icon should produce Some(IconData::Svg(Cow::Borrowed(_)))"
1201        );
1202    }
1203}
1204
1205#[cfg(test)]
1206#[allow(clippy::unwrap_used, clippy::expect_used)]
1207mod load_system_icon_by_name_tests {
1208    use super::*;
1209
1210    #[test]
1211    #[cfg(feature = "material-icons")]
1212    fn system_icon_by_name_material() {
1213        let result = MaterialLoader::new("content_copy").load();
1214        assert!(
1215            result.is_some(),
1216            "content_copy should be found in Material set"
1217        );
1218        assert!(matches!(result.unwrap(), IconData::Svg(_)));
1219    }
1220
1221    #[test]
1222    #[cfg(feature = "lucide-icons")]
1223    fn system_icon_by_name_lucide() {
1224        let result = LucideLoader::new("copy").load();
1225        assert!(result.is_some(), "copy should be found in Lucide set");
1226        assert!(matches!(result.unwrap(), IconData::Svg(_)));
1227    }
1228
1229    #[test]
1230    #[cfg(feature = "material-icons")]
1231    fn system_icon_by_name_unknown_returns_none() {
1232        let result = MaterialLoader::new("nonexistent_xyz").load();
1233        assert!(result.is_none(), "nonexistent name should return None");
1234    }
1235
1236    #[test]
1237    fn system_icon_by_name_sf_on_linux_returns_none() {
1238        // On Linux, SfSymbols set is not available (cfg-gated to macOS)
1239        #[cfg(not(target_os = "macos"))]
1240        {
1241            let result = SfSymbolsLoader::new("doc.on.doc").load();
1242            assert!(
1243                result.is_none(),
1244                "SF Symbols should return None on non-macOS"
1245            );
1246        }
1247    }
1248}
1249
1250#[cfg(test)]
1251#[allow(clippy::unwrap_used, clippy::expect_used)]
1252mod load_custom_icon_tests {
1253    use super::*;
1254
1255    #[test]
1256    #[cfg(feature = "material-icons")]
1257    fn custom_icon_with_icon_role_material() {
1258        let provider: &dyn IconProvider = &IconRole::ActionCopy;
1259        let result = MaterialLoader::new(provider).load();
1260        assert!(
1261            result.is_some(),
1262            "IconRole::ActionCopy should load via material"
1263        );
1264    }
1265
1266    #[test]
1267    #[cfg(feature = "lucide-icons")]
1268    fn custom_icon_with_icon_role_lucide() {
1269        let provider: &dyn IconProvider = &IconRole::ActionCopy;
1270        let result = LucideLoader::new(provider).load();
1271        assert!(
1272            result.is_some(),
1273            "IconRole::ActionCopy should load via lucide"
1274        );
1275    }
1276
1277    #[test]
1278    fn custom_icon_no_cross_set_fallback() {
1279        // Provider that returns None for all sets -- should NOT fall back
1280        #[derive(Debug)]
1281        struct NullProvider;
1282        impl IconProvider for NullProvider {
1283            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1284                None
1285            }
1286            fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1287                None
1288            }
1289        }
1290
1291        let provider: &dyn IconProvider = &NullProvider;
1292        let result = MaterialLoader::new(provider).load();
1293        assert!(
1294            result.is_none(),
1295            "NullProvider should return None (no cross-set fallback)"
1296        );
1297    }
1298
1299    #[test]
1300    fn custom_icon_unknown_set_uses_system() {
1301        // "unknown-set" is not a known IconSet name, falls through to system_icon_set()
1302        #[derive(Debug)]
1303        struct NullProvider;
1304        impl IconProvider for NullProvider {
1305            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1306                None
1307            }
1308            fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1309                None
1310            }
1311        }
1312
1313        // Just verify it doesn't panic -- the actual set chosen depends on platform
1314        let provider: &dyn IconProvider = &NullProvider;
1315        let _result = FreedesktopLoader::new(provider).load();
1316    }
1317
1318    #[test]
1319    #[cfg(feature = "material-icons")]
1320    fn custom_icon_via_dyn_dispatch() {
1321        let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
1322        let provider: &dyn IconProvider = &*boxed;
1323        let result = MaterialLoader::new(provider).load();
1324        assert!(
1325            result.is_some(),
1326            "dyn dispatch through Box<dyn IconProvider> should work"
1327        );
1328    }
1329
1330    #[test]
1331    #[cfg(feature = "material-icons")]
1332    fn custom_icon_bundled_svg_fallback() {
1333        // Provider that returns None from icon_name but Some from icon_svg
1334        #[derive(Debug)]
1335        struct SvgOnlyProvider;
1336        impl IconProvider for SvgOnlyProvider {
1337            fn icon_name(&self, _set: IconSet) -> Option<&str> {
1338                None
1339            }
1340            fn icon_svg(&self, _set: IconSet) -> Option<Cow<'static, [u8]>> {
1341                Some(Cow::Borrowed(b"<svg>test</svg>"))
1342            }
1343        }
1344
1345        let provider: &dyn IconProvider = &SvgOnlyProvider;
1346        let result = MaterialLoader::new(provider).load();
1347        assert!(
1348            result.is_some(),
1349            "provider with icon_svg should return Some"
1350        );
1351        match result.unwrap() {
1352            IconData::Svg(ref cow) => {
1353                assert_eq!(cow.as_ref(), b"<svg>test</svg>");
1354            }
1355            _ => panic!("expected IconData::Svg"),
1356        }
1357    }
1358}
1359
1360#[cfg(test)]
1361#[allow(clippy::unwrap_used, clippy::expect_used)]
1362mod loading_indicator_tests {
1363    use super::*;
1364
1365    // === Dispatch tests (through per-set loader API) ===
1366
1367    #[test]
1368    #[cfg(feature = "lucide-icons")]
1369    fn loading_indicator_lucide_returns_frames() {
1370        let anim = LucideLoader::load_indicator();
1371        assert!(anim.is_some(), "lucide should return Some");
1372        let anim = anim.unwrap();
1373        assert!(
1374            matches!(anim, AnimatedIcon::Frames(_)),
1375            "lucide should be pre-rotated Frames"
1376        );
1377        if let AnimatedIcon::Frames(data) = &anim {
1378            assert_eq!(data.frames().len(), 24);
1379            assert_eq!(data.frame_duration_ms().get(), 42);
1380        }
1381    }
1382
1383    /// Freedesktop loading_indicator returns Some if the active icon theme
1384    /// has a `process-working` sprite sheet (e.g. Breeze), None otherwise.
1385    #[test]
1386    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1387    fn loading_indicator_freedesktop_depends_on_theme() {
1388        let anim = FreedesktopLoader::load_indicator(None);
1389        // Result depends on installed icon theme -- Some if process-working exists
1390        if let Some(anim) = anim {
1391            match anim {
1392                AnimatedIcon::Frames(data) => {
1393                    assert!(
1394                        !data.frames().is_empty(),
1395                        "Frames variant should have at least one frame"
1396                    );
1397                }
1398                AnimatedIcon::Transform(_) => {
1399                    // Single-frame theme icon with Spin -- valid result
1400                }
1401            }
1402        }
1403    }
1404
1405    /// Freedesktop spinner depends on platform and icon theme.
1406    #[test]
1407    fn loading_indicator_freedesktop_does_not_panic() {
1408        let _result = FreedesktopLoader::load_indicator(None);
1409    }
1410
1411    // === Direct spinner construction tests (any platform) ===
1412
1413    #[test]
1414    #[cfg(feature = "lucide-icons")]
1415    fn lucide_spinner_is_frames() {
1416        let anim = crate::spinners::lucide_spinner();
1417        assert!(
1418            matches!(anim, AnimatedIcon::Frames(_)),
1419            "lucide should be pre-rotated Frames"
1420        );
1421    }
1422}
1423
1424// === New builder API tests ===
1425
1426#[cfg(test)]
1427#[allow(clippy::unwrap_used, clippy::expect_used)]
1428mod icon_loader_tests {
1429    use super::*;
1430
1431    #[test]
1432    #[cfg(feature = "material-icons")]
1433    fn icon_loader_basic_role() {
1434        let icon = MaterialLoader::new(IconRole::ActionCopy).load();
1435        assert!(icon.is_some());
1436    }
1437
1438    #[test]
1439    #[cfg(feature = "material-icons")]
1440    fn icon_loader_by_name() {
1441        let icon = MaterialLoader::new("content_copy").load();
1442        assert!(icon.is_some());
1443    }
1444
1445    #[test]
1446    #[cfg(feature = "material-icons")]
1447    fn icon_loader_custom_provider() {
1448        let provider: &dyn IconProvider = &IconRole::ActionCopy;
1449        let icon = MaterialLoader::new(provider).load();
1450        assert!(icon.is_some());
1451    }
1452
1453    #[test]
1454    #[cfg(all(target_os = "linux", feature = "system-icons"))]
1455    fn freedesktop_loader_builder_chain_compiles() {
1456        // Smoke test: the builder chain constructs without runtime errors.
1457        // Size propagates through; the actual load result depends on the
1458        // system theme having 'document-save' at size 48.
1459        let _ = FreedesktopLoader::new(IconRole::ActionSave)
1460            .size(48)
1461            .color([0, 0, 0])
1462            .theme("Adwaita")
1463            .load();
1464    }
1465
1466    #[test]
1467    #[cfg(feature = "material-icons")]
1468    fn icon_loader_load_indicator() {
1469        let anim = MaterialLoader::load_indicator();
1470        assert!(anim.is_some());
1471    }
1472
1473    #[test]
1474    fn load_icon_free_fn_dispatches() {
1475        // load_icon() free function routes to the right per-set loader.
1476        // SfSymbols on Linux is cfg-gated to macOS => None, no panic.
1477        let _ = load_icon(IconRole::ActionCopy, IconSet::SfSymbols);
1478    }
1479
1480    #[test]
1481    fn load_icon_indicator_free_fn_dispatches() {
1482        // SfSymbols and SegoeIcons have no spinner primitive.
1483        assert!(load_icon_indicator(IconSet::SfSymbols).is_none());
1484        assert!(load_icon_indicator(IconSet::SegoeIcons).is_none());
1485    }
1486}
1487
1488#[cfg(all(test, feature = "svg-rasterize"))]
1489#[allow(clippy::unwrap_used, clippy::expect_used)]
1490mod spinner_rasterize_tests {
1491    use super::*;
1492
1493    #[test]
1494    #[cfg(feature = "lucide-icons")]
1495    fn lucide_spinner_icon_rasterizes() {
1496        let anim = crate::spinners::lucide_spinner();
1497        if let AnimatedIcon::Frames(data) = &anim {
1498            let first = data.frames().first();
1499            if let IconData::Svg(cow) = first {
1500                let result = crate::rasterize::rasterize_svg(cow, 24);
1501                assert!(result.is_ok(), "lucide loader should rasterize");
1502                if let Ok(IconData::Rgba { data, .. }) = &result {
1503                    assert!(
1504                        data.iter().any(|&b| b != 0),
1505                        "lucide loader rasterized to empty image"
1506                    );
1507                }
1508            } else {
1509                panic!("lucide spinner frame should be Svg");
1510            }
1511        } else {
1512            panic!("lucide spinner should be Frames");
1513        }
1514    }
1515}
1516
1517// =============================================================================
1518// IconSetChoice tests
1519// =============================================================================
1520
1521#[cfg(test)]
1522mod icon_set_choice_tests {
1523    use super::*;
1524
1525    #[test]
1526    fn system_choice_names_the_detected_theme() {
1527        assert_eq!(
1528            system_choice_label(&Ok("breeze-dark".to_string())),
1529            "system (breeze-dark)"
1530        );
1531    }
1532
1533    #[test]
1534    fn system_choice_shows_a_failed_detection_as_unavailable() {
1535        let label = system_choice_label(&Err(crate::Error::PlatformUnsupported {
1536            platform: "the test's failing detection",
1537        }));
1538        assert_eq!(
1539            label,
1540            "system (unavailable: platform not supported: the test's failing detection)"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_icon_set_choice_display_default() {
1546        let choice = IconSetChoice::Default("Adwaita".to_string());
1547        assert_eq!(choice.to_string(), "default (Adwaita)");
1548    }
1549
1550    #[test]
1551    fn test_icon_set_choice_display_material() {
1552        assert_eq!(IconSetChoice::Material.to_string(), "Material (bundled)");
1553    }
1554
1555    #[test]
1556    fn test_icon_set_choice_display_lucide() {
1557        assert_eq!(IconSetChoice::Lucide.to_string(), "Lucide (bundled)");
1558    }
1559
1560    #[test]
1561    fn test_icon_set_choice_display_freedesktop() {
1562        let choice = IconSetChoice::Freedesktop("breeze".to_string());
1563        assert_eq!(choice.to_string(), "breeze");
1564    }
1565
1566    #[test]
1567    fn test_icon_set_choice_follows_preset() {
1568        assert!(IconSetChoice::Default("Adwaita".to_string()).follows_preset());
1569        assert!(!IconSetChoice::System.follows_preset());
1570        assert!(!IconSetChoice::Freedesktop("breeze".to_string()).follows_preset());
1571        assert!(!IconSetChoice::Material.follows_preset());
1572        assert!(!IconSetChoice::Lucide.follows_preset());
1573    }
1574
1575    #[test]
1576    fn test_icon_set_choice_effective_icon_set() {
1577        // Default returns whatever the theme specifies
1578        let choice = IconSetChoice::Default("Adwaita".to_string());
1579        assert_eq!(
1580            choice.effective_icon_set(IconSet::Freedesktop),
1581            IconSet::Freedesktop
1582        );
1583        assert_eq!(
1584            choice.effective_icon_set(IconSet::Material),
1585            IconSet::Material
1586        );
1587
1588        // System returns the platform's icon set
1589        let sys = IconSetChoice::System.effective_icon_set(IconSet::Material);
1590        assert_eq!(sys, system_icon_set());
1591
1592        // Freedesktop always returns Freedesktop
1593        let choice = IconSetChoice::Freedesktop("breeze".to_string());
1594        assert_eq!(
1595            choice.effective_icon_set(IconSet::Material),
1596            IconSet::Freedesktop
1597        );
1598
1599        // Bundled sets return themselves
1600        assert_eq!(
1601            IconSetChoice::Material.effective_icon_set(IconSet::Freedesktop),
1602            IconSet::Material
1603        );
1604        assert_eq!(
1605            IconSetChoice::Lucide.effective_icon_set(IconSet::Freedesktop),
1606            IconSet::Lucide
1607        );
1608    }
1609
1610    #[test]
1611    fn test_icon_set_choice_freedesktop_theme() {
1612        assert_eq!(
1613            IconSetChoice::Default("Adwaita".to_string()).freedesktop_theme(),
1614            Some("Adwaita")
1615        );
1616        assert_eq!(
1617            IconSetChoice::Freedesktop("breeze".to_string()).freedesktop_theme(),
1618            Some("breeze")
1619        );
1620        assert_eq!(IconSetChoice::System.freedesktop_theme(), None);
1621        assert_eq!(IconSetChoice::Material.freedesktop_theme(), None);
1622        assert_eq!(IconSetChoice::Lucide.freedesktop_theme(), None);
1623    }
1624
1625    #[test]
1626    fn test_default_icon_choice_none() {
1627        // When icon_theme is None, always returns System regardless of icon_set
1628        assert_eq!(
1629            default_icon_choice(IconSet::Freedesktop, None),
1630            IconSetChoice::System
1631        );
1632        assert_eq!(
1633            default_icon_choice(IconSet::Material, None),
1634            IconSetChoice::System
1635        );
1636    }
1637
1638    #[test]
1639    fn test_default_icon_choice_bundled() {
1640        // Material and Lucide are always available
1641        assert_eq!(
1642            default_icon_choice(IconSet::Material, Some("any-theme")),
1643            IconSetChoice::Default("any-theme".to_string())
1644        );
1645        assert_eq!(
1646            default_icon_choice(IconSet::Lucide, Some("any-theme")),
1647            IconSetChoice::Default("any-theme".to_string())
1648        );
1649    }
1650
1651    #[test]
1652    fn test_list_freedesktop_themes_no_panic() {
1653        // Just verify it doesn't panic -- result varies by platform
1654        let themes = list_freedesktop_themes();
1655        // On Linux, should return a non-empty list on most desktop systems.
1656        // On other platforms, returns empty vec.
1657        #[cfg(not(target_os = "linux"))]
1658        assert!(themes.is_empty());
1659        // Suppress unused variable warning
1660        let _ = themes;
1661    }
1662}
1663
1664#[cfg(test)]
1665#[allow(
1666    clippy::unwrap_used,
1667    clippy::expect_used,
1668    clippy::panic,
1669    clippy::indexing_slicing,
1670    reason = "a test fails by panicking"
1671)]
1672mod colorize_tests {
1673    use super::colorize_monochrome_svg;
1674    use crate::color::Rgba;
1675
1676    const BLUE: Rgba = Rgba::new(51, 102, 204, 255);
1677    const RED: Rgba = Rgba::new(255, 0, 0, 255);
1678    const GREEN: Rgba = Rgba::new(20, 184, 82, 255);
1679
1680    #[test]
1681    fn colorize_svg_replaces_fill_black() {
1682        let svg = b"<svg><path fill=\"black\" d=\"M0 0h24v24H0z\"/></svg>";
1683        let result_str = String::from_utf8(colorize_monochrome_svg(svg, BLUE)).unwrap();
1684        assert!(
1685            !result_str.contains("fill=\"black\""),
1686            "fill=\"black\" should be replaced, got: {result_str}"
1687        );
1688        assert!(
1689            result_str.contains("fill=\"#3366cc\""),
1690            "should contain the hex fill, got: {result_str}"
1691        );
1692    }
1693
1694    #[test]
1695    fn colorize_svg_replaces_fill_hex_black() {
1696        let svg = b"<svg><rect fill=\"#000000\" width=\"24\" height=\"24\"/></svg>";
1697        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1698        assert!(
1699            !result_str.contains("#000000"),
1700            "fill=\"#000000\" should be replaced, got: {result_str}"
1701        );
1702    }
1703
1704    #[test]
1705    fn colorize_svg_replaces_fill_short_hex_black() {
1706        let svg = b"<svg><rect fill=\"#000\" width=\"24\" height=\"24\"/></svg>";
1707        let result_str = String::from_utf8(colorize_monochrome_svg(svg, GREEN)).unwrap();
1708        assert!(
1709            !result_str.contains("fill=\"#000\""),
1710            "fill=\"#000\" should be replaced, got: {result_str}"
1711        );
1712    }
1713
1714    #[test]
1715    fn colorize_svg_current_color_still_works() {
1716        let svg = b"<svg><path stroke=\"currentColor\" d=\"M0 0\"/></svg>";
1717        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1718        assert!(
1719            !result_str.contains("currentColor"),
1720            "currentColor should be replaced"
1721        );
1722        assert!(
1723            result_str.contains("#ff0000"),
1724            "should contain the hex colour"
1725        );
1726    }
1727
1728    #[test]
1729    fn colorize_svg_implicit_black_still_works() {
1730        // SVG with no fill attribute at all (Material-style)
1731        let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>";
1732        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1733        assert!(
1734            result_str.contains("fill=\"#ff0000\""),
1735            "should inject fill into root svg tag, got: {result_str}"
1736        );
1737    }
1738
1739    #[test]
1740    fn colorize_svg_non_utf8_returns_original() {
1741        // Non-UTF-8 bytes: valid SVG prefix followed by an invalid byte sequence
1742        let mut svg = b"<svg><path fill=\"black\" d=\"M0 0\"/>".to_vec();
1743        svg.push(0xFF); // invalid UTF-8 byte
1744        svg.extend_from_slice(b"</svg>");
1745        let result = colorize_monochrome_svg(&svg, RED);
1746        assert_eq!(result, svg, "non-UTF-8 input should be returned unchanged");
1747    }
1748
1749    #[test]
1750    fn colorize_svg_replaces_stroke_black() {
1751        let svg = b"<svg><path stroke=\"black\" d=\"M0 0h24\"/></svg>";
1752        let result_str = String::from_utf8(colorize_monochrome_svg(svg, BLUE)).unwrap();
1753        assert!(
1754            !result_str.contains("stroke=\"black\""),
1755            "stroke=\"black\" should be replaced, got: {result_str}"
1756        );
1757        assert!(
1758            result_str.contains("stroke=\"#3366cc\""),
1759            "should contain the hex stroke, got: {result_str}"
1760        );
1761    }
1762
1763    #[test]
1764    fn colorize_svg_replaces_stroke_hex_black() {
1765        let svg = b"<svg><line stroke=\"#000000\" x1=\"0\" y1=\"0\" x2=\"24\" y2=\"24\"/></svg>";
1766        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1767        assert!(
1768            !result_str.contains("#000000"),
1769            "stroke=\"#000000\" should be replaced"
1770        );
1771    }
1772
1773    #[test]
1774    fn colorize_self_closing_svg_produces_valid_xml() {
1775        // Self-closing <svg .../> tag: the fill must be injected before '/'
1776        let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" />";
1777        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1778        assert!(
1779            result_str.contains("fill=\"#ff0000\""),
1780            "should inject fill, got: {result_str}"
1781        );
1782        assert!(
1783            !result_str.contains("/ fill="),
1784            "fill must be before '/', got: {result_str}"
1785        );
1786        assert!(
1787            result_str.trim().ends_with("/>"),
1788            "should remain self-closing, got: {result_str}"
1789        );
1790    }
1791
1792    #[test]
1793    fn colorize_svg_with_fill_white_root() {
1794        // A root fill that is not black is kept, not replaced
1795        let svg = b"<svg fill=\"white\"><path/></svg>";
1796        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1797        assert!(
1798            result_str.contains("fill=\"white\""),
1799            "fill=\"white\" should be preserved, got: {result_str}"
1800        );
1801    }
1802
1803    #[test]
1804    fn colorize_svg_with_fill_none_root() {
1805        // stroke="black" is replaced even when the root has fill="none"
1806        let svg = b"<svg fill=\"none\"><path stroke=\"black\"/></svg>";
1807        let result_str = String::from_utf8(colorize_monochrome_svg(svg, RED)).unwrap();
1808        assert!(
1809            !result_str.contains("stroke=\"black\""),
1810            "stroke=\"black\" should be replaced, got: {result_str}"
1811        );
1812        assert!(
1813            result_str.contains("stroke=\"#ff0000\""),
1814            "should contain the hex stroke, got: {result_str}"
1815        );
1816    }
1817
1818    /// The colour's alpha is discarded: an SVG `fill` takes opaque hex (spec ยง9.2).
1819    #[test]
1820    fn colorize_svg_discards_alpha() {
1821        let svg = b"<svg><path fill=\"black\"/></svg>";
1822        let translucent = Rgba::new(255, 0, 0, 40);
1823        assert_eq!(
1824            colorize_monochrome_svg(svg, translucent),
1825            colorize_monochrome_svg(svg, RED)
1826        );
1827    }
1828}