Coverage Report

Created: 2026-08-05 20:21

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/retroglyph/retroglyph/crates/ui/src/widget/tabs.rs
Line
Count
Source
1
//! [`Tabs`]: a horizontal strip of tab labels with a highlighted selected index.
2
use alloc::vec::Vec;
3
4
use retroglyph_core::color::{Color, Style};
5
use retroglyph_core::grid::Rect;
6
use retroglyph_core::text::truncate_measured;
7
8
use super::{InteractiveWidget, Widget};
9
use crate::Align;
10
use crate::Response;
11
use crate::Sense;
12
use crate::Surface;
13
use crate::Theme;
14
use crate::draw::fill_rect;
15
use 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)]
56
pub 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
65
impl<'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
16
    pub fn new(titles: &'a [&'a str]) -> Self {
70
16
        Self {
71
16
            titles,
72
16
            selected: None,
73
16
            style: Style::new(),
74
16
            selected_style: Style::new(),
75
16
            column_spacing: 1,
76
16
            divider: None,
77
16
        }
78
16
        .theme(Theme::DARK)
79
16
    }
80
81
    /// Select tab `index` (or clear the selection with `None`).
82
    #[must_use]
83
4
    pub const fn select(mut self, index: Option<usize>) -> Self {
84
4
        self.selected = index;
85
4
        self
86
4
    }
87
88
    /// Set the style of unselected tabs.
89
    #[must_use]
90
1
    pub const fn style(mut self, style: Style) -> Self {
91
1
        self.style = style;
92
1
        self
93
1
    }
94
95
    /// Set the style of the selected tab, including its background fill.
96
    #[must_use]
97
1
    pub const fn selected_style(mut self, style: Style) -> Self {
98
1
        self.selected_style = style;
99
1
        self
100
1
    }
101
102
    /// Set the number of blank columns between tabs.
103
    #[must_use]
104
2
    pub const fn column_spacing(mut self, spacing: u16) -> Self {
105
2
        self.column_spacing = spacing;
106
2
        self
107
2
    }
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
1
    pub const fn divider(mut self, divider: Option<char>) -> Self {
113
1
        self.divider = divider;
114
1
        self
115
1
    }
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
17
    pub fn theme(self, theme: Theme) -> Self {
133
17
        self.theme_on(theme, theme.panel_bg)
134
17
    }
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
18
    pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
142
18
        self.style = Style::new().fg(theme.dim).bg(bg);
143
18
        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
144
18
        self
145
18
    }
146
}
147
148
impl 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
17
    fn tab_columns(&self, area: Rect) -> Vec<(usize, u16, u16)> {
155
17
        let mut columns = Vec::with_capacity(self.titles.len());
156
17
        let mut x = area.left();
157
31
        for (index, &title) in 
self.titles17
.
iter17
().
enumerate17
() {
158
31
            if x >= area.right() {
159
1
                break;
160
30
            }
161
            // x < area.right() per the break check above, so this subtraction fits a u16.
162
30
            let avail = area.right() - x;
163
30
            let (_text, text_width) = truncate_measured(title, avail);
164
30
            columns.push((index, x, text_width));
165
30
            x = x.saturating_add(text_width);
166
167
30
            if index + 1 < self.titles.len() {
168
14
                x = x.saturating_add(self.column_spacing);
169
16
            }
170
        }
171
17
        columns
172
17
    }
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
15
    fn draw(&self, surface: &mut Surface<'_>, selected: Option<usize>) {
177
15
        let area = surface.area();
178
15
        if area.width() == 0 || 
area.height() == 014
{
179
1
            return;
180
14
        }
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
14
        let columns = self.tab_columns(area);
187
24
        for &(index, abs_x, text_width) in 
&columns14
{
188
24
            let x = abs_x - area.left();
189
24
            let title = self.titles[index];
190
24
            let style = if Some(index) == selected {
191
6
                self.selected_style
192
            } else {
193
18
                self.style
194
            };
195
24
            if Some(index) == selected && 
text_width > 06
{
196
6
                fill_rect(
197
6
                    surface,
198
6
                    Rect::new(x, 0, text_width, 1),
199
6
                    ' ',
200
6
                    Style::new().bg(style.background()),
201
6
                );
202
18
            }
203
24
            let _ = draw_clipped(surface, (x, 0), text_width, title, Align::Left, style);
204
205
24
            if let Some(
divider2
) = self.divider
206
2
                && 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
1
                let mid = x + text_width + self.column_spacing / 2;
211
1
                if mid < area.width() {
212
1
                    surface.put((mid, 0), divider, Style::new());
213
1
                
}0
214
23
            }
215
        }
216
15
    }
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
2
    fn tab_at(&self, area: Rect, pos: retroglyph_core::grid::Pos) -> Option<usize> {
222
2
        if !area.contains_pos(pos) {
223
0
            return None;
224
2
        }
225
2
        self.tab_columns(area)
226
2
            .into_iter()
227
4
            .
find2
(|&(_, x, width)| pos.x >= x && pos.x < x + width)
228
2
            .map(|(index, _, _)| index)
229
2
    }
