frust-widgets 0.5.1

Baseline widget set for Frust: text, buttons, images, flex, stack and scroll containers, and the authoring helpers for custom widgets.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
//! The `Icon` widget: paints a vector icon from `kurbo::BezPath` path data,
//! scaled from its design box to a requested logical size.
//!
//! [`icon`] takes any [`IconData`] — either a generated [`IconSource`] from the
//! vendored Material Symbols set ([`crate::icons`]) or a user-built
//! [`BezPath`](kurbo::BezPath) via [`IconData::from_path`] — and paints it as a
//! single filled path via [`PaintScene::fill_path`]. Material Symbols outlines
//! are pre-flattened fills, so no stroking is involved.
//!
//! # Color resolution
//!
//! An icon's color follows the framework's **explicit builder value > theme >
//! fallback** precedence (see `docs/CODE_STANDARDS.md`'s Theming conventions):
//! an explicit [`IconView::color`] always wins; otherwise the default resolves
//! `on_surface` from the threaded [`Theme`], falling back to [`DEFAULT_COLOR`]
//! when no theme is present (bare-core tests, pre-theme apps).
//!
//! # Path parsing
//!
//! A generated [`IconSource`] carries its geometry as an SVG path `d` string,
//! parsed via [`BezPath::from_svg`](kurbo::BezPath::from_svg) at widget
//! build/rebuild and cached on the widget. A parse failure is a **wiring bug**
//! (a malformed generated entry), not a runtime-data condition, so it panics
//! with a message saying so — per `docs/CODE_STANDARDS.md`.

use std::sync::Arc;

use frust_core::accesskit::Role;
use frust_core::{
    BoxConstraints, BuildCtx, ChangeFlags, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, View,
    Widget,
};
use frust_theme::Theme;
use kurbo::{Affine, BezPath, Size};
use peniko::{Brush, Color};

/// Default icon side length, in logical px (Material Symbols' 24×24 design box
/// at 1:1).
const DEFAULT_SIZE: f64 = 24.0;

/// Unthemed default icon color (matches the M3 light `on_surface` role, so a
/// pre-theme app renders the same as the themed light default). A theme
/// resolves this from `scheme().on_surface`.
const DEFAULT_COLOR: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);

/// A generated icon: SVG path `d` data plus the side length of its square
/// design box.
///
/// This is the format `scripts/gen_icons.py` emits into [`crate::icons`] — one
/// `pub const <NAME>: IconSource = IconSource { d: "...", design: 24.0 };`
/// entry per glyph. Convert it into an [`IconData`] to paint it (via the `From`
/// impls, or simply by passing it to [`icon`], which takes `impl Into<IconData>`).
#[derive(Clone, Copy, Debug)]
pub struct IconSource {
    /// The SVG `d` path attribute, in the design box's coordinate space
    /// (y-down, `0..design` on each axis).
    pub d: &'static str,
    /// The side length of the square design box `d` is authored against.
    pub design: f64,
}

/// A user-supplied path plus its design box, shared behind an `Arc` so an
/// [`IconData`] clone is cheap.
#[derive(Debug)]
struct PathGeom {
    path: BezPath,
    design: f64,
}

/// The two shapes an [`IconData`] can hold.
#[derive(Clone, Debug)]
enum IconRepr {
    /// A generated static source: parse its `d` lazily at build/rebuild.
    Svg { d: &'static str, design: f64 },
    /// A user-built path + design box, already in `kurbo` form.
    Path(Arc<PathGeom>),
}

/// A cheap-clone handle around an icon's path data and its design box.
///
/// Construct one from a generated [`IconSource`] (via [`From`], or implicitly
/// through [`icon`]) or from any [`BezPath`](kurbo::BezPath) via
/// [`IconData::from_path`] — the composability seam that lets an app supply its
/// own icons without going through the generated set.
#[derive(Clone, Debug)]
pub struct IconData {
    repr: IconRepr,
}

impl IconData {
    /// Build icon data from an arbitrary `kurbo::BezPath` and the side length of
    /// the square design box it was authored against.
    ///
    /// This is the composability requirement: an app can paint any vector shape
    /// as an icon, not just the vendored Material Symbols set. The path is
    /// stored in an `Arc`, so cloning the resulting [`IconData`] (as `app_logic`
    /// does every frame) is a refcount bump, never a copy of the geometry.
    pub fn from_path(path: BezPath, design_size: f64) -> Self {
        Self {
            repr: IconRepr::Path(Arc::new(PathGeom {
                path,
                design: design_size,
            })),
        }
    }

