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