1use frus_core::{Alignment, AlignmentDirectional, AlignmentGeometry, Color, Point, Rect, Scene};
22use frus_core::{TextDirection, TextStyle};
23use frus_layout::{Dimension, Style};
24
25use crate::interaction::Status;
26use crate::theme::Theme;
27use crate::widget::Widget;
28
29const DOT: f32 = 6.0;
31const PILL: f32 = 16.0;
33const PAD_X: f32 = 4.0;
35const MAX_COUNT: u32 = 999;
37const OFFSET: (f32, f32) = (4.0, -4.0);
40
41pub(crate) fn count_label(count: u32, max: u32) -> String {
44 if count > max {
45 format!("{max}+")
46 } else {
47 count.to_string()
48 }
49}
50
51#[derive(Clone, Debug)]
53pub struct Badge {
54 label: Option<String>,
57 count: Option<u32>,
59 max_count: u32,
60 label_visible: bool,
62 background_color: Option<Color>,
63 text_color: Option<Color>,
64 text_style: Option<TextStyle>,
65 small_size: Option<f32>,
66 large_size: Option<f32>,
67 padding: Option<f32>,
68 alignment: Option<AlignmentGeometry>,
69 offset: Option<(f32, f32)>,
70}
71
72impl Badge {
73 pub fn new(text: impl Into<String>) -> Self {
75 Self {
76 label: Some(text.into()),
77 count: None,
78 max_count: MAX_COUNT,
79 label_visible: true,
80 background_color: None,
81 text_color: None,
82 text_style: None,
83 small_size: None,
84 large_size: None,
85 padding: None,
86 alignment: None,
87 offset: None,
88 }
89 }
90
91 pub fn count(count: u32) -> Self {
94 let mut badge = Self::new(String::new());
95 badge.count = Some(count);
96 badge
97 }
98
99 pub fn max_count(mut self, max: u32) -> Self {
101 self.max_count = max.max(1);
102 self
103 }
104
105 pub fn dot() -> Self {
110 let mut badge = Self::new(String::new());
111 badge.label = None;
112 badge
113 }
114
115 pub fn label_visible(mut self, visible: bool) -> Self {
120 self.label_visible = visible;
121 self
122 }
123
124 pub fn background_color(mut self, color: Color) -> Self {
127 self.background_color = Some(color);
128 self
129 }
130
131 pub fn text_color(mut self, color: Color) -> Self {
133 self.text_color = Some(color);
134 self
135 }
136
137 pub fn text_style(mut self, style: TextStyle) -> Self {
140 self.text_style = Some(style);
141 self
142 }
143
144 pub fn small_size(mut self, size: f32) -> Self {
146 self.small_size = Some(size);
147 self
148 }
149
150 pub fn large_size(mut self, size: f32) -> Self {
152 self.large_size = Some(size);
153 self
154 }
155
156 pub fn padding(mut self, padding: f32) -> Self {
158 self.padding = Some(padding);
159 self
160 }
161
162 pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
166 self.alignment = Some(alignment.into());
167 self
168 }
169
170 pub fn offset(mut self, across: f32, down: f32) -> Self {
174 self.offset = Some((across, down));
175 self
176 }
177
178 pub fn on<Msg>(self, child: impl Widget<Msg> + 'static) -> Badged<Msg> {
183 Badged {
184 badge: self,
185 child: [Box::new(child)],
186 }
187 }
188
189 fn hidden(&self) -> bool {
190 !self.label_visible
191 }
192
193 fn text(&self) -> Option<String> {
195 match self.count {
196 Some(count) => Some(count_label(count, self.max_count)),
197 None => self.label.clone(),
198 }
199 }
200
201 fn fill(&self, theme: &Theme) -> Color {
202 self.background_color
203 .or(theme.widgets.badge.background_color)
204 .unwrap_or(theme.scheme.error)
205 }
206
207 fn ink(&self, theme: &Theme) -> Color {
208 self.text_color
209 .or(theme.widgets.badge.text_color)
210 .unwrap_or(theme.scheme.on_error)
211 }
212
213 fn label_style(&self, theme: &Theme) -> TextStyle {
214 self.text_style
215 .or(theme.widgets.badge.text_style)
216 .unwrap_or(TextStyle {
217 size: Some(11.0),
218 ..theme.text.label_small
219 })
220 }
221
222 fn dot_size(&self, theme: &Theme) -> f32 {
223 self.small_size
224 .or(theme.widgets.badge.small_size)
225 .unwrap_or(DOT)
226 }
227
228 fn pill_height(&self, theme: &Theme) -> f32 {
229 self.large_size
230 .or(theme.widgets.badge.large_size)
231 .unwrap_or(PILL)
232 }
233
234 fn pad(&self, theme: &Theme) -> f32 {
235 self.padding
236 .or(theme.widgets.badge.padding)
237 .unwrap_or(PAD_X)
238 }
239
240 fn mark_size(&self, theme: &Theme) -> (f32, f32) {
242 if self.hidden() {
243 return (0.0, 0.0);
244 }
245 let Some(text) = self.text() else {
246 let d = self.dot_size(theme);
247 return (d, d);
248 };
249 let style = self.label_style(theme);
250 let measured = frus_text::measure_style(&text, style);
251 let height = self.pill_height(theme);
252 (
256 (measured.width + self.pad(theme) * 2.0).max(height).ceil(),
257 height,
258 )
259 }
260
261 fn pinned_rect(&self, child: Rect, theme: &Theme) -> Rect {
263 let mark = self.mark_size(theme);
264 let labelled = self.text().is_some();
265 let direction = theme.direction;
266 let alignment = self
267 .alignment
268 .or(theme.widgets.badge.alignment)
269 .unwrap_or(AlignmentGeometry::Directional(
270 AlignmentDirectional::TOP_END,
271 ))
272 .resolve(direction);
273 let offset = if labelled {
274 self.offset
275 .or(theme.widgets.badge.offset)
276 .unwrap_or(match direction {
277 TextDirection::Rtl => (-OFFSET.0, OFFSET.1),
278 _ => OFFSET,
279 })
280 } else {
281 (0.0, 0.0)
282 };
283 let width_offset = if labelled {
286 self.pill_height(theme)
287 } else {
288 self.dot_size(theme)
289 };
290 pinned_at(child, mark, labelled, width_offset, alignment, offset)
291 }
292
293 fn paint_mark(&self, rect: Rect, opacity: f32, theme: &Theme, scene: &mut Scene) {
295 scene.draw_rect(
296 rect,
297 self.fill(theme).fade(opacity),
298 rect.height * 0.5,
299 0.0,
300 Color::TRANSPARENT,
301 );
302 let Some(text) = self.text() else {
303 return;
304 };
305 let style = self.label_style(theme);
306 let measured = frus_text::measure_style(&text, style);
307 scene.text(
311 Point::new(
312 rect.x + (rect.width - measured.width) / 2.0,
313 rect.y + (rect.height - measured.height) / 2.0,
314 ),
315 text,
316 &style.resolved(),
317 self.ink(theme).fade(opacity),
318 );
319 }
320
321 pub(crate) fn paint_pinned(
324 &self,
325 anchor: Rect,
326 opacity: f32,
327 theme: &Theme,
328 scene: &mut Scene,
329 ) {
330 if self.hidden() {
331 return;
332 }
333 self.paint_mark(self.pinned_rect(anchor, theme), opacity, theme, scene);
334 }
335
336 fn describe(&self) -> Option<frus_core::SemanticsProperties> {
337 if self.hidden() {
340 return None;
341 }
342 let text = self.text()?;
343 Some(frus_core::SemanticsProperties::new(frus_core::Role::Label).label(text))
344 }
345}
346
347pub(crate) fn pinned_at(
352 child: Rect,
353 mark: (f32, f32),
354 labelled: bool,
355 width_offset: f32,
356 alignment: Alignment,
357 offset: (f32, f32),
358) -> Rect {
359 let (dx, dy) = if labelled {
360 (offset.0, offset.1 + 8.0)
361 } else {
362 offset
363 };
364 let x = child.x + alignment.fraction_x() * (child.width - width_offset) + dx;
365 let mut y = child.y + alignment.fraction_y() * child.height + dy;
366 if labelled {
367 y -= mark.1 / 2.0;
368 }
369 Rect::new(x, y, mark.0, mark.1)
370}
371
372impl<Msg> Widget<Msg> for Badge {
373 fn style(&self) -> Style {
374 <Self as Widget<Msg>>::style_themed(self, &Theme::default())
375 }
376
377 fn style_themed(&self, theme: &Theme) -> Style {
378 let (width, height) = self.mark_size(theme);
379 Style {
380 width: Dimension::Length(width),
381 height: Dimension::Length(height),
382 ..Default::default()
383 }
384 }
385
386 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
387 &[]
388 }
389
390 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
391 if self.hidden() {
392 return;
393 }
394 self.paint_mark(bounds, status.opacity, theme, scene);
395 }
396
397 fn on_click(&self) -> Option<Msg> {
398 None
399 }
400
401 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
402 self.describe()
403 }
404}
405
406pub struct Badged<Msg = crate::callback::Callback> {
408 badge: Badge,
409 child: [Box<dyn Widget<Msg>>; 1],
410}
411
412impl<Msg> Widget<Msg> for Badged<Msg> {
413 fn style(&self) -> Style {
414 Style::default()
415 }
416
417 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
418 &self.child
419 }
420
421 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
422
423 fn on_click(&self) -> Option<Msg> {
424 None
425 }
426
427 fn paints_over(&self) -> bool {
428 !self.badge.hidden()
429 }
430
431 fn paint_over(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
432 self.badge
433 .paint_pinned(bounds, status.opacity, theme, scene);
434 }
435
436 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
437 self.badge.describe()
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use frus_core::Primitive;
445
446 fn painted(badge: &Badge, bounds: Rect, theme: &Theme) -> Vec<Primitive> {
447 let mut scene = Scene::new();
448 Widget::<()>::paint(badge, bounds, Status::default(), theme, &mut scene);
449 scene.primitives().to_vec()
450 }
451
452 fn texts(primitives: &[Primitive]) -> Vec<String> {
453 primitives
454 .iter()
455 .filter_map(|p| match p {
456 Primitive::Text { text, .. } => Some(text.clone()),
457 _ => None,
458 })
459 .collect()
460 }
461
462 #[test]
463 fn paints_pill_and_text() {
464 let primitives = painted(
465 &Badge::new("3"),
466 Rect::new(0.0, 0.0, 24.0, 18.0),
467 &Theme::default(),
468 );
469 assert!(primitives
470 .iter()
471 .any(|p| matches!(p, Primitive::Rect { .. })));
472 assert!(primitives
473 .iter()
474 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "3")));
475 }
476
477 #[test]
480 fn a_dot_carries_no_text() {
481 let theme = Theme::default();
482 let dot = Badge::dot();
483 let primitives = painted(&dot, Rect::new(0.0, 0.0, 6.0, 6.0), &theme);
484 assert!(!primitives
485 .iter()
486 .any(|p| matches!(p, Primitive::Text { .. })));
487 let style = Widget::<()>::style_themed(&dot, &theme);
488 assert_eq!(style.width, Dimension::Length(DOT));
489 assert_eq!(style.height, Dimension::Length(DOT));
490 }
491
492 #[test]
496 fn a_hidden_badge_takes_no_room_and_draws_nothing() {
497 let theme = Theme::default();
498 for hidden in [
499 Badge::new("99").label_visible(false),
500 Badge::dot().label_visible(false),
501 Badge::count(0).label_visible(false),
502 ] {
503 let style = Widget::<()>::style_themed(&hidden, &theme);
504 assert_eq!(style.width, Dimension::Length(0.0));
505 assert_eq!(style.height, Dimension::Length(0.0));
506 assert!(painted(&hidden, Rect::new(0.0, 0.0, 24.0, 18.0), &theme).is_empty());
507 assert!(Widget::<()>::semantics(&hidden).is_none());
508 }
509 }
510
511 #[test]
514 fn a_count_is_capped() {
515 let theme = Theme::default();
516 let text = |badge: Badge| texts(&painted(&badge, Rect::new(0.0, 0.0, 40.0, 16.0), &theme));
517 assert_eq!(text(Badge::count(7)), vec!["7"]);
518 assert_eq!(text(Badge::count(999)), vec!["999"]);
519 assert_eq!(text(Badge::count(1000)), vec!["999+"]);
520 assert_eq!(text(Badge::count(150).max_count(99)), vec!["99+"]);
521 assert_eq!(count_label(5, 9), "5");
522 assert_eq!(count_label(10, 9), "9+");
523 }
524
525 #[test]
528 fn a_single_digit_is_round_rather_than_wide() {
529 let theme = Theme::default();
530 let one = Badge::new("1");
531 let style = Widget::<()>::style_themed(&one, &theme);
532 assert_eq!(style.width, style.height);
533 }
534
535 #[test]
537 fn a_long_count_widens_the_pill() {
538 let theme = Theme::default();
539 let width = |text: &str| match Widget::<()>::style_themed(&Badge::new(text), &theme).width {
540 Dimension::Length(px) => px,
541 other => panic!("a stated width, not {other:?}"),
542 };
543 let (narrow, wide) = (width("1"), width("1024"));
544 assert!(wide > narrow, "{wide} should exceed {narrow}");
545 }
546
547 #[test]
553 fn a_pinned_badge_sits_where_the_reference_puts_it() {
554 let icon = Rect::new(100.0, 50.0, 24.0, 24.0);
555 let mut theme = Theme::default();
556 let pill = Badge::new("3").pinned_rect(icon, &theme);
557 assert_eq!((pill.x, pill.y), (100.0 + 24.0 - 16.0 + 4.0, 50.0 - 4.0));
558 assert_eq!(pill.height, 16.0);
559 let dot = Badge::dot().pinned_rect(icon, &theme);
560 assert_eq!((dot.x, dot.y, dot.width), (100.0 + 24.0 - 6.0, 50.0, 6.0));
561
562 theme.direction = TextDirection::Rtl;
563 let pill = Badge::new("3").pinned_rect(icon, &theme);
564 assert_eq!(
565 (pill.x, pill.y),
566 (100.0 - 4.0, 50.0 - 4.0),
567 "at the start, mirrored"
568 );
569 let dot = Badge::dot().pinned_rect(icon, &theme);
570 assert_eq!((dot.x, dot.y), (100.0, 50.0));
571 }
572
573 #[test]
576 fn alignment_and_offset_are_the_instances_then_the_themes() {
577 let icon = Rect::new(0.0, 0.0, 24.0, 24.0);
578 let mut theme = Theme::default();
579 let placed = Badge::new("3")
580 .alignment(Alignment::new(-1.0, 1.0))
581 .offset(0.0, -8.0)
582 .pinned_rect(icon, &theme);
583 assert_eq!((placed.x, placed.y), (0.0, 24.0 - 8.0));
584
585 theme.widgets.badge.alignment =
586 Some(AlignmentGeometry::Physical(Alignment::new(-1.0, 1.0)));
587 theme.widgets.badge.offset = Some((0.0, -8.0));
588 let themed = Badge::new("3").pinned_rect(icon, &theme);
589 assert_eq!((themed.x, themed.y), (0.0, 24.0 - 8.0));
590 }
591
592 #[test]
596 fn on_a_child_the_badge_is_drawn_over_it() {
597 let theme = Theme::default();
598 let badged: Badged<()> = Badge::count(3).on(crate::Spacer::default());
599 assert_eq!(Widget::<()>::children(&badged).len(), 1);
600 assert!(badged.paints_over());
601 let mut scene = Scene::new();
602 badged.paint_over(
603 Rect::new(0.0, 0.0, 24.0, 24.0),
604 Status::default(),
605 &theme,
606 &mut scene,
607 );
608 let pill = scene.primitives().iter().find_map(|p| match p {
609 Primitive::Rect { rect, .. } => Some(*rect),
610 _ => None,
611 });
612 let pill = pill.expect("the pill");
613 assert!(
614 pill.y < 0.0 && pill.x + pill.width > 24.0,
615 "past the corner: {pill:?}"
616 );
617 assert_eq!(texts(scene.primitives()), vec!["3"]);
618
619 let hidden: Badged<()> = Badge::count(0)
620 .label_visible(false)
621 .on(crate::Spacer::default());
622 assert!(!hidden.paints_over());
623 assert!(Widget::<()>::semantics(&hidden).is_none());
624 }
625
626 #[test]
629 fn the_colours_are_the_instances_then_the_themes() {
630 let mut theme = Theme::default();
631 let default_fill = |primitives: &[Primitive]| match primitives.first() {
632 Some(Primitive::Rect { color, .. }) => *color,
633 _ => panic!("a pill"),
634 };
635 let plain = painted(&Badge::new("3"), Rect::new(0.0, 0.0, 24.0, 18.0), &theme);
636 assert_eq!(
637 default_fill(&plain),
638 theme.scheme.error,
639 "the scheme's role"
640 );
641
642 theme.widgets.badge.background_color = Some(Color::rgb(0.0, 1.0, 0.0));
643 let themed = painted(&Badge::new("3"), Rect::new(0.0, 0.0, 24.0, 18.0), &theme);
644 assert_eq!(
645 default_fill(&themed),
646 Color::rgb(0.0, 1.0, 0.0),
647 "the theme"
648 );
649
650 let overridden = painted(
651 &Badge::new("3").background_color(Color::rgb(0.0, 0.0, 1.0)),
652 Rect::new(0.0, 0.0, 24.0, 18.0),
653 &theme,
654 );
655 assert_eq!(
656 default_fill(&overridden),
657 Color::rgb(0.0, 0.0, 1.0),
658 "the instance wins"
659 );
660 }
661
662 #[test]
665 fn a_count_is_announced_and_a_dot_is_not() {
666 let counted = Widget::<()>::semantics(&Badge::new("3")).expect("announced");
667 assert_eq!(counted.label.as_deref(), Some("3"));
668 let counted = Widget::<()>::semantics(&Badge::count(1200)).expect("announced");
669 assert_eq!(counted.label.as_deref(), Some("999+"));
670 assert!(Widget::<()>::semantics(&Badge::dot()).is_none());
671 }
672}