Skip to main content

frust_material/
badge.rs

1//! The Material 3 Expressive `Badge`: a small dot or numeric indicator
2//! anchored to the top edge of any child view.
3//!
4//! Ported from `material_3_expressive` v1.0.8's `M3EBadge`/`M3EBadgeTheme`/
5//! `M3EBadgeLayout` (MIT, © 2026 Paa Developments;
6//! `tmp/material_3_expressive/lib/components/badges/m3e_badges.dart`,
7//! `styles/m3e_badge_theme.dart`, `components/m3e_badge_layout.dart`,
8//! retrieved 2026-08-20).
9//! Upstream: <https://github.com/paadevelopments/material_3_expressive>
10//!
11//! [`badge`] wraps any child view; [`BadgeView::dot`] shows an unlabeled dot,
12//! [`BadgeView::count`] shows a numeric label (formatted against
13//! [`BadgeView::max_count`], default 99 — e.g. `count(150)` at the default
14//! max paints `"99+"`). Neither called leaves the child exactly as given, no
15//! wrapper geometry at all (`M3EBadge.build`'s `if (!showDot && count ==
16//! null) { return child; }`) — [`BadgeAlignment`] places the indicator at the
17//! child's own top-left/top-center/top-right corner, nudged by
18//! [`BadgeView::offset`] (default `(8, -6)`, `dx` ignored when centered).
19//!
20//! # Relationship to `IconButtonView::badge`
21//!
22//! [`mod@crate::icon_button`] already carries its own inline badge slot
23//! (`IconButtonView::badge`, `BadgeValue`) — that port is deliberately
24//! **not** rebased onto this module: `IconButtonValue::Count` auto-shows an
25//! unlabeled dot at `0` and clamps to `0..=999_999` with no overflow-`"+"`
26//! rule, positions purely decoratively at the icon button's own visual-box
27//! corner with no reserved layout space, and was ported from a *different*
28//! upstream file (`icon_button_m3e`'s `_buildBadge`, not `m3e_badges.dart`).
29//! This module's [`resolve`] has different precedence (`showDot` always wins
30//! over `count`, never auto-triggered by a zero count), a `maxCount`
31//! overflow rule, and reserves real layout space for the indicator (the
32//! wrapper's own reported size covers both boxes — see
33//! [`BadgeWidget::layout`]) — the two badge shapes are siblings, not one
34//! generalizing the other, so `icon_button` keeps its own copy rather than
35//! composing this one.
36//!
37//! # Composition
38//!
39//! Unlike [`mod@crate::divider`], this component has no baseline
40//! `frust-widgets` counterpart to build over — [`BadgeWidget`] hand-rolls its
41//! own two-region layout (content + indicator), mirroring the reference's
42//! `RenderM3EBadgeLayout::performLayout` exactly (union-of-boxes sizing, a
43//! `shift` clamped to what the (possibly constrained) final size can absorb
44//! — see that method's own `_shift` doc for why a tight parent still keeps
45//! content inside the box).
46//!
47//! # Color roles: `MaterialTokens`-adjacent, not extension-scoped
48//!
49//! `errorContainer`/`onErrorContainer` (the reference's `containerColor`/
50//! `labelColor`) are baseline `ColorScheme` roles (`Theme::scheme()`), not
51//! two of the nine [`crate::MaterialTokens`]-only semantic roles — see
52//! `crate::tokens`'s module docs' role-partition table. This module never
53//! touches `MaterialTokens` or `frust::StatusPalette`; it resolves the plain
54//! `ColorScheme` roles a themed `Theme` always carries.
55
56use frust::Theme;
57use frust::authoring::text::{FontWeight, TextContext, TextLayout, TextStyle};
58use frust::authoring::{
59    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
60    LayoutCtx, PaintCtx, PaintScene, Role, SemanticsCtx, View, Widget, any,
61};
62use kurbo::{Point, Rect, Size, Vec2};
63use peniko::{Brush, Color};
64
65// ---- Theme-token constants (m3e_badge_theme.dart's `M3EBadgeTheme.defaults`) --
66
67/// The dot indicator's diameter, in logical px (`dotSize` default,
68/// `m3e_badge_theme.dart:10`).
69const DOT_SIZE: f64 = 8.0;
70/// The numeric label pill's minimum width *and* height, in logical px
71/// (`labelMinSize` default).
72const LABEL_MIN_SIZE: f64 = 18.0;
73/// Horizontal padding inside the label pill around its text, in logical px
74/// (`labelHorizontalPadding` default).
75const LABEL_H_PAD: f64 = 6.0;
76/// Vertical padding inside the label pill around its text, in logical px
77/// (`labelVerticalPadding` default).
78const LABEL_V_PAD: f64 = 2.0;
79/// The label pill's corner radius, in logical px (`labelCornerRadius`
80/// default) — a fixed radius, not `height / 2.0`
81/// ([`mod@crate::icon_button`]'s own badge slot uses the latter; this port
82/// follows the reference's own token instead, see the [module docs](self)).
83const LABEL_CORNER_RADIUS: f64 = 10.0;
84/// The label text's font size, in logical px (`labelFontSize` default).
85const LABEL_FONT_SIZE: f32 = 10.0;
86/// The nudge away from the anchored top edge unless overridden via
87/// [`BadgeView::offset`] — `dx` is ignored when [`BadgeAlignment::TopCenter`]
88/// (`defaultOffset` default, `Offset(8, -6)`).
89const DEFAULT_OFFSET: Vec2 = Vec2::new(8.0, -6.0);
90/// The overflow ceiling before [`resolve`]'s numeric label switches to
91/// `"{max}+"`, unless overridden via [`BadgeView::max_count`] (`M3EBadge`'s
92/// own `maxCount` default).
93const DEFAULT_MAX_COUNT: u32 = 99;
94
95/// Unthemed-fallback badge container fill (a theme resolves this from
96/// `colors.error_container`) — the M3 baseline light `errorContainer` tone.
97const FALLBACK_CONTAINER: Color = Color::from_rgb8(0xF9, 0xDE, 0xDC);
98/// Unthemed-fallback badge dot/label content color (a theme resolves this
99/// from `colors.on_error_container`).
100const FALLBACK_LABEL: Color = Color::from_rgb8(0x41, 0x0E, 0x0B);
101
102/// Where the indicator anchors on `child`'s own top edge — the reference's
103/// `M3EBadgeAlignment`.
104#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub enum BadgeAlignment {
106    /// The child's top-left corner.
107    TopLeft,
108    /// Horizontally centered on the child's top edge (`offset.dx` ignored).
109    TopCenter,
110    /// The child's top-right corner. Default.
111    #[default]
112    TopRight,
113}
114
115/// `count` formatted against `max` — the reference's `_format`
116/// (`value > max ? '$max+' : '$value'`, `m3e_badges.dart:137`).
117fn format_count(count: u32, max: u32) -> String {
118    if count > max {
119        format!("{max}+")
120    } else {
121        count.to_string()
122    }
123}
124
125/// What [`BadgeView::dot`]/[`BadgeView::count`] resolve to for one layout/
126/// paint pass — the union of `M3EBadge.build`'s three outcomes (no badge /
127/// a bare dot / a labeled pill). `showDot` always wins over `count` when
128/// both are set (`showDot ? _dot(...) : _label(...)`, `m3e_badges.dart:75`).
129#[derive(Clone, Debug, PartialEq)]
130enum ResolvedBadge {
131    /// Neither [`BadgeView::dot`] nor [`BadgeView::count`] was called — the
132    /// child paints exactly as given, no reserved indicator space.
133    None,
134    Dot,
135    Label(String),
136}
137
138fn resolve(show_dot: bool, count: Option<u32>, max_count: u32) -> ResolvedBadge {
139    match (show_dot, count) {
140        (false, None) => ResolvedBadge::None,
141        (true, _) => ResolvedBadge::Dot,
142        (false, Some(count)) => ResolvedBadge::Label(format_count(count, max_count)),
143    }
144}
145
146/// The numeric label's resolved style. Its 10px SemiBold is the badge
147/// theme's own `labelFontSize`, not a type-scale role, so size and weight stay
148/// literal; the family is borrowed from the live theme's `labelSmall` — the
149/// role nearest that size — at layout (unthemed: the platform system UI
150/// family), so a theme swap or a font picker restyles the label along with
151/// the rest of the catalog.
152fn label_style(theme: Option<&Theme>) -> TextStyle {
153    let mut style = TextStyle {
154        weight: FontWeight::SEMI_BOLD,
155        ..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
156    };
157    if let Some(theme) = theme {
158        style.family = theme.type_scale.label_small.family.clone();
159    }
160    style
161}
162
163/// A small, paint-time-rebrushed text run for the numeric label — mirrors
164/// [`mod@crate::icon_button`]'s own `BadgeRun` (`pub(super)` to that module
165/// and out of reach here — see the [module docs](self)' Relationship
166/// section — so this is a parallel, badge-scoped copy of the same idiom:
167/// lazily shaped, re-brushed at paint time so the ink can change
168/// independently of the cached shaping). The cache is keyed on the content
169/// *and* the resolved style, so a theme swap that changes the family reshapes
170/// instead of serving the old face.
171struct LabelRun {
172    content: String,
173    /// The style the cached layout was shaped with.
174    style: TextStyle,
175    layout: Option<TextLayout>,
176}
177
178impl LabelRun {
179    fn new() -> Self {
180        Self {
181            content: String::new(),
182            style: label_style(None),
183            layout: None,
184        }
185    }
186
187    fn set_content(&mut self, content: &str) {
188        if self.content != content {
189            self.content = content.to_string();
190            self.layout = None;
191        }
192    }
193
194    /// Shape (or reuse) the run in `style`, returning its measured size.
195    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
196        if self.style != *style {
197            self.style = style.clone();
198            self.layout = None;
199        }
200        if let Some(layout) = &self.layout {
201            return layout.size();
202        }
203        let text_ctx = ctx.text_context::<TextContext>();
204        let laid = text_ctx.layout(&self.content, &self.style, None);
205        let size = laid.size();
206        self.layout = Some(laid);
207        size
208    }
209
210    fn size(&self) -> Size {
211        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
212    }
213
214    /// Paint the run at `origin` in `color`, overriding the shaping ink.
215    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
216        let Some(layout) = &self.layout else {
217            return;
218        };
219        for mut run in layout.to_scene_runs(origin) {
220            run.brush = Brush::Solid(color);
221            scene.draw_glyph_run(run);
222        }
223    }
224}
225
226/// A declarative badge wrapping any child view. See the [module docs](self).
227pub struct BadgeView<State: 'static> {
228    child: AnyView<State>,
229    show_dot: bool,
230    count: Option<u32>,
231    max_count: u32,
232    alignment: BadgeAlignment,
233    offset: Option<Vec2>,
234    background_color: Option<Color>,
235    foreground_color: Option<Color>,
236    semantic_label: Option<String>,
237}
238
239/// Wrap `child` with a badge slot — no indicator paints until
240/// [`BadgeView::dot`] or [`BadgeView::count`] is chained on. See the
241/// [module docs](self).
242pub fn badge<State: 'static, V: View<State>>(child: V) -> BadgeView<State> {
243    BadgeView {
244        child: any(child),
245        show_dot: false,
246        count: None,
247        max_count: DEFAULT_MAX_COUNT,
248        alignment: BadgeAlignment::default(),
249        offset: None,
250        background_color: None,
251        foreground_color: None,
252        semantic_label: None,
253    }
254}
255
256impl<State: 'static> BadgeView<State> {
257    /// Show an unlabeled dot — wins over [`Self::count`] if both are set.
258    pub fn dot(mut self) -> Self {
259        self.show_dot = true;
260        self
261    }
262
263    /// Show a numeric label, formatted against [`Self::max_count`] (default
264    /// [`DEFAULT_MAX_COUNT`], 99 — e.g. `150` paints `"99+"`).
265    pub fn count(mut self, count: u32) -> Self {
266        self.count = Some(count);
267        self
268    }
269
270    /// Override the overflow ceiling [`Self::count`] formats against.
271    /// [`DEFAULT_MAX_COUNT`] (99) unless set.
272    pub fn max_count(mut self, max_count: u32) -> Self {
273        self.max_count = max_count;
274        self
275    }
276
277    /// Anchor the indicator at a different top-edge corner.
278    /// [`BadgeAlignment::TopRight`] by default.
279    pub fn alignment(mut self, alignment: BadgeAlignment) -> Self {
280        self.alignment = alignment;
281        self
282    }
283
284    /// Override the nudge away from the anchored edge. [`DEFAULT_OFFSET`]
285    /// (`(8, -6)`) unless set; `dx` is ignored when
286    /// [`BadgeAlignment::TopCenter`].
287    pub fn offset(mut self, offset: Vec2) -> Self {
288        self.offset = Some(offset);
289        self
290    }
291
292    /// Override the theme-resolved `errorContainer` container fill.
293    pub fn background_color(mut self, color: Color) -> Self {
294        self.background_color = Some(color);
295        self
296    }
297
298    /// Override the theme-resolved `onErrorContainer` label/dot ink.
299    pub fn foreground_color(mut self, color: Color) -> Self {
300        self.foreground_color = Some(color);
301        self
302    }
303
304    /// Override the indicator's accessibility label. Defaults to
305    /// `"Notifications: {count}"` for a numeric badge or `"Notifications"`
306    /// for a dot (the reference's own default label text).
307    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
308        self.semantic_label = Some(label.into());
309        self
310    }
311}
312
313/// The retained widget for a [`BadgeView`]. See the [module docs](self).
314pub struct BadgeWidget {
315    content: ChildPod,
316    resolved: ResolvedBadge,
317    alignment: BadgeAlignment,
318    offset: Option<Vec2>,
319    background_color: Option<Color>,
320    foreground_color: Option<Color>,
321    semantic_label: Option<String>,
322    count: Option<u32>,
323    label_run: LabelRun,
324    indicator_size: Size,
325    indicator_offset: Point,
326}
327
328impl<State: 'static> View<State> for BadgeView<State> {
329    type Element = BadgeWidget;
330
331    fn build(&self, ctx: &mut BuildCtx<'_>) -> BadgeWidget {
332        BadgeWidget {
333            content: frust::authoring::build_child(&self.child, ctx),
334            resolved: resolve(self.show_dot, self.count, self.max_count),
335            alignment: self.alignment,
336            offset: self.offset,
337            background_color: self.background_color,
338            foreground_color: self.foreground_color,
339            semantic_label: self.semantic_label.clone(),
340            count: self.count,
341            label_run: LabelRun::new(),
342            indicator_size: Size::ZERO,
343            indicator_offset: Point::ZERO,
344        }
345    }
346
347    fn rebuild(
348        &self,
349        prev: &Self,
350        element: &mut BadgeWidget,
351        ctx: &mut BuildCtx<'_>,
352    ) -> ChangeFlags {
353        let mut flags = ChangeFlags::NONE;
354
355        let next_resolved = resolve(self.show_dot, self.count, self.max_count);
356        if element.resolved != next_resolved {
357            element.resolved = next_resolved;
358            flags |= ChangeFlags::LAYOUT;
359        }
360        if element.alignment != self.alignment || element.offset != self.offset {
361            element.alignment = self.alignment;
362            element.offset = self.offset;
363            flags |= ChangeFlags::LAYOUT;
364        }
365        if element.background_color != self.background_color
366            || element.foreground_color != self.foreground_color
367        {
368            element.background_color = self.background_color;
369            element.foreground_color = self.foreground_color;
370            flags |= ChangeFlags::PAINT;
371        }
372        element.semantic_label = self.semantic_label.clone();
373        element.count = self.count;
374
375        flags |=
376            frust::authoring::rebuild_child(&prev.child, &self.child, &mut element.content, ctx);
377        flags
378    }
379
380    fn teardown(&self, element: &mut BadgeWidget, ctx: &mut BuildCtx<'_>) {
381        frust::authoring::teardown_child(&self.child, &mut element.content, ctx);
382    }
383}
384
385impl Widget for BadgeWidget {
386    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
387        let content_size = self.content.layout_child(ctx, bc);
388
389        let ResolvedBadge::None = self.resolved else {
390            self.indicator_size = match &self.resolved {
391                ResolvedBadge::None => unreachable!("handled by the let-else arm above"),
392                ResolvedBadge::Dot => Size::new(DOT_SIZE, DOT_SIZE),
393                ResolvedBadge::Label(text) => {
394                    self.label_run.set_content(text);
395                    let style = label_style(Theme::from_layout_ctx(ctx));
396                    let text_size = self.label_run.shape(ctx, &style);
397                    Size::new(
398                        (text_size.width + LABEL_H_PAD * 2.0).max(LABEL_MIN_SIZE),
399                        (text_size.height + LABEL_V_PAD * 2.0).max(LABEL_MIN_SIZE),
400                    )
401                }
402            };
403
404            let offset = self.offset.unwrap_or(DEFAULT_OFFSET);
405            let dx = match self.alignment {
406                BadgeAlignment::TopLeft => -offset.x,
407                BadgeAlignment::TopCenter => (content_size.width - self.indicator_size.width) / 2.0,
408                BadgeAlignment::TopRight => {
409                    content_size.width - self.indicator_size.width + offset.x
410                }
411            };
412            let indicator_origin = Point::new(dx, offset.y);
413
414            let content_rect = Rect::from_origin_size(Point::ZERO, content_size);
415            let indicator_rect = Rect::from_origin_size(indicator_origin, self.indicator_size);
416            let union = content_rect.union(indicator_rect);
417
418            let size = bc.constrain(union.size());
419
420            // Only shift by what the (possibly constrained) size can absorb,
421            // so a tight parent still keeps content inside the box —
422            // `RenderM3EBadgeLayout::_shift`.
423            let shift_axis = |wanted: f64, available: f64| {
424                if wanted <= 0.0 || available <= 0.0 {
425                    0.0
426                } else {
427                    wanted.min(available)
428                }
429            };
430            let shift = Vec2::new(
431                shift_axis(-union.x0, size.width - content_size.width),
432                shift_axis(-union.y0, size.height - content_size.height),
433            );
434
435            self.content.set_origin(Point::ZERO + shift);
436            self.indicator_offset = indicator_origin + shift;
437
438            return size;
439        };
440
441        // No badge: transparent passthrough, no reserved indicator space.
442        self.content.set_origin(Point::ZERO);
443        bc.constrain(content_size)
444    }
445
446    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
447        self.content.paint_child(ctx, scene);
448
449        let theme = Theme::from_paint_ctx(ctx);
450        let origin = ctx.origin() + self.indicator_offset.to_vec2();
451        match &self.resolved {
452            ResolvedBadge::None => {}
453            ResolvedBadge::Dot => {
454                let bg = self
455                    .background_color
456                    .unwrap_or_else(|| resolve_container(theme));
457                scene.fill_rounded_rect(
458                    origin,
459                    self.indicator_size,
460                    self.indicator_size.width / 2.0,
461                    bg,
462                );
463            }
464            ResolvedBadge::Label(_) => {
465                let bg = self
466                    .background_color
467                    .unwrap_or_else(|| resolve_container(theme));
468                let fg = self
469                    .foreground_color
470                    .unwrap_or_else(|| resolve_label(theme));
471                scene.fill_rounded_rect(origin, self.indicator_size, LABEL_CORNER_RADIUS, bg);
472                let text_size = self.label_run.size();
473                let text_origin = Point::new(
474                    origin.x + (self.indicator_size.width - text_size.width) / 2.0,
475                    origin.y + (self.indicator_size.height - text_size.height) / 2.0,
476                );
477                self.label_run.paint(text_origin, fg, scene);
478            }
479        }
480    }
481
482    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
483        frust::authoring::route_event_single(&mut self.content, ctx, event)
484    }
485
486    fn semantics(&self, ctx: &mut SemanticsCtx) {
487        if !matches!(self.resolved, ResolvedBadge::None) {
488            ctx.push_node(Role::Label, |node| {
489                let label = self
490                    .semantic_label
491                    .clone()
492                    .unwrap_or_else(|| match self.count {
493                        Some(count) => format!("Notifications: {count}"),
494                        None => "Notifications".to_string(),
495                    });
496                node.set_value(label.as_str());
497            });
498        }
499        self.content.semantics_child(ctx);
500    }
501
502    frust::authoring::visit_children!(content);
503}
504
505/// The resolved container fill. Themed: `colors.error_container`. Unthemed:
506/// [`FALLBACK_CONTAINER`] exactly.
507fn resolve_container(theme: Option<&Theme>) -> Color {
508    match theme {
509        Some(theme) => theme.scheme().error_container,
510        None => FALLBACK_CONTAINER,
511    }
512}
513
514/// The resolved dot/label content color. Themed: `colors.on_error_container`.
515/// Unthemed: [`FALLBACK_LABEL`] exactly.
516fn resolve_label(theme: Option<&Theme>) -> Color {
517    match theme {
518        Some(theme) => theme.scheme().on_error_container,
519        None => FALLBACK_LABEL,
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526    use frust::authoring::text::FontFamily;
527    use frust_widgets::test_support::leaf_any;
528    use std::any::Any;
529
530    fn build<S: 'static>(view: &BadgeView<S>) -> BadgeWidget {
531        let mut counter = 0u64;
532        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
533    }
534
535    // ---- format_count -----------------------------------------------------
536
537    #[test]
538    fn format_count_shows_the_exact_value_at_or_under_the_max() {
539        assert_eq!(format_count(0, 99), "0");
540        assert_eq!(format_count(99, 99), "99");
541    }
542
543    #[test]
544    fn format_count_switches_to_overflow_past_the_max() {
545        assert_eq!(format_count(100, 99), "99+");
546        assert_eq!(format_count(150, 99), "99+");
547    }
548
549    #[test]
550    fn format_count_honors_a_custom_max() {
551        assert_eq!(format_count(999, 999), "999");
552        assert_eq!(format_count(1000, 999), "999+");
553    }
554
555    // ---- resolve ------------------------------------------------------------
556
557    #[test]
558    fn resolve_is_none_with_neither_dot_nor_count() {
559        assert_eq!(resolve(false, None, 99), ResolvedBadge::None);
560    }
561
562    #[test]
563    fn resolve_dot_wins_over_count() {
564        assert_eq!(resolve(true, Some(3), 99), ResolvedBadge::Dot);
565    }
566
567    #[test]
568    fn resolve_count_without_dot_is_a_label() {
569        assert_eq!(
570            resolve(false, Some(3), 99),
571            ResolvedBadge::Label("3".to_string())
572        );
573    }
574
575    // ---- Layout: no badge is a transparent passthrough ---------------------
576
577    #[test]
578    fn no_badge_reports_the_child_s_own_size_with_zero_origin() {
579        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
580        let mut w = build(&view);
581        let mut ctx = LayoutCtx::new();
582        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
583        assert_eq!(size, Size::new(40.0, 40.0));
584        assert_eq!(w.content.origin(), Point::ZERO);
585    }
586
587    // ---- Layout: dot geometry -----------------------------------------------
588
589    #[test]
590    fn dot_badge_reserves_room_for_the_indicator() {
591        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
592        let mut w = build(&view);
593        let mut ctx = LayoutCtx::new();
594        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
595        // Default offset (8, -6), top-right: indicator origin
596        // = (40 - 8 + 8, -6) = (40, -6); union with (0,0)-(40,40) covers
597        // x: 0..48, y: -6..40 -> size 48x46.
598        assert_eq!(size, Size::new(48.0, 46.0));
599    }
600
601    #[test]
602    fn dot_badge_shifts_content_to_keep_it_inside_the_reported_box() {
603        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
604        let mut w = build(&view);
605        let mut ctx = LayoutCtx::new();
606        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
607        // The indicator pokes 6px above the content box (offset.dy = -6),
608        // so content shifts down by 6 to stay inside the union.
609        assert_eq!(w.content.origin(), Point::new(0.0, 6.0));
610    }
611
612    // ---- Layout: 3 alignments -----------------------------------------------
613
614    #[test]
615    fn top_right_places_the_indicator_past_the_child_s_right_edge() {
616        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
617        let mut w = build(&view);
618        let mut ctx = LayoutCtx::new();
619        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
620        assert_eq!(w.indicator_offset.x, 40.0 - 8.0 + 8.0);
621    }
622
623    #[test]
624    fn top_left_places_the_indicator_left_of_the_shifted_content() {
625        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
626            .dot()
627            .alignment(BadgeAlignment::TopLeft);
628        let mut w = build(&view);
629        let mut ctx = LayoutCtx::new();
630        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
631        // Indicator origin before the box-absorbing shift: -offset.x = -8;
632        // content and indicator both shift right by 8 to keep everything
633        // inside the reported box (`RenderM3EBadgeLayout::_shift`), so the
634        // indicator ends up flush with the box's left edge while content
635        // sits 8px in — the same 8px gap `offset.dx` requested.
636        assert_eq!(w.indicator_offset.x, 0.0);
637        assert_eq!(w.content.origin().x, 8.0);
638    }
639
640    #[test]
641    fn top_center_ignores_offset_dx_and_centers_on_the_child() {
642        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0))
643            .dot()
644            .alignment(BadgeAlignment::TopCenter);
645        let mut w = build(&view);
646        let mut ctx = LayoutCtx::new();
647        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
648        // Indicator centered on the child's own width before shifting:
649        // (40 - 8) / 2 = 16; content never shifts on x for a centered dot
650        // whose left edge (16) is already >= 0.
651        assert_eq!(w.indicator_offset.x, 16.0);
652    }
653
654    // ---- Layout: numeric label geometry --------------------------------------
655
656    #[test]
657    fn numeric_label_is_at_least_the_theme_s_minimum_pill_size() {
658        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
659        let mut w = build(&view);
660        let mut tcx = TextContext::new();
661        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
662        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
663        assert!(w.indicator_size.width >= LABEL_MIN_SIZE);
664        assert!(w.indicator_size.height >= LABEL_MIN_SIZE);
665    }
666
667    #[test]
668    fn overflowing_count_paints_the_max_plus_label() {
669        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(150);
670        let mut w = build(&view);
671        assert_eq!(
672            resolve(false, Some(150), DEFAULT_MAX_COUNT),
673            ResolvedBadge::Label("99+".to_string())
674        );
675        let mut tcx = TextContext::new();
676        let mut ctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
677        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
678        assert_eq!(w.resolved, ResolvedBadge::Label("99+".to_string()));
679    }
680
681    // ---- Paint ---------------------------------------------------------------
682
683    #[derive(Default)]
684    struct Recorder {
685        rounded_rects: Vec<(Point, Size, f64, Color)>,
686    }
687
688    impl PaintScene for Recorder {
689        fn fill_rect(&mut self, _origin: Point, _size: Size, _color: Color) {}
690        fn draw_text(&mut self, _origin: Point, _text: &str) {}
691        fn fill_rounded_rect(&mut self, origin: Point, size: Size, radius: f64, color: Color) {
692            self.rounded_rects.push((origin, size, radius, color));
693        }
694    }
695
696    #[test]
697    fn no_badge_paints_no_indicator() {
698        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0));
699        let mut w = build(&view);
700        let mut ctx = LayoutCtx::new();
701        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
702        let mut rec = Recorder::default();
703        let mut pctx = PaintCtx::new(Point::ZERO, size);
704        w.paint(&mut pctx, &mut rec);
705        assert!(rec.rounded_rects.is_empty());
706    }
707
708    #[test]
709    fn dot_paints_a_full_circle_radius_with_the_unthemed_fallback() {
710        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot();
711        let mut w = build(&view);
712        let mut ctx = LayoutCtx::new();
713        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
714        let mut rec = Recorder::default();
715        let mut pctx = PaintCtx::new(Point::ZERO, size);
716        w.paint(&mut pctx, &mut rec);
717        assert_eq!(rec.rounded_rects.len(), 1);
718        let (origin, indicator_size, radius, color) = rec.rounded_rects[0];
719        assert_eq!(indicator_size, Size::new(DOT_SIZE, DOT_SIZE));
720        assert_eq!(radius, DOT_SIZE / 2.0);
721        assert_eq!(color, FALLBACK_CONTAINER);
722        assert_eq!(origin, w.indicator_offset);
723    }
724
725    // ---- Typeface: the label's family follows the live theme ----------------
726
727    /// A baseline theme whose `labelSmall` role names `family`.
728    fn theme_with_label_small(family: FontFamily) -> Theme {
729        let mut theme = crate::baseline();
730        theme.type_scale.label_small.family = family;
731        theme
732    }
733
734    /// Lay `w` out against `tcx`, threading `theme` the way the render root
735    /// does.
736    fn layout_themed(w: &mut BadgeWidget, tcx: &mut TextContext, theme: Option<&Theme>) {
737        let mut ctx =
738            LayoutCtx::with_resources(Some(tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
739        w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
740    }
741
742    #[test]
743    fn layout_takes_the_label_family_from_label_small() {
744        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
745        let mut w = build(&view);
746        let mut tcx = TextContext::new();
747        let probe = FontFamily::named("Badge Role Probe");
748        layout_themed(
749            &mut w,
750            &mut tcx,
751            Some(&theme_with_label_small(probe.clone())),
752        );
753        assert_eq!(w.label_run.style.family, probe);
754        // Only the family is themed: the badge's own 10px SemiBold stays.
755        assert_eq!(w.label_run.style.size, LABEL_FONT_SIZE);
756        assert_eq!(w.label_run.style.weight, FontWeight::SEMI_BOLD);
757    }
758
759    #[test]
760    fn without_a_theme_the_label_keeps_the_unthemed_style() {
761        let unthemed = TextStyle {
762            weight: FontWeight::SEMI_BOLD,
763            ..TextStyle::new(LABEL_FONT_SIZE, Color::BLACK)
764        };
765        assert_eq!(label_style(None), unthemed);
766        assert_eq!(unthemed.family, FontFamily::SystemUi);
767
768        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
769        let mut w = build(&view);
770        let mut tcx = TextContext::new();
771        layout_themed(&mut w, &mut tcx, None);
772        assert_eq!(w.label_run.style, unthemed);
773    }
774
775    #[test]
776    fn a_theme_swap_reshapes_the_cached_label() {
777        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
778        let mut w = build(&view);
779        let mut tcx = TextContext::new();
780        let first = theme_with_label_small(FontFamily::named("Badge Swap Probe A"));
781        layout_themed(&mut w, &mut tcx, Some(&first));
782
783        // Control: the same theme again reuses the cached run outright.
784        let settled = tcx.shape_cache_stats();
785        layout_themed(&mut w, &mut tcx, Some(&first));
786        assert_eq!(
787            tcx.shape_cache_stats(),
788            settled,
789            "an unchanged theme reshapes nothing"
790        );
791
792        let second = theme_with_label_small(FontFamily::named("Badge Swap Probe B"));
793        layout_themed(&mut w, &mut tcx, Some(&second));
794        assert!(
795            tcx.shape_cache_stats().shapes > settled.shapes,
796            "a family swap must reshape the label, not serve the old face"
797        );
798    }
799
800    /// The first glyph run's font bytes — the face the label actually shaped in.
801    #[cfg(feature = "bundled-fonts")]
802    fn shaped_font(w: &BadgeWidget) -> Vec<u8> {
803        let layout = w.label_run.layout.as_ref().expect("the label was shaped");
804        let runs = layout.to_scene_runs(Point::ZERO);
805        let run = runs.first().expect("the label shaped to at least one run");
806        run.font.font().data.as_ref().to_vec()
807    }
808
809    #[cfg(feature = "bundled-fonts")]
810    #[test]
811    fn a_theme_swap_between_registered_families_changes_the_shaped_face() {
812        // `font_data()`'s documented order: Roboto Flex, then Roboto Mono.
813        let (flex, mono) = (crate::tokens::font_data()[0], crate::tokens::font_data()[1]);
814        let mut tcx = TextContext::new();
815        for bytes in [flex, mono] {
816            tcx.register_fonts(bytes.to_vec())
817                .expect("the bundled faces must register");
818        }
819        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).count(3);
820        let mut w = build(&view);
821
822        let flex_theme =
823            theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_FLEX_FAMILY));
824        layout_themed(&mut w, &mut tcx, Some(&flex_theme));
825        assert!(
826            shaped_font(&w) == flex,
827            "labelSmall's family shapes the label"
828        );
829
830        let mono_theme =
831            theme_with_label_small(FontFamily::named(crate::tokens::ROBOTO_MONO_FAMILY));
832        layout_themed(&mut w, &mut tcx, Some(&mono_theme));
833        assert!(
834            shaped_font(&w) == mono,
835            "after a theme swap the label must shape in the new family"
836        );
837    }
838
839    #[test]
840    fn explicit_colors_win_over_the_unthemed_fallback() {
841        let bg = Color::from_rgb8(0x11, 0x22, 0x33);
842        let view: BadgeView<()> = badge(leaf_any(40.0, 40.0)).dot().background_color(bg);
843        let mut w = build(&view);
844        let mut ctx = LayoutCtx::new();
845        let size = w.layout(&mut ctx, &BoxConstraints::loose(Size::new(200.0, 200.0)));
846        let mut rec = Recorder::default();
847        let mut pctx = PaintCtx::new(Point::ZERO, size);
848        w.paint(&mut pctx, &mut rec);
849        assert_eq!(rec.rounded_rects[0].3, bg);
850    }
851}