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