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