Skip to main content

frust_widgets/
icon.rs

1//! The `Icon` widget: paints a vector icon from `kurbo::BezPath` path data,
2//! scaled from its design box to a requested logical size.
3//!
4//! [`icon`] takes any [`IconData`] — either a generated [`IconSource`] from the
5//! vendored Material Symbols set ([`crate::icons`]) or a user-built
6//! [`BezPath`](kurbo::BezPath) via [`IconData::from_path`] — and paints it as a
7//! single filled path via [`PaintScene::fill_path`]. Material Symbols outlines
8//! are pre-flattened fills, so no stroking is involved.
9//!
10//! # Color resolution
11//!
12//! An icon's color follows the framework's **explicit builder value > theme >
13//! fallback** precedence (see `docs/CODE_STANDARDS.md`'s Theming conventions):
14//! an explicit [`IconView::color`] always wins; otherwise the default resolves
15//! `on_surface` from the threaded [`Theme`], falling back to [`DEFAULT_COLOR`]
16//! when no theme is present (bare-core tests, pre-theme apps).
17//!
18//! # Path parsing
19//!
20//! A generated [`IconSource`] carries its geometry as an SVG path `d` string,
21//! parsed via [`BezPath::from_svg`](kurbo::BezPath::from_svg) at widget
22//! build/rebuild and cached on the widget. A parse failure is a **wiring bug**
23//! (a malformed generated entry), not a runtime-data condition, so it panics
24//! with a message saying so — per `docs/CODE_STANDARDS.md`.
25
26use std::sync::Arc;
27
28use frust_core::accesskit::Role;
29use frust_core::{
30    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
31    Widget,
32};
33use frust_theme::Theme;
34use kurbo::{Affine, BezPath, Size};
35use peniko::{Brush, Color};
36
37/// Default icon side length, in logical px (Material Symbols' 24×24 design box
38/// at 1:1).
39const DEFAULT_SIZE: f64 = 24.0;
40
41/// Unthemed default icon color (matches the M3 light `on_surface` role, so a
42/// pre-theme app renders the same as the themed light default). A theme
43/// resolves this from `scheme().on_surface`.
44const DEFAULT_COLOR: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
45
46/// A generated icon: SVG path `d` data plus the side length of its square
47/// design box.
48///
49/// This is the format `scripts/gen_icons.py` emits into [`crate::icons`] — one
50/// `pub const <NAME>: IconSource = IconSource { d: "...", design: 24.0 };`
51/// entry per glyph. Convert it into an [`IconData`] to paint it (via the `From`
52/// impls, or simply by passing it to [`icon`], which takes `impl Into<IconData>`).
53#[derive(Clone, Copy, Debug)]
54pub struct IconSource {
55    /// The SVG `d` path attribute, in the design box's coordinate space
56    /// (y-down, `0..design` on each axis).
57    pub d: &'static str,
58    /// The side length of the square design box `d` is authored against.
59    pub design: f64,
60}
61
62/// A user-supplied path plus its design box, shared behind an `Arc` so an
63/// [`IconData`] clone is cheap.
64#[derive(Debug)]
65struct PathGeom {
66    path: BezPath,
67    design: f64,
68}
69
70/// The two shapes an [`IconData`] can hold.
71#[derive(Clone, Debug)]
72enum IconRepr {
73    /// A generated static source: parse its `d` lazily at build/rebuild.
74    Svg { d: &'static str, design: f64 },
75    /// A user-built path + design box, already in `kurbo` form.
76    Path(Arc<PathGeom>),
77}
78
79/// A cheap-clone handle around an icon's path data and its design box.
80///
81/// Construct one from a generated [`IconSource`] (via [`From`], or implicitly
82/// through [`icon`]) or from any [`BezPath`](kurbo::BezPath) via
83/// [`IconData::from_path`] — the composability seam that lets an app supply its
84/// own icons without going through the generated set.
85#[derive(Clone, Debug)]
86pub struct IconData {
87    repr: IconRepr,
88}
89
90impl IconData {
91    /// Build icon data from an arbitrary `kurbo::BezPath` and the side length of
92    /// the square design box it was authored against.
93    ///
94    /// This is the composability requirement: an app can paint any vector shape
95    /// as an icon, not just the vendored Material Symbols set. The path is
96    /// stored in an `Arc`, so cloning the resulting [`IconData`] (as a build
97    /// closure does every frame) is a refcount bump, never a copy of the geometry.
98    pub fn from_path(path: BezPath, design_size: f64) -> Self {
99        Self {
100            repr: IconRepr::Path(Arc::new(PathGeom {
101                path,
102                design: design_size,
103            })),
104        }
105    }
106
107    /// Resolve this handle into a concrete `(design-space path, design box)`
108    /// pair.
109    ///
110    /// For a generated [`IconSource`] this parses its `d` string via
111    /// [`BezPath::from_svg`](kurbo::BezPath::from_svg); a parse failure is a
112    /// wiring bug (a malformed generated entry) and panics. For a user path it
113    /// clones the shared `BezPath` (cheap for the small paths icons are).
114    ///
115    /// Public because out-of-tree design systems (the design-system plugin
116    /// tier) paint icons through it — `IconData` without `resolve` has no
117    /// reachable geometry outside this crate.
118    pub fn resolve(&self) -> (BezPath, f64) {
119        match &self.repr {
120            IconRepr::Svg { d, design } => {
121                let path = BezPath::from_svg(d).unwrap_or_else(|e| {
122                    panic!(
123                        "icon SVG path failed to parse (wiring bug — a malformed \
124                         generated IconSource): {e}"
125                    )
126                });
127                (path, *design)
128            }
129            IconRepr::Path(geom) => (geom.path.clone(), geom.design),
130        }
131    }
132
133    /// Whether `self` and `other` name the same icon geometry, cheaply — an
134    /// `Arc` pointer check for user paths, a `d`/design comparison for generated
135    /// sources. Lets [`IconView::rebuild`] skip re-parsing an unchanged icon.
136    ///
137    /// Public for the same reason as [`IconData::resolve`]: out-of-tree design
138    /// systems need the cheap-identity check to skip re-parsing on rebuild.
139    pub fn same(&self, other: &IconData) -> bool {
140        match (&self.repr, &other.repr) {
141            (
142                IconRepr::Svg {
143                    d: a,
144                    design: design_a,
145                },
146                IconRepr::Svg {
147                    d: b,
148                    design: design_b,
149                },
150            ) => a == b && design_a == design_b,
151            (IconRepr::Path(a), IconRepr::Path(b)) => Arc::ptr_eq(a, b),
152            _ => false,
153        }
154    }
155}
156
157impl From<IconSource> for IconData {
158    fn from(source: IconSource) -> Self {
159        IconData {
160            repr: IconRepr::Svg {
161                d: source.d,
162                design: source.design,
163            },
164        }
165    }
166}
167
168impl From<&IconSource> for IconData {
169    fn from(source: &IconSource) -> Self {
170        IconData::from(*source)
171    }
172}
173
174/// The resolved default icon color: `on_surface` from the threaded theme, or the
175/// [`DEFAULT_COLOR`] fallback when no theme is present.
176fn resolve_default_color(theme: Option<&Theme>) -> Color {
177    match theme {
178        Some(theme) => theme.scheme().on_surface,
179        None => DEFAULT_COLOR,
180    }
181}
182
183/// A declarative icon. See the [module docs](self).
184///
185/// Not generic over app state — an icon carries no callbacks, so like
186/// [`ImageView`](crate::ImageView) it implements `View<State>` for every
187/// `State`.
188pub struct IconView {
189    data: IconData,
190    size: f64,
191    color: Option<Color>,
192    label: Option<String>,
193}
194
195/// Create an icon view over any [`IconData`] source (a generated
196/// [`IconSource`], or a user path via [`IconData::from_path`]), at the default
197/// 24.0 logical size with the theme's `on_surface` color.
198pub fn icon(data: impl Into<IconData>) -> IconView {
199    IconView {
200        data: data.into(),
201        size: DEFAULT_SIZE,
202        color: None,
203        label: None,
204    }
205}
206
207/// PascalCase alias for [`icon`].
208#[allow(non_snake_case)]
209pub fn Icon(data: impl Into<IconData>) -> IconView {
210    icon(data)
211}
212
213impl IconView {
214    /// Set the icon's side length, in logical px (default 24.0). The design box
215    /// is scaled uniformly to this size at paint time.
216    pub fn size(mut self, size: f64) -> Self {
217        self.size = size;
218        self
219    }
220
221    /// Set an explicit icon color, overriding the theme's default `on_surface`
222    /// (explicit builder value wins — see the [module docs](self)).
223    pub fn color(mut self, color: Color) -> Self {
224        self.color = Some(color);
225        self
226    }
227
228    /// Attach an accessible label, contributing a [`Role::Image`] semantics node
229    /// (an unlabelled icon is decorative and reports nothing).
230    pub fn label(mut self, label: impl Into<String>) -> Self {
231        self.label = Some(label.into());
232        self
233    }
234}
235
236impl<State: 'static> View<State> for IconView {
237    type Element = IconWidget;
238
239    fn build(&self, _ctx: &mut BuildCtx<'_>) -> IconWidget {
240        let (base_path, design) = self.data.resolve();
241        IconWidget {
242            data: self.data.clone(),
243            base_path,
244            design,
245            size: self.size,
246            // Natural-size default until `layout` runs — a widget painted
247            // before its first `layout` pass (or a design-system consumer
248            // that skips it, e.g. this module's own paint-only tests) sees
249            // the unconstrained `size×size` box, matching pre-fix behavior.
250            laid_out_size: Size::new(self.size, self.size),
251            color: self.color,
252            label: self.label.clone(),
253        }
254    }
255
256    fn rebuild(
257        &self,
258        prev: &Self,
259        element: &mut IconWidget,
260        _ctx: &mut BuildCtx<'_>,
261    ) -> ChangeFlags {
262        let mut flags = ChangeFlags::NONE;
263        if !prev.data.same(&self.data) {
264            // Only a genuine icon change re-parses; an every-frame rebuild that
265            // re-supplies the same source keeps the cached path.
266            let (base_path, design) = self.data.resolve();
267            element.data = self.data.clone();
268            element.base_path = base_path;
269            element.design = design;
270            flags |= ChangeFlags::PAINT;
271        }
272        if prev.size != self.size {
273            element.size = self.size;
274            // Re-seed the natural-size default (see `build`'s comment); the
275            // LAYOUT flag below re-measures it against real constraints
276            // before the next paint in the normal pipeline, but a caller
277            // that paints without laying out first still sees the new size.
278            element.laid_out_size = Size::new(self.size, self.size);
279            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
280        }
281        if prev.color != self.color {
282            element.color = self.color;
283            flags |= ChangeFlags::PAINT;
284        }
285        if prev.label != self.label {
286            // Semantics are recomputed each frame from the widget, so the label
287            // just needs to be adopted — no layout/paint dirtiness.
288            element.label = self.label.clone();
289        }
290        flags
291    }
292}
293
294/// The retained widget for an [`IconView`].
295pub struct IconWidget {
296    /// Retained for `rebuild`'s cheap same-icon check (avoids re-parsing).
297    data: IconData,
298    /// The parsed path, in its design-box coordinate space (`0..design`).
299    base_path: BezPath,
300    /// The side length of `base_path`'s square design box.
301    design: f64,
302    size: f64,
303    /// The box `layout` last resolved (`bc.constrain(size×size)`) — what
304    /// `paint` actually fills. Matches `size×size` exactly under a loose
305    /// constraint (the natural, overwhelmingly common case); diverges under a
306    /// tight constraint the requested `size` doesn't satisfy.
307    laid_out_size: Size,
308    color: Option<Color>,
309    label: Option<String>,
310}
311
312impl Widget for IconWidget {
313    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
314        // A fixed size×size box, clamped into the incoming constraints (a tight
315        // constraint — e.g. inside a SizedBox — wins outright).
316        let laid_out = bc.constrain(Size::new(self.size, self.size));
317        self.laid_out_size = laid_out;
318        laid_out
319    }
320
321    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
322        let color = self
323            .color
324            .unwrap_or_else(|| resolve_default_color(Theme::from_paint_ctx(ctx)));
325        // Scale the design box to fill the LAID-OUT box (min-side scale, so a
326        // non-square laid-out box never overdraws either axis), then center
327        // the scaled glyph inside it. Under a loose constraint (the common
328        // case) `laid_out_size == size×size`, so `scale == size/design` and
329        // the centering offset is exactly zero — byte-identical to the
330        // pre-fix, size-only scale. `design` is always > 0 for a real icon;
331        // guard against a degenerate design box just in case.
332        let scale = if self.design > 0.0 {
333            self.laid_out_size.width.min(self.laid_out_size.height) / self.design
334        } else {
335            1.0
336        };
337        let glyph_side = self.design * scale;
338        let dx = (self.laid_out_size.width - glyph_side) / 2.0;
339        let dy = (self.laid_out_size.height - glyph_side) / 2.0;
340        let scaled = Affine::translate((dx, dy)) * Affine::scale(scale) * self.base_path.clone();
341        scene.fill_path(ctx.origin(), &scaled, &Brush::Solid(color));
342    }
343
344    fn semantics(&self, ctx: &mut SemanticsCtx) {
345        // An unlabelled icon is decorative and contributes nothing; a labelled
346        // one is a single Image node carrying its accessible name.
347        if let Some(label) = &self.label {
348            ctx.push_node(Role::Image, |node| {
349                node.set_label(label.as_str());
350            });
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use frust_core::BuildCtx;
359    use kurbo::{Point, Rect, Shape};
360
361    /// The design-box side length every vendored Material Symbols icon is
362    /// authored against (a 24×24 viewBox).
363    const MATERIAL_DESIGN_BOX: f64 = 24.0;
364
365    /// A 24×24 design-box square, as a user-supplied path with a known bounding
366    /// box (so a scale check is deterministic regardless of a real glyph's
367    /// extent).
368    fn square_data() -> IconData {
369        let path = Rect::new(0.0, 0.0, MATERIAL_DESIGN_BOX, MATERIAL_DESIGN_BOX).to_path(0.1);
370        IconData::from_path(path, MATERIAL_DESIGN_BOX)
371    }
372
373    fn build(view: &IconView) -> IconWidget {
374        let mut counter = 0u64;
375        <IconView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
376    }
377
378    /// A recording [`PaintScene`] capturing every `fill_path`'s origin, the
379    /// filled path's bounding box, and its solid color.
380    #[derive(Default)]
381    struct RecordingScene {
382        fills: Vec<(Point, Rect, Color)>,
383    }
384
385    impl PaintScene for RecordingScene {
386        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
387        fn draw_text(&mut self, _o: Point, _t: &str) {}
388        fn fill_path(&mut self, origin: Point, path: &BezPath, brush: &Brush) {
389            let color = match brush {
390                Brush::Solid(c) => *c,
391                _ => Color::TRANSPARENT,
392            };
393            self.fills.push((origin, path.bounding_box(), color));
394        }
395    }
396
397    fn paint_rec(w: &mut IconWidget, origin: Point, theme: Option<&Theme>) -> RecordingScene {
398        let mut rec = RecordingScene::default();
399        let mut ctx = match theme {
400            Some(t) => PaintCtx::new(origin, Size::new(w.size, w.size)).with_theme(t),
401            None => PaintCtx::new(origin, Size::new(w.size, w.size)),
402        };
403        w.paint(&mut ctx, &mut rec);
404        rec
405    }
406
407    // -- layout ------------------------------------------------------------
408
409    #[test]
410    fn loose_constraints_use_the_requested_size() {
411        let view = icon(super::super::icons::HOME).size(28.0);
412        let mut w = build(&view);
413        let mut lctx = LayoutCtx::new();
414        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
415        assert_eq!(size, Size::new(28.0, 28.0));
416    }
417
418    #[test]
419    fn tight_constraints_win_over_the_requested_size() {
420        let view = icon(super::super::icons::HOME).size(28.0);
421        let mut w = build(&view);
422        let mut lctx = LayoutCtx::new();
423        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(40.0, 40.0)));
424        assert_eq!(size, Size::new(40.0, 40.0));
425    }
426
427    #[test]
428    fn default_size_is_24() {
429        let view = icon(super::super::icons::HOME);
430        let mut w = build(&view);
431        let mut lctx = LayoutCtx::new();
432        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
433        assert_eq!(size, Size::new(24.0, 24.0));
434    }
435
436    // -- paint / scaling ----------------------------------------------------
437
438    #[test]
439    fn paint_scales_the_design_box_to_the_requested_size() {
440        // A 24×24 design box painted at size 48 should fill a 0..48 box, offset
441        // by the paint origin.
442        let view = icon(square_data()).size(48.0);
443        let mut w = build(&view);
444        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
445        assert_eq!(rec.fills.len(), 1);
446        let (origin, bbox, _) = rec.fills[0];
447        assert_eq!(origin, Point::new(3.0, 5.0));
448        assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
449        assert!((bbox.height() - 48.0).abs() < 1e-6);
450        // The path itself is in local space (origin applied by the scene).
451        assert!((bbox.x0 - 0.0).abs() < 1e-6);
452        assert!((bbox.y0 - 0.0).abs() < 1e-6);
453    }
454
455    #[test]
456    fn paint_natural_size_after_layout_is_unchanged() {
457        // Guards the common case: laying out under a loose constraint (the
458        // overwhelmingly common shape) resolves to size×size, and paint must
459        // still fill exactly that box with zero centering offset — the same
460        // output as `paint_scales_the_design_box_to_the_requested_size`
461        // above, but with `layout` actually run first.
462        let view = icon(square_data()).size(48.0);
463        let mut w = build(&view);
464        let mut lctx = LayoutCtx::new();
465        let laid_out = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
466        assert_eq!(laid_out, Size::new(48.0, 48.0));
467        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
468        let (origin, bbox, _) = rec.fills[0];
469        assert_eq!(origin, Point::new(3.0, 5.0));
470        assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
471        assert!((bbox.height() - 48.0).abs() < 1e-6);
472        assert!((bbox.x0 - 0.0).abs() < 1e-6);
473        assert!((bbox.y0 - 0.0).abs() < 1e-6);
474    }
475
476    #[test]
477    fn paint_fills_a_tight_larger_box_centered() {
478        // Tight-LARGER (G12, fab_menu's collapsed trigger): a 34×34 tight
479        // constraint around a 24-design icon. Pre-fix, `paint` scaled to the
480        // VIEW's configured size (24) and drew at the box origin, leaving
481        // the glyph off-center by (34-24)/2 = 5dp toward the top-left. Fixed
482        // paint must fill the laid-out 34×34 box instead.
483        let view = icon(square_data()); // default size 24.0, design 24.0
484        let mut w = build(&view);
485        let mut lctx = LayoutCtx::new();
486        let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(34.0, 34.0)));
487        assert_eq!(laid_out, Size::new(34.0, 34.0));
488        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
489        let (origin, bbox, _) = rec.fills[0];
490        assert_eq!(origin, Point::new(3.0, 5.0));
491        assert!(
492            (bbox.width() - 34.0).abs() < 1e-6,
493            "glyph must fill the laid-out box (34), not the requested size (24); got {}",
494            bbox.width()
495        );
496        assert!((bbox.height() - 34.0).abs() < 1e-6);
497        // Centered: a square design box scaled to fill a square laid-out box
498        // needs zero translation — the scaled path starts flush at 0..34.
499        assert!((bbox.x0 - 0.0).abs() < 1e-6);
500        assert!((bbox.y0 - 0.0).abs() < 1e-6);
501    }
502
503    #[test]
504    fn paint_fills_a_tight_smaller_box_without_overdraw() {
505        // Tight-SMALLER: a 16×16 tight constraint around a 24-design icon —
506        // the latent overdraw bug (same root cause as G12, never
507        // device-observed since no shipped consumer constrains an icon
508        // smaller than its requested size, but broken all the same). Pre-fix
509        // `paint` drew the full 24×24 glyph regardless, overdrawing past the
510        // laid-out box's edges.
511        let view = icon(square_data()); // default size 24.0, design 24.0
512        let mut w = build(&view);
513        let mut lctx = LayoutCtx::new();
514        let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(16.0, 16.0)));
515        assert_eq!(laid_out, Size::new(16.0, 16.0));
516        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
517        let (origin, bbox, _) = rec.fills[0];
518        assert_eq!(origin, Point::new(3.0, 5.0));
519        assert!(
520            (bbox.width() - 16.0).abs() < 1e-6,
521            "glyph must shrink to the laid-out box (16), not overdraw at the \
522             requested size (24); got {}",
523            bbox.width()
524        );
525        assert!((bbox.height() - 16.0).abs() < 1e-6);
526        assert!((bbox.x0 - 0.0).abs() < 1e-6);
527        assert!((bbox.y0 - 0.0).abs() < 1e-6);
528    }
529
530    #[test]
531    fn user_supplied_path_renders() {
532        // The composability requirement: a hand-built BezPath paints as an icon.
533        let mut path = BezPath::new();
534        path.move_to((0.0, 0.0));
535        path.line_to((10.0, 0.0));
536        path.line_to((10.0, 10.0));
537        path.close_path();
538        let view = icon(IconData::from_path(path, 10.0)).size(20.0);
539        let mut w = build(&view);
540        let rec = paint_rec(&mut w, Point::ZERO, None);
541        assert_eq!(rec.fills.len(), 1, "a user path emits one filled path");
542        let (_, bbox, _) = rec.fills[0];
543        // 10-wide design box scaled 2x -> 20 wide.
544        assert!((bbox.width() - 20.0).abs() < 1e-6);
545    }
546
547    // -- color resolution ---------------------------------------------------
548
549    #[test]
550    fn explicit_color_wins_over_theme() {
551        let explicit = Color::from_rgb8(0xAB, 0xCD, 0xEF);
552        let view = icon(square_data()).color(explicit);
553        let mut w = build(&view);
554        let theme = Theme::neutral();
555        let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
556        assert_eq!(rec.fills[0].2, explicit);
557    }
558
559    #[test]
560    fn themed_default_resolves_on_surface() {
561        let view = icon(square_data());
562        let mut w = build(&view);
563        let theme = Theme::neutral();
564        let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
565        assert_eq!(rec.fills[0].2, theme.scheme().on_surface);
566    }
567
568    #[test]
569    fn unthemed_default_uses_fallback_constant() {
570        let view = icon(square_data());
571        let mut w = build(&view);
572        let rec = paint_rec(&mut w, Point::ZERO, None);
573        assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
574    }
575
576    // -- generated set ------------------------------------------------------
577
578    #[test]
579    fn every_generated_icon_source_parses() {
580        for source in super::super::icons::ALL {
581            let data: IconData = (*source).into();
582            let (path, design) = data.resolve();
583            assert_eq!(design, MATERIAL_DESIGN_BOX);
584            assert!(
585                !path.elements().is_empty(),
586                "generated icon `{}` parsed to an empty path",
587                source.d
588            );
589        }
590    }
591
592    #[test]
593    fn the_icon_button_arc_additions_exist_and_are_in_all() {
594        // The two directional arrows the caret precedent doesn't cover, a
595        // third pointing forward, and a copy affordance — all four are
596        // present in the generated set and enumerable via `ALL`, not just
597        // reachable by name.
598        let additions = super::super::icons::ALL;
599        for (name, d) in [
600            ("ARROW_UPWARD", super::super::icons::ARROW_UPWARD.d),
601            ("ARROW_DOWNWARD", super::super::icons::ARROW_DOWNWARD.d),
602            ("ARROW_FORWARD", super::super::icons::ARROW_FORWARD.d),
603            ("CONTENT_COPY", super::super::icons::CONTENT_COPY.d),
604        ] {
605            assert!(
606                additions.iter().any(|source| source.d == d),
607                "icons::{name} is missing from icons::ALL"
608            );
609        }
610    }
611
612    #[test]
613    fn generated_home_icon_is_usable_end_to_end() {
614        // `icon(icons::HOME).size(28.0)`
615        // builds and paints in a bare-core (no-theme) test.
616        let view = icon(super::super::icons::HOME).size(28.0);
617        let mut w = build(&view);
618        let rec = paint_rec(&mut w, Point::ZERO, None);
619        assert_eq!(rec.fills.len(), 1);
620        assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
621    }
622
623    // -- semantics ----------------------------------------------------------
624
625    /// Slack allowed on either side of the `0..MATERIAL_DESIGN_BOX` design box
626    /// when checking a generated icon's parsed geometry.
627    ///
628    /// `scripts/gen_icons.py`'s `normalize_path_d` renormalizes each glyph's
629    /// coordinates from its source SVG's `viewBox` into the `0..24` box via an
630    /// affine transform, but a handful of upstream Material Symbols exports
631    /// author raw path data that itself extends a little past their own
632    /// declared `viewBox` (optical overshoot at a glyph's rounded tips) — an
633    /// independent Python bbox walker confirmed that, for a handful of icons,
634    /// a few percent past the box is genuine upstream geometry, not a
635    /// transform bug. A direct measurement of the 41 icons
636    /// currently vendored in `icons/mod.rs` (this test, run standalone) finds
637    /// zero icons actually bleeding past `0..24` today — every glyph's bbox
638    /// currently lands strictly inside — so this constant is pure headroom:
639    /// wide enough (a "few percent" of 24px is well under 1px) to tolerate a
640    /// future regeneration reintroducing that documented upstream overshoot
641    /// without needing a bump, while still tight enough to catch a real
642    /// transform regression (e.g. a wrong `viewBox` scale/offset putting a
643    /// whole glyph noticeably outside the box).
644    const BBOX_TOLERANCE: f64 = 2.0;
645
646    #[test]
647    fn every_generated_icon_bbox_lands_in_design_box() {
648        let mut max_bleed: f64 = f64::NEG_INFINITY;
649        let mut worst: Option<&str> = None;
650        for source in super::super::icons::ALL {
651            let path = BezPath::from_svg(source.d).unwrap_or_else(|e| {
652                panic!("icon SVG path failed to parse: {e} (d={:?})", source.d)
653            });
654            let bbox = path.bounding_box();
655            let design = source.design;
656            assert!(
657                bbox.x0 >= -BBOX_TOLERANCE
658                    && bbox.y0 >= -BBOX_TOLERANCE
659                    && bbox.x1 <= design + BBOX_TOLERANCE
660                    && bbox.y1 <= design + BBOX_TOLERANCE,
661                "icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) lands outside the \
662                 {design}x{design} design box beyond the {BBOX_TOLERANCE}px \
663                 tolerance",
664                source.d,
665                bbox.x0,
666                bbox.y0,
667                bbox.x1,
668                bbox.y1
669            );
670            // Non-degenerate check: a zero (or near-zero) scale bug would
671            // collapse every glyph's bbox to a point/sliver.
672            assert!(
673                bbox.width() + bbox.height() > design / 4.0,
674                "icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) is degenerately \
675                 small for a {design}x{design} design box — suspect a \
676                 zero-scale bug",
677                source.d,
678                bbox.x0,
679                bbox.y0,
680                bbox.x1,
681                bbox.y1
682            );
683            let bleed = [-bbox.x0, -bbox.y0, bbox.x1 - design, bbox.y1 - design, 0.0]
684                .into_iter()
685                .fold(f64::NEG_INFINITY, f64::max);
686            if bleed > max_bleed {
687                max_bleed = bleed;
688                worst = Some(source.d);
689            }
690        }
691        // Informational only (not asserted): surfaces the worst observed
692        // bleed when run with `-- --nocapture`, for eyeballing against
693        // BBOX_TOLERANCE's headroom.
694        if let Some(d) = worst {
695            println!("max bbox bleed observed: {max_bleed:.4}px (icon d={d:?})");
696        }
697    }
698
699    #[test]
700    fn rebuild_adopts_a_new_size_and_flags_layout() {
701        let mut counter = 0u64;
702        let prev = icon(square_data()).size(24.0);
703        let mut w = <IconView as View<()>>::build(&prev, &mut BuildCtx::new(&mut counter));
704        let next = icon(square_data()).size(32.0);
705        let flags =
706            <IconView as View<()>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
707        assert_eq!(w.size, 32.0);
708        assert!(flags.needs_layout());
709        assert!(flags.needs_paint());
710    }
711}