    /// Resolve this handle into a concrete `(design-space path, design box)`
    /// pair.
    ///
    /// For a generated [`IconSource`] this parses its `d` string via
    /// [`BezPath::from_svg`](kurbo::BezPath::from_svg); a parse failure is a
    /// wiring bug (a malformed generated entry) and panics. For a user path it
    /// clones the shared `BezPath` (cheap for the small paths icons are).
    ///
    /// Public because out-of-tree design systems (the design-system plugin
    /// tier) paint icons through it — `IconData` without `resolve` has no
    /// reachable geometry outside this crate.
    pub fn resolve(&self) -> (BezPath, f64) {
        match &self.repr {
            IconRepr::Svg { d, design } => {
                let path = BezPath::from_svg(d).unwrap_or_else(|e| {
                    panic!(
                        "icon SVG path failed to parse (wiring bug — a malformed \
                         generated IconSource): {e}"
                    )
                });
                (path, *design)
            }
            IconRepr::Path(geom) => (geom.path.clone(), geom.design),
        }
    }

    /// Whether `self` and `other` name the same icon geometry, cheaply — an
    /// `Arc` pointer check for user paths, a `d`/design comparison for generated
    /// sources. Lets [`IconView::rebuild`] skip re-parsing an unchanged icon.
    ///
    /// Public for the same reason as [`IconData::resolve`]: out-of-tree design
    /// systems need the cheap-identity check to skip re-parsing on rebuild.
    pub fn same(&self, other: &IconData) -> bool {
        match (&self.repr, &other.repr) {
            (
                IconRepr::Svg {
                    d: a,
                    design: design_a,
                },
                IconRepr::Svg {
                    d: b,
                    design: design_b,
                },
            ) => a == b && design_a == design_b,
            (IconRepr::Path(a), IconRepr::Path(b)) => Arc::ptr_eq(a, b),
            _ => false,
        }
    }
}

impl From<IconSource> for IconData {
    fn from(source: IconSource) -> Self {
        IconData {
            repr: IconRepr::Svg {
                d: source.d,
                design: source.design,
            },
        }
    }
}

impl From<&IconSource> for IconData {
    fn from(source: &IconSource) -> Self {
        IconData::from(*source)
    }
}

/// The resolved default icon color: `on_surface` from the threaded theme, or the
/// [`DEFAULT_COLOR`] fallback when no theme is present.
fn resolve_default_color(theme: Option<&Theme>) -> Color {
    match theme {
        Some(theme) => theme.scheme().on_surface,
        None => DEFAULT_COLOR,
    }
}

/// A declarative icon. See the [module docs](self).
///
/// Not generic over app state — an icon carries no callbacks, so like
/// [`ImageView`](crate::ImageView) it implements `View<State>` for every
/// `State`.
pub struct IconView {
    data: IconData,
    size: f64,
    color: Option<Color>,
    label: Option<String>,
}

/// Create an icon view over any [`IconData`] source (a generated
/// [`IconSource`], or a user path via [`IconData::from_path`]), at the default
/// 24.0 logical size with the theme's `on_surface` color.
pub fn icon(data: impl Into<IconData>) -> IconView {
    IconView {
        data: data.into(),
        size: DEFAULT_SIZE,
        color: None,
        label: None,
    }
}

/// PascalCase alias for [`icon`].
#[allow(non_snake_case)]
pub fn Icon(data: impl Into<IconData>) -> IconView {
    icon(data)
}

impl IconView {
    /// Set the icon's side length, in logical px (default 24.0). The design box
    /// is scaled uniformly to this size at paint time.
    pub fn size(mut self, size: f64) -> Self {
        self.size = size;
        self
    }

