1use 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
228pub struct BadgeDefaults;
230
231impl BadgeDefaults {
232 pub const SIZE: Dp = Dp(6.0);
234 pub const LARGE_SIZE: Dp = Dp(16.0);
236
237 pub const SHAPE: Shape = Shape::CAPSULE;
239
240 pub const WITH_CONTENT_HORIZONTAL_PADDING: Dp = Dp(4.0);
242
243 pub const WITH_CONTENT_HORIZONTAL_OFFSET: Dp = Dp(12.0);
245 pub const WITH_CONTENT_VERTICAL_OFFSET: Dp = Dp(14.0);
247
248 pub const OFFSET: Dp = Dp(6.0);
250
251 pub fn container_color() -> Color {
253 MaterialTheme::default().color_scheme.error
254 }
255}
256
257#[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#[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#[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;