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::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Theme;
6use crate::draw::fill_rect;
7use crate::text::truncate as truncate_to_cols;
8
9/// A horizontal strip of `titles` with the tab at `selected` highlighted.
10///
11/// Unlike [`Table`](super::Table)/[`List`](super::List), `Tabs` is a plain [`Widget`], not a
12/// [`StatefulWidget`](super::StatefulWidget): there is no scroll offset for a tab strip, only a
13/// selected index, so it takes `selected: Option<usize>` directly (set via [`Tabs::select`])
14/// rather than a [`ListState`](crate::ListState) -- the app is free to drive that index however
15/// it likes (a plain `usize` it owns, a [`FocusRing`](crate::FocusRing), whatever fits), the same
16/// "app- or interaction-machinery-driven, widget just reads it" division of labor as every other
17/// widget here.
18///
19/// Titles render left to right, `column_spacing` blank columns apart (default `1`, matching
20/// [`Table::column_spacing`](super::Table::column_spacing)), with an optional single-character
21/// `divider` (default `None`, i.e. no divider) centered in that spacing -- set with
22/// [`Tabs::divider`]. Drawing stops once a title would start past the area's right edge; there is
23/// no horizontal scrolling.
24///
25/// `style` and `selected_style` each default to the same fixed palette as
26/// [`Table`](super::Table)'s `row_style`/`selected_style`; set them with [`Tabs::style`]/
27/// [`Tabs::selected_style`].
28#[derive(Clone, Copy, Debug)]
29pub struct Tabs<'a> {
30    titles: &'a [&'a str],
31    selected: Option<usize>,
32    style: Style,
33    selected_style: Style,
34    column_spacing: u16,
35    divider: Option<char>,
36}
37
38impl<'a> Tabs<'a> {
39    /// A tab strip over `titles`, with nothing selected and the default style.
40    #[must_use]
41    pub fn new(titles: &'a [&'a str]) -> Self {
42        Self {
43            titles,
44            selected: None,
45            style: Style::new().fg(Color::Rgb {
46                r: 170,
47                g: 175,
48                b: 190,
49            }),
50            selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
51                r: 40,
52                g: 60,
53                b: 90,
54            }),
55            column_spacing: 1,
56            divider: None,
57        }
58    }
59
60    /// Select tab `index` (or clear the selection with `None`).
61    #[must_use]
62    pub const fn select(mut self, index: Option<usize>) -> Self {
63        self.selected = index;
64        self
65    }
66
67    /// Set the style of unselected tabs.
68    #[must_use]
69    pub const fn style(mut self, style: Style) -> Self {
70        self.style = style;
71        self
72    }
73
74    /// Set the style of the selected tab, including its background fill.
75    #[must_use]
76    pub const fn selected_style(mut self, style: Style) -> Self {
77        self.selected_style = style;
78        self
79    }
80
81    /// Set the number of blank columns between tabs.
82    #[must_use]
83    pub const fn column_spacing(mut self, spacing: u16) -> Self {
84        self.column_spacing = spacing;
85        self
86    }
87
88    /// Set a divider character drawn within the spacing between tabs. `None` (the default) draws
89    /// no divider -- just `column_spacing` blank columns.
90    #[must_use]
91    pub const fn divider(mut self, divider: Option<char>) -> Self {
92        self.divider = divider;
93        self
94    }
95
96    /// Applies `theme`'s named roles to this tab strip: `style` becomes `theme.dim` (unselected
97    /// tabs read as de-emphasized) on `theme.panel_bg`, and `selected_style` becomes
98    /// `theme.accent` on `theme.panel_bg`.
99    ///
100    /// `style` sets an explicit background rather than leaving it at [`Style::new()`]'s default:
101    /// an unset background isn't "transparent" once a real backend draws it (a bare
102    /// `Color::Default` cell paints as solid black behind the glyph -- see
103    /// `retroglyph-software`'s `DEFAULT_BG`), so this widget assumes it's drawn on
104    /// `theme.panel_bg`, true when composed with a themed [`super::Panel`]/[`super::Modal`].
105    /// Drawing this tab strip directly on the raw screen background instead needs a manual
106    /// `.style(...)` override afterwards.
107    ///
108    /// Call before any manual [`Tabs::style`]/[`Tabs::selected_style`] override you want to keep.
109    #[must_use]
110    pub fn theme(self, theme: Theme) -> Self {
111        self.theme_on(theme, theme.panel_bg)
112    }
113
114    /// Same as [`Tabs::theme`], but `style`/`selected_style` are drawn on `bg` instead of
115    /// `theme.panel_bg` -- for a tab strip drawn directly on a backdrop other than a themed
116    /// [`super::Panel`]/[`super::Modal`]'s fill. [`Tabs::theme`] is exactly
117    /// `theme_on(theme, theme.panel_bg)`.
118    #[must_use]
119    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
120        self.style = Style::new().fg(theme.dim).bg(bg);
121        self.selected_style = Style::new().fg(theme.accent).bg(bg);
122        self
123    }
124}
125
126impl<B: Backend> Widget<B> for Tabs<'_> {
127    fn render(self, area: Rect, term: &mut Terminal<B>) {
128        if area.width() == 0 || area.height() == 0 {
129            return;
130        }
131
132        let y = area.top();
133        let mut x = area.left();
134        for (index, &title) in self.titles.iter().enumerate() {
135            if x >= area.right() {
136                break;
137            }
138            let avail = (area.right() - x) as usize;
139            let text = truncate_to_cols(title, avail);
140            let style = if Some(index) == self.selected {
141                self.selected_style
142            } else {
143                self.style
144            };
145            let text_width = text.chars().count() as u16;
146            if Some(index) == self.selected && text_width > 0 {
147                fill_rect(
148                    term,
149                    Rect::new(x, y, text_width, 1),
150                    ' ',
151                    Style::new().bg(style.background()),
152                );
153            }
154            term.reset_style()
155                .fg(style.foreground())
156                .bg(style.background());
157            term.print(x, y, text);
158            x = x.saturating_add(text_width);
159
160            if index + 1 < self.titles.len() {
161                if let Some(divider) = self.divider {
162                    let mid = x + self.column_spacing / 2;
163                    if mid < area.right() {
164                        term.reset_style();
165                        term.put(mid, y, divider);
166                    }
167                }
168                x = x.saturating_add(self.column_spacing);
169            }
170        }
171        term.reset_style();
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use retroglyph_core::Headless;
178
179    use super::*;
180
181    #[test]
182    fn draws_every_title_left_to_right() {
183        let area = Rect::new(0, 0, 20, 1);
184        let titles = ["One", "Two"];
185        let mut term = Terminal::new(Headless::new(20, 1));
186        Tabs::new(&titles).render(area, &mut term);
187
188        assert_eq!(term.grid().get(0, 0).glyph(), 'O');
189        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
190        assert_eq!(term.grid().get(4, 0).glyph(), 'T');
191    }
192
193    #[test]
194    fn highlights_the_selected_tab() {
195        let area = Rect::new(0, 0, 20, 1);
196        let titles = ["One", "Two"];
197        let mut term = Terminal::new(Headless::new(20, 1));
198        Tabs::new(&titles).select(Some(1)).render(area, &mut term);
199
200        let selected_bg = term.grid().get(4, 0).style().background();
201        let plain_bg = term.grid().get(0, 0).style().background();
202        assert_ne!(selected_bg, plain_bg);
203    }
204
205    #[test]
206    fn nothing_highlighted_when_unselected() {
207        let area = Rect::new(0, 0, 20, 1);
208        let titles = ["One", "Two"];
209        let mut term = Terminal::new(Headless::new(20, 1));
210        Tabs::new(&titles).render(area, &mut term);
211
212        let bg0 = term.grid().get(0, 0).style().background();
213        let bg1 = term.grid().get(4, 0).style().background();
214        assert_eq!(bg0, bg1);
215    }
216
217    #[test]
218    fn column_spacing_can_be_overridden() {
219        let area = Rect::new(0, 0, 20, 1);
220        let titles = ["A", "B"];
221        let mut term = Terminal::new(Headless::new(20, 1));
222        Tabs::new(&titles).column_spacing(3).render(area, &mut term);
223
224        // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4.
225        assert_eq!(term.grid().get(0, 0).glyph(), 'A');
226        assert_eq!(term.grid().get(4, 0).glyph(), 'B');
227    }
228
229    #[test]
230    fn divider_renders_between_tabs_when_set() {
231        let area = Rect::new(0, 0, 20, 1);
232        let titles = ["A", "B"];
233        let mut term = Terminal::new(Headless::new(20, 1));
234        Tabs::new(&titles)
235            .column_spacing(3)
236            .divider(Some('|'))
237            .render(area, &mut term);
238
239        // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2.
240        assert_eq!(term.grid().get(2, 0).glyph(), '|');
241    }
242
243    #[test]
244    fn no_divider_by_default() {
245        let area = Rect::new(0, 0, 20, 1);
246        let titles = ["A", "B"];
247        let mut term = Terminal::new(Headless::new(20, 1));
248        Tabs::new(&titles).render(area, &mut term);
249
250        assert_eq!(term.grid().get(1, 0).glyph(), ' ');
251    }
252
253    #[test]
254    fn stops_drawing_past_the_area_width_without_panicking() {
255        let area = Rect::new(0, 0, 4, 1);
256        let titles = ["Alpha", "Bravo", "Charlie"];
257        let mut term = Terminal::new(Headless::new(4, 1));
258        Tabs::new(&titles).render(area, &mut term); // must not panic
259
260        assert_eq!(term.grid().get(0, 0).glyph(), 'A');
261    }
262
263    #[test]
264    fn style_can_be_overridden() {
265        let area = Rect::new(0, 0, 20, 1);
266        let titles = ["One"];
267        let custom = Style::new().fg(Color::RED);
268        let mut term = Terminal::new(Headless::new(20, 1));
269        Tabs::new(&titles).style(custom).render(area, &mut term);
270
271        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
272    }
273
274    #[test]
275    fn selected_style_can_be_overridden() {
276        let area = Rect::new(0, 0, 20, 1);
277        let titles = ["One"];
278        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
279        let mut term = Terminal::new(Headless::new(20, 1));
280        Tabs::new(&titles)
281            .selected_style(custom)
282            .select(Some(0))
283            .render(area, &mut term);
284
285        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
286        assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
287    }
288
289    #[test]
290    fn zero_width_is_a_no_op() {
291        let area = Rect::new(0, 0, 0, 1);
292        let titles = ["One"];
293        let mut term = Terminal::new(Headless::new(1, 1));
294        Tabs::new(&titles).render(area, &mut term);
295        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
296    }
297
298    #[test]
299    fn theme_maps_named_roles_onto_style_and_selected_style() {
300        let area = Rect::new(0, 0, 20, 1);
301        let titles = ["One", "Two"];
302        let mut term = Terminal::new(Headless::new(20, 1));
303        Tabs::new(&titles)
304            .theme(Theme::DARK)
305            .select(Some(1))
306            .render(area, &mut term);
307
308        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.dim);
309        assert_eq!(
310            term.grid().get(0, 0).style().background(),
311            Theme::DARK.panel_bg
312        );
313        assert_eq!(
314            term.grid().get(4, 0).style().foreground(),
315            Theme::DARK.accent
316        );
317        assert_eq!(
318            term.grid().get(4, 0).style().background(),
319            Theme::DARK.panel_bg
320        );
321    }
322
323    #[test]
324    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
325        let area = Rect::new(0, 0, 20, 1);
326        let titles = ["One"];
327        let mut term = Terminal::new(Headless::new(20, 1));
328        Tabs::new(&titles)
329            .theme_on(Theme::DARK, Color::Default)
330            .select(Some(0))
331            .render(area, &mut term);
332
333        assert_eq!(
334            term.grid().get(0, 0).style().foreground(),
335            Theme::DARK.accent
336        );
337        assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
338    }
339}