Skip to main content

ftui_widgets/
badge.rs

1#![forbid(unsafe_code)]
2
3//! Badge widget.
4//!
5//! A small, single-line label with background + foreground styling and
6//! configurable left/right padding. Intended for "status", "priority", etc.
7//!
8//! Design goals:
9//! - No per-render heap allocations (draws directly to the `Frame`)
10//! - Deterministic output (stable padding + truncation)
11//! - Tiny-area safe (0 width/height is a no-op)
12
13use crate::{Widget, apply_style, clear_text_row, draw_text_span};
14use ftui_core::geometry::Rect;
15use ftui_render::cell::Cell;
16use ftui_render::frame::Frame;
17use ftui_style::Style;
18use ftui_text::display_width;
19
20/// A compact label with padding and style.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub struct Badge<'a> {
23    label: &'a str,
24    style: Style,
25    pad_left: u16,
26    pad_right: u16,
27}
28
29impl<'a> Badge<'a> {
30    /// Create a new badge with 1 cell padding on each side.
31    #[must_use]
32    pub fn new(label: &'a str) -> Self {
33        Self {
34            label,
35            style: Style::default(),
36            pad_left: 1,
37            pad_right: 1,
38        }
39    }
40
41    /// Set the badge style (foreground/background/attrs).
42    #[must_use]
43    pub fn with_style(mut self, style: Style) -> Self {
44        self.style = style;
45        self
46    }
47
48    /// Set the left/right padding in cells.
49    #[must_use]
50    pub fn with_padding(mut self, left: u16, right: u16) -> Self {
51        self.pad_left = left;
52        self.pad_right = right;
53        self
54    }
55
56    /// Display width in terminal cells (label width + padding).
57    #[inline]
58    #[must_use]
59    pub fn width(&self) -> u16 {
60        let label_width = display_width(self.label) as u16;
61        label_width
62            .saturating_add(self.pad_left)
63            .saturating_add(self.pad_right)
64    }
65
66    #[inline]
67    fn render_spaces(
68        frame: &mut Frame,
69        mut x: u16,
70        y: u16,
71        n: u16,
72        style: Style,
73        max_x: u16,
74    ) -> u16 {
75        let mut cell = Cell::from_char(' ');
76        apply_style(&mut cell, style);
77        for _ in 0..n {
78            if x >= max_x {
79                break;
80            }
81            frame.buffer.set_fast(x, y, cell);
82            x = x.saturating_add(1);
83        }
84        x
85    }
86}
87
88impl Widget for Badge<'_> {
89    fn render(&self, area: Rect, frame: &mut Frame) {
90        if area.is_empty() {
91            return;
92        }
93
94        let deg = frame.buffer.degradation;
95        if !deg.render_content() {
96            return;
97        }
98
99        let style = if deg.apply_styling() {
100            self.style
101        } else {
102            Style::default()
103        };
104
105        let y = area.y;
106        let max_x = area.right();
107        let mut x = area.x;
108
109        clear_text_row(frame, area, style);
110
111        x = Self::render_spaces(frame, x, y, self.pad_left, style, max_x);
112        x = draw_text_span(frame, x, y, self.label, style, max_x);
113        let _ = Self::render_spaces(frame, x, y, self.pad_right, style, max_x);
114    }
115
116    fn is_essential(&self) -> bool {
117        false
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use ftui_render::budget::DegradationLevel;
125    use ftui_render::cell::PackedRgba;
126    use ftui_render::grapheme_pool::GraphemePool;
127
128    #[test]
129    fn width_includes_padding() {
130        let badge = Badge::new("OK");
131        assert_eq!(badge.width(), 4);
132        let badge = Badge::new("OK").with_padding(2, 3);
133        assert_eq!(badge.width(), 7);
134    }
135
136    #[test]
137    fn renders_padded_label_with_style() {
138        let style = Style::new()
139            .fg(PackedRgba::rgb(1, 2, 3))
140            .bg(PackedRgba::rgb(4, 5, 6));
141        let badge = Badge::new("OK").with_style(style);
142
143        let mut pool = GraphemePool::new();
144        let mut frame = Frame::new(10, 1, &mut pool);
145        badge.render(Rect::new(0, 0, 10, 1), &mut frame);
146
147        let expected = [' ', 'O', 'K', ' '];
148        for (x, ch) in expected.into_iter().enumerate() {
149            let cell = frame.buffer.get(x as u16, 0).unwrap();
150            assert_eq!(cell.content.as_char(), Some(ch));
151            assert_eq!(cell.fg, PackedRgba::rgb(1, 2, 3));
152            assert_eq!(cell.bg, PackedRgba::rgb(4, 5, 6));
153        }
154    }
155
156    #[test]
157    fn truncates_in_small_area() {
158        let style = Style::new()
159            .fg(PackedRgba::rgb(1, 2, 3))
160            .bg(PackedRgba::rgb(4, 5, 6));
161        let badge = Badge::new("OK").with_style(style);
162
163        let mut pool = GraphemePool::new();
164        let mut frame = Frame::new(2, 1, &mut pool);
165        badge.render(Rect::new(0, 0, 2, 1), &mut frame);
166
167        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
168        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('O'));
169    }
170
171    #[test]
172    fn default_padding_is_one() {
173        let badge = Badge::new("X");
174        // "X" is 1 wide + 1 left + 1 right = 3
175        assert_eq!(badge.width(), 3);
176    }
177
178    #[test]
179    fn zero_padding() {
180        let badge = Badge::new("AB").with_padding(0, 0);
181        assert_eq!(badge.width(), 2);
182    }
183
184    #[test]
185    fn empty_label_width() {
186        let badge = Badge::new("");
187        // 0 label + 1 left + 1 right = 2
188        assert_eq!(badge.width(), 2);
189    }
190
191    #[test]
192    fn render_empty_area_is_noop() {
193        let badge = Badge::new("Test");
194        let mut pool = GraphemePool::new();
195        let mut frame = Frame::new(10, 1, &mut pool);
196        badge.render(Rect::new(0, 0, 0, 0), &mut frame);
197        // Should not panic
198    }
199
200    #[test]
201    fn is_not_essential() {
202        let badge = Badge::new("OK");
203        assert!(!badge.is_essential());
204    }
205
206    #[test]
207    fn render_no_styling_drops_configured_style() {
208        let style = Style::new()
209            .fg(PackedRgba::rgb(1, 2, 3))
210            .bg(PackedRgba::rgb(4, 5, 6));
211        let badge = Badge::new("OK").with_style(style);
212        let expected_badge = Badge::new("OK");
213
214        let mut pool = GraphemePool::new();
215        let mut frame = Frame::new(10, 1, &mut pool);
216        frame.buffer.degradation = DegradationLevel::NoStyling;
217        badge.render(Rect::new(0, 0, 10, 1), &mut frame);
218
219        let mut expected_pool = GraphemePool::new();
220        let mut expected = Frame::new(10, 1, &mut expected_pool);
221        expected_badge.render(Rect::new(0, 0, 10, 1), &mut expected);
222
223        assert_eq!(frame.buffer.get(1, 0), expected.buffer.get(1, 0));
224    }
225
226    #[test]
227    fn render_skeleton_is_noop() {
228        let badge = Badge::new("OK").with_style(
229            Style::new()
230                .fg(PackedRgba::rgb(1, 2, 3))
231                .bg(PackedRgba::rgb(4, 5, 6)),
232        );
233
234        let mut pool = GraphemePool::new();
235        let mut frame = Frame::new(10, 1, &mut pool);
236        let mut expected_pool = GraphemePool::new();
237        let expected = Frame::new(10, 1, &mut expected_pool);
238        frame.buffer.degradation = DegradationLevel::Skeleton;
239        badge.render(Rect::new(0, 0, 10, 1), &mut frame);
240
241        for x in 0..10 {
242            assert_eq!(frame.buffer.get(x, 0), expected.buffer.get(x, 0));
243        }
244    }
245
246    #[test]
247    fn render_shorter_label_clears_stale_suffix() {
248        let long = Badge::new("LONG");
249        let short = Badge::new("OK");
250
251        let mut pool = GraphemePool::new();
252        let mut frame = Frame::new(8, 1, &mut pool);
253        long.render(Rect::new(0, 0, 8, 1), &mut frame);
254        short.render(Rect::new(0, 0, 8, 1), &mut frame);
255
256        let row: String = (0..8)
257            .map(|x| {
258                frame
259                    .buffer
260                    .get(x, 0)
261                    .and_then(|cell| cell.content.as_char())
262                    .unwrap_or(' ')
263            })
264            .collect();
265        assert_eq!(row, " OK     ");
266    }
267
268    #[test]
269    fn badge_eq_and_hash() {
270        let a = Badge::new("X").with_padding(1, 1);
271        let b = Badge::new("X").with_padding(1, 1);
272        assert_eq!(a, b);
273
274        let mut set = std::collections::HashSet::new();
275        set.insert(a);
276        assert!(set.contains(&b));
277    }
278
279    #[test]
280    fn badge_debug() {
281        let badge = Badge::new("OK");
282        let s = format!("{badge:?}");
283        assert!(s.contains("Badge"));
284        assert!(s.contains("OK"));
285    }
286}