Skip to main content

retroglyph_ui/widget/
tabs.rs

1//! [`Tabs`]: a horizontal strip of tab labels with a highlighted selected index.
2use 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/// 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::ListState): the app is free to drive that index however
23/// it likes (a plain `usize` it owns, a [`FocusRing`](crate::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::{Surface, Tabs, Widget};
47///
48/// let titles = ["Overview", "Details", "Settings"];
49/// let area = Rect::new(0, 0, 30, 1);
50/// let mut grid = Grid::new(30, 1);
51/// Tabs::new(&titles)
52///     .select(Some(0))
53///     .render(&mut Surface::new(&mut grid, area, 0));
54/// ```
55#[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    /// A tab strip over `titles`, with nothing selected, styled from [`Theme::DARK`] (as if
67    /// [`Tabs::theme`] had been called).
68    #[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    /// Select tab `index` (or clear the selection with `None`).
82    #[must_use]
83    pub const fn select(mut self, index: Option<usize>) -> Self {
84        self.selected = index;
85        self
86    }
87
88    /// Set the style of unselected tabs.
89    #[must_use]
90    pub const fn style(mut self, style: Style) -> Self {
91        self.style = style;
92        self
93    }
94
95    /// Set the style of the selected tab, including its background fill.
96    #[must_use]
97    pub const fn selected_style(mut self, style: Style) -> Self {
98        self.selected_style = style;
99        self
100    }
101
102    /// Set the number of blank columns between tabs.
103    #[must_use]
104    pub const fn column_spacing(mut self, spacing: u16) -> Self {
105        self.column_spacing = spacing;
106        self
107    }
108
109    /// Set a divider character drawn within the spacing between tabs. `None` (the default) draws
110    /// no divider: just `column_spacing` blank columns.
111    #[must_use]
112    pub const fn divider(mut self, divider: Option<char>) -> Self {
113        self.divider = divider;
114        self
115    }
116
117    /// Applies `theme`'s named roles to this tab strip: `style` becomes `theme.dim` (unselected
118    /// tabs read as de-emphasized) on `theme.panel_bg`, and `selected_style` becomes `theme.bg`
119    /// on `theme.accent`: the same bright-on-accent highlight [`super::List::theme`] and
120    /// [`super::Table::theme`] give their own selected state.
121    ///
122    /// `style` sets an explicit background rather than leaving it at [`Style::new()`]'s default:
123    /// an unset background isn't "transparent" once a real backend draws it (a bare
124    /// `Color::Default` cell paints as solid black behind the glyph; see
125    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
126    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
127    /// Drawing this tab strip directly on the raw screen background instead needs a manual
128    /// `.style(...)` override afterwards.
129    ///
130    /// Call before any manual [`Tabs::style`]/[`Tabs::selected_style`] override you want to keep.
131    #[must_use]
132    pub fn theme(self, theme: Theme) -> Self {
133        self.theme_on(theme, theme.panel_bg)
134    }
135
136    /// Same as [`Tabs::theme`], but `style` is drawn on `bg` instead of `theme.panel_bg`: for a
137    /// tab strip drawn directly on a backdrop other than a themed [`super::Panel`]/
138    /// [`super::Modal`]'s fill. `selected_style` still uses `theme.accent` as its background,
139    /// unaffected by `bg`. [`Tabs::theme`] is exactly `theme_on(theme, theme.panel_bg)`.
140    #[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    /// Each drawn tab's `(index, start_x, text_width)`, left to right, stopping once a title
150    /// would start past `area`'s right edge: the same layout [`Tabs::draw`] paints and
151    /// [`Tabs::tab_at`] hit-tests against, computed once here so the two can't diverge. `x`
152    /// values are absolute grid coordinates (matching `area`'s own space), the same space
153    /// [`Response::pointer_pos`] reports in, since [`Tabs::tab_at`] compares them directly.
154    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            // x < area.right() per the break check above, so this subtraction fits a u16.
162            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    /// The shared drawing routine both [`Widget::render`] and [`InteractiveWidget::render`] use,
175    /// parameterized on which tab (if any) is highlighted as selected.
176    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        // `tab_columns` reports absolute `x`, matching `area`'s own space (needed so
183        // `Tabs::tab_at` can compare it directly against an absolute pointer position); `put`/
184        // `print`/`fill_rect` below address this surface's own local coordinates instead, so
185        // `area.left()` is subtracted back out at the point of drawing.
186        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                // Divider column: floor of half the inter-tab gap, so it sits just left of center
209                // for an even `column_spacing`.
210                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    /// The index of the tab whose column range contains `pos`, or `None` if `pos` falls in the
219    /// spacing between tabs, past the last drawn tab, or outside `area` entirely: a click there
220    /// selects nothing rather than clamping to the nearest tab.
221    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    /// A single id covers the whole strip: clicking resolves which tab via
242    /// [`Response::pointer_pos`] and this strip's own column layout, rather than each tab
243    /// registering its own id.
244    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        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
278        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        // Themed tabs (the `new()` default) distinguish the selected tab by both foreground and
290        // background color: `theme.bg` on `theme.accent` vs `theme.dim` on `theme.panel_bg`.
291        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        // With nothing selected, both tabs use the same unselected `style`, so their backgrounds
306        // match (unlike a selected tab, which gets `theme.accent` instead).
307        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        // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4.
321        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        // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2.
334        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)); // must not panic
355
356        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        // `selected_style` uses `theme.accent` as its background regardless of `bg`: only the
431        // unselected `style` picks up the custom backdrop.
432        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)), // over "Two"
450            ..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)), // past "Two", still inside the wide area
473            ..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); // unchanged: not clamped to the last tab
483    }
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        // "設定" is 4 columns wide; both characters must draw in full.
494        assert_eq!(grid[Pos::new(0, 0)].glyph(), '設');
495        assert_eq!(grid[Pos::new(2, 0)].glyph(), '定');
496        // "次" (2 cols) starts after "設定" (4) + column_spacing (1) = column 5.
497        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        // "設定" occupies 4 columns starting at 0; "次" must start no earlier than column 5
508        // (4 + column_spacing 1), never inside "設定"'s own span.
509        assert_eq!(columns[0], (0, 0, 4));
510        assert_eq!(columns[1].1, 5);
511    }
512}