1use crate::geometry::{Rect, Size};
4use crate::style::CellStyle;
5use crate::text;
6use crate::widget::{MeasureCx, PaintCx, Widget};
7
8use super::cells;
9
10const COUNT_LIMIT: u32 = 99;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Badge {
23 label: String,
24 variant: Option<String>,
25 count: Option<u32>,
26}
27
28impl Badge {
29 #[must_use]
31 pub fn new(label: impl Into<String>) -> Self {
32 Self { label: label.into(), variant: None, count: None }
33 }
34
35 #[must_use]
38 pub fn variant(mut self, variant: impl Into<String>) -> Self {
39 self.variant = Some(variant.into());
40 self
41 }
42
43 #[must_use]
45 pub fn count(mut self, count: u32) -> Self {
46 self.count = Some(count);
47 self
48 }
49
50 fn count_text(&self) -> Option<String> {
51 self.count.map(|count| if count > COUNT_LIMIT { format!(" {COUNT_LIMIT}+ ") } else { format!(" {count} ") })
52 }
53}
54
55impl<Msg: 'static> Widget<Msg> for Badge {
56 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
57 let dot = text::width(&cx.env().icons().glyph("dot"));
58 let count = self.count_text().as_deref().map_or(0, text::width);
59 let width = cells::sum([1, dot, 1, text::width(&self.label), 1, count]);
62 Size::new(width, 1).min(available)
63 }
64
65 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
66 if area.is_empty() {
67 return;
68 }
69 let variant = self.variant.as_deref();
70 let style = cx.style("badge", variant, &[]);
71 let surface = style.text();
72 let bg = surface.bg.unwrap_or_else(|| cx.color("raised"));
73 let fg = surface.fg.unwrap_or_else(|| cx.color("dim"));
74 let dot_color = style.color("dot").unwrap_or(fg);
75
76 let count = self.count_text();
77 let count_width = count.as_deref().map_or(0, text::width).min(area.width);
78 let pill_width = area.width - count_width;
79 cx.clear(Rect::new(area.x, area.y, pill_width, 1), bg);
80
81 let glyph = cx.env().icons().glyph("dot").into_owned();
82 let mut x = area.x + 1;
83 let right = area.x + i32::from(pill_width) - 1;
84 let budget = |x: i32| crate::geometry::clamp_u16(right - x);
85 x += i32::from(cx.text(x, area.y, &glyph, CellStyle::fg(dot_color), budget(x)));
86 x += 1;
87 let label = text::truncate(&self.label, budget(x)).into_owned();
88 cx.text(x, area.y, &label, CellStyle { bg: None, ..surface }, budget(x));
89
90 if let Some(count) = count {
91 let count_style = cx.style("badge-count", variant, &[]).text();
92 let count_bg = count_style.bg.unwrap_or(bg);
93 let start = area.x + i32::from(pill_width);
94 cx.clear(Rect::new(start, area.y, count_width, 1), count_bg);
95 cx.text(start, area.y, &count, count_style, count_width);
96 }
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::icons::GlyphMode;
104 use crate::runtime::{App, Command, Harness};
105 use crate::widget::View;
106
107 struct Demo(Vec<Badge>);
108
109 impl App for Demo {
110 type Msg = ();
111 fn update(&mut self, _: ()) -> Command<()> {
112 Command::none()
113 }
114 fn view(&self, ui: &mut View<'_, ()>) {
115 ui.row(|ui| {
116 for badge in &self.0 {
117 ui.add(badge.clone());
118 }
119 })
120 .gap(1);
121 }
122 }
123
124 #[test]
125 fn draws_a_tinted_pill_with_a_marker() {
126 let h = Harness::new(Demo(vec![Badge::new("Running").variant("success"), Badge::new("Idle")]), 30, 1);
127 assert_eq!(h.screen(), " ● Running ● Idle\n");
128 let theme = h.env().theme();
129 let success = theme.color("success").expect("token");
130 let surface = theme.color("surface").expect("token");
131 assert_eq!(h.bg(0, 0), Some(surface.mix(success, 0.16)));
132 assert_eq!(h.fg(1, 0), Some(success));
133 assert_eq!(h.fg(3, 0), Some(success));
134 assert_eq!(h.bg(12, 0), theme.color("raised"));
135 assert_eq!(h.fg(15, 0), theme.color("dim"));
136 }
137
138 #[test]
139 fn count_segment_caps_at_ninety_nine() {
140 let h = Harness::new(
141 Demo(vec![Badge::new("Alerts").variant("danger").count(3), Badge::new("Logs").count(250)]),
142 40,
143 1,
144 );
145 assert_eq!(h.screen(), " ● Alerts 3 ● Logs 99+\n");
146 assert_ne!(h.bg(10, 0), h.bg(8, 0), "the count sits on a stronger tint");
147 }
148
149 #[test]
150 fn truncates_when_narrow_and_uses_ascii_marker() {
151 let mut h = Harness::new(Demo(vec![Badge::new("Degraded performance").variant("warning")]), 12, 1);
152 assert_eq!(h.screen(), " ● Degrade…\n");
153 h.set_glyph_mode(GlyphMode::Ascii);
154 assert_eq!(h.screen(), " * Degrade…\n");
155 }
156
157 #[test]
158 fn a_label_wider_than_any_screen_is_cut_without_overflowing() {
159 let h = Harness::new(Demo(vec![Badge::new("x".repeat(70_000)).count(7)]), 12, 1);
160 assert_eq!(h.screen(), " ● xxxx… 7\n");
161 }
162}