revue/widget/
badge.rs

1//! Badge widget for labels and status indicators
2
3use super::traits::{RenderContext, View, WidgetProps};
4use crate::render::{Cell, Modifier};
5use crate::style::Color;
6use crate::{impl_props_builders, impl_styled_view};
7
8/// Badge variant/color preset
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum BadgeVariant {
11    /// Default/neutral (gray)
12    #[default]
13    Default,
14    /// Primary (blue)
15    Primary,
16    /// Success (green)
17    Success,
18    /// Warning (yellow/orange)
19    Warning,
20    /// Error/Danger (red)
21    Error,
22    /// Info (cyan)
23    Info,
24}
25
26impl BadgeVariant {
27    /// Get colors for this variant (bg, fg)
28    pub fn colors(&self) -> (Color, Color) {
29        match self {
30            BadgeVariant::Default => (Color::rgb(80, 80, 80), Color::WHITE),
31            BadgeVariant::Primary => (Color::rgb(50, 100, 200), Color::WHITE),
32            BadgeVariant::Success => (Color::rgb(40, 160, 80), Color::WHITE),
33            BadgeVariant::Warning => (Color::rgb(200, 150, 40), Color::BLACK),
34            BadgeVariant::Error => (Color::rgb(200, 60, 60), Color::WHITE),
35            BadgeVariant::Info => (Color::rgb(60, 160, 180), Color::WHITE),
36        }
37    }
38}
39
40/// Badge shape
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
42pub enum BadgeShape {
43    /// Rounded with space padding (default)
44    #[default]
45    Rounded,
46    /// Square/rectangular
47    Square,
48    /// Pill shape (extra rounded)
49    Pill,
50    /// Dot indicator
51    Dot,
52}
53
54/// A badge widget for labels, counts, and status indicators
55///
56/// # Example
57///
58/// ```rust,ignore
59/// use revue::prelude::*;
60///
61/// hstack()
62///     .child(text("Messages"))
63///     .child(badge("5").primary())
64/// ```
65pub struct Badge {
66    /// Content text
67    text: String,
68    /// Variant
69    variant: BadgeVariant,
70    /// Shape
71    shape: BadgeShape,
72    /// Custom background color
73    bg_color: Option<Color>,
74    /// Custom foreground color
75    fg_color: Option<Color>,
76    /// Bold text
77    bold: bool,
78    /// Max width (0 = auto)
79    max_width: u16,
80    props: WidgetProps,
81}
82
83impl Badge {
84    /// Create a new badge
85    pub fn new(text: impl Into<String>) -> Self {
86        Self {
87            text: text.into(),
88            variant: BadgeVariant::Default,
89            shape: BadgeShape::Rounded,
90            bg_color: None,
91            fg_color: None,
92            bold: false,
93            max_width: 0,
94            props: WidgetProps::new(),
95        }
96    }
97
98    /// Create a dot badge (status indicator)
99    pub fn dot() -> Self {
100        Self {
101            text: String::new(),
102            variant: BadgeVariant::Default,
103            shape: BadgeShape::Dot,
104            bg_color: None,
105            fg_color: None,
106            bold: false,
107            max_width: 0,
108            props: WidgetProps::new(),
109        }
110    }
111
112    /// Set variant
113    pub fn variant(mut self, variant: BadgeVariant) -> Self {
114        self.variant = variant;
115        self
116    }
117
118    /// Set shape
119    pub fn shape(mut self, shape: BadgeShape) -> Self {
120        self.shape = shape;
121        self
122    }
123
124    /// Primary variant shorthand
125    pub fn primary(mut self) -> Self {
126        self.variant = BadgeVariant::Primary;
127        self
128    }
129
130    /// Success variant shorthand
131    pub fn success(mut self) -> Self {
132        self.variant = BadgeVariant::Success;
133        self
134    }
135
136    /// Warning variant shorthand
137    pub fn warning(mut self) -> Self {
138        self.variant = BadgeVariant::Warning;
139        self
140    }
141
142    /// Error variant shorthand
143    pub fn error(mut self) -> Self {
144        self.variant = BadgeVariant::Error;
145        self
146    }
147
148    /// Info variant shorthand
149    pub fn info(mut self) -> Self {
150        self.variant = BadgeVariant::Info;
151        self
152    }
153
154    /// Pill shape shorthand
155    pub fn pill(mut self) -> Self {
156        self.shape = BadgeShape::Pill;
157        self
158    }
159
160    /// Square shape shorthand
161    pub fn square(mut self) -> Self {
162        self.shape = BadgeShape::Square;
163        self
164    }
165
166    /// Set custom background color
167    pub fn bg(mut self, color: Color) -> Self {
168        self.bg_color = Some(color);
169        self
170    }
171
172    /// Set custom foreground color
173    pub fn fg(mut self, color: Color) -> Self {
174        self.fg_color = Some(color);
175        self
176    }
177
178    /// Set custom colors
179    pub fn colors(mut self, bg: Color, fg: Color) -> Self {
180        self.bg_color = Some(bg);
181        self.fg_color = Some(fg);
182        self
183    }
184
185    /// Make text bold
186    pub fn bold(mut self) -> Self {
187        self.bold = true;
188        self
189    }
190
191    /// Set max width
192    pub fn max_width(mut self, width: u16) -> Self {
193        self.max_width = width;
194        self
195    }
196
197    /// Get effective colors
198    fn effective_colors(&self) -> (Color, Color) {
199        let (default_bg, default_fg) = self.variant.colors();
200        (
201            self.bg_color.unwrap_or(default_bg),
202            self.fg_color.unwrap_or(default_fg),
203        )
204    }
205}
206
207impl Default for Badge {
208    fn default() -> Self {
209        Self::new("")
210    }
211}
212
213impl View for Badge {
214    fn render(&self, ctx: &mut RenderContext) {
215        let area = ctx.area;
216        let (bg, fg) = self.effective_colors();
217
218        match self.shape {
219            BadgeShape::Dot => {
220                // Just a colored dot
221                let mut cell = Cell::new('●');
222                cell.fg = Some(bg); // Use bg color as the dot color
223                ctx.buffer.set(area.x, area.y, cell);
224            }
225            BadgeShape::Rounded | BadgeShape::Square | BadgeShape::Pill => {
226                let text_len = self.text.chars().count() as u16;
227                let padding = match self.shape {
228                    BadgeShape::Pill => 2,
229                    BadgeShape::Rounded => 1,
230                    BadgeShape::Square => 1,
231                    _ => 1,
232                };
233
234                let total_width = text_len + padding * 2;
235                let width = if self.max_width > 0 {
236                    total_width.min(self.max_width).min(area.width)
237                } else {
238                    total_width.min(area.width)
239                };
240
241                // Render background and text
242                for i in 0..width {
243                    let x = area.x + i;
244                    let ch = if i < padding || i >= width - padding {
245                        ' '
246                    } else {
247                        let char_idx = (i - padding) as usize;
248                        self.text.chars().nth(char_idx).unwrap_or(' ')
249                    };
250
251                    let mut cell = Cell::new(ch);
252                    cell.fg = Some(fg);
253                    cell.bg = Some(bg);
254                    if self.bold {
255                        cell.modifier |= Modifier::BOLD;
256                    }
257                    ctx.buffer.set(x, area.y, cell);
258                }
259            }
260        }
261    }
262
263    crate::impl_view_meta!("Badge");
264}
265
266/// Create a new badge
267pub fn badge(text: impl Into<String>) -> Badge {
268    Badge::new(text)
269}
270
271/// Create a dot badge
272pub fn dot_badge() -> Badge {
273    Badge::dot()
274}
275
276impl_styled_view!(Badge);
277impl_props_builders!(Badge);
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::layout::Rect;
283    use crate::render::Buffer;
284
285    #[test]
286    fn test_badge_new() {
287        let b = Badge::new("Test");
288        assert_eq!(b.text, "Test");
289        assert_eq!(b.variant, BadgeVariant::Default);
290    }
291
292    #[test]
293    fn test_badge_variants() {
294        let b = badge("OK").success();
295        assert_eq!(b.variant, BadgeVariant::Success);
296
297        let b = badge("Error").error();
298        assert_eq!(b.variant, BadgeVariant::Error);
299
300        let b = badge("Info").info();
301        assert_eq!(b.variant, BadgeVariant::Info);
302    }
303
304    #[test]
305    fn test_badge_shapes() {
306        let b = badge("Tag").pill();
307        assert_eq!(b.shape, BadgeShape::Pill);
308
309        let b = badge("Box").square();
310        assert_eq!(b.shape, BadgeShape::Square);
311    }
312
313    #[test]
314    fn test_badge_dot() {
315        let b = Badge::dot().success();
316        assert_eq!(b.shape, BadgeShape::Dot);
317    }
318
319    #[test]
320    fn test_badge_render() {
321        let mut buffer = Buffer::new(20, 1);
322        let area = Rect::new(0, 0, 20, 1);
323        let mut ctx = RenderContext::new(&mut buffer, area);
324
325        let b = badge("NEW").primary();
326        b.render(&mut ctx);
327
328        // Should have padding + text
329        let text: String = (0..20)
330            .filter_map(|x| buffer.get(x, 0).map(|c| c.symbol))
331            .collect();
332        assert!(text.contains("NEW"));
333    }
334
335    #[test]
336    fn test_badge_dot_render() {
337        let mut buffer = Buffer::new(5, 1);
338        let area = Rect::new(0, 0, 5, 1);
339        let mut ctx = RenderContext::new(&mut buffer, area);
340
341        let b = dot_badge().success();
342        b.render(&mut ctx);
343
344        assert_eq!(buffer.get(0, 0).map(|c| c.symbol), Some('●'));
345    }
346
347    #[test]
348    fn test_variant_colors() {
349        let (bg, fg) = BadgeVariant::Success.colors();
350        assert_eq!(fg, Color::WHITE);
351        assert_ne!(bg, Color::WHITE);
352    }
353
354    #[test]
355    fn test_custom_colors() {
356        let b = badge("Test").bg(Color::MAGENTA).fg(Color::BLACK);
357
358        let (bg, fg) = b.effective_colors();
359        assert_eq!(bg, Color::MAGENTA);
360        assert_eq!(fg, Color::BLACK);
361    }
362
363    #[test]
364    fn test_helper_functions() {
365        let b = badge("Hi");
366        assert_eq!(b.text, "Hi");
367
368        let d = dot_badge();
369        assert_eq!(d.shape, BadgeShape::Dot);
370    }
371}