230
}
231
232
impl Widget for Tabs<'_> {
233
13
    fn render(&self, surface: &mut Surface<'_>) {
234
13
        self.draw(surface, self.selected);
235
13
    }
236
}
237
238
impl<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
0
    fn sense(&self) -> Sense {
245
0
        Sense::click() | Sense::HOVER
246
0
    }
247
248
2
    fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
249
2
        let area = surface.area();
250
251
2
        if response.clicked()
252
2
            && let Some(pos) = response.pointer_pos()
253
2
            && let Some(
index1
) = self.tab_at(area, pos)
254
1
        {
255
1
            *state = index;
256
1
        }
257
258
2
        self.draw(surface, Some(*state));
259
2
    }
260
}
261
262
#[cfg(test)]
263
mod tests {
264
    use retroglyph_core::grid::{Grid, Pos};
265
266
    use super::*;
267
268
    #[test]
269
1
    fn draws_every_title_left_to_right() {
270
1
        let area = Rect::new(0, 0, 20, 1);
271
1
        let titles = ["One", "Two"];
272
1
        let mut grid = Grid::new(20, 1);
273
1
        let tabs = Tabs::new(&titles);
274
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
275
276
1
        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'O');
277
        // "One" (3) + column_spacing (1) = tab 2 starts at column 4.
278
1
        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'T');
279
1
    }
280
281
    #[test]
282
1
    fn highlights_the_selected_tab() {
283
1
        let area = Rect::new(0, 0, 20, 1);
284
1
        let titles = ["One", "Two"];
285
1
        let mut grid = Grid::new(20, 1);
286
1
        let tabs = Tabs::new(&titles).select(Some(1));
287
1
        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
1
        let selected_style = grid[Pos::new(4, 0)].style();
292
1
        let unselected_style = grid[Pos::new(0, 0)].style();
293
1
        assert_ne!(selected_style.foreground(), unselected_style.foreground());
294
1
        assert_ne!(selected_style.background(), unselected_style.background());
295
1
    }
296
297
    #[test]
298
1
    fn nothing_highlighted_when_unselected() {
299
1
        let area = Rect::new(0, 0, 20, 1);
300
1
        let titles = ["One", "Two"];
301
1
        let mut grid = Grid::new(20, 1);
302
1
        let tabs = Tabs::new(&titles);
303
1
        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
1
        let bg0 = grid[Pos::new(0, 0)].style().background();
308
1
        let bg1 = grid[Pos::new(4, 0)].style().background();
309
1
        assert_eq!(bg0, bg1);
310
1
    }
311
312
    #[test]
313
1
    fn column_spacing_can_be_overridden() {
314
1
        let area = Rect::new(0, 0, 20, 1);
315
1
        let titles = ["A", "B"];
316
1
        let mut grid = Grid::new(20, 1);
317
1
        let tabs = Tabs::new(&titles).column_spacing(3);
318
1
        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
1
        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
322
1
        assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
323
1
    }
324
325
    #[test]
326
1
    fn divider_renders_between_tabs_when_set() {
327
1
        let area = Rect::new(0, 0, 20, 1);
328
1
        let titles = ["A", "B"];
329
1
        let mut grid = Grid::new(20, 1);
330
1
        let tabs = Tabs::new(&titles).column_spacing(3).divider(Some('|'));
331
1
        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
1
        assert_eq!(grid[Pos::new(2, 0)].glyph(), '|');
335
1
    }
336
337
    #[test]
338
1
    fn no_divider_by_default() {
339
1
        let area = Rect::new(0, 0, 20, 1);
340
1
        let titles = ["A", "B"];
341
1
        let mut grid = Grid::new(20, 1);
342
1
        let tabs = Tabs::new(&titles);
343
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
344
345
1
        assert_eq!(grid[Pos::new(1, 0)].glyph(), ' ');
346
1
    }
347
348
    #[test]
349
1
    fn stops_drawing_past_the_area_width_without_panicking() {
350
1
        let area = Rect::new(0, 0, 4, 1);
351
1
        let titles = ["Alpha", "Bravo", "Charlie"];
352
1
        let mut grid = Grid::new(4, 1);
353
1
        let tabs = Tabs::new(&titles);
354
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0)); // must not panic
355
356
1
        assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
357
1
    }
358
359
    #[test]
360
1
    fn style_can_be_overridden() {
361
        use retroglyph_core::color::Color;
362
363
1
        let area = Rect::new(0, 0, 20, 1);
364
1
        let titles = ["One"];
365
1
        let custom = Style::new().fg(Color::RED);
366
1
        let mut grid = Grid::new(20, 1);
367
1
        let tabs = Tabs::new(&titles).style(custom);
368
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
369
370
1
        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
371
1
    }
372
373
    #[test]
374
1
    fn selected_style_can_be_overridden() {
375
        use retroglyph_core::color::Color;
376
377
1
        let area = Rect::new(0, 0, 20, 1);
378
1
        let titles = ["One"];
379
1
        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
380
1
        let mut grid = Grid::new(20, 1);
381
1
        let tabs = Tabs::new(&titles).selected_style(custom).select(Some(0));
382
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
383
384
1
        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::GREEN);
