Skip to main content

qframe/widgets/
badge.rs

1//! Badges: small status pills.
2
3use crate::geometry::{Rect, Size};
4use crate::style::CellStyle;
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8use super::cells;
9
10/// Counts above this are shown as `99+`.
11const COUNT_LIMIT: u32 = 99;
12
13/// How a count reads wherever the framework shows one as a badge: the number, or `99+` above 99.
14pub(crate) fn count_label(count: u32) -> String {
15    if count > COUNT_LIMIT { format!("{COUNT_LIMIT}+") } else { count.to_string() }
16}
17
18/// A status pill: a tinted surface with a marker dot and a word, and optionally a count.
19///
20/// The shape is the tint, one cell of padding on each side, never brackets. Tones come from
21/// theme variants: no variant is neutral, and the built-in themes define `success`, `warning`,
22/// `danger`, `info` and `accent`. The marker keeps status readable without colour.
23///
24/// Style keys: `badge` and `badge.<variant>` (`bg`, `fg`, `dot`), `badge-count` and
25/// `badge-count.<variant>` (`bg`, `fg`, `bold`).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Badge {
28    label: String,
29    variant: Option<String>,
30    count: Option<u32>,
31}
32
33impl Badge {
34    /// A neutral badge reading `label`.
35    #[must_use]
36    pub fn new(label: impl Into<String>) -> Self {
37        Self { label: label.into(), variant: None, count: None }
38    }
39
40    /// Theme variant for the tone, e.g. `"success"`, `"warning"`, `"danger"`, `"info"` or
41    /// `"accent"`.
42    #[must_use]
43    pub fn variant(mut self, variant: impl Into<String>) -> Self {
44        self.variant = Some(variant.into());
45        self
46    }
47
48    /// Adds a count in a stronger segment after the label; counts above 99 read `99+`.
49    #[must_use]
50    pub fn count(mut self, count: u32) -> Self {
51        self.count = Some(count);
52        self
53    }
54
55    fn count_text(&self) -> Option<String> {
56        self.count.map(|count| format!(" {} ", count_label(count)))
57    }
58}
59
60impl<Msg: 'static> Widget<Msg> for Badge {
61    fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
62        let dot = text::width(&cx.env().icons().glyph("dot"));
63        let count = self.count_text().as_deref().map_or(0, text::width);
64        // Padding, the dot, a space, the label, padding, then the count segment. A label wider than
65        // any screen saturates instead of overflowing.
66        let width = cells::sum([1, dot, 1, text::width(&self.label), 1, count]);
67        Size::new(width, 1).min(available)
68    }
69
70    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
71        if area.is_empty() {
72            return;
73        }
74        let variant = self.variant.as_deref();
75        let style = cx.style("badge", variant, &[]);
76        let surface = style.text();
77        let bg = surface.bg.unwrap_or_else(|| cx.color("raised"));
78        let fg = surface.fg.unwrap_or_else(|| cx.color("dim"));
79        let dot_color = style.color("dot").unwrap_or(fg);
80
81        let count = self.count_text();
82        let count_width = count.as_deref().map_or(0, text::width).min(area.width);
83        let pill_width = area.width - count_width;
84        cx.clear(Rect::new(area.x, area.y, pill_width, 1), bg);
85
86        let glyph = cx.env().icons().glyph("dot").into_owned();
87        let mut x = area.x + 1;
88        let right = area.x + i32::from(pill_width) - 1;
89        let budget = |x: i32| crate::geometry::clamp_u16(right - x);
90        x += i32::from(cx.text(x, area.y, &glyph, CellStyle::fg(dot_color), budget(x)));
91        x += 1;
92        let label = text::truncate(&self.label, budget(x)).into_owned();
93        cx.text(x, area.y, &label, CellStyle { bg: None, ..surface }, budget(x));
94
95        if let Some(count) = count {
96            let count_style = cx.style("badge-count", variant, &[]).text();
97            let count_bg = count_style.bg.unwrap_or(bg);
98            let start = area.x + i32::from(pill_width);
99            cx.clear(Rect::new(start, area.y, count_width, 1), count_bg);
100            cx.text(start, area.y, &count, count_style, count_width);
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::icons::GlyphMode;
109    use crate::runtime::{App, Command, Harness};
110    use crate::widget::View;
111
112    struct Demo(Vec<Badge>);
113
114    impl App for Demo {
115        type Msg = ();
116        fn update(&mut self, _: ()) -> Command<()> {
117            Command::none()
118        }
119        fn view(&self, ui: &mut View<'_, ()>) {
120            ui.row(|ui| {
121                for badge in &self.0 {
122                    ui.add(badge.clone());
123                }
124            })
125            .gap(1);
126        }
127    }
128
129    #[test]
130    fn draws_a_tinted_pill_with_a_marker() {
131        let h = Harness::new(Demo(vec![Badge::new("Running").variant("success"), Badge::new("Idle")]), 30, 1);
132        assert_eq!(h.screen(), " ● Running   ● Idle\n");
133        let theme = h.env().theme();
134        let success = theme.color("success").expect("token");
135        let surface = theme.color("surface").expect("token");
136        assert_eq!(h.bg(0, 0), Some(surface.mix(success, 0.16)));
137        assert_eq!(h.fg(1, 0), Some(success));
138        assert_eq!(h.fg(3, 0), Some(success));
139        assert_eq!(h.bg(12, 0), theme.color("raised"));
140        assert_eq!(h.fg(15, 0), theme.color("dim"));
141    }
142
143    #[test]
144    fn count_segment_caps_at_ninety_nine() {
145        let h = Harness::new(
146            Demo(vec![Badge::new("Alerts").variant("danger").count(3), Badge::new("Logs").count(250)]),
147            40,
148            1,
149        );
150        assert_eq!(h.screen(), " ● Alerts  3   ● Logs  99+\n");
151        assert_ne!(h.bg(10, 0), h.bg(8, 0), "the count sits on a stronger tint");
152    }
153
154    #[test]
155    fn truncates_when_narrow_and_uses_ascii_marker() {
156        let mut h = Harness::new(Demo(vec![Badge::new("Degraded performance").variant("warning")]), 12, 1);
157        assert_eq!(h.screen(), " ● Degrade…\n");
158        h.set_glyph_mode(GlyphMode::Ascii);
159        assert_eq!(h.screen(), " * Degrade…\n");
160    }
161
162    #[test]
163    fn a_label_wider_than_any_screen_is_cut_without_overflowing() {
164        let h = Harness::new(Demo(vec![Badge::new("x".repeat(70_000)).count(7)]), 12, 1);
165        assert_eq!(h.screen(), " ● xxxx…  7\n");
166    }
167}