    /// Set an explicit icon color, overriding the theme's default `on_surface`
    /// (explicit builder value wins — see the [module docs](self)).
    pub fn color(mut self, color: Color) -> Self {
        self.color = Some(color);
        self
    }

    /// Attach an accessible label, contributing a [`Role::Image`] semantics node
    /// (an unlabelled icon is decorative and reports nothing).
    pub fn label(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }
}

impl<State: 'static> View<State> for IconView {
    type Element = IconWidget;

    fn build(&self, _ctx: &mut BuildCtx<'_>) -> IconWidget {
        let (base_path, design) = self.data.resolve();
        IconWidget {
            data: self.data.clone(),
            base_path,
            design,
            size: self.size,
            // Natural-size default until `layout` runs — a widget painted
            // before its first `layout` pass (or a design-system consumer
            // that skips it, e.g. this module's own paint-only tests) sees
            // the unconstrained `size×size` box, matching pre-fix behavior.
            laid_out_size: Size::new(self.size, self.size),
            color: self.color,
            label: self.label.clone(),
        }
    }

    fn rebuild(
        &self,
        prev: &Self,
        element: &mut IconWidget,
        _ctx: &mut BuildCtx<'_>,
    ) -> ChangeFlags {
        let mut flags = ChangeFlags::NONE;
        if !prev.data.same(&self.data) {
            // Only a genuine icon change re-parses; an every-frame rebuild that
            // re-supplies the same source keeps the cached path.
            let (base_path, design) = self.data.resolve();
            element.data = self.data.clone();
            element.base_path = base_path;
            element.design = design;
            flags |= ChangeFlags::PAINT;
        }
        if prev.size != self.size {
            element.size = self.size;
            // Re-seed the natural-size default (see `build`'s comment); the
            // LAYOUT flag below re-measures it against real constraints
            // before the next paint in the normal pipeline, but a caller
            // that paints without laying out first still sees the new size.
            element.laid_out_size = Size::new(self.size, self.size);
            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
        }
        if prev.color != self.color {
            element.color = self.color;
            flags |= ChangeFlags::PAINT;
        }
        if prev.label != self.label {
            // Semantics are recomputed each frame from the widget, so the label
            // just needs to be adopted — no layout/paint dirtiness.
            element.label = self.label.clone();
        }
        flags
    }
}

/// The retained widget for an [`IconView`].
pub struct IconWidget {
    /// Retained for `rebuild`'s cheap same-icon check (avoids re-parsing).
    data: IconData,
    /// The parsed path, in its design-box coordinate space (`0..design`).
    base_path: BezPath,
    /// The side length of `base_path`'s square design box.
    design: f64,
    size: f64,
    /// The box `layout` last resolved (`bc.constrain(size×size)`) — what
    /// `paint` actually fills. Matches `size×size` exactly under a loose
    /// constraint (the natural, overwhelmingly common case); diverges under a
    /// tight constraint the requested `size` doesn't satisfy.
    laid_out_size: Size,
    color: Option<Color>,
    label: Option<String>,
}

