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    #[must_use]
58    pub fn width(&self) -> u16 {
59        let label_width = display_width(self.label) as u16;
60        label_width
61            .saturating_add(self.pad_left)
62            .saturating_add(self.pad_right)
63    }
64
65    #[inline]
66    fn render_spaces(
67        frame: &mut Frame,
68        mut x: u16,
69        y: u16,
70        n: u16,
71        style: Style,
72        max_x: u16,
73    ) -> u16 {
74        let mut cell = Cell::from_char(' ');
75        apply_style(&mut cell, style);
76        for _ in 0..n {
77            if x >= max_x {
78                break;
79            }
80            frame.buffer.set(x, y, cell);
81            x = x.saturating_add(1);
82        }
83        x
84    }
85}
86
87impl Widget for Badge<'_> {
88    fn render(&self, area: Rect, frame: &mut Frame) {
89        if area.is_empty() {
90            return;
91        }
92
93        let y = area.y;
94        let max_x = area.right();
95        let mut x = area.x;
96
97        x = Self::render_spaces(frame, x, y, self.pad_left, self.style, max_x);
98        x = draw_text_span(frame, x, y, self.label, self.style, max_x);
99        let _ = Self::render_spaces(frame, x, y, self.pad_right, self.style, max_x);
100    }
101
102    fn is_essential(&self) -> bool {
103        false
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use ftui_render::cell::PackedRgba;
111    use ftui_render::grapheme_pool::GraphemePool;
112
113    #[test]
114    fn width_includes_padding() {
115        let badge = Badge::new("OK");
116        assert_eq!(badge.width(), 4);
117        let badge = Badge::new("OK").with_padding(2, 3);
118        assert_eq!(badge.width(), 7);
119    }
120
121    #[test]
122    fn renders_padded_label_with_style() {
123        let style = Style::new()
124            .fg(PackedRgba::rgb(1, 2, 3))
125            .bg(PackedRgba::rgb(4, 5, 6));
126        let badge = Badge::new("OK").with_style(style);
127
128        let mut pool = GraphemePool::new();
129        let mut frame = Frame::new(10, 1, &mut pool);
130        badge.render(Rect::new(0, 0, 10, 1), &mut frame);
131
132        let expected = [' ', 'O', 'K', ' '];
133        for (x, ch) in expected.into_iter().enumerate() {
134            let cell = frame.buffer.get(x as u16, 0).unwrap();
135            assert_eq!(cell.content.as_char(), Some(ch));
136            assert_eq!(cell.fg, PackedRgba::rgb(1, 2, 3));
137            assert_eq!(cell.bg, PackedRgba::rgb(4, 5, 6));
138        }
139    }
140
141    #[test]
142    fn truncates_in_small_area() {
143        let style = Style::new()
144            .fg(PackedRgba::rgb(1, 2, 3))
145            .bg(PackedRgba::rgb(4, 5, 6));
146        let badge = Badge::new("OK").with_style(style);
147
148        let mut pool = GraphemePool::new();
149        let mut frame = Frame::new(2, 1, &mut pool);
150        badge.render(Rect::new(0, 0, 2, 1), &mut frame);
151
152        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some(' '));
153        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('O'));
154    }
155}