1#![forbid(unsafe_code)]
2
3use 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#[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 #[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 #[must_use]
43 pub fn with_style(mut self, style: Style) -> Self {
44 self.style = style;
45 self
46 }
47
48 #[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 #[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 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 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 }
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}