impl Widget for IconWidget {
    fn layout(&mut self, _ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
        // A fixed size×size box, clamped into the incoming constraints (a tight
        // constraint — e.g. inside a SizedBox — wins outright).
        let laid_out = bc.constrain(Size::new(self.size, self.size));
        self.laid_out_size = laid_out;
        laid_out
    }

    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
        let color = self
            .color
            .unwrap_or_else(|| resolve_default_color(Theme::from_paint_ctx(ctx)));
        // Scale the design box to fill the LAID-OUT box (min-side scale, so a
        // non-square laid-out box never overdraws either axis), then center
        // the scaled glyph inside it. Under a loose constraint (the common
        // case) `laid_out_size == size×size`, so `scale == size/design` and
        // the centering offset is exactly zero — byte-identical to the
        // pre-fix, size-only scale. `design` is always > 0 for a real icon;
        // guard against a degenerate design box just in case.
        let scale = if self.design > 0.0 {
            self.laid_out_size.width.min(self.laid_out_size.height) / self.design
        } else {
            1.0
        };
        let glyph_side = self.design * scale;
        let dx = (self.laid_out_size.width - glyph_side) / 2.0;
        let dy = (self.laid_out_size.height - glyph_side) / 2.0;
        let scaled = Affine::translate((dx, dy)) * Affine::scale(scale) * self.base_path.clone();
        scene.fill_path(ctx.origin(), &scaled, &Brush::Solid(color));
    }

    fn semantics(&self, ctx: &mut SemanticsCtx) {
        // An unlabelled icon is decorative and contributes nothing; a labelled
        // one is a single Image node carrying its accessible name.
        if let Some(label) = &self.label {
            ctx.push_node(Role::Image, |node| {
                node.set_label(label.as_str());
            });
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use frust_core::BuildCtx;
    use kurbo::{Point, Rect, Shape};

    /// The design-box side length every vendored Material Symbols icon is
    /// authored against (a 24×24 viewBox).
    const MATERIAL_DESIGN_BOX: f64 = 24.0;

    /// A 24×24 design-box square, as a user-supplied path with a known bounding
    /// box (so a scale check is deterministic regardless of a real glyph's
    /// extent).
    fn square_data() -> IconData {
        let path = Rect::new(0.0, 0.0, MATERIAL_DESIGN_BOX, MATERIAL_DESIGN_BOX).to_path(0.1);
        IconData::from_path(path, MATERIAL_DESIGN_BOX)
    }

    fn build(view: &IconView) -> IconWidget {
        let mut counter = 0u64;
        <IconView as View<()>>::build(view, &mut BuildCtx::new(&mut counter))
    }

    /// A recording [`PaintScene`] capturing every `fill_path`'s origin, the
    /// filled path's bounding box, and its solid color.
    #[derive(Default)]
    struct RecordingScene {
        fills: Vec<(Point, Rect, Color)>,
    }

    impl PaintScene for RecordingScene {
        fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
        fn draw_text(&mut self, _o: Point, _t: &str) {}
        fn fill_path(&mut self, origin: Point, path: &BezPath, brush: &Brush) {
            let color = match brush {
                Brush::Solid(c) => *c,
                _ => Color::TRANSPARENT,
            };
            self.fills.push((origin, path.bounding_box(), color));
        }
    }

    fn paint_rec(w: &mut IconWidget, origin: Point, theme: Option<&Theme>) -> RecordingScene {
        let mut rec = RecordingScene::default();
        let mut ctx = match theme {
            Some(t) => PaintCtx::new(origin, Size::new(w.size, w.size)).with_theme(t),
            None => PaintCtx::new(origin, Size::new(w.size, w.size)),
        };
        w.paint(&mut ctx, &mut rec);
        rec
    }

    // -- layout ------------------------------------------------------------

    #[test]
    fn loose_constraints_use_the_requested_size() {
        let view = icon(super::super::icons::HOME).size(28.0);
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
        assert_eq!(size, Size::new(28.0, 28.0));
    }

    #[test]
    fn tight_constraints_win_over_the_requested_size() {
        let view = icon(super::super::icons::HOME).size(28.0);
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(40.0, 40.0)));
        assert_eq!(size, Size::new(40.0, 40.0));
    }

    #[test]
    fn default_size_is_24() {
        let view = icon(super::super::icons::HOME);
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let size = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
        assert_eq!(size, Size::new(24.0, 24.0));
    }

    // -- paint / scaling ----------------------------------------------------

    #[test]
    fn paint_scales_the_design_box_to_the_requested_size() {
        // A 24×24 design box painted at size 48 should fill a 0..48 box, offset
        // by the paint origin.
        let view = icon(square_data()).size(48.0);
        let mut w = build(&view);
        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
        assert_eq!(rec.fills.len(), 1);
        let (origin, bbox, _) = rec.fills[0];
        assert_eq!(origin, Point::new(3.0, 5.0));
        assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
        assert!((bbox.height() - 48.0).abs() < 1e-6);
        // The path itself is in local space (origin applied by the scene).
        assert!((bbox.x0 - 0.0).abs() < 1e-6);
        assert!((bbox.y0 - 0.0).abs() < 1e-6);
    }

    #[test]
    fn paint_natural_size_after_layout_is_unchanged() {
        // Guards the common case: laying out under a loose constraint (the
        // overwhelmingly common shape) resolves to size×size, and paint must
        // still fill exactly that box with zero centering offset — the same
        // output as `paint_scales_the_design_box_to_the_requested_size`
        // above, but with `layout` actually run first.
        let view = icon(square_data()).size(48.0);
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let laid_out = w.layout(&mut lctx, &BoxConstraints::loose(Size::new(500.0, 500.0)));
        assert_eq!(laid_out, Size::new(48.0, 48.0));
        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
        let (origin, bbox, _) = rec.fills[0];
        assert_eq!(origin, Point::new(3.0, 5.0));
        assert!((bbox.width() - 48.0).abs() < 1e-6, "path scaled to size");
        assert!((bbox.height() - 48.0).abs() < 1e-6);
        assert!((bbox.x0 - 0.0).abs() < 1e-6);
        assert!((bbox.y0 - 0.0).abs() < 1e-6);
    }

    #[test]
    fn paint_fills_a_tight_larger_box_centered() {
        // Tight-LARGER (G12, fab_menu's collapsed trigger): a 34×34 tight
        // constraint around a 24-design icon. Pre-fix, `paint` scaled to the
        // VIEW's configured size (24) and drew at the box origin, leaving
        // the glyph off-center by (34-24)/2 = 5dp toward the top-left. Fixed
        // paint must fill the laid-out 34×34 box instead.
        let view = icon(square_data()); // default size 24.0, design 24.0
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(34.0, 34.0)));
        assert_eq!(laid_out, Size::new(34.0, 34.0));
        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
        let (origin, bbox, _) = rec.fills[0];
        assert_eq!(origin, Point::new(3.0, 5.0));
        assert!(
            (bbox.width() - 34.0).abs() < 1e-6,
            "glyph must fill the laid-out box (34), not the requested size (24); got {}",
            bbox.width()
        );
        assert!((bbox.height() - 34.0).abs() < 1e-6);
        // Centered: a square design box scaled to fill a square laid-out box
        // needs zero translation — the scaled path starts flush at 0..34.
        assert!((bbox.x0 - 0.0).abs() < 1e-6);
        assert!((bbox.y0 - 0.0).abs() < 1e-6);
    }

    #[test]
    fn paint_fills_a_tight_smaller_box_without_overdraw() {
        // Tight-SMALLER: a 16×16 tight constraint around a 24-design icon —
        // the latent overdraw bug (same root cause as G12, never
        // device-observed since no shipped consumer constrains an icon
        // smaller than its requested size, but broken all the same). Pre-fix
        // `paint` drew the full 24×24 glyph regardless, overdrawing past the
        // laid-out box's edges.
        let view = icon(square_data()); // default size 24.0, design 24.0
        let mut w = build(&view);
        let mut lctx = LayoutCtx::new();
        let laid_out = w.layout(&mut lctx, &BoxConstraints::tight(Size::new(16.0, 16.0)));
        assert_eq!(laid_out, Size::new(16.0, 16.0));
        let rec = paint_rec(&mut w, Point::new(3.0, 5.0), None);
        let (origin, bbox, _) = rec.fills[0];
        assert_eq!(origin, Point::new(3.0, 5.0));
        assert!(
            (bbox.width() - 16.0).abs() < 1e-6,
            "glyph must shrink to the laid-out box (16), not overdraw at the \
             requested size (24); got {}",
            bbox.width()
        );
        assert!((bbox.height() - 16.0).abs() < 1e-6);
        assert!((bbox.x0 - 0.0).abs() < 1e-6);
        assert!((bbox.y0 - 0.0).abs() < 1e-6);
    }

    #[test]
    fn user_supplied_path_renders() {
        // The composability requirement: a hand-built BezPath paints as an icon.
        let mut path = BezPath::new();
        path.move_to((0.0, 0.0));
        path.line_to((10.0, 0.0));
        path.line_to((10.0, 10.0));
        path.close_path();
        let view = icon(IconData::from_path(path, 10.0)).size(20.0);
        let mut w = build(&view);
        let rec = paint_rec(&mut w, Point::ZERO, None);
        assert_eq!(rec.fills.len(), 1, "a user path emits one filled path");
        let (_, bbox, _) = rec.fills[0];
        // 10-wide design box scaled 2x -> 20 wide.
        assert!((bbox.width() - 20.0).abs() < 1e-6);
    }

    // -- color resolution ---------------------------------------------------

    #[test]
    fn explicit_color_wins_over_theme() {
        let explicit = Color::from_rgb8(0xAB, 0xCD, 0xEF);
        let view = icon(square_data()).color(explicit);
        let mut w = build(&view);
        let theme = Theme::neutral();
        let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
        assert_eq!(rec.fills[0].2, explicit);
    }

    #[test]
    fn themed_default_resolves_on_surface() {
        let view = icon(square_data());
        let mut w = build(&view);
        let theme = Theme::neutral();
        let rec = paint_rec(&mut w, Point::ZERO, Some(&theme));
        assert_eq!(rec.fills[0].2, theme.scheme().on_surface);
    }

    #[test]
    fn unthemed_default_uses_fallback_constant() {
        let view = icon(square_data());
        let mut w = build(&view);
        let rec = paint_rec(&mut w, Point::ZERO, None);
        assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
    }

    // -- generated set ------------------------------------------------------

    #[test]
    fn every_generated_icon_source_parses() {
        for source in super::super::icons::ALL {
            let data: IconData = (*source).into();
            let (path, design) = data.resolve();
            assert_eq!(design, MATERIAL_DESIGN_BOX);
            assert!(
                !path.elements().is_empty(),
                "generated icon `{}` parsed to an empty path",
                source.d
            );
        }
    }

    #[test]
    fn the_icon_button_arc_additions_exist_and_are_in_all() {
        // The two directional arrows the caret precedent doesn't cover, a
        // third pointing forward, and a copy affordance — all four are
        // present in the generated set and enumerable via `ALL`, not just
        // reachable by name.
        let additions = super::super::icons::ALL;
        for (name, d) in [
            ("ARROW_UPWARD", super::super::icons::ARROW_UPWARD.d),
            ("ARROW_DOWNWARD", super::super::icons::ARROW_DOWNWARD.d),
            ("ARROW_FORWARD", super::super::icons::ARROW_FORWARD.d),
            ("CONTENT_COPY", super::super::icons::CONTENT_COPY.d),
        ] {
            assert!(
                additions.iter().any(|source| source.d == d),
                "icons::{name} is missing from icons::ALL"
            );
        }
    }

    #[test]
    fn generated_home_icon_is_usable_end_to_end() {
        // `icon(icons::HOME).size(28.0)`
        // builds and paints in a bare-core (no-theme) test.
        let view = icon(super::super::icons::HOME).size(28.0);
        let mut w = build(&view);
        let rec = paint_rec(&mut w, Point::ZERO, None);
        assert_eq!(rec.fills.len(), 1);
        assert_eq!(rec.fills[0].2, DEFAULT_COLOR);
    }

    // -- semantics ----------------------------------------------------------

    /// Slack allowed on either side of the `0..MATERIAL_DESIGN_BOX` design box
    /// when checking a generated icon's parsed geometry.
    ///
    /// `scripts/gen_icons.py`'s `normalize_path_d` renormalizes each glyph's
    /// coordinates from its source SVG's `viewBox` into the `0..24` box via an
    /// affine transform, but a handful of upstream Material Symbols exports
    /// author raw path data that itself extends a little past their own
    /// declared `viewBox` (optical overshoot at a glyph's rounded tips) — an
    /// independent Python bbox walker confirmed that, for a handful of icons,
    /// a few percent past the box is genuine upstream geometry, not a
    /// transform bug. A direct measurement of the 41 icons
    /// currently vendored in `icons/mod.rs` (this test, run standalone) finds
    /// zero icons actually bleeding past `0..24` today — every glyph's bbox
    /// currently lands strictly inside — so this constant is pure headroom:
    /// wide enough (a "few percent" of 24px is well under 1px) to tolerate a
    /// future regeneration reintroducing that documented upstream overshoot
    /// without needing a bump, while still tight enough to catch a real
    /// transform regression (e.g. a wrong `viewBox` scale/offset putting a
    /// whole glyph noticeably outside the box).
    const BBOX_TOLERANCE: f64 = 2.0;

    #[test]
    fn every_generated_icon_bbox_lands_in_design_box() {
        let mut max_bleed: f64 = f64::NEG_INFINITY;
        let mut worst: Option<&str> = None;
        for source in super::super::icons::ALL {
            let path = BezPath::from_svg(source.d).unwrap_or_else(|e| {
                panic!("icon SVG path failed to parse: {e} (d={:?})", source.d)
            });
            let bbox = path.bounding_box();
            let design = source.design;
            assert!(
                bbox.x0 >= -BBOX_TOLERANCE
                    && bbox.y0 >= -BBOX_TOLERANCE
                    && bbox.x1 <= design + BBOX_TOLERANCE
                    && bbox.y1 <= design + BBOX_TOLERANCE,
                "icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) lands outside the \
                 {design}x{design} design box beyond the {BBOX_TOLERANCE}px \
                 tolerance",
                source.d,
                bbox.x0,
                bbox.y0,
                bbox.x1,
                bbox.y1
            );
            // Non-degenerate check: a zero (or near-zero) scale bug would
            // collapse every glyph's bbox to a point/sliver.
            assert!(
                bbox.width() + bbox.height() > design / 4.0,
                "icon `{}` bbox ({:.4},{:.4})-({:.4},{:.4}) is degenerately \
                 small for a {design}x{design} design box — suspect a \
                 zero-scale bug",
                source.d,
                bbox.x0,
                bbox.y0,
                bbox.x1,
                bbox.y1
            );
            let bleed = [-bbox.x0, -bbox.y0, bbox.x1 - design, bbox.y1 - design, 0.0]
                .into_iter()
                .fold(f64::NEG_INFINITY, f64::max);
            if bleed > max_bleed {
                max_bleed = bleed;
                worst = Some(source.d);
            }
        }
        // Informational only (not asserted): surfaces the worst observed
        // bleed when run with `-- --nocapture`, for eyeballing against
        // BBOX_TOLERANCE's headroom.
        if let Some(d) = worst {
            println!("max bbox bleed observed: {max_bleed:.4}px (icon d={d:?})");
        }
    }

    #[test]
    fn rebuild_adopts_a_new_size_and_flags_layout() {
        let mut counter = 0u64;
        let prev = icon(square_data()).size(24.0);
        let mut w = <IconView as View<()>>::build(&prev, &mut BuildCtx::new(&mut counter));
        let next = icon(square_data()).size(32.0);
        let flags =
            <IconView as View<()>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
        assert_eq!(w.size, 32.0);
        assert!(flags.needs_layout());
        assert!(flags.needs_paint());
    }
}