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}