Skip to main content

frus_widgets/
badge.rs

1//! [`Badge`]: the small mark that says *there is something here* — a count on an inbox,
2//! a dot on a bell.
3//!
4//! Two shapes, and the reference draws the same distinction. With a label it is a pill
5//! carrying a number or a word; without one it is a **dot**, which says the same thing
6//! without saying how much. A dot is what most notification marks are, and it is the one
7//! this widget could not draw at all before milestone 378.
8//!
9//! Two ways to place it (milestone 707):
10//!
11//! - **On its own**, a pill you put where you like — in a row, beside a title.
12//! - **On a child**, with [`Badge::on`]: pinned to the child's top-end corner, partly over
13//!   it, the way the reference pins it — the count on a mail icon, the dot on a bell. Where on
14//!   the child is [`Badge::alignment`] and [`Badge::offset`], each from the theme otherwise,
15//!   and mirrored for a right-to-left reading.
16//!
17//! [`Badge::count`] writes a number, capped as the reference caps it (`999+`), and
18//! [`Badge::label_visible`] takes the badge away — not to a dot, away — which is what a count
19//! of zero wants.
20
21use 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
29/// The reference's `smallSize`: the diameter of a badge with no label.
30const DOT: f32 = 6.0;
31/// The reference's `largeSize`: the height of a badge that carries one.
32const PILL: f32 = 16.0;
33/// The label's room either side, inside the pill.
34const PAD_X: f32 = 4.0;
35/// The reference's `maxCount`: the largest count written out before it becomes `999+`.
36const MAX_COUNT: u32 = 999;
37/// The reference's offset for a pinned badge with a label, for a left-to-right reading: a
38/// little past the child's end, a little above its top.
39const OFFSET: (f32, f32) = (4.0, -4.0);
40
41/// What a count reads as: the number, or `max+` past `max` — the reference's
42/// `Badge.count`.
43pub(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/// A small mark: a counted pill, or a dot.
52#[derive(Clone, Debug)]
53pub struct Badge {
54    /// The text on the pill. `None` is a **dot**, which is a shape rather than an empty
55    /// label — see [`Badge::dot`].
56    label: Option<String>,
57    /// A count, written by [`Badge::count`] and capped at `max_count`.
58    count: Option<u32>,
59    max_count: u32,
60    /// Whether the badge is shown at all: the reference's `isLabelVisible`.
61    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    /// A badge carrying `text`.
74    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    /// A badge carrying a **count**: the number, or `999+` past
92    /// [`max_count`](Self::max_count). The reference's `Badge.count`.
93    pub fn count(count: u32) -> Self {
94        let mut badge = Self::new(String::new());
95        badge.count = Some(count);
96        badge
97    }
98
99    /// The largest count written out; past it the badge reads `max+`. 999 by default.
100    pub fn max_count(mut self, max: u32) -> Self {
101        self.max_count = max.max(1);
102        self
103    }
104
105    /// A badge with **no** label: a small dot.
106    ///
107    /// What most notification marks are. *Something happened* is the whole message, and a
108    /// count that nobody reads is a number taking up room on a bell.
109    pub fn dot() -> Self {
110        let mut badge = Self::new(String::new());
111        badge.label = None;
112        badge
113    }
114
115    /// Whether the badge is shown. `false` takes it away — on its own it takes no room and
116    /// draws nothing, and on a child only the child is left — so a count of zero needs no
117    /// `if` around the widget: `Badge::count(n).label_visible(n > 0)`. The reference's
118    /// `isLabelVisible`.
119    pub fn label_visible(mut self, visible: bool) -> Self {
120        self.label_visible = visible;
121        self
122    }
123
124    /// The pill's fill; the theme's `error` otherwise, as the reference has it — a badge
125    /// is an alert, not an accent.
126    pub fn background_color(mut self, color: Color) -> Self {
127        self.background_color = Some(color);
128        self
129    }
130
131    /// The label's colour on that fill; the theme's `on_error` otherwise.
132    pub fn text_color(mut self, color: Color) -> Self {
133        self.text_color = Some(color);
134        self
135    }
136
137    /// The label's whole style — size, weight, family. Its colour is
138    /// [`text_color`](Badge::text_color)'s.
139    pub fn text_style(mut self, style: TextStyle) -> Self {
140        self.text_style = Some(style);
141        self
142    }
143
144    /// The diameter of a badge with no label. The reference's `smallSize`.
145    pub fn small_size(mut self, size: f32) -> Self {
146        self.small_size = Some(size);
147        self
148    }
149
150    /// The height of a badge that carries one. The reference's `largeSize`.
151    pub fn large_size(mut self, size: f32) -> Self {
152        self.large_size = Some(size);
153        self
154    }
155
156    /// The label's room either side.
157    pub fn padding(mut self, padding: f32) -> Self {
158        self.padding = Some(padding);
159        self
160    }
161
162    /// Where on its child a pinned badge goes ([`Badge::on`]): the child's top-end corner
163    /// by default, or the theme's. An [`AlignmentDirectional`] follows the reading
164    /// direction.
165    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
166        self.alignment = Some(alignment.into());
167        self
168    }
169
170    /// How far a pinned badge with a label is moved from its place, `(across, down)`: a
171    /// little past the end and above the top by default — `(4, -4)`, mirrored for a
172    /// right-to-left reading — or the theme's. A dot is not moved: it sits in the corner.
173    pub fn offset(mut self, across: f32, down: f32) -> Self {
174        self.offset = Some((across, down));
175        self
176    }
177
178    /// Pins this badge to `child`: drawn over it, at its top-end corner unless
179    /// [`alignment`](Self::alignment) says otherwise. The child is laid out, and takes
180    /// input, exactly as it would alone; the badge takes no room of its own and may reach
181    /// past the child's edges.
182    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    /// The text drawn, or `None` for a dot.
194    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    /// The box the mark itself occupies; nothing when it is hidden.
241    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        // Never narrower than it is tall: a single digit in a wide pill reads as a
253        // mistake, and the reference rounds a one-character badge to a circle for the
254        // same reason.
255        (
256            (measured.width + self.pad(theme) * 2.0).max(height).ceil(),
257            height,
258        )
259    }
260
261    /// Where the mark goes when pinned over `child`: the reference's placement.
262    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        // The width a pinned badge is taken to have when it is placed: its height for a
284        // pill, its diameter for a dot — the reference's `widthOffset`.
285        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    /// Draws the mark in `rect`.
294    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        // Centred both ways rather than placed from the corner: the pill is as wide as
308        // its label needs *or* as wide as it is tall, whichever is more, so a one-digit
309        // badge has room to spare and a corner offset would sit it off to one side.
310        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    /// Draws the mark pinned over `anchor` — a child's box, or the glyph a navigation
322    /// destination hangs its count on.
323    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        // A dot has nothing to read out. A count does, and a screen reader that never
338        // hears it is a reader who never learns there are three unread messages.
339        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
347/// Where a badge `mark` big goes over `child`: `alignment` along the child's box less the
348/// badge's `width_offset` across, moved by `offset`, and a labelled badge raised by half its
349/// height — the reference's `_RenderBadge`, whose offset for a label also carries the
350/// `(0, 8)` it adds so as not to move the badges laid out before its placement changed.
351pub(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
406/// A [`Badge`] pinned to a child: see [`Badge::on`].
407pub 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    /// A dot is a **shape**, not an empty label: no text primitive at all, and a box as
478    /// wide as it is tall.
479    #[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    /// **A hidden badge is gone**, as the reference has it: no room, nothing drawn, nothing
493    /// read out — not a dot, which would still say *something is here* when the count is
494    /// zero (milestone 707; it used to fall back to the dot).
495    #[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    /// **A count is written out up to its cap, then `cap+`** — `999+` by default, as the
512    /// reference's `Badge.count` writes it.
513    #[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    /// A one-character badge is never narrower than it is tall: a lone digit in a wide
526    /// pill reads as a mistake.
527    #[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    /// A long count still grows: the floor is a minimum, not a size.
536    #[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    /// **Pinned, a labelled badge sits where the reference puts it**: on a 24-pixel icon at
548    /// the top-end corner, its left edge 12 pixels in (the icon's width less the pill's
549    /// height, plus the offset's 4) and its top 4 above the icon's (the offset's -4 and the
550    /// 8 the reference adds, less half the pill); a dot right in the corner; and the
551    /// mirror image for a right-to-left reading.
552    #[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    /// **Alignment and offset are the instance's, then the theme's**: a bottom-start badge
574    /// with no offset sits on the child's bottom-start corner, raised by half its height.
575    #[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    /// **On a child, the child is the box and the badge is drawn over it**: the child is the
593    /// one child, the badge paints after the subtree and reaches past the child's corner, and
594    /// a hidden badge leaves the child alone.
595    #[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    /// The colours resolve **instance, then theme, then the scheme's role** — the chain
627    /// every widget since `Chip` has followed.
628    #[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    /// A count is read out. A dot is not: there is nothing to say beyond the mark itself,
663    /// and announcing an empty label would interrupt a reader with nothing.
664    #[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}