1use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::{Rect, Size};
4use retroglyph_core::text::width;
5
6use super::strip::Strip;
7use super::{InteractiveWidget, MinSize, Widget};
8use crate::Surface;
9use crate::align::Align;
10use crate::draw::fill_rect;
11use crate::interact::Density;
12use crate::interact::Response;
13use crate::interact::Sense;
14use crate::text::draw_clipped;
15use crate::theme::Theme;
16
17#[derive(Clone, Copy, Debug)]
57pub struct Tabs<'a> {
58 titles: &'a [&'a str],
59 selected: Option<usize>,
60 style: Style,
61 selected_style: Style,
62 column_spacing: u16,
63 divider: Option<char>,
64}
65
66impl<'a> Tabs<'a> {
67 #[must_use]
70 pub fn new(titles: &'a [&'a str]) -> Self {
71 Self {
72 titles,
73 selected: None,
74 style: Style::new(),
75 selected_style: Style::new(),
76 column_spacing: 1,
77 divider: None,
78 }
79 .theme(Theme::DARK)
80 }
81
82 #[must_use]
84 pub const fn select(mut self, index: Option<usize>) -> Self {
85 self.selected = index;
86 self
87 }
88
89 #[must_use]
91 pub const fn style(mut self, style: Style) -> Self {
92 self.style = style;
93 self
94 }
95
96 #[must_use]
98 pub const fn selected_style(mut self, style: Style) -> Self {
99 self.selected_style = style;
100 self
101 }
102
103 #[must_use]
105 pub const fn column_spacing(mut self, spacing: u16) -> Self {
106 self.column_spacing = spacing;
107 self
108 }
109
110 #[must_use]
113 pub const fn divider(mut self, divider: Option<char>) -> Self {
114 self.divider = divider;
115 self
116 }
117
118 #[must_use]
133 pub fn theme(self, theme: Theme) -> Self {
134 self.theme_on(theme, theme.panel_bg)
135 }
136
137 #[must_use]
142 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
143 self.style = Style::new().fg(theme.dim).bg(bg);
144 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
145 self
146 }
147}
148
149impl Tabs<'_> {
150 fn draw(&self, surface: &mut Surface<'_>, selected: Option<usize>) {
153 let area = surface.area();
154 if area.width() == 0 || area.height() == 0 {
155 return;
156 }
157
158 let strip = Strip::new(self.titles, self.column_spacing);
163 for (index, abs_x, text_width) in strip.columns(area) {
164 let x = abs_x - area.left();
165 let title = self.titles[index];
166 let style = if Some(index) == selected {
167 self.selected_style
168 } else {
169 self.style
170 };
171 if Some(index) == selected && text_width > 0 {
172 fill_rect(
173 surface,
174 Rect::new(x, 0, text_width, 1),
175 ' ',
176 Style::new().bg(style.background()),
177 );
178 }
179 let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
180
181 if let Some(divider) = self.divider
182 && index + 1 < self.titles.len()
183 {
184 let mid = x + text_width + self.column_spacing / 2;
187 if mid < area.width() {
188 surface.put((mid, 0), divider, Style::new());
189 }
190 }
191 }
192 }
193
194 fn tab_at(&self, area: Rect, pos: retroglyph_core::grid::Pos) -> Option<usize> {
198 Strip::new(self.titles, self.column_spacing).index_at(area, pos)
199 }
200}
201
202impl MinSize for Tabs<'_> {
203 fn min_size(&self, density: Density) -> Size {
209 let mut content_width = 0u16;
210 for (index, &title) in self.titles.iter().enumerate() {
211 content_width = content_width.saturating_add(width(title));
212 if index + 1 < self.titles.len() {
213 content_width = content_width.saturating_add(self.column_spacing);
214 }
215 }
216 Size::new(content_width, 1).max(density.min_target_size())
217 }
218}
219
220impl Widget for Tabs<'_> {
221 fn render(&self, surface: &mut Surface<'_>) {
222 self.draw(surface, self.selected);
223 }
224}
225
226impl<Id> InteractiveWidget<Id> for Tabs<'_> {
227 type State = usize;
228
229 fn sense(&self) -> Sense {
233 Sense::click() | Sense::HOVER
234 }
235
236 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
237 let area = surface.area();
238
239 if response.clicked()
240 && let Some(pos) = response.pointer_pos()
241 && let Some(index) = self.tab_at(area, pos)
242 {
243 *state = index;
244 }
245
246 self.draw(surface, Some(*state));
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use alloc::vec::Vec;
253
254 use retroglyph_core::grid::{Grid, HasSize as _, Pos};
255
256 use super::*;
257
258 #[test]
259 fn draws_every_title_left_to_right() {
260 let area = Rect::new(0, 0, 20, 1);
261 let titles = ["One", "Two"];
262 let mut grid = Grid::new(20, 1);
263 let tabs = Tabs::new(&titles);
264 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
265
266 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'O');
267 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'T');
269 }
270
271 #[test]
272 fn highlights_the_selected_tab() {
273 let area = Rect::new(0, 0, 20, 1);
274 let titles = ["One", "Two"];
275 let mut grid = Grid::new(20, 1);
276 let tabs = Tabs::new(&titles).select(Some(1));
277 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
278
279 let selected_style = grid[Pos::new(4, 0)].style();
282 let unselected_style = grid[Pos::new(0, 0)].style();
283 assert_ne!(selected_style.foreground(), unselected_style.foreground());
284 assert_ne!(selected_style.background(), unselected_style.background());
285 }
286
287 #[test]
288 fn nothing_highlighted_when_unselected() {
289 let area = Rect::new(0, 0, 20, 1);
290 let titles = ["One", "Two"];
291 let mut grid = Grid::new(20, 1);
292 let tabs = Tabs::new(&titles);
293 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
294
295 let bg0 = grid[Pos::new(0, 0)].style().background();
298 let bg1 = grid[Pos::new(4, 0)].style().background();
299 assert_eq!(bg0, bg1);
300 }
301
302 #[test]
303 fn column_spacing_can_be_overridden() {
304 let area = Rect::new(0, 0, 20, 1);
305 let titles = ["A", "B"];
306 let mut grid = Grid::new(20, 1);
307 let tabs = Tabs::new(&titles).column_spacing(3);
308 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
309
310 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
312 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
313 }
314
315 #[test]
316 fn divider_renders_between_tabs_when_set() {
317 let area = Rect::new(0, 0, 20, 1);
318 let titles = ["A", "B"];
319 let mut grid = Grid::new(20, 1);
320 let tabs = Tabs::new(&titles).column_spacing(3).divider(Some('|'));
321 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
322
323 assert_eq!(grid[Pos::new(2, 0)].glyph(), '|');
325 }
326
327 #[test]
328 fn no_divider_by_default() {
329 let area = Rect::new(0, 0, 20, 1);
330 let titles = ["A", "B"];
331 let mut grid = Grid::new(20, 1);
332 let tabs = Tabs::new(&titles);
333 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
334
335 assert_eq!(grid[Pos::new(1, 0)].glyph(), ' ');
336 }
337
338 #[test]
339 fn stops_drawing_past_the_area_width_without_panicking() {
340 let area = Rect::new(0, 0, 4, 1);
341 let titles = ["Alpha", "Bravo", "Charlie"];
342 let mut grid = Grid::new(4, 1);
343 let tabs = Tabs::new(&titles);
344 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
347 }
348
349 #[test]
350 fn style_can_be_overridden() {
351 use retroglyph_core::color::Color;
352
353 let area = Rect::new(0, 0, 20, 1);
354 let titles = ["One"];
355 let custom = Style::new().fg(Color::RED);
356 let mut grid = Grid::new(20, 1);
357 let tabs = Tabs::new(&titles).style(custom);
358 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
359
360 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
361 }
362
363 #[test]
364 fn selected_style_can_be_overridden() {
365 use retroglyph_core::color::Color;
366
367 let area = Rect::new(0, 0, 20, 1);
368 let titles = ["One"];
369 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
370 let mut grid = Grid::new(20, 1);
371 let tabs = Tabs::new(&titles).selected_style(custom).select(Some(0));
372 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
373
374 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::GREEN);
375 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::BLUE);
376 }
377
378 #[test]
379 fn zero_width_is_a_no_op() {
380 let area = Rect::new(0, 0, 0, 1);
381 let titles = ["One"];
382 let mut grid = Grid::new(1, 1);
383 let tabs = Tabs::new(&titles);
384 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
385 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
386 }
387
388 #[test]
389 fn theme_maps_named_roles_onto_style_and_selected_style() {
390 let area = Rect::new(0, 0, 20, 1);
391 let titles = ["One", "Two"];
392 let mut grid = Grid::new(20, 1);
393 let tabs = Tabs::new(&titles).theme(Theme::DARK).select(Some(1));
394 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
395
396 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
397 assert_eq!(
398 grid[Pos::new(0, 0)].style().background(),
399 Theme::DARK.panel_bg
400 );
401 assert_eq!(grid[Pos::new(4, 0)].style().foreground(), Theme::DARK.bg);
402 assert_eq!(
403 grid[Pos::new(4, 0)].style().background(),
404 Theme::DARK.accent
405 );
406 }
407
408 #[test]
409 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
410 use retroglyph_core::color::Color;
411
412 let area = Rect::new(0, 0, 20, 1);
413 let titles = ["One"];
414 let mut grid = Grid::new(20, 1);
415 let tabs = Tabs::new(&titles)
416 .theme_on(Theme::DARK, Color::Default)
417 .select(Some(0));
418 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
419
420 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.bg);
423 assert_eq!(
424 grid[Pos::new(0, 0)].style().background(),
425 Theme::DARK.accent
426 );
427 }
428
429 #[test]
430 fn click_selects_the_tab_under_the_pointer() {
431 let area = Rect::new(0, 0, 20, 1);
432 let titles = ["One", "Two"];
433 let tabs = Tabs::new(&titles);
434 let mut state = 0usize;
435
436 let response: Response<()> = Response {
437 hovered: true,
438 clicked: true,
439 pointer_pos: Some(Pos::new(4, 0)), ..Response::default()
441 };
442 let mut grid = Grid::new(20, 1);
443 InteractiveWidget::render(
444 &tabs,
445 &mut Surface::new(&mut grid, area, 0),
446 &mut state,
447 response,
448 );
449 assert_eq!(state, 1);
450 }
451
452 #[test]
453 fn click_past_the_last_tab_selects_nothing() {
454 let area = Rect::new(0, 0, 20, 1);
455 let titles = ["One", "Two"];
456 let tabs = Tabs::new(&titles);
457 let mut state = 0usize;
458
459 let response: Response<()> = Response {
460 hovered: true,
461 clicked: true,
462 pointer_pos: Some(Pos::new(10, 0)), ..Response::default()
464 };
465 let mut grid = Grid::new(20, 1);
466 InteractiveWidget::render(
467 &tabs,
468 &mut Surface::new(&mut grid, area, 0),
469 &mut state,
470 response,
471 );
472 assert_eq!(state, 0); }
474
475 #[test]
476 fn tabs_wide_title_loses_half_its_characters() {
477 let area = Rect::new(0, 0, 20, 1);
478 let titles = ["設定", "次"];
479 let mut grid = Grid::new(20, 1);
480 let tabs = Tabs::new(&titles);
481 Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
482
483 assert_eq!(grid[Pos::new(0, 0)].glyph(), '設');
485 assert_eq!(grid[Pos::new(2, 0)].glyph(), '定');
486 assert_eq!(grid[Pos::new(5, 0)].glyph(), '次');
488 }
489
490 #[test]
491 fn tabs_wide_title_does_not_overlap_the_next_tab() {
492 let area = Rect::new(0, 0, 20, 1);
493 let titles = ["設定", "次"];
494 let tabs = Tabs::new(&titles);
495 let columns: Vec<_> = Strip::new(&titles, tabs.column_spacing)
496 .columns(area)
497 .collect();
498
499 assert_eq!(columns[0], (0, 0, 4));
502 assert_eq!(columns[1].1, 5);
503 }
504
505 #[test]
506 fn min_size_sums_title_widths_and_spacing() {
507 let titles = ["One", "Two"];
510 let size = Tabs::new(&titles).min_size(Density::Mouse);
511 assert_eq!(size.width(), 7);
512 assert_eq!(size.height(), 1);
513 }
514
515 #[test]
516 fn min_size_floors_a_narrow_strip_at_the_density_minimum() {
517 let titles = ["A"];
519 let size = Tabs::new(&titles).min_size(Density::Touch);
520 assert_eq!(size, Density::Touch.min_target_size());
521 }
522
523 #[test]
524 fn min_size_respects_custom_column_spacing() {
525 let titles = ["A", "B"];
528 let size = Tabs::new(&titles)
529 .column_spacing(3)
530 .min_size(Density::Mouse);
531 assert_eq!(size.width(), 6);
532 }
533}