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(mut self, theme: Theme) -> Self {
111        self.style = Style::new().fg(theme.dim).bg(theme.panel_bg);
112        self.selected_style = Style::new().fg(theme.accent).bg(theme.panel_bg);
113        self
114    }
115}
116
117impl<B: Backend> Widget<B> for Tabs<'_> {
118    fn render(self, area: Rect, term: &mut Terminal<B>) {
119        if area.width() == 0 || area.height() == 0 {
120            return;
121        }
122
123        let y = area.top();
124        let mut x = area.left();
125        for (index, &title) in self.titles.iter().enumerate() {
126            if x >= area.right() {
127                break;
128            }
129            let avail = (area.right() - x) as usize;
130            let text = truncate_to_cols(title, avail);
131            let style = if Some(index) == self.selected {
132                self.selected_style
133            } else {
134                self.style
135            };
136            let text_width = text.chars().count() as u16;
137            if Some(index) == self.selected && text_width > 0 {
138                fill_rect(
139                    term,
140                    Rect::new(x, y, text_width, 1),
141                    ' ',
142                    Style::new().bg(style.background()),
143                );
144            }
145            term.reset_style()
146                .fg(style.foreground())
147                .bg(style.background());
148            term.print(x, y, &text);
149            x = x.saturating_add(text_width);
150
151            if index + 1 < self.titles.len() {
152                if let Some(divider) = self.divider {
153                    let mid = x + self.column_spacing / 2;
154                    if mid < area.right() {
155                        term.reset_style();
156                        term.put(mid, y, divider);
157                    }
158                }
159                x = x.saturating_add(self.column_spacing);
160            }
161        }
162        term.reset_style();
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use retroglyph_core::Headless;
169
170    use super::*;
171
172    #[test]
173    fn draws_every_title_left_to_right() {
174        let area = Rect::new(0, 0, 20, 1);
175        let titles = ["One", "Two"];
176        let mut term = Terminal::new(Headless::new(20, 1));
177        Tabs::new(&titles).render(area, &mut term);
178
179        assert_eq!(term.grid().get(0, 0).glyph(), 'O');
180        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
181        assert_eq!(term.grid().get(4, 0).glyph(), 'T');
182    }
183
184    #[test]
185    fn highlights_the_selected_tab() {
186        let area = Rect::new(0, 0, 20, 1);
187        let titles = ["One", "Two"];
188        let mut term = Terminal::new(Headless::new(20, 1));
189        Tabs::new(&titles).select(Some(1)).render(area, &mut term);
190
191        let selected_bg = term.grid().get(4, 0).style().background();
192        let plain_bg = term.grid().get(0, 0).style().background();
193        assert_ne!(selected_bg, plain_bg);
194    }
195
196    #[test]
197    fn nothing_highlighted_when_unselected() {
198        let area = Rect::new(0, 0, 20, 1);
199        let titles = ["One", "Two"];
200        let mut term = Terminal::new(Headless::new(20, 1));
201        Tabs::new(&titles).render(area, &mut term);
202
203        let bg0 = term.grid().get(0, 0).style().background();
204        let bg1 = term.grid().get(4, 0).style().background();
205        assert_eq!(bg0, bg1);
206    }
207
208    #[test]
209    fn column_spacing_can_be_overridden() {
210        let area = Rect::new(0, 0, 20, 1);
211        let titles = ["A", "B"];
212        let mut term = Terminal::new(Headless::new(20, 1));
213        Tabs::new(&titles).column_spacing(3).render(area, &mut term);
214
215        // Default spacing (1) would put "B" at column 2; spacing 3 pushes it to column 4.
216        assert_eq!(term.grid().get(0, 0).glyph(), 'A');
217        assert_eq!(term.grid().get(4, 0).glyph(), 'B');
218    }
219
220    #[test]
221    fn divider_renders_between_tabs_when_set() {
222        let area = Rect::new(0, 0, 20, 1);
223        let titles = ["A", "B"];
224        let mut term = Terminal::new(Headless::new(20, 1));
225        Tabs::new(&titles)
226            .column_spacing(3)
227            .divider(Some('|'))
228            .render(area, &mut term);
229
230        // "A" at 0, spacing [1,3), midpoint at 1 + 3/2 = 2.
231        assert_eq!(term.grid().get(2, 0).glyph(), '|');
232    }
233
234    #[test]
235    fn no_divider_by_default() {
236        let area = Rect::new(0, 0, 20, 1);
237        let titles = ["A", "B"];
238        let mut term = Terminal::new(Headless::new(20, 1));
239        Tabs::new(&titles).render(area, &mut term);
240
241        assert_eq!(term.grid().get(1, 0).glyph(), ' ');
242    }
243
244    #[test]
245    fn stops_drawing_past_the_area_width_without_panicking() {
246        let area = Rect::new(0, 0, 4, 1);
247        let titles = ["Alpha", "Bravo", "Charlie"];
248        let mut term = Terminal::new(Headless::new(4, 1));
249        Tabs::new(&titles).render(area, &mut term); // must not panic
250
251        assert_eq!(term.grid().get(0, 0).glyph(), 'A');
252    }
253
254    #[test]
255    fn style_can_be_overridden() {
256        let area = Rect::new(0, 0, 20, 1);
257        let titles = ["One"];
258        let custom = Style::new().fg(Color::RED);
259        let mut term = Terminal::new(Headless::new(20, 1));
260        Tabs::new(&titles).style(custom).render(area, &mut term);
261
262        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
263    }
264
265    #[test]
266    fn selected_style_can_be_overridden() {
267        let area = Rect::new(0, 0, 20, 1);
268        let titles = ["One"];
269        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
270        let mut term = Terminal::new(Headless::new(20, 1));
271        Tabs::new(&titles)
272            .selected_style(custom)
273            .select(Some(0))
274            .render(area, &mut term);
275
276        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
277        assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
278    }
279
280    #[test]
281    fn zero_width_is_a_no_op() {
282        let area = Rect::new(0, 0, 0, 1);
283        let titles = ["One"];
284        let mut term = Terminal::new(Headless::new(1, 1));
285        Tabs::new(&titles).render(area, &mut term);
286        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
287    }
288
289    #[test]
290    fn theme_maps_named_roles_onto_style_and_selected_style() {
291        let area = Rect::new(0, 0, 20, 1);
292        let titles = ["One", "Two"];
293        let mut term = Terminal::new(Headless::new(20, 1));
294        Tabs::new(&titles)
295            .theme(Theme::DARK)
296            .select(Some(1))
297            .render(area, &mut term);
298
299        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.dim);
300        assert_eq!(
301            term.grid().get(0, 0).style().background(),
302            Theme::DARK.panel_bg
303        );
304        assert_eq!(
305            term.grid().get(4, 0).style().foreground(),
306            Theme::DARK.accent
307        );
308        assert_eq!(
309            term.grid().get(4, 0).style().background(),
310            Theme::DARK.panel_bg
311        );
312    }
313}