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