Skip to main content

teksilo_widgets/primitives/
icon_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! IconWidget — a vector or raster icon rendered at a configurable size.
5//!
6//! Supports multiple source formats: programmatic `Path` (checkmarks,
7//! chevrons, dots), SVG strings, PNG, static WebP, and animated WebP. Icons
8//! default to **tintable** mode — the pixels are treated as an alpha mask
9//! and multiplied by the widget's color property (defaults to
10//! `TextRole::Primary`) so they follow theme switches automatically.
11//! `IconMode::FullColor` preserves original pixel colors and is appropriate
12//! for emoji-style graphics or brand logos.
13//!
14//! For arbitrary-aspect-ratio photos or artwork see
15//! [`ImageWidget`](super::image_widget::ImageWidget).
16//!
17//! ## Accessibility
18//!
19//! Icons are decorative by default — they set no accessibility role and
20//! announce nothing. The parent widget (e.g. `Button`, `IconButton`) is
21//! responsible for the accessible label.
22//!
23//! ```rust
24//! # use teksilo_widgets::primitives::icon_widget::{IconWidget, IconMode};
25//! # use teksilo_tokens::TextRole;
26//! let _check = IconWidget::checkmark(20.0);
27//!
28//! let _chevron = IconWidget::chevron_down(16.0)
29//!     .color(TextRole::Primary)
30//!     .follow_text_scale(false);
31//! ```
32
33use std::borrow::Cow;
34
35use teksilo_canvas::svg::{SvgDrawOp, SvgIcon};
36use teksilo_canvas::{
37    AnimatedIcon, AnimatedQuadClass, Canvas, Path, PathCommand, Point, RasterIcon, Rect, Size,
38    SizeProposal,
39};
40use teksilo_core::accessibility::AccessNodeBuilder;
41use teksilo_core::animated_quad::{AnimatedQuadHandle, AnimatedQuadKind};
42use teksilo_core::color_prop::ColorProp;
43use teksilo_core::signal::Signal;
44use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
45use teksilo_tokens::{Color, Easing, TextRole};
46
47/// Whether an icon is rendered as a theme-tinted mask or in its original colors.
48///
49/// Applies to every source an [`IconWidget`] can hold — raster *and* SVG. For an
50/// SVG the two modes select between the two representations the parser builds
51/// (see [`teksilo_canvas::svg`]): [`Tintable`](Self::Tintable) draws the merged
52/// silhouette in the widget's color, [`FullColor`](Self::FullColor) walks the
53/// document-ordered ops and honours each shape's own fill / stroke / gradient.
54///
55/// The default is [`Tintable`](Self::Tintable), which is what a UI glyph wants —
56/// it follows the theme into dark mode. Reach for
57/// [`FullColor`](Self::FullColor) for artwork whose colors *are* the content: a
58/// brand mark, a flag, a colored file-type badge. A `currentColor` shape inside
59/// full-color artwork still takes the widget's color, so the two are mixable.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum IconMode {
62    /// Treat as an alpha mask: tint the whole icon with the widget's color.
63    Tintable,
64    /// Render the icon's own colors; the widget color supplies `currentColor`
65    /// and its alpha attenuates the result.
66    FullColor,
67}
68
69/// The source data for an icon.
70#[derive(Debug, Clone)]
71enum IconSource {
72    /// A vector path (SVG or programmatic).
73    Path(Path),
74    /// A parsed SVG icon — scaling deferred to paint time using display_size.
75    Svg(SvgIcon),
76    /// A decoded raster image (PNG or static WebP).
77    /// `upload_pixels` holds the ready-to-upload data (alpha mask pre-applied for tintable).
78    Raster {
79        name: String,
80        icon: RasterIcon,
81        upload_pixels: Vec<u8>,
82    },
83    /// An animated image (animated WebP).
84    /// `frame_upload_pixels` holds pre-computed pixels per frame — used
85    /// by the legacy signal-based path (reduced-motion fallback and
86    /// the static first-frame render). `sprite_atlas` is the shader
87    /// path's pre-packed grid of all frames, built lazily on first
88    /// build() when reduced-motion is off. `anim_handle` is the
89    /// registry slot returned by `ctx.animated_quad` — set in pair
90    /// with `sprite_atlas`, used at paint time.
91    Animated {
92        name: String,
93        icon: AnimatedIcon,
94        frame_upload_pixels: Vec<Vec<u8>>,
95        /// Legacy signal-based frame index driver. `Some` only when
96        /// shader pipeline is disabled (reduced-motion, atlas build
97        /// failed, etc.); otherwise frame cycling runs shader-side.
98        frame_signal: Option<Signal<f32>>,
99        /// Sprite-atlas state for the shader pipeline. `Some` once
100        /// `build()` has packed the frames into a grid.
101        sprite_atlas: Option<SpriteAtlas>,
102        /// Animated-quad handle returned by `ctx.animated_quad`.
103        /// `Some` only when the shader path is active (paired with
104        /// `sprite_atlas`).
105        anim_handle: Option<AnimatedQuadHandle>,
106    },
107}
108
109/// Packed frame-grid for an animated icon, prepared once per mount
110/// and reused across every paint. The renderer uploads the atlas
111/// pixels as a single texture; the shader samples the cell for the
112/// current frame based on `AnimParams::phase` written by the tree.
113#[derive(Debug, Clone)]
114struct SpriteAtlas {
115    /// Unique name under which the atlas is registered with
116    /// `Canvas::ensure_image_registered` / the renderer's
117    /// `ImageManager`.
118    name: String,
119    /// Full atlas pixels in RGBA row-major. `cols × frame_w` wide,
120    /// `rows × frame_h` tall. Owned `Vec<u8>` so the widget can pass
121    /// `Cow::Owned` to `ensure_image_registered` each paint without
122    /// recomputing; uploaded once by the renderer's image manager.
123    pixels: Vec<u8>,
124    width: u32,
125    height: u32,
126    cols: u32,
127    rows: u32,
128}
129
130/// A leaf widget that renders an icon from a path, SVG string, PNG, or WebP source.
131pub struct IconWidget {
132    source: IconSource,
133    /// Design size: the coordinate space the path was created in.
134    /// Used as the denominator when scaling the path to fit bounds.
135    design_size: f32,
136    /// Display size: what the icon reports for layout (size_that_fits).
137    /// Defaults to `design_size` but can be overridden via `icon_size()`.
138    display_size: f32,
139    /// Fill/tint color. Defaults to [`TextRole::Primary`] so icons follow
140    /// the surrounding text color across theme switches without binding.
141    color: ColorProp,
142    /// Rendering mode.
143    mode: IconMode,
144    /// When `true`, the reported size is multiplied by the global accessibility
145    /// text scale (`ctx.text_scale`) so the icon grows alongside adjacent text.
146    /// Off by default — most icons (toolbar glyphs, chevrons) have fixed
147    /// footprints that must not inflate. Opt in via
148    /// [`follow_text_scale`](Self::follow_text_scale); used by `SeverityBadge`
149    /// so status glyphs track the text they sit beside.
150    follow_text_scale: bool,
151}
152
153// Auto-generate unique names from data pointer for embedded resources.
154// Compile-time data from include_bytes! has stable pointer addresses.
155fn auto_name(prefix: &str, ptr: usize) -> String {
156    format!("_icon_{prefix}_{ptr:x}")
157}
158
159/// Prepare raster pixels for upload: apply alpha mask for tintable mode,
160/// or use original pixels for full-color mode.
161fn prepare_pixels(icon: &RasterIcon, mode: IconMode) -> Vec<u8> {
162    match mode {
163        IconMode::Tintable => icon.to_alpha_mask().pixels().to_vec(),
164        IconMode::FullColor => icon.pixels().to_vec(),
165    }
166}
167
168impl IconWidget {
169    /// Create an icon from a custom path. The path should be defined
170    /// in coordinates matching the given size (e.g., 0..24 for size=24).
171    pub fn from_path(path: Path, size: f32) -> Self {
172        Self {
173            source: IconSource::Path(path),
174            design_size: size,
175            display_size: size,
176            color: ColorProp::TextRole(TextRole::Primary),
177            mode: IconMode::Tintable,
178            follow_text_scale: false,
179        }
180    }
181
182    /// A checkmark icon (✓) at the given size.
183    pub fn checkmark(size: f32) -> Self {
184        let mut path = Path::new();
185        let s = size;
186        path.move_to(Point::new(s * 0.2, s * 0.5));
187        path.line_to(Point::new(s * 0.4, s * 0.75));
188        path.line_to(Point::new(s * 0.8, s * 0.25));
189        Self::from_path(path, size)
190    }
191
192    /// A short horizontal dash at the given size — used as the
193    /// indeterminate-state glyph for tristate menu items (mirrors
194    /// the Windows "mixed-state" convention).
195    pub fn dash(size: f32) -> Self {
196        let s = size;
197        let y_mid = s * 0.5;
198        let half_thickness = s * 0.06; // ~12% of size, matches Checkbox indeterminate bar
199        let mut path = Path::new();
200        path.move_to(Point::new(s * 0.2, y_mid - half_thickness));
201        path.line_to(Point::new(s * 0.8, y_mid - half_thickness));
202        path.line_to(Point::new(s * 0.8, y_mid + half_thickness));
203        path.line_to(Point::new(s * 0.2, y_mid + half_thickness));
204        path.close();
205        Self::from_path(path, size)
206    }
207
208    /// A small filled disc centered in the given size — used as the
209    /// selected-state glyph for radio menu items.
210    pub fn radio_dot(size: f32) -> Self {
211        let s = size;
212        let path = Path::circle(Point::new(s * 0.5, s * 0.5), s * 0.25);
213        Self::from_path(path, size)
214    }
215
216    /// A downward-pointing chevron (▼) at the given size.
217    pub fn chevron_down(size: f32) -> Self {
218        let mut path = Path::new();
219        let s = size;
220        path.move_to(Point::new(s * 0.25, s * 0.35));
221        path.line_to(Point::new(s * 0.5, s * 0.65));
222        path.line_to(Point::new(s * 0.75, s * 0.35));
223        Self::from_path(path, size)
224    }
225
226    /// A right-pointing chevron (▶) at the given size.
227    pub fn chevron_right(size: f32) -> Self {
228        let mut path = Path::new();
229        let s = size;
230        path.move_to(Point::new(s * 0.35, s * 0.25));
231        path.line_to(Point::new(s * 0.65, s * 0.5));
232        path.line_to(Point::new(s * 0.35, s * 0.75));
233        Self::from_path(path, size)
234    }
235
236    /// A left-pointing chevron (◀) at the given size.
237    pub fn chevron_left(size: f32) -> Self {
238        let mut path = Path::new();
239        let s = size;
240        path.move_to(Point::new(s * 0.65, s * 0.25));
241        path.line_to(Point::new(s * 0.35, s * 0.5));
242        path.line_to(Point::new(s * 0.65, s * 0.75));
243        Self::from_path(path, size)
244    }
245
246    /// An upward-pointing chevron (▲) at the given size.
247    pub fn chevron_up(size: f32) -> Self {
248        let mut path = Path::new();
249        let s = size;
250        path.move_to(Point::new(s * 0.25, s * 0.65));
251        path.line_to(Point::new(s * 0.5, s * 0.35));
252        path.line_to(Point::new(s * 0.75, s * 0.65));
253        Self::from_path(path, size)
254    }
255
256    /// Create an icon from an SVG string. Parses the SVG and extracts
257    /// geometry, ignoring any colors in the SVG. Display size defaults
258    /// to the SVG's viewBox dimensions; use [`icon_size`](Self::icon_size)
259    /// to override.
260    ///
261    /// If parsing fails, logs the error in debug mode and produces an empty icon.
262    pub fn from_svg(svg_str: &str) -> Self {
263        match SvgIcon::parse(svg_str) {
264            Ok(icon) => Self::from_svg_icon(&icon),
265            Err(_e) => {
266                #[cfg(debug_assertions)]
267                eprintln!("teksilo: SVG parse error: {_e}");
268                Self::from_path(Path::new(), 0.0)
269            }
270        }
271    }
272
273    /// Create an icon from a pre-parsed [`SvgIcon`]. Display size
274    /// defaults to the SVG's viewBox; use [`icon_size`](Self::icon_size)
275    /// to override. Scaling is deferred to paint time.
276    pub fn from_svg_icon(icon: &SvgIcon) -> Self {
277        let vb_size = icon.width().max(icon.height());
278        Self {
279            source: IconSource::Svg(icon.clone()),
280            design_size: vb_size,
281            display_size: vb_size,
282            color: ColorProp::TextRole(TextRole::Primary),
283            mode: IconMode::Tintable,
284            follow_text_scale: false,
285        }
286    }
287
288    /// Create an icon from PNG data.
289    ///
290    /// If decoding fails, logs the error in debug mode and produces an empty icon.
291    pub fn from_png(data: &'static [u8], size: f32) -> Self {
292        match RasterIcon::decode_png(data) {
293            Ok(icon) => {
294                let name = auto_name("png", data.as_ptr() as usize);
295                let mode = IconMode::Tintable;
296                let upload_pixels = prepare_pixels(&icon, mode);
297                Self {
298                    source: IconSource::Raster {
299                        name,
300                        icon,
301                        upload_pixels,
302                    },
303                    design_size: size,
304                    display_size: size,
305                    color: ColorProp::TextRole(TextRole::Primary),
306                    mode,
307                    follow_text_scale: false,
308                }
309            }
310            Err(_e) => {
311                #[cfg(debug_assertions)]
312                eprintln!("teksilo: PNG decode error: {_e}");
313                Self::from_path(Path::new(), size)
314            }
315        }
316    }
317
318    /// Create an icon from WebP data. Auto-detects static vs animated.
319    ///
320    /// If decoding fails, logs the error in debug mode and produces an empty icon.
321    pub fn from_webp(data: &'static [u8], size: f32) -> Self {
322        let mode = IconMode::Tintable;
323        // Try animated first
324        if let Ok(anim) = AnimatedIcon::decode_webp(data) {
325            let name = auto_name("webp", data.as_ptr() as usize);
326            let frame_upload_pixels: Vec<Vec<u8>> = anim
327                .frames()
328                .iter()
329                .map(|f| prepare_pixels(f, mode))
330                .collect();
331            return Self {
332                source: IconSource::Animated {
333                    name,
334                    icon: anim,
335                    frame_upload_pixels,
336                    frame_signal: None,
337                    sprite_atlas: None,
338                    anim_handle: None,
339                },
340                design_size: size,
341                display_size: size,
342                color: ColorProp::TextRole(TextRole::Primary),
343                mode,
344                follow_text_scale: false,
345            };
346        }
347        // Fall back to static
348        match RasterIcon::decode_webp(data) {
349            Ok(icon) => {
350                let name = auto_name("webp", data.as_ptr() as usize);
351                let upload_pixels = prepare_pixels(&icon, mode);
352                Self {
353                    source: IconSource::Raster {
354                        name,
355                        icon,
356                        upload_pixels,
357                    },
358                    design_size: size,
359                    display_size: size,
360                    color: ColorProp::TextRole(TextRole::Primary),
361                    mode,
362                    follow_text_scale: false,
363                }
364            }
365            Err(_e) => {
366                #[cfg(debug_assertions)]
367                eprintln!("teksilo: WebP decode error: {_e}");
368                Self::from_path(Path::new(), size)
369            }
370        }
371    }
372
373    /// Create an icon from a pre-decoded [`RasterIcon`].
374    /// Accepts a reference — pixel data is copied internally.
375    pub fn from_raster(icon: &RasterIcon, size: f32) -> Self {
376        let name = format!("_icon_raster_{:p}", icon as *const RasterIcon);
377        let mode = IconMode::Tintable;
378        let upload_pixels = prepare_pixels(icon, mode);
379        Self {
380            source: IconSource::Raster {
381                name,
382                icon: icon.clone(),
383                upload_pixels,
384            },
385            design_size: size,
386            display_size: size,
387            color: ColorProp::TextRole(TextRole::Primary),
388            mode,
389            follow_text_scale: false,
390        }
391    }
392
393    /// Create an icon from a pre-decoded [`AnimatedIcon`].
394    /// Accepts a reference — frame data is copied internally.
395    pub fn from_animated(icon: &AnimatedIcon, size: f32) -> Self {
396        let name = format!("_icon_anim_{:p}", icon as *const AnimatedIcon);
397        let mode = IconMode::Tintable;
398        let frame_upload_pixels: Vec<Vec<u8>> = icon
399            .frames()
400            .iter()
401            .map(|f| prepare_pixels(f, mode))
402            .collect();
403        Self {
404            source: IconSource::Animated {
405                name,
406                icon: icon.clone(),
407                frame_upload_pixels,
408                frame_signal: None,
409                sprite_atlas: None,
410                anim_handle: None,
411            },
412            design_size: size,
413            display_size: size,
414            color: ColorProp::TextRole(TextRole::Primary),
415            mode,
416            follow_text_scale: false,
417        }
418    }
419
420    /// Set the icon rendering mode (tintable or full-color).
421    /// Re-computes cached pixel data for raster/animated icons.
422    pub fn mode(mut self, mode: IconMode) -> Self {
423        if self.mode == mode {
424            return self;
425        }
426        self.mode = mode;
427        match &mut self.source {
428            IconSource::Raster {
429                icon,
430                upload_pixels,
431                ..
432            } => {
433                *upload_pixels = prepare_pixels(icon, mode);
434            }
435            IconSource::Animated {
436                icon,
437                frame_upload_pixels,
438                sprite_atlas,
439                anim_handle,
440                ..
441            } => {
442                *frame_upload_pixels = icon
443                    .frames()
444                    .iter()
445                    .map(|f| prepare_pixels(f, mode))
446                    .collect();
447                // Invalidate the atlas — pixels bake the mode
448                // (Tintable pre-applies the alpha mask, FullColor
449                // keeps raw RGBA); the next build() repacks.
450                *sprite_atlas = None;
451                *anim_handle = None;
452            }
453            IconSource::Path(_) | IconSource::Svg(_) => {}
454        }
455        self
456    }
457
458    /// Set the tint. Accepts any `impl Into<ColorProp>`:
459    ///
460    /// - A raw `Color` — a frozen literal.
461    /// - A [`TextRole`] / `SurfaceRole` / `BorderRole` — resolved against
462    ///   the theme at paint time (reactive across theme switches).
463    /// - A `Signal<Color>` — reactive state (usually interaction-driven).
464    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
465        self.color = color.into();
466        self
467    }
468
469    /// Set the display size of the icon. The path/image is scaled to fit
470    /// this size during rendering. This does not affect the design-time
471    /// coordinate space — SVG paths scale correctly.
472    pub fn icon_size(mut self, size: f32) -> Self {
473        self.display_size = size;
474        self
475    }
476
477    /// Make this icon grow with the global accessibility text scale
478    /// (`ctx.text_scale`). Off by default. Enable for icons that sit inline
479    /// with text and should scale together — e.g. status glyphs in a
480    /// `SeverityBadge`. The reported (and rendered) size becomes
481    /// `display_size × text_scale`.
482    pub fn follow_text_scale(mut self, follow: bool) -> Self {
483        self.follow_text_scale = follow;
484        self
485    }
486
487    /// The on-screen square extent (dp) this icon renders at — its
488    /// `display_size`. Lets a container reserve the right width without
489    /// laying the icon out first.
490    pub(crate) fn display_size(&self) -> f32 {
491        self.display_size
492    }
493
494    /// Create a scaled copy of a programmatic [`IconSource::Path`] to fit
495    /// within the given bounds. SVG sources are scaled separately in
496    /// `paint` (fill via `to_path_in_rect`, strokes via
497    /// `stroked_paths_in_rect`) so their stroke widths scale correctly.
498    fn scaled_path(&self, bounds: Rect) -> Path {
499        let path = match &self.source {
500            IconSource::Path(p) => p,
501            _ => return Path::new(),
502        };
503        if path.is_empty() {
504            return path.clone();
505        }
506        let scale_x = bounds.width / self.design_size;
507        let scale_y = bounds.height / self.design_size;
508        let offset_x = bounds.x;
509        let offset_y = bounds.y;
510
511        let mut scaled = Path::new();
512        for cmd in &path.commands {
513            match *cmd {
514                PathCommand::MoveTo(p) => {
515                    scaled.move_to(Point::new(
516                        p.x * scale_x + offset_x,
517                        p.y * scale_y + offset_y,
518                    ));
519                }
520                PathCommand::LineTo(p) => {
521                    scaled.line_to(Point::new(
522                        p.x * scale_x + offset_x,
523                        p.y * scale_y + offset_y,
524                    ));
525                }
526                PathCommand::QuadTo { control, to } => {
527                    scaled.quad_to(
528                        Point::new(
529                            control.x * scale_x + offset_x,
530                            control.y * scale_y + offset_y,
531                        ),
532                        Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
533                    );
534                }
535                PathCommand::CubicTo {
536                    control1,
537                    control2,
538                    to,
539                } => {
540                    scaled.cubic_to(
541                        Point::new(
542                            control1.x * scale_x + offset_x,
543                            control1.y * scale_y + offset_y,
544                        ),
545                        Point::new(
546                            control2.x * scale_x + offset_x,
547                            control2.y * scale_y + offset_y,
548                        ),
549                        Point::new(to.x * scale_x + offset_x, to.y * scale_y + offset_y),
550                    );
551                }
552                PathCommand::ArcTo {
553                    rect,
554                    start_angle,
555                    sweep_angle,
556                } => {
557                    scaled.arc_to(
558                        Rect::new(
559                            rect.x * scale_x + offset_x,
560                            rect.y * scale_y + offset_y,
561                            rect.width * scale_x,
562                            rect.height * scale_y,
563                        ),
564                        start_angle,
565                        sweep_angle,
566                    );
567                }
568                PathCommand::Close => {
569                    scaled.close();
570                }
571            }
572        }
573        scaled
574    }
575
576    /// Paint a raster icon into the canvas using pre-computed upload pixels.
577    fn paint_raster(
578        &self,
579        bounds: Rect,
580        canvas: &mut Canvas,
581        name: &str,
582        width: u32,
583        height: u32,
584        upload_pixels: &[u8],
585        color: Color,
586    ) {
587        if !canvas.has_pending_image(name) {
588            canvas.ensure_image_registered(name, width, height, Cow::Owned(upload_pixels.to_vec()));
589        }
590        match self.mode {
591            IconMode::Tintable => canvas.draw_tinted_image(bounds, name, color),
592            IconMode::FullColor => canvas.draw_image(bounds, name),
593        }
594    }
595}
596
597impl std::fmt::Debug for IconWidget {
598    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
599        f.debug_struct("IconWidget")
600            .field("display_size", &self.display_size)
601            .field("mode", &self.mode)
602            .finish()
603    }
604}
605
606impl Widget for IconWidget {
607    fn build(
608        &mut self,
609        ctx: &mut teksilo_core::build_context::BuildContext,
610    ) -> Vec<teksilo_core::widget_id::WidgetId> {
611        // Register color binding
612        {
613            let self_id = ctx.self_id();
614            let registry = ctx.binding_registry();
615            self.color.register_if_bound(
616                self_id,
617                registry,
618                teksilo_core::binding::BindingLevel::RepaintOnly,
619            );
620        }
621
622        // For animated icons: prefer the shader-driven sprite path
623        // (paint() emits ONE AnimatedQuad and doesn't re-run per
624        // frame; the GPU samples the current frame from a packed
625        // atlas). When reduced-motion is on, fall back to the static
626        // first-frame render — no animation scheduled at all.
627        let mode = self.mode;
628        let icon_color = self.color.clone();
629        if let IconSource::Animated {
630            name,
631            icon,
632            frame_upload_pixels,
633            frame_signal,
634            sprite_atlas,
635            anim_handle,
636        } = &mut self.source
637        {
638            if ctx.prefers_reduced_motion() {
639                *frame_signal = None;
640                *sprite_atlas = None;
641                *anim_handle = None;
642            } else {
643                // Pack frames into an atlas once; reuse across rebuilds.
644                if sprite_atlas.is_none() {
645                    *sprite_atlas = build_sprite_atlas(name, icon, frame_upload_pixels);
646                }
647                if let Some(atlas) = sprite_atlas.as_ref() {
648                    // Tintable icons bake an alpha mask in the pixel
649                    // buffer, so the shader must multiply by the
650                    // widget's color to get the final tint. FullColor
651                    // icons pass pixels through untouched (no tint).
652                    let tint = match mode {
653                        IconMode::Tintable => Some(icon_color),
654                        IconMode::FullColor => None,
655                    };
656                    *anim_handle = Some(ctx.animated_quad(AnimatedQuadKind::SpriteCycle {
657                        image_name: atlas.name.clone(),
658                        frame_count: icon.frame_count() as u32,
659                        cols: atlas.cols,
660                        rows: atlas.rows,
661                        period: icon.total_duration(),
662                        tint,
663                    }));
664                    // Shader drives everything now — drop the legacy
665                    // signal so the scheduler doesn't tick it.
666                    *frame_signal = None;
667                } else {
668                    // Atlas build failed (e.g. zero-size frames);
669                    // fall back to the legacy signal path.
670                    let signal = ctx.animated_signal(0.0);
671                    {
672                        let self_id = ctx.self_id();
673                        let registry = ctx.binding_registry();
674                        signal.bind_to(
675                            self_id,
676                            registry,
677                            teksilo_core::binding::BindingLevel::RepaintOnly,
678                        );
679                    }
680                    let frame_count = icon.frame_count() as f32;
681                    let period = icon.total_duration();
682                    signal.animate_looping(
683                        frame_count,
684                        period,
685                        Easing::Linear,
686                        Some(std::time::Duration::from_millis(33)),
687                    );
688                    *frame_signal = Some(signal);
689                }
690            }
691        }
692
693        Vec::new()
694    }
695
696    fn layout_response(
697        &self,
698        _proposal: SizeProposal,
699        ctx: &LayoutContext,
700    ) -> teksilo_core::widget::LayoutResponse {
701        // Paint fills `bounds`, so growing the reported size here is the only
702        // change needed to scale the rendered glyph with the text scale.
703        let size = if self.follow_text_scale {
704            self.display_size * ctx.text_scale
705        } else {
706            self.display_size
707        };
708        Size::new(size, size).into()
709    }
710
711    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
712        let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
713
714        match &self.source {
715            IconSource::Path(_) => {
716                if color.a() > 0.0 {
717                    let scaled = self.scaled_path(bounds);
718                    if !scaled.is_empty() {
719                        canvas.fill_path(&scaled, color);
720                    }
721                }
722            }
723            IconSource::Svg(icon) => {
724                if color.a() <= 0.0 {
725                    return;
726                }
727                // Full-color artwork (a brand mark, a colored file-type icon)
728                // keeps its own paints and its document order — see
729                // `SvgIcon::draw_ops_in_rect`. A monochrome document is drawn
730                // through the tinted path either way: the result is identical,
731                // and the tinted path merges the whole icon into one fill.
732                if self.mode == IconMode::FullColor && !icon.is_monochrome() {
733                    for op in icon.draw_ops_in_rect(bounds, color) {
734                        match op {
735                            SvgDrawOp::Fill {
736                                path,
737                                fill_rule,
738                                paint,
739                            } => canvas.fill_path_with_rule(&path, paint, fill_rule),
740                            SvgDrawOp::Stroke { path, style, paint } => {
741                                canvas.stroke_path_with_paint(&path, paint, style)
742                            }
743                        }
744                    }
745                    return;
746                }
747
748                // Tinted: filled geometry (the default for most icons) +
749                // even-odd / transparent fills + stroked geometry (line-style
750                // icons). An icon may carry any mix.
751                let fill = icon.to_path_in_rect(bounds);
752                if !fill.is_empty() {
753                    canvas.fill_path(&fill, color);
754                }
755                for (path, rule, opacity) in icon.extra_fills_in_rect(bounds) {
756                    let c = color.with_alpha(color.a() * opacity);
757                    if !path.is_empty() && c.a() > 0.0 {
758                        canvas.fill_path_with_rule(&path, c, rule);
759                    }
760                }
761                for (path, style, opacity) in icon.stroked_paths_in_rect(bounds) {
762                    let c = color.with_alpha(color.a() * opacity);
763                    if !path.is_empty() && c.a() > 0.0 {
764                        canvas.stroke_path(&path, c, style);
765                    }
766                }
767            }
768            IconSource::Raster {
769                name,
770                icon,
771                upload_pixels,
772            } => {
773                self.paint_raster(
774                    bounds,
775                    canvas,
776                    name,
777                    icon.width(),
778                    icon.height(),
779                    upload_pixels,
780                    color,
781                );
782            }
783            IconSource::Animated {
784                name,
785                icon,
786                frame_upload_pixels,
787                frame_signal,
788                sprite_atlas,
789                anim_handle,
790            } => {
791                // Shader path: one AnimatedQuad — the renderer
792                // samples the packed atlas at the current frame's
793                // cell, driven by per-frame uniforms from the tree.
794                if let (Some(atlas), Some(handle)) = (sprite_atlas, anim_handle) {
795                    // Register the atlas pixels (idempotent — skipped
796                    // if already pending or uploaded this frame).
797                    canvas.ensure_image_registered(
798                        atlas.name.clone(),
799                        atlas.width,
800                        atlas.height,
801                        std::borrow::Cow::Owned(atlas.pixels.clone()),
802                    );
803                    canvas.draw_animated_quad(
804                        bounds,
805                        handle.slot(),
806                        AnimatedQuadClass::Sprite {
807                            image_name: atlas.name.clone(),
808                        },
809                    );
810                    return;
811                }
812                // Legacy path: signal-driven frame index with one
813                // per-frame image registration. Used when
814                // reduced-motion is on (frame_signal is None →
815                // frame 0 shown statically) or when atlas build
816                // failed and we fell back.
817                let idx = frame_signal
818                    .as_ref()
819                    .map(|s| (s.get() as usize).min(icon.frame_count().saturating_sub(1)))
820                    .unwrap_or(0);
821                let frame_name = format!("{name}_f{idx}");
822                let frame = &icon.frames()[idx];
823                let pixels = &frame_upload_pixels[idx];
824                self.paint_raster(
825                    bounds,
826                    canvas,
827                    &frame_name,
828                    frame.width(),
829                    frame.height(),
830                    pixels,
831                    color,
832                );
833            }
834        }
835    }
836
837    fn accessibility(&self, _builder: &mut AccessNodeBuilder) {
838        // Icons are typically decorative — the parent widget sets the semantic role.
839    }
840}
841
842/// Pack animated-icon frames into a single square-ish sprite atlas.
843/// The returned `SpriteAtlas` carries the packed pixels, grid layout,
844/// and a placeholder handle (`handle.slot() == 0`) that `build()`
845/// overwrites with the real scheduler-issued handle before paint runs.
846///
847/// Frames are laid out row-major: frame index `i` occupies cell
848/// `(i % cols, i / cols)`. Unused tail cells (when `frame_count <
849/// cols * rows`) are left zeroed — the shader clamps the sampled
850/// frame index so it never reads past the last frame.
851///
852/// Returns `None` when the frames have zero dimensions; the caller
853/// falls back to the legacy signal-driven path.
854fn build_sprite_atlas(
855    name: &str,
856    icon: &AnimatedIcon,
857    frame_pixels: &[Vec<u8>],
858) -> Option<SpriteAtlas> {
859    let frames = icon.frames();
860    if frames.is_empty() {
861        return None;
862    }
863    let frame_w = frames[0].width();
864    let frame_h = frames[0].height();
865    if frame_w == 0 || frame_h == 0 {
866        return None;
867    }
868
869    let n = frames.len() as u32;
870    let cols = (n as f32).sqrt().ceil() as u32;
871    let rows = n.div_ceil(cols);
872    let atlas_w = cols * frame_w;
873    let atlas_h = rows * frame_h;
874    let mut pixels = vec![0u8; (atlas_w * atlas_h * 4) as usize];
875
876    for (i, cell) in frame_pixels.iter().enumerate() {
877        let i = i as u32;
878        let col = i % cols;
879        let row = i / cols;
880        let dst_x = col * frame_w;
881        let dst_y = row * frame_h;
882        // Copy row-by-row; source is tightly packed (frame_w × 4 bytes
883        // per row), destination stride is atlas_w × 4.
884        for y in 0..frame_h {
885            let src_start = (y * frame_w * 4) as usize;
886            let src_end = src_start + (frame_w * 4) as usize;
887            if src_end > cell.len() {
888                break; // truncated frame — shouldn't happen, but be defensive
889            }
890            let dst_start = (((dst_y + y) * atlas_w + dst_x) * 4) as usize;
891            let dst_end = dst_start + (frame_w * 4) as usize;
892            pixels[dst_start..dst_end].copy_from_slice(&cell[src_start..src_end]);
893        }
894    }
895
896    Some(SpriteAtlas {
897        name: format!("{name}_sprite_atlas"),
898        pixels,
899        width: atlas_w,
900        height: atlas_h,
901        cols,
902        rows,
903    })
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use teksilo_core::widget_tree::WidgetTree;
910
911    #[test]
912    fn icon_intrinsic_size() {
913        let mut tree = WidgetTree::new();
914        let icon = tree.add(IconWidget::checkmark(24.0));
915        tree.layout(SizeProposal::unspecified());
916        let b = tree.bounds(icon);
917        assert!((b.width - 24.0).abs() < 0.01);
918        assert!((b.height - 24.0).abs() < 0.01);
919    }
920
921    #[test]
922    fn icon_custom_size() {
923        let mut tree = WidgetTree::new();
924        let icon = tree.add(IconWidget::chevron_down(16.0));
925        tree.layout(SizeProposal::unspecified());
926        let b = tree.bounds(icon);
927        assert!((b.width - 16.0).abs() < 0.01);
928        assert!((b.height - 16.0).abs() < 0.01);
929    }
930
931    #[test]
932    fn follow_text_scale_grows_with_user_scale() {
933        // Opt-in icon doubles at 200% text scale; a default icon stays put.
934        let mut tree = WidgetTree::new();
935        let scaled = tree.add(IconWidget::checkmark(20.0).follow_text_scale(true));
936        let fixed = tree.add(IconWidget::checkmark(20.0));
937        tree.set_user_text_scale(2.0);
938        tree.layout(SizeProposal::unspecified());
939        let bs = tree.bounds(scaled);
940        let bf = tree.bounds(fixed);
941        assert!(
942            (bs.width - 40.0).abs() < 0.01,
943            "opted-in icon should double: {bs:?}"
944        );
945        assert!(
946            (bf.width - 20.0).abs() < 0.01,
947            "default icon must not scale: {bf:?}"
948        );
949    }
950
951    #[test]
952    fn icon_paints_path() {
953        let mut tree = WidgetTree::new();
954        tree.add(IconWidget::checkmark(24.0).color(Color::BLACK));
955        tree.layout(SizeProposal::exact(24.0, 24.0));
956        let frame = tree.render();
957        assert!(!frame.paths.is_empty(), "icon should render a path");
958    }
959
960    #[test]
961    fn empty_path_does_not_paint() {
962        let mut tree = WidgetTree::new();
963        tree.add(IconWidget::from_path(Path::new(), 24.0).color(Color::BLACK));
964        tree.layout(SizeProposal::exact(24.0, 24.0));
965        let frame = tree.render();
966        assert!(frame.paths.is_empty(), "empty path should not render");
967    }
968
969    #[test]
970    fn icon_from_svg() {
971        let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
972            <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
973        </svg>"#;
974        let mut tree = WidgetTree::new();
975        tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
976        tree.layout(SizeProposal::exact(24.0, 24.0));
977        let frame = tree.render();
978        assert!(!frame.paths.is_empty(), "SVG icon should render a path");
979    }
980
981    #[test]
982    fn icon_from_svg_line_style_renders_stroke_not_fill() {
983        // A line-style (fill=none, stroke=…) SVG must render as a stroked
984        // outline, not a filled blob. Regression guard for the SVG icon
985        // parser gaining stroke support.
986        let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
987            <circle cx="12" cy="12" r="10"/>
988        </svg>"#;
989        let mut tree = WidgetTree::new();
990        tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
991        tree.layout(SizeProposal::exact(24.0, 24.0));
992        let frame = tree.render();
993        assert_eq!(
994            frame.paths.len(),
995            1,
996            "line-style SVG should render exactly one (stroked) path"
997        );
998        assert!(
999            frame.paths[0].stroke_style.width > 0.0,
1000            "the rendered path must be stroked, not filled"
1001        );
1002    }
1003
1004    #[test]
1005    fn icon_from_svg_evenodd_emits_evenodd_path_entry() {
1006        // A fill-rule="evenodd" icon must reach the renderer as a PathEntry
1007        // carrying FillRule::EvenOdd (so holes punch instead of filling in).
1008        let svg = r#"<svg viewBox="0 0 24 24">
1009            <path fill-rule="evenodd" d="M2 2L22 2L22 22Z"/>
1010        </svg>"#;
1011        let mut tree = WidgetTree::new();
1012        tree.add(IconWidget::from_svg(svg).color(Color::BLACK));
1013        tree.layout(SizeProposal::exact(24.0, 24.0));
1014        let frame = tree.render();
1015        assert_eq!(frame.paths.len(), 1, "evenodd icon renders one path");
1016        assert_eq!(
1017            frame.paths[0].fill_rule,
1018            teksilo_canvas::FillRule::EvenOdd,
1019            "the fill rule must reach the PathEntry"
1020        );
1021    }
1022
1023    #[test]
1024    fn icon_from_svg_invalid_fallback() {
1025        let mut tree = WidgetTree::new();
1026        tree.add(IconWidget::from_svg("<not-svg>"));
1027        tree.layout(SizeProposal::exact(24.0, 24.0));
1028        let frame = tree.render();
1029        // Invalid SVG should produce empty icon, no panic
1030        assert!(frame.paths.is_empty());
1031    }
1032
1033    #[test]
1034    fn icon_mode_default_is_tintable() {
1035        let icon = IconWidget::checkmark(24.0);
1036        assert_eq!(icon.mode, IconMode::Tintable);
1037    }
1038
1039    #[test]
1040    fn icon_mode_can_be_set() {
1041        let icon = IconWidget::checkmark(24.0).mode(IconMode::FullColor);
1042        assert_eq!(icon.mode, IconMode::FullColor);
1043    }
1044
1045    #[test]
1046    fn raster_icon_paints_image() {
1047        let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1048        let mut tree = WidgetTree::new();
1049        tree.add(IconWidget::from_raster(&icon, 24.0).color(Color::BLACK));
1050        tree.layout(SizeProposal::exact(24.0, 24.0));
1051        let frame = tree.render();
1052        // Raster icon should produce an image draw command
1053        assert!(
1054            !frame.images.is_empty(),
1055            "raster icon should render an image"
1056        );
1057    }
1058
1059    #[test]
1060    fn raster_icon_tintable_has_tint() {
1061        let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1062        let mut tree = WidgetTree::new();
1063        tree.add(
1064            IconWidget::from_raster(&icon, 24.0)
1065                .color(Color::from_hex("#FF0000"))
1066                .mode(IconMode::Tintable),
1067        );
1068        tree.layout(SizeProposal::exact(24.0, 24.0));
1069        let frame = tree.render();
1070        assert!(
1071            frame.images[0].tint.is_some(),
1072            "tintable icon should have tint"
1073        );
1074    }
1075
1076    #[test]
1077    fn raster_icon_fullcolor_no_tint() {
1078        let icon = RasterIcon::from_raw(vec![255; 4], 1, 1);
1079        let mut tree = WidgetTree::new();
1080        tree.add(IconWidget::from_raster(&icon, 24.0).mode(IconMode::FullColor));
1081        tree.layout(SizeProposal::exact(24.0, 24.0));
1082        let frame = tree.render();
1083        assert!(
1084            frame.images[0].tint.is_none(),
1085            "full-color icon should not have tint"
1086        );
1087    }
1088
1089    // ── Enabled-state-aware role substitution ─────────────────────
1090    //
1091    // When the leaf paints with a role-based ColorProp and any
1092    // ancestor's arena `enabled_state` resolves to false, the leaf
1093    // must substitute `TextRole::Disabled` automatically — this is
1094    // the lynchpin of the "composites stop owning enabled state"
1095    // architecture and the fix for the format-toolbar bug where
1096    // table-op IconButtons stayed full-color when `is_in_table` was
1097    // false.
1098
1099    /// Color of the single rendered `path` for an `IconWidget` from
1100    /// `from_path`. The path-icon code puts the resolved fill on
1101    /// `PathEntry.color`, distinct from raster's `image.tint`.
1102    fn path_icon_color(frame: &teksilo_canvas::RenderFrame) -> [f32; 4] {
1103        frame
1104            .paths
1105            .first()
1106            .map(|p| p.color)
1107            .expect("path icon should render at least one path")
1108    }
1109
1110    #[test]
1111    fn role_based_icon_uses_text_disabled_when_self_disabled() {
1112        // Direct case: bind `enabled_when` on the IconWidget itself.
1113        // Default role is `TextRole::Primary`; when disabled the leaf
1114        // must resolve to `theme.colors.text_disabled`.
1115        let mut tree = WidgetTree::new();
1116        let theme = teksilo_core::presets::intui::light();
1117        tree.set_theme(theme.clone());
1118
1119        let icon = tree.add(IconWidget::checkmark(24.0));
1120        tree.enabled_when(icon, false);
1121        tree.layout(SizeProposal::exact(24.0, 24.0));
1122        let frame = tree.render();
1123        let color = path_icon_color(&frame);
1124
1125        let expected = theme.colors.text_disabled.to_array();
1126        assert_eq!(
1127            color, expected,
1128            "default-role IconWidget under enabled_when(false) must paint at text_disabled, got {color:?}"
1129        );
1130    }
1131
1132    #[test]
1133    fn role_based_icon_uses_text_primary_when_self_enabled() {
1134        let mut tree = WidgetTree::new();
1135        let theme = teksilo_core::presets::intui::light();
1136        tree.set_theme(theme.clone());
1137
1138        tree.add(IconWidget::checkmark(24.0));
1139        tree.layout(SizeProposal::exact(24.0, 24.0));
1140        let frame = tree.render();
1141        let color = path_icon_color(&frame);
1142
1143        let expected = theme.colors.text_primary.to_array();
1144        assert_eq!(
1145            color, expected,
1146            "default-role IconWidget without enabled_state must paint at text_primary, got {color:?}"
1147        );
1148    }
1149
1150    #[test]
1151    fn role_based_icon_flips_when_bound_signal_flips_without_rebuild() {
1152        // The FormatToolbar bug as a unit test. Bind a Signal<bool>
1153        // via `enabled_when`; flip it after layout+render; re-render;
1154        // verify the leaf's color flipped from primary to disabled
1155        // and back to primary, without any rebuild.
1156        use teksilo_core::signal::Signal;
1157
1158        let mut tree = WidgetTree::new();
1159        let theme = teksilo_core::presets::intui::light();
1160        tree.set_theme(theme.clone());
1161
1162        let is_enabled = Signal::new(true);
1163        let icon = tree.add(IconWidget::checkmark(24.0));
1164        tree.enabled_when(icon, is_enabled.clone());
1165
1166        tree.layout(SizeProposal::exact(24.0, 24.0));
1167        let primary = theme.colors.text_primary.to_array();
1168        let disabled = theme.colors.text_disabled.to_array();
1169        assert_eq!(path_icon_color(&tree.render()), primary, "starts primary");
1170
1171        is_enabled.set(false);
1172        tree.layout(SizeProposal::exact(24.0, 24.0));
1173        assert_eq!(
1174            path_icon_color(&tree.render()),
1175            disabled,
1176            "after flipping signal to false the leaf must repaint at the disabled color"
1177        );
1178
1179        is_enabled.set(true);
1180        tree.layout(SizeProposal::exact(24.0, 24.0));
1181        assert_eq!(
1182            path_icon_color(&tree.render()),
1183            primary,
1184            "flipping back to true must re-resolve to primary"
1185        );
1186    }
1187
1188    #[test]
1189    fn explicit_color_does_not_dim_when_disabled() {
1190        // The static-opt-out contract: when the caller passed a
1191        // literal `Color`, role substitution is bypassed entirely.
1192        // A disabled subtree with an explicit-color icon keeps the
1193        // caller's literal — same model as Static() everywhere else.
1194        let mut tree = WidgetTree::new();
1195        let red = Color::from_hex("#FF0000");
1196        let icon = tree.add(IconWidget::checkmark(24.0).color(red));
1197        tree.enabled_when(icon, false);
1198        tree.layout(SizeProposal::exact(24.0, 24.0));
1199        let frame = tree.render();
1200        let color = path_icon_color(&frame);
1201        assert_eq!(
1202            color,
1203            red.to_array(),
1204            "explicit-color icons must NOT auto-dim when disabled — caller picked the literal, framework respects it"
1205        );
1206    }
1207
1208    // ── Full-color SVG ──────────────────────────────────────────────────────
1209
1210    /// A two-color mark reaches the renderer as two paths carrying **its own**
1211    /// colors, not the widget's — and in document order, so the shape authored
1212    /// last paints last.
1213    #[test]
1214    fn full_color_svg_keeps_its_own_colors_in_document_order() {
1215        let svg = r##"<svg viewBox="0 0 24 24">
1216            <rect width="24" height="24" fill="#5865F2"/>
1217            <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1218        </svg>"##;
1219        let mut tree = WidgetTree::new();
1220        // A tint that appears nowhere in the artwork: if the widget ever leaks
1221        // through, the assertion below names it.
1222        tree.add(
1223            IconWidget::from_svg(svg)
1224                .icon_size(24.0)
1225                .mode(IconMode::FullColor)
1226                .color(Color::from_hex("#FF0000")),
1227        );
1228        tree.layout(SizeProposal::exact(24.0, 24.0));
1229        let frame = tree.render();
1230
1231        assert_eq!(frame.paths.len(), 2, "one path per authored shape");
1232        assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1233        assert_eq!(frame.paths[1].color, Color::from_hex("#FFFFFF").to_array());
1234    }
1235
1236    /// The same artwork in the default `Tintable` mode is a single merged
1237    /// silhouette in the widget's color — the pre-existing behaviour, which
1238    /// full-color support must not disturb.
1239    #[test]
1240    fn tintable_mode_still_merges_a_colored_svg_into_one_tinted_path() {
1241        let svg = r##"<svg viewBox="0 0 24 24">
1242            <rect width="24" height="24" fill="#5865F2"/>
1243            <circle cx="12" cy="12" r="6" fill="#FFFFFF"/>
1244        </svg>"##;
1245        let mut tree = WidgetTree::new();
1246        tree.add(
1247            IconWidget::from_svg(svg)
1248                .icon_size(24.0)
1249                .color(Color::from_hex("#FF0000")),
1250        );
1251        tree.layout(SizeProposal::exact(24.0, 24.0));
1252        let frame = tree.render();
1253
1254        assert_eq!(frame.paths.len(), 1, "both shapes merge into one fill");
1255        assert_eq!(
1256            frame.paths[0].color,
1257            Color::from_hex("#FF0000").to_array(),
1258            "tintable ignores the artwork's colors and takes the widget's"
1259        );
1260    }
1261
1262    /// `currentColor` inside full-color artwork still follows the theme — the
1263    /// mixed case: fixed brand colors plus one themed accent.
1264    #[test]
1265    fn current_color_follows_the_widget_inside_full_color_artwork() {
1266        let svg = r##"<svg viewBox="0 0 24 24">
1267            <rect width="24" height="24" fill="#5865F2"/>
1268            <rect width="8" height="8" fill="currentColor"/>
1269        </svg>"##;
1270        let mut tree = WidgetTree::new();
1271        let accent = Color::from_hex("#00FF00");
1272        tree.add(
1273            IconWidget::from_svg(svg)
1274                .icon_size(24.0)
1275                .mode(IconMode::FullColor)
1276                .color(accent),
1277        );
1278        tree.layout(SizeProposal::exact(24.0, 24.0));
1279        let frame = tree.render();
1280        assert_eq!(frame.paths[0].color, Color::from_hex("#5865F2").to_array());
1281        assert_eq!(frame.paths[1].color, accent.to_array());
1282    }
1283
1284    /// A gradient fill reaches the renderer as gradient `PaintData` — the
1285    /// dedicated pipeline — rather than being flattened to a solid.
1286    #[test]
1287    fn a_gradient_fill_reaches_the_renderer_as_a_gradient() {
1288        let svg = r##"<svg viewBox="0 0 24 24">
1289            <linearGradient id="g">
1290              <stop offset="0" stop-color="#FF0000"/>
1291              <stop offset="1" stop-color="#0000FF"/>
1292            </linearGradient>
1293            <rect width="24" height="24" fill="url(#g)"/>
1294        </svg>"##;
1295        let mut tree = WidgetTree::new();
1296        tree.add(
1297            IconWidget::from_svg(svg)
1298                .icon_size(24.0)
1299                .mode(IconMode::FullColor)
1300                .color(Color::BLACK),
1301        );
1302        tree.layout(SizeProposal::exact(24.0, 24.0));
1303        let frame = tree.render();
1304        match &frame.paths[0].paint_data {
1305            teksilo_canvas::PaintData::LinearGradient { start, end, stops } => {
1306                assert_eq!(stops.len(), 2);
1307                assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
1308                // Rect-local: the ramp spans the icon's 24 dp box.
1309                assert!(start[0].abs() < 0.01);
1310                assert!((end[0] - 24.0).abs() < 0.01, "end {end:?}");
1311            }
1312            other => panic!("expected a linear gradient paint, got {other:?}"),
1313        }
1314    }
1315
1316    /// A gradient **stroke** is a gradient too — and its coordinates are
1317    /// re-based onto the stroke's expanded bounds, so the ramp lands where the
1318    /// identical fill gradient would. (Strokes were solid-only before.)
1319    #[test]
1320    fn a_gradient_stroke_is_rebased_onto_the_expanded_stroke_bounds() {
1321        let svg = r##"<svg viewBox="0 0 24 24">
1322            <linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="24" y2="0">
1323              <stop offset="0" stop-color="#FF0000"/>
1324              <stop offset="1" stop-color="#0000FF"/>
1325            </linearGradient>
1326            <rect x="2" y="2" width="20" height="20" fill="none" stroke="url(#g)" stroke-width="4"/>
1327        </svg>"##;
1328        let mut tree = WidgetTree::new();
1329        tree.add(
1330            IconWidget::from_svg(svg)
1331                .icon_size(24.0)
1332                .mode(IconMode::FullColor)
1333                .color(Color::BLACK),
1334        );
1335        tree.layout(SizeProposal::exact(24.0, 24.0));
1336        let frame = tree.render();
1337
1338        let entry = &frame.paths[0];
1339        assert!(entry.stroke_style.width > 0.0, "must be a stroke");
1340        match &entry.paint_data {
1341            teksilo_canvas::PaintData::LinearGradient { start, end, .. } => {
1342                // The ramp runs 0→24 in user space; the path sits at x=2, and the
1343                // stroke expands its bounds by the 4 dp width — so relative to the
1344                // rect the shader normalizes against, x=0 lands at +2 and x=24 at
1345                // +26. Un-rebased it would read -2 / +22 and the gradient would sit
1346                // visibly offset from the geometry.
1347                assert!((start[0] - 2.0).abs() < 0.05, "start {start:?}");
1348                assert!((end[0] - 26.0).abs() < 0.05, "end {end:?}");
1349            }
1350            other => panic!("expected a linear gradient stroke, got {other:?}"),
1351        }
1352    }
1353
1354    /// The widget's alpha attenuates full-color artwork without replacing its
1355    /// colors (what `IconMode::FullColor` promises for a raster, now true for
1356    /// vectors too).
1357    #[test]
1358    fn the_widget_alpha_dims_full_color_artwork() {
1359        let svg =
1360            r##"<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="#FF0000"/></svg>"##;
1361        let mut tree = WidgetTree::new();
1362        tree.add(
1363            IconWidget::from_svg(svg)
1364                .icon_size(24.0)
1365                .mode(IconMode::FullColor)
1366                .color(Color::new(0.0, 0.0, 0.0, 0.5)),
1367        );
1368        tree.layout(SizeProposal::exact(24.0, 24.0));
1369        let frame = tree.render();
1370        let c = frame.paths[0].color;
1371        assert_eq!([c[0], c[1], c[2]], [1.0, 0.0, 0.0], "the red must survive");
1372        assert!((c[3] - 0.5).abs() < 1e-5, "…at half alpha, got {}", c[3]);
1373    }
1374}