Skip to main content

retroglyph_ui/widget/
tabs.rs

1//! [`Tabs`]: a horizontal strip of tab labels with a highlighted selected index.
2use 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/// A horizontal strip of `titles` with the tab at `selected` highlighted.
18///
19/// Unlike [`Table`](super::Table)/[`List`](super::List), `Tabs` is a plain [`Widget`], not a
20/// [`StatefulWidget`](super::StatefulWidget): there is no scroll offset for a tab strip, only a
21/// selected index, so it takes `selected: Option<usize>` directly (set via [`Tabs::select`])
22/// rather than a [`ListState`](crate::state::ListState): the app is free to drive that index however
23/// it likes (a plain `usize` it owns, a [`FocusRing`](crate::interact::FocusRing), whatever fits), the same
24/// "app- or interaction-machinery-driven, widget just reads it" division of labor as every other
25/// widget here.
26///
27/// Titles render left to right, `column_spacing` blank columns apart (default `1`, matching
28/// [`Table::column_spacing`](super::Table::column_spacing)), with an optional single-character
29/// `divider` (default `None`, i.e. no divider) centered in that spacing: set with
30/// [`Tabs::divider`]. Drawing stops once a title would start past the area's right edge; there is
31/// no horizontal scrolling.
32///
33/// `style` and `selected_style` default to [`Theme::DARK`] (as if [`Tabs::theme`] had been
34/// called); set them with [`Tabs::style`]/[`Tabs::selected_style`].
35///
36/// As an [`InteractiveWidget`], `type State = usize` (the same index a caller already threads
37/// into [`Tabs::select`] for the plain [`Widget`] path): a single id covers the whole strip, and
38/// a click selects the tab whose column range contains [`Response::pointer_pos`], resolved
39/// against the very same per-tab column layout the drawing routine uses, so the two can't
40/// diverge.
41///
42/// # Examples
43///
44/// ```
45/// use retroglyph_core::grid::{Grid, Rect};
46/// use retroglyph_ui::widget::{Tabs, Widget};
47/// use retroglyph_ui::Surface;
48///
49/// let titles = ["Overview", "Details", "Settings"];
50/// let area = Rect::new(0, 0, 30, 1);
51/// let mut grid = Grid::new(30, 1);
52/// Tabs::new(&titles)
53///     .select(Some(0))
54///     .render(&mut Surface::new(&mut grid, area, 0));
55/// ```
56#[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    /// A tab strip over `titles`, with nothing selected, styled from [`Theme::DARK`] (as if
68    /// [`Tabs::theme`] had been called).
69    #[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    /// Select tab `index` (or clear the selection with `None`).
83    #[must_use]
84    pub const fn select(mut self, index: Option<usize>) -> Self {
85        self.selected = index;
86        self
87    }
88
89    /// Set the style of unselected tabs.
90    #[must_use]
91    pub const fn style(mut self, style: Style) -> Self {
92        self.style = style;
93        self
94    }
95
96    /// Set the style of the selected tab, including its background fill.
97    #[must_use]
98    pub const fn selected_style(mut self, style: Style) -> Self {
99        self.selected_style = style;
100        self
101    }
102
103    /// Set the number of blank columns between tabs.
104    #[must_use]
105    pub const fn column_spacing(mut self, spacing: u16) -> Self {
106        self.column_spacing = spacing;
107        self
108    }
109
110    /// Set a divider character drawn within the spacing between tabs. `None` (the default) draws
111    /// no divider: just `column_spacing` blank columns.
112    #[must_use]
113    pub const fn divider(mut self, divider: Option<char>) -> Self {
114        self.divider = divider;
115        self
116    }
117
118    /// Applies `theme`'s named roles to this tab strip: `style` becomes `theme.dim` (unselected
119    /// tabs read as de-emphasized) on `theme.panel_bg`, and `selected_style` becomes `theme.bg`
120    /// on `theme.accent`: the same bright-on-accent highlight [`super::List::theme`] and
121    /// [`super::Table::theme`] give their own selected state.
122    ///
123    /// `style` sets an explicit background rather than leaving it at [`Style::new()`]'s default:
124    /// an unset background isn't "transparent" once a real backend draws it (a bare
125    /// `Color::Default` cell paints as solid black behind the glyph; see
126    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
127    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
128    /// Drawing this tab strip directly on the raw screen background instead needs a manual
129    /// `.style(...)` override afterwards.
130    ///
131    /// Call before any manual [`Tabs::style`]/[`Tabs::selected_style`] override you want to keep.
132    #[must_use]
133    pub fn theme(self, theme: Theme) -> Self {
134        self.theme_on(theme, theme.panel_bg)
135    }
136
137    /// Same as [`Tabs::theme`], but `style` is drawn on `bg` instead of `theme.panel_bg`: for a
138    /// tab strip drawn directly on a backdrop other than a themed [`super::Panel`]/
139    /// [`super::Modal`]'s fill. `selected_style` still uses `theme.accent` as its background,
140    /// unaffected by `bg`. [`Tabs::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
141    #[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    /// The shared drawing routine both [`Widget::render`] and [`InteractiveWidget::render`] use,
151    /// parameterized on which tab (if any) is highlighted as selected.
152    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        // `Strip::columns` reports absolute `x`, matching `area`'s own space (needed so
159        // `Strip::index_at` can compare it directly against an absolute pointer position); `put`/
160        // `print`/`fill_rect` below address this surface's own local coordinates instead, so
161        // `area.left()` is subtracted back out at the point of drawing.
162        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                // Divider column: floor of half the inter-tab gap, so it sits just left of center
185                // for an even `column_spacing`.
186                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    /// The index of the tab whose column range contains `pos`, or `None` if `pos` falls in the
195    /// spacing between tabs, past the last drawn tab, or outside `area` entirely: a click there
196    /// selects nothing rather than clamping to the nearest tab.
197    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    /// Every title's display width, `column_spacing` columns apart, one row tall, floored at
204    /// `density`'s [`min_target_size`](Density::min_target_size) the same way [`Button`](super::Button)
205    /// floors its own label: unlike the drawing routine, this ignores any drawing-time
206    /// truncation, since `min_size` answers how much room the strip needs, not how it degrades
207    /// when denied that room.
208    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    /// A single id covers the whole strip: clicking resolves which tab via
230    /// [`Response::pointer_pos`] and this strip's own column layout, rather than each tab
231    /// registering its own id.
232    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        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
268        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        // Themed tabs (the `new()` default) distinguish the selected tab by both foreground and
280        // background color: `theme.bg` on `theme.accent` vs `theme.dim` on `theme.panel_bg`.
281        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        // With nothing selected, both tabs use the same unselected `style`, so their backgrounds
296        // match (unlike a selected tab, which gets `theme.accent` instead).
297        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        // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4.
311        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        // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2.
324        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)); // must not panic
345
346        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        // `selected_style` uses `theme.accent` as its background regardless of `bg`: only the
421        // unselected `style` picks up the custom backdrop.
422        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)), // over "Two"
440            ..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)), // past "Two", still inside the wide area
463            ..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); // unchanged: not clamped to the last tab
473    }
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        // "設定" is 4 columns wide; both characters must draw in full.
484        assert_eq!(grid[Pos::new(0, 0)].glyph(), '設');
485        assert_eq!(grid[Pos::new(2, 0)].glyph(), '定');
486        // "次" (2 cols) starts after "設定" (4) + column_spacing (1) = column 5.
487        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        // "設定" occupies 4 columns starting at 0; "次" must start no earlier than column 5
500        // (4 + column_spacing 1), never inside "設定"'s own span.
501        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        // "One" (3) + spacing (1) + "Two" (3) = 7 cols, past either density's 6-cell floor, and
508        // a mouse density's 1-row floor doesn't grow the height, so the natural width wins.
509        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        // A single 1-col title is nowhere near either density's 6-cell width floor.
518        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        // "A" (1) + spacing (3) + "B" (1) = 5 cols, still under the 6-cell floor, so the floor
526        // wins; this only proves the spacing was actually added into the sum before flooring.
527        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}