Skip to main content

tessera_components/
badge.rs

1//! Material Design badge primitives.
2//!
3//! ## Usage
4//!
5//! Highlight counts or status markers on top of icons and other UI elements.
6
7use tessera_ui::{
8    AxisConstraint, Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier,
9    Px, PxPosition, PxSize, RenderSlot,
10    layout::{MeasureScope, layout},
11    provide_context, tessera, use_context,
12};
13
14use crate::{
15    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
16    boxed::boxed,
17    pipelines::shape::command::ShapeCommand,
18    row::row,
19    shape_def::{ResolvedShape, Shape},
20    theme::{ContentColor, MaterialTheme, TextStyle, content_color_for},
21};
22
23fn badge_pixels(value: Dp) -> Px {
24    Px::from_f32(value.to_pixels_f32().round())
25}
26
27#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
28struct BadgedBoxLayout;
29
30impl tessera_ui::LayoutModifierNode for BadgedBoxLayout {
31    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
32        let mut result = LayoutResult::default();
33        let children = input.children();
34        debug_assert_eq!(
35            children.len(),
36            2,
37            "badged_box expects exactly two children: anchor and badge",
38        );
39
40        let parent_constraint = *input.parent_constraint().as_ref();
41        let badge_constraint = Constraint::new(
42            parent_constraint.width.without_min(),
43            parent_constraint.height.without_min(),
44        );
45
46        let anchor = children[0];
47        let badge = children[1];
48
49        let badge_data = badge.measure(input, &badge_constraint)?;
50        let anchor_size = anchor.measure(input, &parent_constraint)?;
51
52        result.place_child(anchor, PxPosition::new(Px(0), Px(0)));
53
54        let badge_size_px = badge_pixels(BadgeDefaults::SIZE);
55        let has_content = badge_data.width > badge_size_px;
56
57        let horizontal_offset = badge_pixels(if has_content {
58            BadgeDefaults::WITH_CONTENT_HORIZONTAL_OFFSET
59        } else {
60            BadgeDefaults::OFFSET
61        });
62
63        let vertical_offset = badge_pixels(if has_content {
64            BadgeDefaults::WITH_CONTENT_VERTICAL_OFFSET
65        } else {
66            BadgeDefaults::OFFSET
67        });
68
69        let badge_x = anchor_size.width - horizontal_offset;
70        let badge_y = -badge_data.height + vertical_offset;
71
72        result.place_child(badge, PxPosition::new(badge_x, badge_y));
73
74        Ok(result.with_size(ComputedData {
75            width: anchor_size.width,
76            height: anchor_size.height,
77        }))
78    }
79}
80
81#[derive(Clone, Copy, PartialEq)]
82struct BadgeLayout {
83    container_color: Color,
84}
85
86impl tessera_ui::LayoutModifierNode for BadgeLayout {
87    fn measure_eq(&self, _other: &Self) -> bool {
88        true
89    }
90
91    fn placement_eq(&self, other: &Self) -> bool {
92        self.measure_eq(other)
93    }
94
95    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
96        let size_px = badge_pixels(BadgeDefaults::SIZE);
97        let intrinsic = Constraint::new(
98            AxisConstraint::new(size_px, None),
99            AxisConstraint::new(size_px, None),
100        );
101        let effective = Constraint::new(
102            intrinsic.width.intersect(input.parent_constraint().width()),
103            intrinsic
104                .height
105                .intersect(input.parent_constraint().height()),
106        );
107
108        let width = effective.width.clamp(size_px);
109        let height = effective.height.clamp(size_px);
110
111        Ok(LayoutResult::new(ComputedData { width, height }))
112    }
113}
114
115impl tessera_ui::DrawModifierNode for BadgeLayout {
116    fn draw(
117        &self,
118        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
119        content: &mut dyn tessera_ui::DrawModifierContent,
120    ) {
121        draw_badge(ctx, content, self.container_color);
122    }
123}
124
125#[derive(Clone, Copy, PartialEq)]
126struct BadgeWithContentLayout {
127    container_color: Color,
128    padding_px: Px,
129}
130
131impl tessera_ui::LayoutModifierNode for BadgeWithContentLayout {
132    fn measure_eq(&self, other: &Self) -> bool {
133        self.padding_px == other.padding_px
134    }
135
136    fn placement_eq(&self, other: &Self) -> bool {
137        self.measure_eq(other)
138    }
139
140    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
141        let mut result = LayoutResult::default();
142        let children = input.children();
143        debug_assert_eq!(
144            children.len(),
145            1,
146            "badge_with_content expects a single row child",
147        );
148
149        let min_size_px = badge_pixels(BadgeDefaults::LARGE_SIZE);
150        let intrinsic = Constraint::new(
151            AxisConstraint::new(min_size_px, None),
152            AxisConstraint::new(min_size_px, None),
153        );
154        let effective = Constraint::new(
155            intrinsic.width.intersect(input.parent_constraint().width()),
156            intrinsic
157                .height
158                .intersect(input.parent_constraint().height()),
159        );
160
161        let max_width = effective
162            .width
163            .resolve_max()
164            .map(|v| (v - self.padding_px * 2).max(Px(0)));
165        let max_height = effective.height.resolve_max();
166
167        let child_constraint = Constraint::new(
168            AxisConstraint::new(Px::ZERO, max_width),
169            AxisConstraint::new(Px::ZERO, max_height),
170        );
171
172        let row = children[0];
173        let row_data = row.measure(input, &child_constraint)?;
174
175        let measured_width = (row_data.width + self.padding_px * 2).max(min_size_px);
176        let measured_height = row_data.height.max(min_size_px);
177
178        let width = effective.width.clamp(measured_width);
179        let height = effective.height.clamp(measured_height);
180
181        let x = (width - row_data.width).max(Px(0)) / 2;
182        let y = (height - row_data.height).max(Px(0)) / 2;
183        result.place_child(row, PxPosition::new(x, y));
184
185        Ok(result.with_size(ComputedData { width, height }))
186    }
187}
188
189impl tessera_ui::DrawModifierNode for BadgeWithContentLayout {
190    fn draw(
191        &self,
192        ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
193        content: &mut dyn tessera_ui::DrawModifierContent,
194    ) {
195        draw_badge(ctx, content, self.container_color);
196    }
197}
198
199fn draw_badge(
200    ctx: &mut tessera_ui::DrawModifierContext<'_, '_>,
201    content: &mut dyn tessera_ui::DrawModifierContent,
202    color: Color,
203) {
204    let input = &mut *ctx.render_input;
205    let mut metadata = input.metadata_mut();
206    let size = metadata
207        .computed_data()
208        .expect("badge must have computed size before record");
209
210    let ResolvedShape::Rounded {
211        corner_radii,
212        corner_g2,
213    } = BadgeDefaults::SHAPE.resolve_for_size(PxSize::new(size.width, size.height))
214    else {
215        unreachable!("badge shape must resolve to a rounded rectangle");
216    };
217
218    metadata
219        .fragment_mut()
220        .push_draw_command(ShapeCommand::Rect {
221            color,
222            corner_radii,
223            corner_g2,
224        });
225    content.draw(input);
226}
227
228/// Default values for [`badge`], [`badge_with_content`], and [`badged_box`].
229pub struct BadgeDefaults;
230
231impl BadgeDefaults {
232    /// Default badge size when it has no content.
233    pub const SIZE: Dp = Dp(6.0);
234    /// Default badge size when it has content.
235    pub const LARGE_SIZE: Dp = Dp(16.0);
236
237    /// Default badge shape.
238    pub const SHAPE: Shape = Shape::CAPSULE;
239
240    /// Horizontal padding for badges with content.
241    pub const WITH_CONTENT_HORIZONTAL_PADDING: Dp = Dp(4.0);
242
243    /// Horizontal offset for badges with content relative to the anchor.
244    pub const WITH_CONTENT_HORIZONTAL_OFFSET: Dp = Dp(12.0);
245    /// Vertical offset for badges with content relative to the anchor.
246    pub const WITH_CONTENT_VERTICAL_OFFSET: Dp = Dp(14.0);
247
248    /// Offset for badges without content relative to the anchor.
249    pub const OFFSET: Dp = Dp(6.0);
250
251    /// Default light-theme container color for a badge.
252    pub fn container_color() -> Color {
253        MaterialTheme::default().color_scheme.error
254    }
255}
256
257/// # badged_box
258///
259/// Positions a badge relative to an anchor element.
260///
261/// ## Usage
262///
263/// Display counts or status indicators on top of icons in navigation or
264/// toolbars.
265///
266/// ## Parameters
267///
268/// - `badge` — badge slot rendered on top of content.
269/// - `content` — anchor content slot.
270/// - `modifier` — layout, drawing, and accessibility configuration.
271///
272/// ## Examples
273///
274/// ```rust
275/// tessera_ui::testing::layout_test(|| {
276///     tessera_components::badge::badged_box();
277/// }).run();
278/// ```
279#[tessera]
280pub fn badged_box(
281    badge: Option<RenderSlot>,
282    content: Option<RenderSlot>,
283    modifier: Option<Modifier>,
284) {
285    layout()
286        .modifier(modifier.unwrap_or_default())
287        .policy(BadgedBoxLayout)
288        .child(move || {
289            boxed()
290                .alignment(Alignment::Center)
291                .children_shared(content.unwrap_or_else(RenderSlot::empty));
292            boxed().children_shared(badge.unwrap_or_else(RenderSlot::empty));
293        });
294}
295
296/// # badge
297///
298/// Renders an icon-only badge.
299///
300/// ## Usage
301///
302/// Mark an icon as having new activity without showing a numeric count.
303///
304/// ## Parameters
305///
306/// - `container_color` — optional background color of the badge.
307/// - `content_color` — optional preferred content color for descendants.
308/// - `modifier` — layout, drawing, and accessibility configuration.
309///
310/// ## Examples
311///
312/// ```rust
313/// use tessera_components::badge::badge;
314/// use tessera_ui::Color;
315/// # use tessera_components::theme::{MaterialTheme, material_theme};
316/// # use tessera_ui::tessera;
317/// # #[tessera]
318/// # fn component() {
319/// # material_theme()
320/// #     .theme(|| MaterialTheme::default())
321/// #     .child(|| {
322/// badge().content_color(Color::WHITE);
323/// #     });
324/// # }
325/// # tessera_ui::testing::layout_test(component).run();
326/// ```
327#[tessera]
328pub fn badge(
329    container_color: Option<Color>,
330    content_color: Option<Color>,
331    modifier: Option<Modifier>,
332) {
333    let _ = content_color;
334    let container_color = if let Some(color) = container_color {
335        color
336    } else {
337        use_context::<MaterialTheme>()
338            .expect("MaterialTheme must be provided")
339            .get()
340            .color_scheme
341            .error
342    };
343    let policy = BadgeLayout { container_color };
344    layout()
345        .modifier(modifier.unwrap_or_default())
346        .policy(policy)
347        .draw(policy);
348}
349
350/// # badge_with_content
351///
352/// Renders a badge that contains short content, such as a number.
353///
354/// ## Usage
355///
356/// Display compact counts (for example, unread messages) on top of navigation
357/// icons.
358///
359/// ## Parameters
360///
361/// - `container_color` — optional background color of the badge.
362/// - `content_color` — optional preferred content color for descendants.
363/// - `content` — optional content slot rendered inside the badge.
364/// - `modifier` — layout, drawing, and accessibility configuration.
365///
366/// ## Examples
367///
368/// ```rust
369/// use tessera_components::badge::badge_with_content;
370/// use tessera_ui::Color;
371/// # use tessera_components::theme::{MaterialTheme, material_theme};
372/// # use tessera_ui::tessera;
373/// # #[tessera]
374/// # fn component() {
375/// # material_theme()
376/// #     .theme(|| MaterialTheme::default())
377/// #     .child(|| {
378/// badge_with_content()
379///     .content_color(Color::WHITE)
380///     .content(|| {});
381/// #     });
382/// # }
383/// # tessera_ui::testing::layout_test(component).run();
384/// ```
385#[tessera]
386pub fn badge_with_content(
387    container_color: Option<Color>,
388    content_color: Option<Color>,
389    content: Option<RenderSlot>,
390    modifier: Option<Modifier>,
391) {
392    let content = content.unwrap_or_else(RenderSlot::empty);
393    let theme = use_context::<MaterialTheme>()
394        .expect("MaterialTheme must be provided")
395        .get();
396    let scheme = theme.color_scheme;
397    let typography = theme.typography;
398
399    let container_color = container_color.unwrap_or(scheme.error);
400    let content_color = if let Some(color) = content_color {
401        color
402    } else if let Some(color) = content_color_for(container_color, &scheme) {
403        color
404    } else {
405        use_context::<ContentColor>()
406            .map(|color| color.get().current)
407            .unwrap_or(ContentColor::default().current)
408    };
409
410    let padding_px = badge_pixels(BadgeDefaults::WITH_CONTENT_HORIZONTAL_PADDING);
411    let policy = BadgeWithContentLayout {
412        container_color,
413        padding_px,
414    };
415    layout()
416        .modifier(modifier.unwrap_or_default())
417        .policy(policy)
418        .draw(policy)
419        .child(move || {
420            provide_context(
421                || ContentColor {
422                    current: content_color,
423                },
424                || {
425                    let inherited = use_context::<ContentColor>().expect("badge content color");
426                    if inherited.get().current != content_color {
427                        inherited.set(ContentColor {
428                            current: content_color,
429                        });
430                    }
431                    provide_context(
432                        || typography.label_small,
433                        move || {
434                            let inherited = use_context::<TextStyle>().expect("badge text style");
435                            if inherited.get() != typography.label_small {
436                                inherited.set(typography.label_small);
437                            }
438                            row()
439                                .main_axis_alignment(MainAxisAlignment::Center)
440                                .cross_axis_alignment(CrossAxisAlignment::Center)
441                                .children_shared(content);
442                        },
443                    );
444                },
445            );
446        });
447}
448
449#[cfg(test)]
450mod tests;