1use native_theme::{AnimatedIcon, IconData, IconProvider, load_custom_icon};
9
10#[derive(Debug, Clone)]
15pub struct AnimatedSvgHandles {
16 pub handles: Vec<iced_core::svg::Handle>,
18 pub frame_duration_ms: u32,
20}
21
22#[must_use]
28pub fn to_image_handle(data: &IconData) -> Option<iced_core::image::Handle> {
29 match data {
30 IconData::Rgba {
31 width,
32 height,
33 data,
34 } => Some(iced_core::image::Handle::from_rgba(
35 *width,
36 *height,
37 data.clone(),
38 )),
39 _ => None,
40 }
41}
42
43#[must_use]
50pub fn to_svg_handle(
51 data: &IconData,
52 color: Option<iced_core::Color>,
53) -> Option<iced_core::svg::Handle> {
54 match data {
55 IconData::Svg(bytes) => {
56 let final_bytes = if let Some(c) = color {
57 colorize_monochrome_svg(bytes, c)
58 } else {
59 bytes.clone()
60 };
61 Some(iced_core::svg::Handle::from_memory(final_bytes))
62 }
63 _ => None,
64 }
65}
66
67#[must_use]
72pub fn custom_icon_to_image_handle(
73 provider: &(impl IconProvider + ?Sized),
74 icon_set: native_theme::IconSet,
75) -> Option<iced_core::image::Handle> {
76 let data = load_custom_icon(provider, icon_set)?;
77 to_image_handle(&data)
78}
79
80#[must_use]
85pub fn custom_icon_to_svg_handle(
86 provider: &(impl IconProvider + ?Sized),
87 icon_set: native_theme::IconSet,
88 color: Option<iced_core::Color>,
89) -> Option<iced_core::svg::Handle> {
90 let data = load_custom_icon(provider, icon_set)?;
91 to_svg_handle(&data, color)
92}
93
94#[must_use]
132pub fn animated_frames_to_svg_handles(
133 anim: &AnimatedIcon,
134 color: Option<iced_core::Color>,
135) -> Option<AnimatedSvgHandles> {
136 match anim {
137 AnimatedIcon::Frames {
138 frames,
139 frame_duration_ms,
140 } => {
141 let handles: Vec<_> = frames
142 .iter()
143 .filter_map(|f| to_svg_handle(f, color))
144 .collect();
145 if handles.is_empty() {
146 None
147 } else {
148 Some(AnimatedSvgHandles {
149 handles,
150 frame_duration_ms: *frame_duration_ms,
151 })
152 }
153 }
154 _ => None,
155 }
156}
157
158#[must_use]
184pub fn spin_rotation_radians(elapsed: std::time::Duration, duration_ms: u32) -> iced_core::Radians {
185 if duration_ms == 0 {
186 return iced_core::Radians(0.0);
187 }
188 let progress = (elapsed.as_millis() as f32 % duration_ms as f32) / duration_ms as f32;
189 iced_core::Radians(progress * std::f32::consts::TAU)
190}
191
192#[must_use]
194pub fn into_image_handle(data: IconData) -> Option<iced_core::image::Handle> {
195 match data {
196 IconData::Rgba {
197 width,
198 height,
199 data,
200 } => Some(iced_core::image::Handle::from_rgba(width, height, data)),
201 _ => None,
202 }
203}
204
205#[must_use]
207pub fn into_svg_handle(
208 data: IconData,
209 color: Option<iced_core::Color>,
210) -> Option<iced_core::svg::Handle> {
211 match data {
212 IconData::Svg(bytes) => {
213 let final_bytes = if let Some(c) = color {
214 colorize_monochrome_svg(&bytes, c)
215 } else {
216 bytes
217 };
218 Some(iced_core::svg::Handle::from_memory(final_bytes))
219 }
220 _ => None,
221 }
222}
223
224fn colorize_monochrome_svg(svg_bytes: &[u8], color: iced_core::Color) -> Vec<u8> {
257 let r = (color.r.clamp(0.0, 1.0) * 255.0).round() as u8;
258 let g = (color.g.clamp(0.0, 1.0) * 255.0).round() as u8;
259 let b = (color.b.clamp(0.0, 1.0) * 255.0).round() as u8;
260 let hex = format!("#{:02x}{:02x}{:02x}", r, g, b);
261
262 let Ok(svg_str) = std::str::from_utf8(svg_bytes) else {
266 return svg_bytes.to_vec();
267 };
268
269 if svg_str.contains("currentColor") {
272 return svg_str.replace("currentColor", &hex).into_bytes();
273 }
274
275 let fill_hex = format!("fill=\"{}\"", hex);
278 let stroke_hex = format!("stroke=\"{}\"", hex);
279 let replaced = svg_str
280 .replace("fill=\"black\"", &fill_hex)
281 .replace("fill=\"#000000\"", &fill_hex)
282 .replace("fill=\"#000\"", &fill_hex)
283 .replace("stroke=\"black\"", &stroke_hex)
284 .replace("stroke=\"#000000\"", &stroke_hex)
285 .replace("stroke=\"#000\"", &stroke_hex);
286 if replaced != svg_str {
287 return replaced.into_bytes();
288 }
289
290 if let Some(pos) = svg_str.find("<svg")
293 && let Some(close) = svg_str[pos..].find('>')
294 {
295 let tag_end = pos + close;
296 let tag = &svg_str[pos..tag_end];
297 if !tag.contains("fill=") {
298 let inject_pos = if tag_end > 0 && svg_str.as_bytes()[tag_end - 1] == b'/' {
300 tag_end - 1
301 } else {
302 tag_end
303 };
304 let mut result = String::with_capacity(svg_str.len() + 20);
305 result.push_str(&svg_str[..inject_pos]);
306 result.push_str(&format!(" fill=\"{}\"", hex));
307 result.push_str(&svg_str[inject_pos..]);
308 return result.into_bytes();
309 }
310 }
311
312 svg_bytes.to_vec()
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used, clippy::expect_used)]
317mod tests {
318 use super::*;
319 use native_theme::IconData;
320
321 #[test]
322 fn to_image_handle_with_rgba_returns_some() {
323 let data = IconData::Rgba {
324 width: 24,
325 height: 24,
326 data: vec![0u8; 24 * 24 * 4],
327 };
328 assert!(to_image_handle(&data).is_some());
329 }
330
331 #[test]
332 fn to_image_handle_with_svg_returns_none() {
333 let data = IconData::Svg(b"<svg></svg>".to_vec());
334 assert!(to_image_handle(&data).is_none());
335 }
336
337 #[test]
338 fn to_svg_handle_with_svg_returns_some() {
339 let data = IconData::Svg(b"<svg></svg>".to_vec());
340 assert!(to_svg_handle(&data, None).is_some());
341 }
342
343 #[test]
344 fn to_svg_handle_with_rgba_returns_none() {
345 let data = IconData::Rgba {
346 width: 16,
347 height: 16,
348 data: vec![255u8; 16 * 16 * 4],
349 };
350 assert!(to_svg_handle(&data, None).is_none());
351 }
352
353 #[test]
354 fn to_svg_handle_colored_replaces_current_color() {
355 let svg = b"<svg><path stroke=\"currentColor\" fill=\"currentColor\"/></svg>".to_vec();
356 let data = IconData::Svg(svg);
357 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
358
359 let handle = to_svg_handle(&data, Some(color));
360 assert!(handle.is_some());
361
362 let colored = colorize_monochrome_svg(
364 b"<svg><path stroke=\"currentColor\" fill=\"currentColor\"/></svg>",
365 color,
366 );
367 let result = String::from_utf8(colored).unwrap();
368 assert!(result.contains("#ff0000"));
369 assert!(!result.contains("currentColor"));
370 }
371
372 #[test]
373 fn to_svg_handle_colored_injects_fill_for_material_style() {
374 let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>".to_vec();
375 let color = iced_core::Color::from_rgb(0.0, 0.5, 1.0);
376
377 let colored = colorize_monochrome_svg(&svg, color);
378 let result = String::from_utf8(colored).unwrap();
379 assert!(result.contains("fill=\"#0080ff\""));
380 }
381
382 #[test]
383 fn to_svg_handle_colored_with_rgba_returns_none() {
384 let data = IconData::Rgba {
385 width: 16,
386 height: 16,
387 data: vec![0u8; 16 * 16 * 4],
388 };
389 let color = iced_core::Color::WHITE;
390 assert!(to_svg_handle(&data, Some(color)).is_none());
391 }
392
393 #[derive(Debug)]
396 struct TestSvgProvider;
397
398 impl native_theme::IconProvider for TestSvgProvider {
399 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
400 None
401 }
402 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
403 Some(b"<svg xmlns='http://www.w3.org/2000/svg'><circle cx='12' cy='12' r='10'/></svg>")
404 }
405 }
406
407 #[derive(Debug)]
408 struct EmptyProvider;
409
410 impl native_theme::IconProvider for EmptyProvider {
411 fn icon_name(&self, _set: native_theme::IconSet) -> Option<&str> {
412 None
413 }
414 fn icon_svg(&self, _set: native_theme::IconSet) -> Option<&'static [u8]> {
415 None
416 }
417 }
418
419 #[test]
420 fn custom_icon_to_image_handle_with_svg_provider_returns_none() {
421 let result = custom_icon_to_image_handle(&TestSvgProvider, native_theme::IconSet::Material);
423 assert!(result.is_none());
424 }
425
426 #[test]
427 fn custom_icon_to_svg_handle_with_svg_provider_returns_some() {
428 let result =
429 custom_icon_to_svg_handle(&TestSvgProvider, native_theme::IconSet::Material, None);
430 assert!(result.is_some());
431 }
432
433 #[test]
434 fn custom_icon_to_svg_handle_with_color_returns_some() {
435 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
436 let result = custom_icon_to_svg_handle(
437 &TestSvgProvider,
438 native_theme::IconSet::Material,
439 Some(color),
440 );
441 assert!(result.is_some());
442 }
443
444 #[test]
445 fn custom_icon_to_image_handle_with_empty_provider_returns_none() {
446 let result = custom_icon_to_image_handle(&EmptyProvider, native_theme::IconSet::Material);
447 assert!(result.is_none());
448 }
449
450 #[test]
451 fn custom_icon_to_svg_handle_with_empty_provider_returns_none() {
452 let result =
453 custom_icon_to_svg_handle(&EmptyProvider, native_theme::IconSet::Material, None);
454 assert!(result.is_none());
455 }
456
457 #[test]
458 fn custom_icon_helpers_accept_dyn_provider() {
459 let boxed: Box<dyn native_theme::IconProvider> = Box::new(TestSvgProvider);
460 let result = custom_icon_to_svg_handle(&*boxed, native_theme::IconSet::Material, None);
461 assert!(result.is_some());
462 }
463
464 #[test]
465 fn colorize_svg_preserves_existing_fill() {
466 let svg = b"<svg fill=\"red\"><path d=\"M0 0\"/></svg>";
467 let color = iced_core::Color::from_rgb(0.0, 1.0, 0.0);
468
469 let colored = colorize_monochrome_svg(svg, color);
470 let result = String::from_utf8(colored).unwrap();
471 assert!(result.contains("fill=\"red\""));
473 assert!(!result.contains("#00ff00"));
474 }
475
476 use std::time::Duration;
479
480 #[test]
481 fn animated_frames_returns_handles() {
482 let anim = AnimatedIcon::Frames {
483 frames: vec![
484 IconData::Svg(b"<svg></svg>".to_vec()),
485 IconData::Svg(b"<svg></svg>".to_vec()),
486 ],
487 frame_duration_ms: 80,
488 };
489 let result = animated_frames_to_svg_handles(&anim, None);
490 assert!(result.is_some());
491 let anim_handles = result.unwrap();
492 assert_eq!(anim_handles.handles.len(), 2);
493 assert_eq!(anim_handles.frame_duration_ms, 80);
494 }
495
496 #[test]
497 fn animated_frames_transform_returns_none() {
498 let anim = AnimatedIcon::Transform {
499 icon: IconData::Svg(b"<svg></svg>".to_vec()),
500 animation: native_theme::TransformAnimation::Spin { duration_ms: 1000 },
501 };
502 let result = animated_frames_to_svg_handles(&anim, None);
503 assert!(result.is_none());
504 }
505
506 #[test]
507 fn animated_frames_empty_returns_none() {
508 let anim = AnimatedIcon::Frames {
509 frames: vec![],
510 frame_duration_ms: 80,
511 };
512 let result = animated_frames_to_svg_handles(&anim, None);
513 assert!(result.is_none());
514 }
515
516 #[test]
517 fn animated_frames_rgba_only_returns_none() {
518 let anim = AnimatedIcon::Frames {
519 frames: vec![IconData::Rgba {
520 width: 16,
521 height: 16,
522 data: vec![0u8; 16 * 16 * 4],
523 }],
524 frame_duration_ms: 80,
525 };
526 let result = animated_frames_to_svg_handles(&anim, None);
527 assert!(result.is_none());
528 }
529
530 #[test]
531 fn spin_rotation_zero_elapsed() {
532 let radians = spin_rotation_radians(Duration::ZERO, 1000);
533 assert_eq!(radians, iced_core::Radians(0.0));
534 }
535
536 #[test]
537 fn spin_rotation_half() {
538 let radians = spin_rotation_radians(Duration::from_millis(500), 1000);
539 let expected = std::f32::consts::PI;
540 assert!(
541 (radians.0 - expected).abs() < 0.001,
542 "Expected ~{}, got {}",
543 expected,
544 radians.0
545 );
546 }
547
548 #[test]
549 fn spin_rotation_full_wraps() {
550 let radians = spin_rotation_radians(Duration::from_millis(1000), 1000);
551 assert!(
552 radians.0.abs() < 0.001,
553 "Expected ~0.0 (wrapped), got {}",
554 radians.0
555 );
556 }
557
558 #[test]
559 fn spin_rotation_zero_duration_returns_zero() {
560 let radians = spin_rotation_radians(Duration::from_millis(500), 0);
561 assert_eq!(radians.0, 0.0, "zero duration should return 0.0, not NaN");
562 assert!(!radians.0.is_nan(), "must not be NaN");
563 }
564
565 #[test]
566 fn colorize_replaces_explicit_black_fill() {
567 let svg = b"<svg><path fill=\"black\" d=\"M0 0\"/></svg>";
568 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
569 let result = colorize_monochrome_svg(svg, color);
570 let result_str = String::from_utf8(result).unwrap();
571 assert!(
572 !result_str.contains("fill=\"black\""),
573 "fill=\"black\" should be replaced, got: {}",
574 result_str
575 );
576 }
577
578 #[test]
579 fn into_image_handle_with_rgba() {
580 let data = IconData::Rgba {
581 width: 24,
582 height: 24,
583 data: vec![0u8; 24 * 24 * 4],
584 };
585 assert!(into_image_handle(data).is_some());
586 }
587
588 #[test]
589 fn into_image_handle_with_svg_returns_none() {
590 let data = IconData::Svg(b"<svg></svg>".to_vec());
591 assert!(into_image_handle(data).is_none());
592 }
593
594 #[test]
595 fn into_svg_handle_with_svg() {
596 let data = IconData::Svg(b"<svg></svg>".to_vec());
597 assert!(into_svg_handle(data, None).is_some());
598 }
599
600 #[test]
601 fn into_svg_handle_with_rgba_returns_none() {
602 let data = IconData::Rgba {
603 width: 16,
604 height: 16,
605 data: vec![0u8; 16 * 16 * 4],
606 };
607 assert!(into_svg_handle(data, None).is_none());
608 }
609
610 #[test]
611 fn animated_frames_mixed_svg_rgba_filters_rgba() {
612 let anim = AnimatedIcon::Frames {
613 frames: vec![
614 IconData::Svg(b"<svg></svg>".to_vec()),
615 IconData::Rgba {
616 width: 16,
617 height: 16,
618 data: vec![0u8; 16 * 16 * 4],
619 },
620 IconData::Svg(b"<svg></svg>".to_vec()),
621 ],
622 frame_duration_ms: 80,
623 };
624 let result = animated_frames_to_svg_handles(&anim, None);
625 assert!(result.is_some());
626 let handles = result.unwrap();
627 assert_eq!(handles.handles.len(), 2);
629 }
630
631 #[test]
632 fn colorize_self_closing_svg_produces_valid_xml() {
633 let svg = b"<svg xmlns='http://www.w3.org/2000/svg'/>";
634 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
635 let result = colorize_monochrome_svg(svg, color);
636 let result_str = String::from_utf8(result).unwrap();
637 assert!(
639 result_str.contains("fill=\"#") && result_str.ends_with("/>"),
640 "self-closing SVG should remain valid XML, got: {}",
641 result_str
642 );
643 assert!(
644 !result_str.contains("/ fill="),
645 "fill should not be between / and >, got: {}",
646 result_str
647 );
648 }
649
650 #[test]
651 fn colorize_replaces_fill_hex_000000() {
652 let svg = b"<svg><path fill=\"#000000\" d=\"M0 0\"/></svg>";
653 let color = iced_core::Color::from_rgb(0.0, 1.0, 0.0);
654 let result = colorize_monochrome_svg(svg, color);
655 let result_str = String::from_utf8(result).unwrap();
656 assert!(
657 result_str.contains("fill=\"#00ff00\""),
658 "fill=\"#000000\" should be replaced, got: {}",
659 result_str
660 );
661 }
662
663 #[test]
664 fn colorize_replaces_fill_hex_short() {
665 let svg = b"<svg><path fill=\"#000\" d=\"M0 0\"/></svg>";
666 let color = iced_core::Color::from_rgb(0.0, 0.0, 1.0);
667 let result = colorize_monochrome_svg(svg, color);
668 let result_str = String::from_utf8(result).unwrap();
669 assert!(
670 result_str.contains("fill=\"#0000ff\""),
671 "fill=\"#000\" should be replaced, got: {}",
672 result_str
673 );
674 }
675
676 #[test]
677 fn colorize_replaces_stroke_black() {
678 let svg = b"<svg><path stroke=\"black\" d=\"M0 0\"/></svg>";
679 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
680 let result = colorize_monochrome_svg(svg, color);
681 let result_str = String::from_utf8(result).unwrap();
682 assert!(
683 result_str.contains("stroke=\"#ff0000\""),
684 "stroke=\"black\" should be replaced, got: {}",
685 result_str
686 );
687 }
688
689 #[test]
690 fn colorize_replaces_stroke_hex_000000() {
691 let svg = b"<svg><path stroke=\"#000000\" d=\"M0 0\"/></svg>";
692 let color = iced_core::Color::from_rgb(0.0, 1.0, 0.0);
693 let result = colorize_monochrome_svg(svg, color);
694 let result_str = String::from_utf8(result).unwrap();
695 assert!(
696 result_str.contains("stroke=\"#00ff00\""),
697 "stroke=\"#000000\" should be replaced, got: {}",
698 result_str
699 );
700 }
701
702 #[test]
703 fn colorize_replaces_stroke_hex_short() {
704 let svg = b"<svg><path stroke=\"#000\" d=\"M0 0\"/></svg>";
705 let color = iced_core::Color::from_rgb(0.0, 0.0, 1.0);
706 let result = colorize_monochrome_svg(svg, color);
707 let result_str = String::from_utf8(result).unwrap();
708 assert!(
709 result_str.contains("stroke=\"#0000ff\""),
710 "stroke=\"#000\" should be replaced, got: {}",
711 result_str
712 );
713 }
714
715 #[test]
716 fn colorize_non_utf8_returns_original_bytes() {
717 let svg: Vec<u8> = b"<svg>\xff<path d=\"M0 0\"/></svg>".to_vec();
719 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
720 let result = colorize_monochrome_svg(&svg, color);
721 assert_eq!(result, svg, "non-UTF-8 SVG should pass through unmodified");
723 }
724
725 #[test]
726 fn animated_frames_with_color_colorizes_frames() {
727 let anim = AnimatedIcon::Frames {
728 frames: vec![IconData::Svg(
729 b"<svg><path fill=\"currentColor\"/></svg>".to_vec(),
730 )],
731 frame_duration_ms: 80,
732 };
733 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
734 let result = animated_frames_to_svg_handles(&anim, Some(color));
735 assert!(result.is_some(), "should produce handles with color");
736 }
737
738 #[test]
740 fn colorize_mixed_fill_attributes() {
741 let svg = b"<svg><rect fill=\"black\" width=\"10\" height=\"10\"/><rect fill=\"red\" width=\"10\" height=\"10\"/></svg>";
743 let color = iced_core::Color::from_rgb(0.0, 1.0, 0.0);
744 let result = colorize_monochrome_svg(svg, color);
745 let result_str = String::from_utf8(result).unwrap();
746 assert!(
747 !result_str.contains("fill=\"black\""),
748 "fill=\"black\" should be replaced, got: {}",
749 result_str
750 );
751 assert!(
752 result_str.contains("fill=\"red\""),
753 "fill=\"red\" should be preserved, got: {}",
754 result_str
755 );
756 }
757
758 #[test]
760 fn colorize_non_black_fills_preserved() {
761 let svg = b"<svg fill=\"white\"><path d=\"M0 0\"/></svg>";
762 let color = iced_core::Color::from_rgb(1.0, 0.0, 0.0);
763 let result = colorize_monochrome_svg(svg, color);
764 let result_str = String::from_utf8(result).unwrap();
765 assert!(
768 result_str.contains("fill=\"white\""),
769 "fill=\"white\" should be preserved, got: {}",
770 result_str
771 );
772 }
773
774 #[test]
776 fn spin_rotation_large_elapsed_wraps() {
777 let duration_ms = 1000;
778 let elapsed = std::time::Duration::from_secs(1_000_000);
780 let result = spin_rotation_radians(elapsed, duration_ms);
781 assert!(
784 result.0.abs() < 0.01,
785 "very large elapsed should wrap to near-zero, got {}",
786 result.0
787 );
788 }
789
790 #[test]
792 fn from_preset_single_variant_fallback() {
793 let result = crate::from_preset("catppuccin-mocha", false);
796 assert!(
797 result.is_ok(),
798 "single-variant preset should fallback to available variant: {:?}",
799 result.err()
800 );
801 }
802}