1#[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#[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 _ => None,
66 }
67}
68
69#[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 _ => load_icon(role, set, fg_color),
110 }
111}
112
113#[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#[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#[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#[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 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 if let Some(svg) = provider.icon_svg(set) {
284 return Some(IconData::Svg(svg.to_vec()));
285 }
286
287 None
289}
290
291#[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 let result = load_icon(IconRole::ActionCopy, IconSet::Freedesktop, None);
335 let _ = result;
339 }
340
341 #[test]
342 #[cfg(feature = "material-icons")]
343 fn load_icon_all_roles_material() {
344 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 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 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 let _result = load_icon(IconRole::ActionCopy, IconSet::SfSymbols, None);
378 }
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 #[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 #[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 #[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 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 #[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 #[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 #[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 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 }
576 }
577 }
578 }
579
580 #[test]
582 fn loading_indicator_freedesktop_does_not_panic() {
583 let _result = loading_indicator(IconSet::Freedesktop);
584 }
585
586 #[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}