Skip to main content

native_theme/
icons.rs

1//! Icon loading and dispatch.
2
3#[allow(unused_imports)]
4use crate::model::icons::{icon_name, system_icon_theme};
5use crate::model::{AnimatedIcon, IconData, IconProvider, IconRole, IconSet};
6#[allow(unused_imports)]
7use crate::model::{bundled_icon_by_name, bundled_icon_svg};
8
9/// Load an icon for the given role using the specified icon set.
10///
11/// Dispatches to the appropriate platform loader or bundled icon set
12/// based on the [`IconSet`] variant:
13///
14/// - [`IconSet::Freedesktop`] -- freedesktop theme lookup at 24 px using the
15///   system's installed icon theme (requires `system-icons` feature, Linux only)
16/// - [`IconSet::SfSymbols`] -- SF Symbols lookup
17///   (requires `system-icons` feature, macOS only)
18/// - [`IconSet::SegoeIcons`] -- Segoe Fluent lookup
19///   (requires `system-icons` feature, Windows only)
20/// - [`IconSet::Material`] -- bundled Material SVG
21///   (requires `material-icons` feature)
22/// - [`IconSet::Lucide`] -- bundled Lucide SVG
23///   (requires `lucide-icons` feature)
24///
25/// Returns `None` when the required feature is not enabled, the platform
26/// doesn't match, or the role has no icon in the requested set.
27/// There is **no cross-set fallback** -- each set is self-contained.
28///
29/// # Examples
30///
31/// ```
32/// use native_theme::{load_icon, IconRole, IconSet};
33///
34/// // With material-icons feature enabled
35/// # #[cfg(feature = "material-icons")]
36/// # {
37/// let icon = load_icon(IconRole::ActionCopy, IconSet::Material, None);
38/// assert!(icon.is_some());
39/// # }
40/// ```
41#[must_use = "this returns the loaded icon data; it does not display it"]
42#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
43pub fn load_icon(role: IconRole, set: IconSet, fg_color: Option<[u8; 3]>) -> Option<IconData> {
44    match set {
45        #[cfg(all(target_os = "linux", feature = "system-icons"))]
46        IconSet::Freedesktop => crate::freedesktop::load_freedesktop_icon(role, 24, fg_color),
47
48        #[cfg(all(target_os = "macos", feature = "system-icons"))]
49        IconSet::SfSymbols => crate::sficons::load_sf_icon(role),
50
51        #[cfg(all(target_os = "windows", feature = "system-icons"))]
52        IconSet::SegoeIcons => crate::winicons::load_windows_icon(role),
53
54        #[cfg(feature = "material-icons")]
55        IconSet::Material => {
56            bundled_icon_svg(role, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
57        }
58
59        #[cfg(feature = "lucide-icons")]
60        IconSet::Lucide => {
61            bundled_icon_svg(role, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
62        }
63
64        // Non-matching platform or unknown set: no cross-set fallback
65        _ => None,
66    }
67}
68
69/// Load an icon using a specific freedesktop icon theme instead of the
70/// system default.
71///
72/// For [`IconSet::Freedesktop`], loads from the `preferred_theme` directory
73/// (e.g. `"Adwaita"`, `"breeze"`). For bundled icon sets ([`IconSet::Material`],
74/// [`IconSet::Lucide`]), `preferred_theme` is ignored --- the icons are compiled
75/// in and always available.
76///
77/// Use [`is_freedesktop_theme_available()`] first to check whether the theme
78/// is installed. If the theme is not installed, freedesktop lookups will fall
79/// through to `hicolor` and may return unexpected icons.
80///
81/// # Examples
82///
83/// ```
84/// use native_theme::{load_icon_from_theme, IconRole, IconSet};
85///
86/// # #[cfg(feature = "material-icons")]
87/// # {
88/// // Bundled sets ignore the theme parameter
89/// let icon = load_icon_from_theme(IconRole::ActionCopy, IconSet::Material, "anything", None);
90/// assert!(icon.is_some());
91/// # }
92/// ```
93#[must_use = "this returns the loaded icon data; it does not display it"]
94#[allow(unreachable_patterns, clippy::needless_return, unused_variables)]
95pub fn load_icon_from_theme(
96    role: IconRole,
97    set: IconSet,
98    preferred_theme: &str,
99    fg_color: Option<[u8; 3]>,
100) -> Option<IconData> {
101    match set {
102        #[cfg(all(target_os = "linux", feature = "system-icons"))]
103        IconSet::Freedesktop => {
104            let name = icon_name(role, IconSet::Freedesktop)?;
105            crate::freedesktop::load_freedesktop_icon_by_name(name, preferred_theme, 24, fg_color)
106        }
107
108        // Bundled and platform sets --- preferred_theme is irrelevant
109        _ => load_icon(role, set, fg_color),
110    }
111}
112
113/// Check whether a freedesktop icon theme is installed on this system.
114///
115/// Looks for the theme's `index.theme` file in the standard XDG icon
116/// directories (`$XDG_DATA_DIRS/icons/<theme>/` and
117/// `$XDG_DATA_HOME/icons/<theme>/`).
118///
119/// Always returns `false` on non-Linux platforms.
120#[must_use]
121pub fn is_freedesktop_theme_available(theme: &str) -> bool {
122    #[cfg(target_os = "linux")]
123    {
124        let data_dirs = std::env::var("XDG_DATA_DIRS")
125            .unwrap_or_else(|_| "/usr/share:/usr/local/share".to_string());
126        for dir in data_dirs.split(':') {
127            if std::path::Path::new(dir)
128                .join("icons")
129                .join(theme)
130                .join("index.theme")
131                .exists()
132            {
133                return true;
134            }
135        }
136        let data_home = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
137            std::env::var("HOME")
138                .map(|h| format!("{h}/.local/share"))
139                .unwrap_or_default()
140        });
141        if !data_home.is_empty() {
142            return std::path::Path::new(&data_home)
143                .join("icons")
144                .join(theme)
145                .join("index.theme")
146                .exists();
147        }
148        false
149    }
150    #[cfg(not(target_os = "linux"))]
151    {
152        false
153    }
154}
155
156/// Load a system icon by its platform-specific name string.
157///
158/// Dispatches to the appropriate platform loader based on the icon set:
159/// - [`IconSet::Freedesktop`] -- freedesktop icon theme lookup (system theme)
160/// - [`IconSet::SfSymbols`] -- macOS SF Symbols
161/// - [`IconSet::SegoeIcons`] -- Windows Segoe Fluent / stock icons
162/// - [`IconSet::Material`] / [`IconSet::Lucide`] -- bundled SVG lookup by name
163///
164/// Returns `None` if the icon is not found on the current platform or
165/// the icon set is not available.
166///
167/// # Examples
168///
169/// ```
170/// use native_theme::{load_system_icon_by_name, IconSet};
171///
172/// # #[cfg(feature = "material-icons")]
173/// # {
174/// let icon = load_system_icon_by_name("content_copy", IconSet::Material, None);
175/// assert!(icon.is_some());
176/// # }
177/// ```
178#[must_use = "this returns the loaded icon data; it does not display it"]
179#[allow(unreachable_patterns, unused_variables)]
180pub fn load_system_icon_by_name(
181    name: &str,
182    set: IconSet,
183    fg_color: Option<[u8; 3]>,
184) -> Option<IconData> {
185    match set {
186        #[cfg(all(target_os = "linux", feature = "system-icons"))]
187        IconSet::Freedesktop => {
188            let theme = system_icon_theme();
189            crate::freedesktop::load_freedesktop_icon_by_name(name, theme, 24, fg_color)
190        }
191
192        #[cfg(all(target_os = "macos", feature = "system-icons"))]
193        IconSet::SfSymbols => crate::sficons::load_sf_icon_by_name(name),
194
195        #[cfg(all(target_os = "windows", feature = "system-icons"))]
196        IconSet::SegoeIcons => crate::winicons::load_windows_icon_by_name(name),
197
198        #[cfg(feature = "material-icons")]
199        IconSet::Material => {
200            bundled_icon_by_name(name, IconSet::Material).map(|b| IconData::Svg(b.to_vec()))
201        }
202
203        #[cfg(feature = "lucide-icons")]
204        IconSet::Lucide => {
205            bundled_icon_by_name(name, IconSet::Lucide).map(|b| IconData::Svg(b.to_vec()))
206        }
207
208        _ => None,
209    }
210}
211
212/// Return the loading/spinner animation for the given icon set.
213///
214/// This is the animated-icon counterpart of [`load_icon()`].
215///
216/// # Dispatch
217///
218/// - [`IconSet::Material`] -- `progress_activity.svg` with continuous spin transform (1000ms)
219/// - [`IconSet::Lucide`] -- `loader.svg` with continuous spin transform (1000ms)
220/// - [`IconSet::Freedesktop`] -- loads `process-working` sprite sheet from active icon theme
221/// - Other sets -- `None`
222///
223/// # Examples
224///
225/// ```
226/// // Result depends on enabled features and platform
227/// let anim = native_theme::loading_indicator(native_theme::IconSet::Lucide);
228/// # #[cfg(feature = "lucide-icons")]
229/// # assert!(anim.is_some());
230/// ```
231#[must_use = "this returns animation data; it does not display anything"]
232pub fn loading_indicator(set: IconSet) -> Option<AnimatedIcon> {
233    match set {
234        #[cfg(all(target_os = "linux", feature = "system-icons"))]
235        IconSet::Freedesktop => crate::freedesktop::load_freedesktop_spinner(),
236
237        #[cfg(feature = "material-icons")]
238        IconSet::Material => Some(crate::spinners::material_spinner()),
239
240        #[cfg(feature = "lucide-icons")]
241        IconSet::Lucide => Some(crate::spinners::lucide_spinner()),
242
243        _ => None,
244    }
245}
246
247/// Load an icon from any [`IconProvider`], dispatching through the standard
248/// platform loading chain.
249///
250/// # Fallback chain
251///
252/// 1. Provider's [`icon_name()`](IconProvider::icon_name) -- passed to platform
253///    system loader via [`load_system_icon_by_name()`]
254/// 2. Provider's [`icon_svg()`](IconProvider::icon_svg) -- bundled SVG data
255/// 3. `None` -- **no cross-set fallback** (mixing icon sets is forbidden)
256///
257/// # Examples
258///
259/// ```
260/// use native_theme::{load_custom_icon, IconRole, IconSet};
261///
262/// // IconRole implements IconProvider, so it works with load_custom_icon
263/// # #[cfg(feature = "material-icons")]
264/// # {
265/// let icon = load_custom_icon(&IconRole::ActionCopy, IconSet::Material, None);
266/// assert!(icon.is_some());
267/// # }
268/// ```
269#[must_use = "this returns the loaded icon data; it does not display it"]
270pub fn load_custom_icon(
271    provider: &(impl IconProvider + ?Sized),
272    set: IconSet,
273    fg_color: Option<[u8; 3]>,
274) -> Option<IconData> {
275    // Step 1: Try system loader with provider's name mapping
276    if let Some(name) = provider.icon_name(set)
277        && let Some(data) = load_system_icon_by_name(name, set, fg_color)
278    {
279        return Some(data);
280    }
281
282    // Step 2: Try bundled SVG from provider
283    if let Some(svg) = provider.icon_svg(set) {
284        return Some(IconData::Svg(svg.to_vec()));
285    }
286
287    // No cross-set fallback -- return None
288    None
289}
290
291// =============================================================================
292// Tests
293// =============================================================================
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::expect_used)]
297mod load_icon_tests {
298    use super::*;
299
300    #[test]
301    #[cfg(feature = "material-icons")]
302    fn load_icon_material_returns_svg() {
303        let result = load_icon(IconRole::ActionCopy, IconSet::Material, None);
304        assert!(result.is_some(), "material ActionCopy should return Some");
305        match result.unwrap() {
306            IconData::Svg(bytes) => {
307                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
308                assert!(content.contains("<svg"), "should contain SVG data");
309            }
310            _ => panic!("expected IconData::Svg for bundled material icon"),
311        }
312    }
313
314    #[test]
315    #[cfg(feature = "lucide-icons")]
316    fn load_icon_lucide_returns_svg() {
317        let result = load_icon(IconRole::ActionCopy, IconSet::Lucide, None);
318        assert!(result.is_some(), "lucide ActionCopy should return Some");
319        match result.unwrap() {
320            IconData::Svg(bytes) => {
321                let content = std::str::from_utf8(&bytes).expect("should be valid UTF-8");
322                assert!(content.contains("<svg"), "should contain SVG data");
323            }
324            _ => panic!("expected IconData::Svg for bundled lucide icon"),
325        }
326    }
327
328    #[test]
329    #[cfg(feature = "material-icons")]
330    fn load_icon_unknown_theme_no_cross_set_fallback() {
331        // On Linux (test platform), unknown theme resolves to system_icon_set() = Freedesktop.
332        // Without system-icons feature, Freedesktop falls through to wildcard -> None.
333        // No cross-set Material fallback.
334        let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop, None);
335        // Without system-icons, this falls to wildcard which returns None
336        // With system-icons, this dispatches to load_freedesktop_icon which may return Some
337        // Either way, no panic
338        let _ = result;
339    }
340
341    #[test]
342    #[cfg(feature = "material-icons")]
343    fn load_icon_all_roles_material() {
344        // Material has 42 of 42 roles mapped, all return Some
345        let mut some_count = 0;
346        for role in IconRole::ALL {
347            if load_icon(role, IconSet::Material, None).is_some() {
348                some_count += 1;
349            }
350        }
351        // bundled_icon_svg covers all 42 roles for Material
352        assert_eq!(
353            some_count, 42,
354            "Material should cover all 42 roles via bundled SVGs"
355        );
356    }
357
358    #[test]
359    #[cfg(feature = "lucide-icons")]
360    fn load_icon_all_roles_lucide() {
361        let mut some_count = 0;
362        for role in IconRole::ALL {
363            if load_icon(role, IconSet::Lucide, None).is_some() {
364                some_count += 1;
365            }
366        }
367        // bundled_icon_svg covers all 42 roles for Lucide
368        assert_eq!(
369            some_count, 42,
370            "Lucide should cover all 42 roles via bundled SVGs"
371        );
372    }
373
374    #[test]
375    fn load_icon_unrecognized_set_no_features() {
376        // SfSymbols on Linux without system-icons: falls through to wildcard -> None
377        let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols, None);
378        // Just verifying it doesn't panic
379    }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used, clippy::expect_used)]
384mod load_system_icon_by_name_tests {
385    use super::*;
386
387    #[test]
388    #[cfg(feature = "material-icons")]
389    fn system_icon_by_name_material() {
390        let result = load_system_icon_by_name("content_copy", IconSet::Material, None);
391        assert!(
392            result.is_some(),
393            "content_copy should be found in Material set"
394        );
395        assert!(matches!(result.unwrap(), IconData::Svg(_)));
396    }
397
398    #[test]
399    #[cfg(feature = "lucide-icons")]
400    fn system_icon_by_name_lucide() {
401        let result = load_system_icon_by_name("copy", IconSet::Lucide, None);
402        assert!(result.is_some(), "copy should be found in Lucide set");
403        assert!(matches!(result.unwrap(), IconData::Svg(_)));
404    }
405
406    #[test]
407    #[cfg(feature = "material-icons")]
408    fn system_icon_by_name_unknown_returns_none() {
409        let result = load_system_icon_by_name("nonexistent_xyz", IconSet::Material, None);
410        assert!(result.is_none(), "nonexistent name should return None");
411    }
412
413    #[test]
414    fn system_icon_by_name_sf_on_linux_returns_none() {
415        // On Linux, SfSymbols set is not available (cfg-gated to macOS)
416        #[cfg(not(target_os = "macos"))]
417        {
418            let result = load_system_icon_by_name("doc.on.doc", IconSet::SfSymbols, None);
419            assert!(
420                result.is_none(),
421                "SF Symbols should return None on non-macOS"
422            );
423        }
424    }
425}
426
427#[cfg(test)]
428#[allow(clippy::unwrap_used, clippy::expect_used)]
429mod load_custom_icon_tests {
430    use super::*;
431
432    #[test]
433    #[cfg(feature = "material-icons")]
434    fn custom_icon_with_icon_role_material() {
435        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Material, None);
436        assert!(
437            result.is_some(),
438            "IconRole::ActionCopy should load via material"
439        );
440    }
441
442    #[test]
443    #[cfg(feature = "lucide-icons")]
444    fn custom_icon_with_icon_role_lucide() {
445        let result = load_custom_icon(&IconRole::ActionCopy, IconSet::Lucide, None);
446        assert!(
447            result.is_some(),
448            "IconRole::ActionCopy should load via lucide"
449        );
450    }
451
452    #[test]
453    fn custom_icon_no_cross_set_fallback() {
454        // Provider that returns None for all sets -- should NOT fall back
455        #[derive(Debug)]
456        struct NullProvider;
457        impl IconProvider for NullProvider {
458            fn icon_name(&self, _set: IconSet) -> Option<&str> {
459                None
460            }
461            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
462                None
463            }
464        }
465
466        let result = load_custom_icon(&NullProvider, IconSet::Material, None);
467        assert!(
468            result.is_none(),
469            "NullProvider should return None (no cross-set fallback)"
470        );
471    }
472
473    #[test]
474    fn custom_icon_unknown_set_uses_system() {
475        // "unknown-set" is not a known IconSet name, falls through to system_icon_set()
476        #[derive(Debug)]
477        struct NullProvider;
478        impl IconProvider for NullProvider {
479            fn icon_name(&self, _set: IconSet) -> Option<&str> {
480                None
481            }
482            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
483                None
484            }
485        }
486
487        // Just verify it doesn't panic -- the actual set chosen depends on platform
488        let _result = load_custom_icon(&NullProvider, IconSet::Freedesktop, None);
489    }
490
491    #[test]
492    #[cfg(feature = "material-icons")]
493    fn custom_icon_via_dyn_dispatch() {
494        let boxed: Box<dyn IconProvider> = Box::new(IconRole::ActionCopy);
495        let result = load_custom_icon(&*boxed, IconSet::Material, None);
496        assert!(
497            result.is_some(),
498            "dyn dispatch through Box<dyn IconProvider> should work"
499        );
500    }
501
502    #[test]
503    #[cfg(feature = "material-icons")]
504    fn custom_icon_bundled_svg_fallback() {
505        // Provider that returns None from icon_name but Some from icon_svg
506        #[derive(Debug)]
507        struct SvgOnlyProvider;
508        impl IconProvider for SvgOnlyProvider {
509            fn icon_name(&self, _set: IconSet) -> Option<&str> {
510                None
511            }
512            fn icon_svg(&self, _set: IconSet) -> Option<&'static [u8]> {
513                Some(b"<svg>test</svg>")
514            }
515        }
516
517        let result = load_custom_icon(&SvgOnlyProvider, IconSet::Material, None);
518        assert!(
519            result.is_some(),
520            "provider with icon_svg should return Some"
521        );
522        match result.unwrap() {
523            IconData::Svg(bytes) => {
524                assert_eq!(bytes, b"<svg>test</svg>");
525            }
526            _ => panic!("expected IconData::Svg"),
527        }
528    }
529}
530
531#[cfg(test)]
532#[allow(clippy::unwrap_used, clippy::expect_used)]
533mod loading_indicator_tests {
534    use super::*;
535
536    // === Dispatch tests (through loading_indicator public API) ===
537
538    #[test]
539    #[cfg(feature = "lucide-icons")]
540    fn loading_indicator_lucide_returns_frames() {
541        let anim = loading_indicator(IconSet::Lucide);
542        assert!(anim.is_some(), "lucide should return Some");
543        let anim = anim.unwrap();
544        assert!(
545            matches!(anim, AnimatedIcon::Frames { .. }),
546            "lucide should be pre-rotated Frames"
547        );
548        if let AnimatedIcon::Frames {
549            frames,
550            frame_duration_ms,
551        } = &anim
552        {
553            assert_eq!(frames.len(), 24);
554            assert_eq!(*frame_duration_ms, 42);
555        }
556    }
557
558    /// Freedesktop loading_indicator returns Some if the active icon theme
559    /// has a `process-working` sprite sheet (e.g. Breeze), None otherwise.
560    #[test]
561    #[cfg(all(target_os = "linux", feature = "system-icons"))]
562    fn loading_indicator_freedesktop_depends_on_theme() {
563        let anim = loading_indicator(IconSet::Freedesktop);
564        // Result depends on installed icon theme -- Some if process-working exists
565        if let Some(anim) = anim {
566            match anim {
567                AnimatedIcon::Frames { frames, .. } => {
568                    assert!(
569                        !frames.is_empty(),
570                        "Frames variant should have at least one frame"
571                    );
572                }
573                AnimatedIcon::Transform { .. } => {
574                    // Single-frame theme icon with Spin -- valid result
575                }
576            }
577        }
578    }
579
580    /// Freedesktop spinner depends on platform and icon theme.
581    #[test]
582    fn loading_indicator_freedesktop_does_not_panic() {
583        let _result = loading_indicator(IconSet::Freedesktop);
584    }
585
586    // === Direct spinner construction tests (any platform) ===
587
588    #[test]
589    #[cfg(feature = "lucide-icons")]
590    fn lucide_spinner_is_frames() {
591        let anim = crate::spinners::lucide_spinner();
592        assert!(
593            matches!(anim, AnimatedIcon::Frames { .. }),
594            "lucide should be pre-rotated Frames"
595        );
596    }
597}
598
599#[cfg(all(test, feature = "svg-rasterize"))]
600#[allow(clippy::unwrap_used, clippy::expect_used)]
601mod spinner_rasterize_tests {
602    use super::*;
603
604    #[test]
605    #[cfg(feature = "lucide-icons")]
606    fn lucide_spinner_icon_rasterizes() {
607        let anim = crate::spinners::lucide_spinner();
608        if let AnimatedIcon::Frames { frames, .. } = &anim {
609            let first = frames.first().expect("should have at least one frame");
610            if let IconData::Svg(bytes) = first {
611                let result = crate::rasterize::rasterize_svg(bytes, 24);
612                assert!(result.is_ok(), "lucide loader should rasterize");
613                if let Ok(IconData::Rgba { data, .. }) = &result {
614                    assert!(
615                        data.iter().any(|&b| b != 0),
616                        "lucide loader rasterized to empty image"
617                    );
618                }
619            } else {
620                panic!("lucide spinner frame should be Svg");
621            }
622        } else {
623            panic!("lucide spinner should be Frames");
624        }
625    }
626}