Skip to main content

native_theme_iced/
icons.rs

1//! Icon conversion helpers for iced.
2//!
3//! Converts [`native_theme::IconData`] variants into iced-compatible handles.
4//! Since iced separates raster images (`iced::widget::Image`) from SVG
5//! images (`iced::widget::Svg`), this module provides separate conversion
6//! functions for each variant.
7
8use native_theme::{AnimatedIcon, IconData, IconProvider, load_custom_icon};
9
10/// Converted animation frames with timing metadata.
11///
12/// Returned by [`animated_frames_to_svg_handles`]. Contains the
13/// SVG handles and the per-frame duration needed to drive playback.
14#[derive(Debug, Clone)]
15pub struct AnimatedSvgHandles {
16    /// SVG handles ready for iced rendering.
17    pub handles: Vec<iced_core::svg::Handle>,
18    /// Duration of each frame in milliseconds.
19    pub frame_duration_ms: u32,
20}
21
22/// Converts RGBA [`IconData`] to an iced [`iced_core::image::Handle`].
23///
24/// Returns `Some(Handle)` for [`IconData::Rgba`] data, or `None` for
25/// [`IconData::Svg`]. SVG icons should use [`to_svg_handle()`] and
26/// `iced::widget::Svg` instead.
27#[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/// Converts SVG [`IconData`] to an iced [`iced_core::svg::Handle`].
44///
45/// Returns `Some(Handle)` for [`IconData::Svg`] data, or `None` for
46/// [`IconData::Rgba`]. When `color` is `Some`, colorizes the SVG for
47/// monochrome icon sets (Material, Lucide). Pass `None` for multi-color
48/// system icons to preserve their native palette.
49#[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/// Load a custom RGBA icon from an [`IconProvider`] and convert to an iced image handle.
68///
69/// Returns `None` if the provider has no icon for the given set, or if the loaded
70/// icon is SVG (use [`custom_icon_to_svg_handle()`] for SVG icons).
71#[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/// Load a custom SVG icon from an [`IconProvider`] and convert to an iced SVG handle.
81///
82/// Returns `None` if the provider has no icon for the given set, or if the loaded
83/// icon is RGBA. When `color` is `Some`, colorizes monochrome SVGs.
84#[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/// Convert all frames of an [`AnimatedIcon::Frames`] to iced SVG handles.
95///
96/// Returns `Some(AnimatedSvgHandles)` when the icon is the `Frames` variant
97/// and at least one frame is SVG. Returns `None` for `Transform` variants,
98/// empty frame sets, or if all frames are RGBA.
99///
100/// Only SVG frames are included. RGBA frames are silently excluded because
101/// iced's `Svg` widget cannot render raster data. The returned animation may
102/// have fewer frames than the input, causing it to play faster. If all frames
103/// are non-SVG, returns `None`.
104///
105/// When `color` is `Some`, colorizes monochrome SVG frames (Material, Lucide)
106/// to match the given color. Pass `None` for multi-color system icons.
107///
108/// **Call this once and cache the result.** Do not call on every frame tick.
109/// Index into the cached `handles` using an `iced::time::every()` subscription
110/// that increments a frame counter.
111///
112/// Callers should check [`native_theme::prefers_reduced_motion()`] and fall
113/// back to [`AnimatedIcon::first_frame()`] for a static display when the user
114/// has requested reduced motion.
115///
116/// # Examples
117///
118/// ```no_run
119/// use native_theme_iced::icons::animated_frames_to_svg_handles;
120///
121/// if let Some(anim) = native_theme::loading_indicator(native_theme::IconSet::Material) {
122///     if let Some(anim_handles) = animated_frames_to_svg_handles(&anim, None) {
123///         // Cache `anim_handles`, then in subscription():
124///         // iced::time::every(Duration::from_millis(anim_handles.frame_duration_ms as u64))
125///         //     .map(|_| Message::AnimationTick)
126///         // In update(): frame_index = (frame_index + 1) % anim_handles.handles.len();
127///         // In view(): Svg::new(anim_handles.handles[frame_index].clone())
128///     }
129/// }
130/// ```
131#[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/// Compute the current rotation angle for a spin animation.
159///
160/// Returns a [`Radians`](iced_core::Radians) value representing the current
161/// rotation based on `elapsed` time and `duration_ms` (the full rotation
162/// period from [`native_theme::TransformAnimation::Spin`]).
163///
164/// The angle wraps around via modulo, so values of `elapsed` greater than
165/// `duration_ms` cycle correctly.
166///
167/// Use the result with `Svg::rotation(Rotation::Floating(angle))` -- use
168/// `Rotation::Floating` (not `Rotation::Solid`) to avoid layout jitter
169/// during rotation.
170///
171/// Callers should check [`native_theme::prefers_reduced_motion()`] and
172/// skip animation when the user has requested reduced motion.
173///
174/// # Examples
175///
176/// ```no_run
177/// use native_theme_iced::icons::spin_rotation_radians;
178///
179/// let elapsed = std::time::Duration::from_millis(500);
180/// let angle = spin_rotation_radians(elapsed, 1000);
181/// // Use with: Svg::new(handle).rotation(Rotation::Floating(angle))
182/// ```
183#[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/// Consuming version of [`to_image_handle`] — moves the [`IconData`] instead of borrowing.
193#[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/// Consuming version of [`to_svg_handle`] — moves the [`IconData`] instead of borrowing.
206#[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
224/// Colorize a **monochrome** SVG icon with the given color.
225///
226/// Works correctly for bundled icon sets (Material, Lucide) which use
227/// `currentColor` or implicit black fills. For multi-color SVGs from
228/// freedesktop system themes, use [`to_svg_handle()`] instead to
229/// preserve the original icon colors.
230///
231/// Note: Only RGB channels are used; the alpha channel is discarded during
232/// hex conversion. For transparency, use `Svg::opacity()` on the rendered element.
233///
234/// ## Replacement strategy
235///
236/// 1. If `currentColor` appears anywhere in the SVG, it is replaced globally
237///    (not scoped to individual attributes). This correctly handles `fill`,
238///    `stroke`, `stop-color`, etc. For monochrome icons this is the desired
239///    behavior. Multi-color SVGs should not use this function.
240///
241/// 2. Explicit black fills and strokes (`"black"`, `"#000000"`, `"#000"`)
242///    are replaced in both `fill=` and `stroke=` attributes.
243///
244/// 3. If no replacements are found and the root `<svg>` tag lacks a `fill=`
245///    attribute, a `fill` is injected. Note: if the root tag has `fill="none"`
246///    (common in stroke-based SVGs), no injection occurs -- the explicit black
247///    stroke replacements from step 2 handle colorization instead.
248///
249/// ## Limitations
250///
251/// - Colors in CSS `style` attributes (e.g., `style="fill:black"`) are not
252///   replaced (except `currentColor` which is caught by step 1).
253/// - Only the first `<svg` in the document is considered for fill injection.
254///   SVGs with `<svg` inside comments could cause incorrect injection, though
255///   this is extremely unlikely with real icon files.
256fn 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    // Validate UTF-8 before attempting string operations. Non-UTF-8 SVGs
263    // (e.g., legacy Latin-1 encoded system icons) pass through unmodified
264    // rather than being corrupted by lossy replacement.
265    let Ok(svg_str) = std::str::from_utf8(svg_bytes) else {
266        return svg_bytes.to_vec();
267    };
268
269    // Replace currentColor (handles Lucide-style SVGs).
270    // This is a global replacement that covers fill, stroke, stop-color, etc.
271    if svg_str.contains("currentColor") {
272        return svg_str.replace("currentColor", &hex).into_bytes();
273    }
274
275    // Replace explicit black fills and strokes (handles third-party SVGs).
276    // Stroke replacements handle outline-style icons that use stroke="black".
277    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    // No currentColor or explicit black found -- inject fill into the root
291    // <svg> tag (handles Material-style SVGs with implicit black fill).
292    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            // Handle self-closing tags: inject before '/' in '<svg .../>'
299            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        // Verify the colorization happened by checking the internal SVG
363        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    // --- custom_icon tests ---
394
395    #[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        // SVG data is not RGBA, so to_image_handle returns None
422        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        // Should not inject a second fill since one already exists
472        assert!(result.contains("fill=\"red\""));
473        assert!(!result.contains("#00ff00"));
474    }
475
476    // --- animated icon tests ---
477
478    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        // RGBA frame should be filtered out, leaving 2 SVG frames
628        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        // Should inject fill before '/', not between '/' and '>'
638        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        // SVG bytes with an invalid UTF-8 sequence (0xFF is never valid UTF-8)
718        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        // Should return the original bytes unchanged, not corrupt them
722        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    // Issue 14.3: colorize with mixed fill attributes (both black and non-black)
739    #[test]
740    fn colorize_mixed_fill_attributes() {
741        // SVG with both fill="black" (should be replaced) and fill="red" (should be preserved)
742        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    // Issue 14.3: colorize with non-black explicit fills (fill="white", fill="#FFF")
759    #[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        // fill="white" is not black, should not be replaced by phases 1-2.
766        // Phase 3 (root fill injection) should be skipped since fill= already exists.
767        assert!(
768            result_str.contains("fill=\"white\""),
769            "fill=\"white\" should be preserved, got: {}",
770            result_str
771        );
772    }
773
774    // Issue 14.3: spin_rotation_radians with very large elapsed (wraps correctly)
775    #[test]
776    fn spin_rotation_large_elapsed_wraps() {
777        let duration_ms = 1000;
778        // 1_000_000 seconds = 1M full rotations. Result should be near 0.
779        let elapsed = std::time::Duration::from_secs(1_000_000);
780        let result = spin_rotation_radians(elapsed, duration_ms);
781        // The progress should be (elapsed_ms % duration_ms) / duration_ms.
782        // 1_000_000_000ms % 1000 = 0, so rotation should be ~0.
783        assert!(
784            result.0.abs() < 0.01,
785            "very large elapsed should wrap to near-zero, got {}",
786            result.0
787        );
788    }
789
790    // Issue 14.3: from_preset with a single-variant preset
791    #[test]
792    fn from_preset_single_variant_fallback() {
793        // catppuccin-mocha is dark-only; requesting light should still succeed
794        // via fallback (into_variant tries the other mode when primary is empty)
795        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}