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//! It is a **standalone** mark: a pill you put where you like, in a row or beside a
10//! title. The reference's also takes a child and pins itself to that child's corner — see
11//! the note for milestone 378 for why that half is a separate step and not a builder.
12
13use 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
20/// The reference's `smallSize`: the diameter of a badge with no label.
21const DOT: f32 = 6.0;
22/// The reference's `largeSize`: the height of a badge that carries one.
23const PILL: f32 = 16.0;
24/// The label's room either side, inside the pill.
25const PAD_X: f32 = 4.0;
26
27/// A small mark: a counted pill, or a dot.
28pub struct Badge {
29    /// The text on the pill. `None` is a **dot**, which is a shape rather than an empty
30    /// label — see [`Badge::dot`].
31    label: Option<String>,
32    /// Whether the label is shown at all; `false` falls back to the dot. The reference's
33    /// `isLabelVisible`, and what a count of zero wants.
34    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    /// A badge carrying `text`.
45    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    /// A badge with **no** label: a small dot.
59    ///
60    /// What most notification marks are. *Something happened* is the whole message, and a
61    /// count that nobody reads is a number taking up room on a bell.
62    pub fn dot() -> Self {
63        let mut badge = Self::new(String::new());
64        badge.label = None;
65        badge
66    }
67
68    /// Hides the label without changing the badge to a dot at the call site.
69    ///
70    /// The reference's `isLabelVisible`, and it earns its place: a count of zero wants
71    /// the mark gone or shrunk, and deciding that at the call site means an `if` around
72    /// a widget rather than a value inside one.
73    pub fn label_visible(mut self, visible: bool) -> Self {
74        self.label_visible = visible;
75        self
76    }
77
78    /// The pill's fill; the theme's `error` otherwise, as the reference has it — a badge
79    /// is an alert, not an accent.
80    pub fn background_color(mut self, color: Color) -> Self {
81        self.background_color = Some(color);
82        self
83    }
84
85    /// The label's colour on that fill; the theme's `on_error` otherwise.
86    pub fn text_color(mut self, color: Color) -> Self {
87        self.text_color = Some(color);
88        self
89    }
90
91    /// The label's whole style — size, weight, family. Its colour is
92    /// [`text_color`](Badge::text_color)'s.
93    pub fn text_style(mut self, style: TextStyle) -> Self {
94        self.text_style = Some(style);
95        self
96    }
97
98    /// The diameter of a badge with no label. The reference's `smallSize`.
99    pub fn small_size(mut self, size: f32) -> Self {
100        self.small_size = Some(size);
101        self
102    }
103
104    /// The height of a badge that carries one. The reference's `largeSize`.
105    pub fn large_size(mut self, size: f32) -> Self {
106        self.large_size = Some(size);
107        self
108    }
109
110    /// The label's room either side.
111    pub fn padding(mut self, padding: f32) -> Self {
112        self.padding = Some(padding);
113        self
114    }
115
116    /// The text actually drawn, or `None` for a dot.
117    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    /// The box the mark itself occupies.
164    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        // Never narrower than it is tall: a single digit in a wide pill reads as a
173        // mistake, and the reference rounds a one-character badge to a circle for the
174        // same reason.
175        (
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        // Centred both ways rather than placed from the corner: the pill is as wide as
215        // its label needs *or* as wide as it is tall, whichever is more, so a one-digit
216        // badge has room to spare and a corner offset would sit it off to one side.
217        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        // A dot has nothing to read out. A count does, and a screen reader that never
234        // hears it is a reader who never learns there are three unread messages.
235        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    /// A dot is a **shape**, not an empty label: no text primitive at all, and a box as
267    /// wide as it is tall.
268    #[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    /// Hiding the label falls back to the dot rather than to an empty pill, which would
282    /// be a wide blank mark saying nothing.
283    #[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    /// A one-character badge is never narrower than it is tall: a lone digit in a wide
294    /// pill reads as a mistake.
295    #[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    /// A long count still grows: the floor is a minimum, not a size.
304    #[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    /// The colours resolve **instance, then theme, then the scheme's role** — the chain
316    /// every widget since `Chip` has followed.
317    #[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    /// A count is read out. A dot is not: there is nothing to say beyond the mark itself,
352    /// and announcing an empty label would interrupt a reader with nothing.
353    #[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}