385
1
        assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::BLUE);
386
1
    }
387
388
    #[test]
389
1
    fn zero_width_is_a_no_op() {
390
1
        let area = Rect::new(0, 0, 0, 1);
391
1
        let titles = ["One"];
392
1
        let mut grid = Grid::new(1, 1);
393
1
        let tabs = Tabs::new(&titles);
394
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
395
1
        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
396
1
    }
397
398
    #[test]
399
1
    fn theme_maps_named_roles_onto_style_and_selected_style() {
400
1
        let area = Rect::new(0, 0, 20, 1);
401
1
        let titles = ["One", "Two"];
402
1
        let mut grid = Grid::new(20, 1);
403
1
        let tabs = Tabs::new(&titles).theme(Theme::DARK).select(Some(1));
404
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
405
406
1
        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.dim);
407
1
        assert_eq!(
408
1
            grid[Pos::new(0, 0)].style().background(),
409
            Theme::DARK.panel_bg
410
        );
411
1
        assert_eq!(grid[Pos::new(4, 0)].style().foreground(), Theme::DARK.bg);
412
1
        assert_eq!(
413
1
            grid[Pos::new(4, 0)].style().background(),
414
            Theme::DARK.accent
415
        );
416
1
    }
417
418
    #[test]
419
1
    fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
420
        use retroglyph_core::color::Color;
421
422
1
        let area = Rect::new(0, 0, 20, 1);
423
1
        let titles = ["One"];
424
1
        let mut grid = Grid::new(20, 1);
425
1
        let tabs = Tabs::new(&titles)
426
1
            .theme_on(Theme::DARK, Color::Default)
427
1
            .select(Some(0));
428
1
        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
1
        assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.bg);
433
1
        assert_eq!(
434
1
            grid[Pos::new(0, 0)].style().background(),
435
            Theme::DARK.accent
436
        );
437
1
    }
438
439
    #[test]
440
1
    fn click_selects_the_tab_under_the_pointer() {
441
1
        let area = Rect::new(0, 0, 20, 1);
442
1
        let titles = ["One", "Two"];
443
1
        let tabs = Tabs::new(&titles);
444
1
        let mut state = 0usize;
445
446
1
        let response: Response<()> = Response {
447
1
            hovered: true,
448
1
            clicked: true,
449
1
            pointer_pos: Some(Pos::new(4, 0)), // over "Two"
450
1
            ..Response::default()
451
1
        };
452
1
        let mut grid = Grid::new(20, 1);
453
1
        InteractiveWidget::render(
454
1
            &tabs,
455
1
            &mut Surface::new(&mut grid, area, 0),
456
1
            &mut state,
457
1
            response,
458
        );
459
1
        assert_eq!(state, 1);
460
1
    }
461
462
    #[test]
463
1
    fn click_past_the_last_tab_selects_nothing() {
464
1
        let area = Rect::new(0, 0, 20, 1);
465
1
        let titles = ["One", "Two"];
466
1
        let tabs = Tabs::new(&titles);
467
1
        let mut state = 0usize;
468
469
1
        let response: Response<()> = Response {
470
1
            hovered: true,
471
1
            clicked: true,
472
1
            pointer_pos: Some(Pos::new(10, 0)), // past "Two", still inside the wide area
473
1
            ..Response::default()
474
1
        };
475
1
        let mut grid = Grid::new(20, 1);
476
1
        InteractiveWidget::render(
477
1
            &tabs,
478
1
            &mut Surface::new(&mut grid, area, 0),
479
1
            &mut state,
480
1
            response,
481
        );
482
1
        assert_eq!(state, 0); // unchanged: not clamped to the last tab
483
1
    }
484
485
    #[test]
486
1
    fn tabs_wide_title_loses_half_its_characters() {
487
1
        let area = Rect::new(0, 0, 20, 1);
488
1
        let titles = ["設定", "次"];
489
1
        let mut grid = Grid::new(20, 1);
490
1
        let tabs = Tabs::new(&titles);
491
1
        Widget::render(&tabs, &mut Surface::new(&mut grid, area, 0));
492
493
        // "設定" is 4 columns wide; both characters must draw in full.
494
1
        assert_eq!(grid[Pos::new(0, 0)].glyph(), '設');
495
1
        assert_eq!(grid[Pos::new(2, 0)].glyph(), '定');
496
        // "次" (2 cols) starts after "設定" (4) + column_spacing (1) = column 5.
497
1
        assert_eq!(grid[Pos::new(5, 0)].glyph(), '次');
498
1
    }
499
500
    #[test]
501
1
    fn tabs_wide_title_does_not_overlap_the_next_tab() {
502
1
        let area = Rect::new(0, 0, 20, 1);
503
1
        let titles = ["設定", "次"];
504
1
        let tabs = Tabs::new(&titles);
505
1
        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
1
        assert_eq!(columns[0], (0, 0, 4));
510
1
        assert_eq!(columns[1].1, 5);
511
1
    }
512
}