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, 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 y = area.y;
95        let max_x = area.right();
96        let mut x = area.x;
97
98        x = Self::render_spaces(frame, x, y, self.pad_left, self.style, max_x);
99        x = draw_text_span(frame, x, y, self.label, self.style, max_x);
100        let _ = Self::render_spaces(frame, x, y, self.pad_right, self.style, max_x);
101    }
102
103    fn is_essential(&self) -> bool {
104        false
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use ftui_render::cell::PackedRgba;
112    use ftui_render::grapheme_pool::GraphemePool;
113
114    #[test]
115    fn width_includes_padding() {
116        let badge = Badge::new("OK");
117        assert_eq!(badge.width(), 4);
118        let badge = Badge::new("OK").with_padding(2, 3);
119        assert_eq!(badge.width(), 7);
120    }
121
122    #[test]
123    fn renders_padded_label_with_style() {
124        let style = Style::new()
125            .fg(PackedRgba::rgb(1, 2, 3))
126            .bg(PackedRgba::rgb(4, 5, 6));
127        let badge = Badge::new("OK").with_style(style);
128
129        let mut pool = GraphemePool::new();
130        let mut frame = Frame::new(10, 1, &mut pool);
131        badge.render(Rect::new(0, 0, 10, 1), &mut frame);
132
133        let expected = [' ', 'O', 'K', ' '];
134        for (x, ch) in expected.into_iter().enumerate() {
135            let cell = frame.buffer.get(x as u16, 0).unwrap();
136            assert_eq!(cell.content.as_char(), Some(ch));
137            assert_eq!(cell.fg, PackedRgba::rgb(1, 2, 3));
138            assert_eq!(cell.bg, PackedRgba::rgb(4, 5, 6));
139        }
140    }
141
142    #[test]
143    fn truncates_in_small_area() {
144        let style = Style::new()
145            .fg(PackedRgba::rgb(1, 2, 3))
146            .bg(PackedRgba::rgb(4, 5, 6));
147        let badge = Badge::new("OK").with_style(style);
148
149        let mut pool = GraphemePool::new();
150        let mut frame = Frame::new(2, 1, &mut pool);
151        badge.render(Rect::new(0, 0, 2, 1), &mut frame);
152
153        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
154        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('O'));
155    }
156
157    #[test]
158    fn default_padding_is_one() {
159        let badge = Badge::new("X");
160        // "X" is 1 wide + 1 left + 1 right = 3
161        assert_eq!(badge.width(), 3);
162    }
163
164    #[test]
165    fn zero_padding() {
166        let badge = Badge::new("AB").with_padding(0, 0);
167        assert_eq!(badge.width(), 2);
168    }
169
170    #[test]
171    fn empty_label_width() {
172        let badge = Badge::new("");
173        // 0 label + 1 left + 1 right = 2
174        assert_eq!(badge.width(), 2);
175    }
176
177    #[test]
178    fn render_empty_area_is_noop() {
179        let badge = Badge::new("Test");
180        let mut pool = GraphemePool::new();
181        let mut frame = Frame::new(10, 1, &mut pool);
182        badge.render(Rect::new(0, 0, 0, 0), &mut frame);
183        // Should not panic
184    }
185
186    #[test]
187    fn is_not_essential() {
188        let badge = Badge::new("OK");
189        assert!(!badge.is_essential());
190    }
191
192    #[test]
193    fn badge_eq_and_hash() {
194        let a = Badge::new("X").with_padding(1, 1);
195        let b = Badge::new("X").with_padding(1, 1);
196        assert_eq!(a, b);
197
198        let mut set = std::collections::HashSet::new();
199        set.insert(a);
200        assert!(set.contains(&b));
201    }
202
203    #[test]
204    fn badge_debug() {
205        let badge = Badge::new("OK");
206        let s = format!("{badge:?}");
207        assert!(s.contains("Badge"));
208        assert!(s.contains("OK"));
209    }
210}