Skip to main content

retroglyph_widgets/widget/
tabs.rs

1//! [`Tabs`]: a horizontal strip of tab labels with a highlighted selected index.
2use 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/// A horizontal strip of `titles` with the tab at `selected` highlighted.
11///
12/// Unlike [`Table`](super::Table)/[`List`](super::List), `Tabs` is a plain [`Widget`], not a
13/// [`StatefulWidget`](super::StatefulWidget): there is no scroll offset for a tab strip, only a
14/// selected index, so it takes `selected: Option<usize>` directly (set via [`Tabs::select`])
15/// rather than a [`ListState`](crate::ListState): the app is free to drive that index however
16/// it likes (a plain `usize` it owns, a [`FocusRing`](crate::FocusRing), whatever fits), the same
17/// "app- or interaction-machinery-driven, widget just reads it" division of labor as every other
18/// widget here.
19///
20/// Titles render left to right, `column_spacing` blank columns apart (default `1`, matching
21/// [`Table::column_spacing`](super::Table::column_spacing)), with an optional single-character
22/// `divider` (default `None`, i.e. no divider) centered in that spacing: set with
23/// [`Tabs::divider`]. Drawing stops once a title would start past the area's right edge; there is
24/// no horizontal scrolling.
25///
26/// `style` and `selected_style` each default to the same fixed palette as
27/// [`Table`](super::Table)'s `row_style`/`selected_style`; set them with [`Tabs::style`]/
28/// [`Tabs::selected_style`].
29///
30/// # Examples
31///
32/// ```
33/// use retroglyph_core::{Grid, Rect};
34/// use retroglyph_widgets::{Surface, Tabs, Widget};
35///
36/// let titles = ["Overview", "Details", "Settings"];
37/// let area = Rect::new(0, 0, 30, 1);
38/// let mut grid = Grid::new(30, 1);
39/// Tabs::new(&titles)
40///     .select(Some(0))
41///     .render(area, &mut Surface::new(&mut grid, area, 0));
42/// ```
43#[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    /// A tab strip over `titles`, with nothing selected and the default style.
55    #[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    /// Select tab `index` (or clear the selection with `None`).
76    #[must_use]
77    pub const fn select(mut self, index: Option<usize>) -> Self {
78        self.selected = index;
79        self
80    }
81
82    /// Set the style of unselected tabs.
83    #[must_use]
84    pub const fn style(mut self, style: Style) -> Self {
85        self.style = style;
86        self
87    }
88
89    /// Set the style of the selected tab, including its background fill.
90    #[must_use]
91    pub const fn selected_style(mut self, style: Style) -> Self {
92        self.selected_style = style;
93        self
94    }
95
96    /// Set the number of blank columns between tabs.
97    #[must_use]
98    pub const fn column_spacing(mut self, spacing: u16) -> Self {
99        self.column_spacing = spacing;
100        self
101    }
102
103    /// Set a divider character drawn within the spacing between tabs. `None` (the default) draws
104    /// no divider: just `column_spacing` blank columns.
105    #[must_use]
106    pub const fn divider(mut self, divider: Option<char>) -> Self {
107        self.divider = divider;
108        self
109    }
110
111    /// Applies `theme`'s named roles to this tab strip: `style` becomes `theme.dim` (unselected
112    /// tabs read as de-emphasized) on `theme.panel_bg`, and `selected_style` becomes
113    /// `theme.accent` on `theme.panel_bg`.
114    ///
115    /// `style` sets an explicit background rather than leaving it at [`Style::new()`]'s default:
116    /// an unset background isn't "transparent" once a real backend draws it (a bare
117    /// `Color::Default` cell paints as solid black behind the glyph; see
118    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
119    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
120    /// Drawing this tab strip directly on the raw screen background instead needs a manual
121    /// `.style(...)` override afterwards.
122    ///
123    /// Call before any manual [`Tabs::style`]/[`Tabs::selected_style`] override you want to keep.
124    #[must_use]
125    pub fn theme(self, theme: Theme) -> Self {
126        self.theme_on(theme, theme.panel_bg)
127    }
128
129    /// Same as [`Tabs::theme`], but `style`/`selected_style` are drawn on `bg` instead of
130    /// `theme.panel_bg`: for a tab strip drawn directly on a backdrop other than a themed
131    /// [`super::Panel`]/[`super::Modal`]'s fill. [`Tabs::theme`] is exactly
132    /// `theme_on(theme, theme.panel_bg)`.
133    #[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            // `text` is bounded to `avail` columns above, itself derived from the `u16`
161            // `area.right()`/`x`, so narrowing the char count back is always exact.
162            #[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        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
203        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        // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4.
242        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        // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2.
257        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)); // must not panic
276
277        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}