retroglyph_widgets/widget/
tabs.rs1use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Theme;
6use crate::draw::fill_rect;
7use crate::text::truncate as truncate_to_cols;
8
9#[derive(Clone, Copy, Debug)]
29pub struct Tabs<'a> {
30 titles: &'a [&'a str],
31 selected: Option<usize>,
32 style: Style,
33 selected_style: Style,
34 column_spacing: u16,
35 divider: Option<char>,
36}
37
38impl<'a> Tabs<'a> {
39 #[must_use]
41 pub fn new(titles: &'a [&'a str]) -> Self {
42 Self {
43 titles,
44 selected: None,
45 style: Style::new().fg(Color::Rgb {
46 r: 170,
47 g: 175,
48 b: 190,
49 }),
50 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
51 r: 40,
52 g: 60,
53 b: 90,
54 }),
55 column_spacing: 1,
56 divider: None,
57 }
58 }
59
60 #[must_use]
62 pub const fn select(mut self, index: Option<usize>) -> Self {
63 self.selected = index;
64 self
65 }
66
67 #[must_use]
69 pub const fn style(mut self, style: Style) -> Self {
70 self.style = style;
71 self
72 }
73
74 #[must_use]
76 pub const fn selected_style(mut self, style: Style) -> Self {
77 self.selected_style = style;
78 self
79 }
80
81 #[must_use]
83 pub const fn column_spacing(mut self, spacing: u16) -> Self {
84 self.column_spacing = spacing;
85 self
86 }
87
88 #[must_use]
91 pub const fn divider(mut self, divider: Option<char>) -> Self {
92 self.divider = divider;
93 self
94 }
95
96 #[must_use]
110 pub fn theme(self, theme: Theme) -> Self {
111 self.theme_on(theme, theme.panel_bg)
112 }
113
114 #[must_use]
119 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
120 self.style = Style::new().fg(theme.dim).bg(bg);
121 self.selected_style = Style::new().fg(theme.accent).bg(bg);
122 self
123 }
124}
125
126impl<B: Backend> Widget<B> for Tabs<'_> {
127 fn render(self, area: Rect, term: &mut Terminal<B>) {
128 if area.width() == 0 || area.height() == 0 {
129 return;
130 }
131
132 let y = area.top();
133 let mut x = area.left();
134 for (index, &title) in self.titles.iter().enumerate() {
135 if x >= area.right() {
136 break;
137 }
138 let avail = (area.right() - x) as usize;
139 let text = truncate_to_cols(title, avail);
140 let style = if Some(index) == self.selected {
141 self.selected_style
142 } else {
143 self.style
144 };
145 let text_width = text.chars().count() as u16;
146 if Some(index) == self.selected && text_width > 0 {
147 fill_rect(
148 term,
149 Rect::new(x, y, text_width, 1),
150 ' ',
151 Style::new().bg(style.background()),
152 );
153 }
154 term.reset_style()
155 .fg(style.foreground())
156 .bg(style.background());
157 term.print(x, y, text);
158 x = x.saturating_add(text_width);
159
160 if index + 1 < self.titles.len() {
161 if let Some(divider) = self.divider {
162 let mid = x + self.column_spacing / 2;
163 if mid < area.right() {
164 term.reset_style();
165 term.put(mid, y, divider);
166 }
167 }
168 x = x.saturating_add(self.column_spacing);
169 }
170 }
171 term.reset_style();
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use retroglyph_core::Headless;
178
179 use super::*;
180
181 #[test]
182 fn draws_every_title_left_to_right() {
183 let area = Rect::new(0, 0, 20, 1);
184 let titles = ["One", "Two"];
185 let mut term = Terminal::new(Headless::new(20, 1));
186 Tabs::new(&titles).render(area, &mut term);
187
188 assert_eq!(term.grid().get(0, 0).glyph(), 'O');
189 assert_eq!(term.grid().get(4, 0).glyph(), 'T');
191 }
192
193 #[test]
194 fn highlights_the_selected_tab() {
195 let area = Rect::new(0, 0, 20, 1);
196 let titles = ["One", "Two"];
197 let mut term = Terminal::new(Headless::new(20, 1));
198 Tabs::new(&titles).select(Some(1)).render(area, &mut term);
199
200 let selected_bg = term.grid().get(4, 0).style().background();
201 let plain_bg = term.grid().get(0, 0).style().background();
202 assert_ne!(selected_bg, plain_bg);
203 }
204
205 #[test]
206 fn nothing_highlighted_when_unselected() {
207 let area = Rect::new(0, 0, 20, 1);
208 let titles = ["One", "Two"];
209 let mut term = Terminal::new(Headless::new(20, 1));
210 Tabs::new(&titles).render(area, &mut term);
211
212 let bg0 = term.grid().get(0, 0).style().background();
213 let bg1 = term.grid().get(4, 0).style().background();
214 assert_eq!(bg0, bg1);
215 }
216
217 #[test]
218 fn column_spacing_can_be_overridden() {
219 let area = Rect::new(0, 0, 20, 1);
220 let titles = ["A", "B"];
221 let mut term = Terminal::new(Headless::new(20, 1));
222 Tabs::new(&titles).column_spacing(3).render(area, &mut term);
223
224 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
226 assert_eq!(term.grid().get(4, 0).glyph(), 'B');
227 }
228
229 #[test]
230 fn divider_renders_between_tabs_when_set() {
231 let area = Rect::new(0, 0, 20, 1);
232 let titles = ["A", "B"];
233 let mut term = Terminal::new(Headless::new(20, 1));
234 Tabs::new(&titles)
235 .column_spacing(3)
236 .divider(Some('|'))
237 .render(area, &mut term);
238
239 assert_eq!(term.grid().get(2, 0).glyph(), '|');
241 }
242
243 #[test]
244 fn no_divider_by_default() {
245 let area = Rect::new(0, 0, 20, 1);
246 let titles = ["A", "B"];
247 let mut term = Terminal::new(Headless::new(20, 1));
248 Tabs::new(&titles).render(area, &mut term);
249
250 assert_eq!(term.grid().get(1, 0).glyph(), ' ');
251 }
252
253 #[test]
254 fn stops_drawing_past_the_area_width_without_panicking() {
255 let area = Rect::new(0, 0, 4, 1);
256 let titles = ["Alpha", "Bravo", "Charlie"];
257 let mut term = Terminal::new(Headless::new(4, 1));
258 Tabs::new(&titles).render(area, &mut term); assert_eq!(term.grid().get(0, 0).glyph(), 'A');
261 }
262
263 #[test]
264 fn style_can_be_overridden() {
265 let area = Rect::new(0, 0, 20, 1);
266 let titles = ["One"];
267 let custom = Style::new().fg(Color::RED);
268 let mut term = Terminal::new(Headless::new(20, 1));
269 Tabs::new(&titles).style(custom).render(area, &mut term);
270
271 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
272 }
273
274 #[test]
275 fn selected_style_can_be_overridden() {
276 let area = Rect::new(0, 0, 20, 1);
277 let titles = ["One"];
278 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
279 let mut term = Terminal::new(Headless::new(20, 1));
280 Tabs::new(&titles)
281 .selected_style(custom)
282 .select(Some(0))
283 .render(area, &mut term);
284
285 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
286 assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
287 }
288
289 #[test]
290 fn zero_width_is_a_no_op() {
291 let area = Rect::new(0, 0, 0, 1);
292 let titles = ["One"];
293 let mut term = Terminal::new(Headless::new(1, 1));
294 Tabs::new(&titles).render(area, &mut term);
295 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
296 }
297
298 #[test]
299 fn theme_maps_named_roles_onto_style_and_selected_style() {
300 let area = Rect::new(0, 0, 20, 1);
301 let titles = ["One", "Two"];
302 let mut term = Terminal::new(Headless::new(20, 1));
303 Tabs::new(&titles)
304 .theme(Theme::DARK)
305 .select(Some(1))
306 .render(area, &mut term);
307
308 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.dim);
309 assert_eq!(
310 term.grid().get(0, 0).style().background(),
311 Theme::DARK.panel_bg
312 );
313 assert_eq!(
314 term.grid().get(4, 0).style().foreground(),
315 Theme::DARK.accent
316 );
317 assert_eq!(
318 term.grid().get(4, 0).style().background(),
319 Theme::DARK.panel_bg
320 );
321 }
322
323 #[test]
324 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
325 let area = Rect::new(0, 0, 20, 1);
326 let titles = ["One"];
327 let mut term = Terminal::new(Headless::new(20, 1));
328 Tabs::new(&titles)
329 .theme_on(Theme::DARK, Color::Default)
330 .select(Some(0))
331 .render(area, &mut term);
332
333 assert_eq!(
334 term.grid().get(0, 0).style().foreground(),
335 Theme::DARK.accent
336 );
337 assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
338 }
339}