1use frus_core::{Color, Point, Rect, Scene, TextStyle};
14use frus_layout::{Dimension, Style};
15
16use crate::interaction::Status;
17use crate::theme::Theme;
18use crate::widget::Widget;
19
20const DOT: f32 = 6.0;
22const PILL: f32 = 16.0;
24const PAD_X: f32 = 4.0;
26
27pub struct Badge {
29 label: Option<String>,
32 label_visible: bool,
35 background_color: Option<Color>,
36 text_color: Option<Color>,
37 text_style: Option<TextStyle>,
38 small_size: Option<f32>,
39 large_size: Option<f32>,
40 padding: Option<f32>,
41}
42
43impl Badge {
44 pub fn new(text: impl Into<String>) -> Self {
46 Self {
47 label: Some(text.into()),
48 label_visible: true,
49 background_color: None,
50 text_color: None,
51 text_style: None,
52 small_size: None,
53 large_size: None,
54 padding: None,
55 }
56 }
57
58 pub fn dot() -> Self {
63 let mut badge = Self::new(String::new());
64 badge.label = None;
65 badge
66 }
67
68 pub fn label_visible(mut self, visible: bool) -> Self {
74 self.label_visible = visible;
75 self
76 }
77
78 pub fn background_color(mut self, color: Color) -> Self {
81 self.background_color = Some(color);
82 self
83 }
84
85 pub fn text_color(mut self, color: Color) -> Self {
87 self.text_color = Some(color);
88 self
89 }
90
91 pub fn text_style(mut self, style: TextStyle) -> Self {
94 self.text_style = Some(style);
95 self
96 }
97
98 pub fn small_size(mut self, size: f32) -> Self {
100 self.small_size = Some(size);
101 self
102 }
103
104 pub fn large_size(mut self, size: f32) -> Self {
106 self.large_size = Some(size);
107 self
108 }
109
110 pub fn padding(mut self, padding: f32) -> Self {
112 self.padding = Some(padding);
113 self
114 }
115
116 fn shown(&self) -> Option<&str> {
118 match self.label_visible {
119 true => self.label.as_deref(),
120 false => None,
121 }
122 }
123
124 fn fill(&self, theme: &Theme) -> Color {
125 self.background_color
126 .or(theme.widgets.badge.background_color)
127 .unwrap_or(theme.scheme.error)
128 }
129
130 fn ink(&self, theme: &Theme) -> Color {
131 self.text_color
132 .or(theme.widgets.badge.text_color)
133 .unwrap_or(theme.scheme.on_error)
134 }
135
136 fn label_style(&self, theme: &Theme) -> TextStyle {
137 self.text_style
138 .or(theme.widgets.badge.text_style)
139 .unwrap_or(TextStyle {
140 size: Some(11.0),
141 ..theme.text.label_small
142 })
143 }
144
145 fn dot_size(&self, theme: &Theme) -> f32 {
146 self.small_size
147 .or(theme.widgets.badge.small_size)
148 .unwrap_or(DOT)
149 }
150
151 fn pill_height(&self, theme: &Theme) -> f32 {
152 self.large_size
153 .or(theme.widgets.badge.large_size)
154 .unwrap_or(PILL)
155 }
156
157 fn pad(&self, theme: &Theme) -> f32 {
158 self.padding
159 .or(theme.widgets.badge.padding)
160 .unwrap_or(PAD_X)
161 }
162
163 fn mark_size(&self, theme: &Theme) -> (f32, f32) {
165 let Some(text) = self.shown() else {
166 let d = self.dot_size(theme);
167 return (d, d);
168 };
169 let style = self.label_style(theme);
170 let measured = frus_text::measure_style(text, style);
171 let height = self.pill_height(theme);
172 (
176 (measured.width + self.pad(theme) * 2.0).max(height).ceil(),
177 height,
178 )
179 }
180}
181
182impl<Msg> Widget<Msg> for Badge {
183 fn style(&self) -> Style {
184 <Self as Widget<Msg>>::style_themed(self, &Theme::default())
185 }
186
187 fn style_themed(&self, theme: &Theme) -> Style {
188 let (width, height) = self.mark_size(theme);
189 Style {
190 width: Dimension::Length(width),
191 height: Dimension::Length(height),
192 ..Default::default()
193 }
194 }
195
196 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
197 &[]
198 }
199
200 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
201 let o = status.opacity;
202 scene.draw_rect(
203 bounds,
204 self.fill(theme).fade(o),
205 bounds.height * 0.5,
206 0.0,
207 Color::TRANSPARENT,
208 );
209 let Some(text) = self.shown() else {
210 return;
211 };
212 let style = self.label_style(theme);
213 let measured = frus_text::measure_style(text, style);
214 scene.text(
218 Point::new(
219 bounds.x + (bounds.width - measured.width) / 2.0,
220 bounds.y + (bounds.height - measured.height) / 2.0,
221 ),
222 text.to_string(),
223 &style.resolved(),
224 self.ink(theme).fade(o),
225 );
226 }
227
228 fn on_click(&self) -> Option<Msg> {
229 None
230 }
231
232 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
233 let text = self.shown()?;
236 Some(frus_core::SemanticsProperties::new(frus_core::Role::Label).label(text))
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use frus_core::Primitive;
244
245 fn painted(badge: &Badge, bounds: Rect, theme: &Theme) -> Vec<Primitive> {
246 let mut scene = Scene::new();
247 Widget::<()>::paint(badge, bounds, Status::default(), theme, &mut scene);
248 scene.primitives().to_vec()
249 }
250
251 #[test]
252 fn paints_pill_and_text() {
253 let primitives = painted(
254 &Badge::new("3"),
255 Rect::new(0.0, 0.0, 24.0, 18.0),
256 &Theme::default(),
257 );
258 assert!(primitives
259 .iter()
260 .any(|p| matches!(p, Primitive::Rect { .. })));
261 assert!(primitives
262 .iter()
263 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "3")));
264 }
265
266 #[test]
269 fn a_dot_carries_no_text() {
270 let theme = Theme::default();
271 let dot = Badge::dot();
272 let primitives = painted(&dot, Rect::new(0.0, 0.0, 6.0, 6.0), &theme);
273 assert!(!primitives
274 .iter()
275 .any(|p| matches!(p, Primitive::Text { .. })));
276 let style = Widget::<()>::style_themed(&dot, &theme);
277 assert_eq!(style.width, Dimension::Length(DOT));
278 assert_eq!(style.height, Dimension::Length(DOT));
279 }
280
281 #[test]
284 fn a_hidden_label_becomes_the_dot() {
285 let theme = Theme::default();
286 let hidden = Badge::new("99").label_visible(false);
287 assert_eq!(
288 Widget::<()>::style_themed(&hidden, &theme).width,
289 Dimension::Length(DOT)
290 );
291 }
292
293 #[test]
296 fn a_single_digit_is_round_rather_than_wide() {
297 let theme = Theme::default();
298 let one = Badge::new("1");
299 let style = Widget::<()>::style_themed(&one, &theme);
300 assert_eq!(style.width, style.height);
301 }
302
303 #[test]
305 fn a_long_count_widens_the_pill() {
306 let theme = Theme::default();
307 let width = |text: &str| match Widget::<()>::style_themed(&Badge::new(text), &theme).width {
308 Dimension::Length(px) => px,
309 other => panic!("a stated width, not {other:?}"),
310 };
311 let (narrow, wide) = (width("1"), width("1024"));
312 assert!(wide > narrow, "{wide} should exceed {narrow}");
313 }
314
315 #[test]
318 fn the_colours_are_the_instances_then_the_themes() {
319 let mut theme = Theme::default();
320 let default_fill = |primitives: &[Primitive]| match primitives.first() {
321 Some(Primitive::Rect { color, .. }) => *color,
322 _ => panic!("a pill"),
323 };
324 let plain = painted(&Badge::new("3"), Rect::new(0.0, 0.0, 24.0, 18.0), &theme);
325 assert_eq!(
326 default_fill(&plain),
327 theme.scheme.error,
328 "the scheme's role"
329 );
330
331 theme.widgets.badge.background_color = Some(Color::rgb(0.0, 1.0, 0.0));
332 let themed = painted(&Badge::new("3"), Rect::new(0.0, 0.0, 24.0, 18.0), &theme);
333 assert_eq!(
334 default_fill(&themed),
335 Color::rgb(0.0, 1.0, 0.0),
336 "the theme"
337 );
338
339 let overridden = painted(
340 &Badge::new("3").background_color(Color::rgb(0.0, 0.0, 1.0)),
341 Rect::new(0.0, 0.0, 24.0, 18.0),
342 &theme,
343 );
344 assert_eq!(
345 default_fill(&overridden),
346 Color::rgb(0.0, 0.0, 1.0),
347 "the instance wins"
348 );
349 }
350
351 #[test]
354 fn a_count_is_announced_and_a_dot_is_not() {
355 let counted = Widget::<()>::semantics(&Badge::new("3")).expect("announced");
356 assert_eq!(counted.label.as_deref(), Some("3"));
357 assert!(Widget::<()>::semantics(&Badge::dot()).is_none());
358 }
359}