Skip to main content

retroglyph_widgets/widget/
table.rs

1//! [`Table`]: a fixed-column, scrollable table with a highlighted row.
2use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::StatefulWidget;
5use super::window::visible_window;
6use crate::ListState;
7use crate::Theme;
8use crate::draw::fill_rect;
9use crate::text::truncate as truncate_to_cols;
10
11/// A fixed-column, scrollable table with a [`ListState`]-driven highlighted
12/// row.
13///
14/// `headers` render on the first row of the area it's rendered into;
15/// `rows` follow, one per line, clipped to that area. `widths` gives each
16/// column's cell width; columns are space-separated and truncated to fit.
17///
18/// `state.offset()` is the index of the first row drawn below the header --
19/// rendering draws whatever window `offset` names and does not clamp or
20/// auto-scroll it, matching [`ListState`]'s existing "only the caller knows
21/// the viewport height" design. Call
22/// [`state.ensure_visible(visible_row_count)`](ListState::ensure_visible)
23/// before rendering to keep `state.selected()` on-screen. If `selected()` is
24/// `Some` and its row falls within the visible window, that row is drawn
25/// with an inverted highlight background; if it has scrolled out of view,
26/// no row is highlighted.
27///
28/// `header_style`, `row_style`, and `selected_style` each default to a fixed
29/// palette (a light blue-gray header, a dim gray-blue for unselected rows,
30/// and a bright-white-on-dark-blue highlight for the selected row); set them
31/// with [`Table::header_style`], [`Table::row_style`], and
32/// [`Table::selected_style`]. `column_spacing` defaults to `1` (a single
33/// blank column between cells); set it with [`Table::column_spacing`].
34#[derive(Clone, Copy, Debug)]
35pub struct Table<'a> {
36    headers: &'a [&'a str],
37    widths: &'a [u16],
38    rows: &'a [Vec<String>],
39    header_style: Style,
40    row_style: Style,
41    selected_style: Style,
42    column_spacing: u16,
43}
44
45impl<'a> Table<'a> {
46    /// A table with the given header labels, column widths, and rows, in the
47    /// default style.
48    #[must_use]
49    pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [Vec<String>]) -> Self {
50        Self {
51            headers,
52            widths,
53            rows,
54            header_style: Style::new().fg(Color::Rgb {
55                r: 210,
56                g: 210,
57                b: 230,
58            }),
59            row_style: Style::new().fg(Color::Rgb {
60                r: 170,
61                g: 175,
62                b: 190,
63            }),
64            selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
65                r: 40,
66                g: 60,
67                b: 90,
68            }),
69            column_spacing: 1,
70        }
71    }
72
73    /// Set the header row's style.
74    #[must_use]
75    pub const fn header_style(mut self, style: Style) -> Self {
76        self.header_style = style;
77        self
78    }
79
80    /// Set the style of unselected rows.
81    #[must_use]
82    pub const fn row_style(mut self, style: Style) -> Self {
83        self.row_style = style;
84        self
85    }
86
87    /// Set the style of the selected row, including its background fill.
88    #[must_use]
89    pub const fn selected_style(mut self, style: Style) -> Self {
90        self.selected_style = style;
91        self
92    }
93
94    /// Set the number of blank columns between cells.
95    #[must_use]
96    pub const fn column_spacing(mut self, spacing: u16) -> Self {
97        self.column_spacing = spacing;
98        self
99    }
100
101    /// Applies `theme`'s named roles to this table's row styles: `header_style` becomes
102    /// `theme.fg` (brighter, matching the header's original brighter-than-row default) on
103    /// `theme.panel_bg`, `row_style` becomes `theme.dim` (the same de-emphasized role a plain
104    /// body row already reads as) on `theme.panel_bg`, and `selected_style` becomes `theme.bg`
105    /// on `theme.accent` -- the same bright-on-accent highlight [`super::List::theme`] and
106    /// [`super::Button::theme`] use.
107    ///
108    /// `header_style`/`row_style` always set an explicit background rather than leaving it at
109    /// [`Style::new()`]'s default: an unset background isn't "transparent" once a real backend
110    /// draws it (a bare `Color::Default` cell paints as solid black behind the glyph, not
111    /// whatever was there before -- see `retroglyph-software`'s `DEFAULT_BG`), so this widget
112    /// assumes it's drawn on `theme.panel_bg` -- true when composed with a themed
113    /// [`super::Panel`]/[`super::Modal`], the common case -- rather than risk a black box behind
114    /// every row on a light [`Theme`]. Drawing this table directly on the raw screen background
115    /// instead of inside a themed panel needs a manual `.header_style(...)`/`.row_style(...)`
116    /// override afterwards.
117    ///
118    /// Call before any manual [`Table::header_style`]/[`Table::row_style`]/
119    /// [`Table::selected_style`] override you want to keep.
120    #[must_use]
121    pub fn theme(mut self, theme: Theme) -> Self {
122        self.header_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
123        self.row_style = Style::new().fg(theme.dim).bg(theme.panel_bg);
124        self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
125        self
126    }
127}
128
129impl<B: Backend> StatefulWidget<B> for Table<'_> {
130    type State = ListState;
131
132    fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
133        if area.width() == 0 || area.height() == 0 {
134            return;
135        }
136        draw_row(
137            term,
138            area,
139            area.top(),
140            self.headers,
141            self.widths,
142            RowStyle {
143                style: self.header_style,
144                bg: None,
145                column_spacing: self.column_spacing,
146            },
147        );
148
149        let visible_rows = area.height_usize().saturating_sub(1);
150        let selected = state.selected();
151        for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
152            let y = area.top() + 1 + (row_index - state.offset()) as u16;
153            let (style, bg) = if Some(row_index) == selected {
154                (self.selected_style, Some(self.selected_style.background()))
155            } else {
156                (self.row_style, None)
157            };
158            let cells: Vec<&str> = row.iter().map(String::as_str).collect();
159            draw_row(
160                term,
161                area,
162                y,
163                &cells,
164                self.widths,
165                RowStyle {
166                    style,
167                    bg,
168                    column_spacing: self.column_spacing,
169                },
170            );
171        }
172        term.reset_style();
173    }
174}
175
176/// The style and layout options for drawing one [`Table`] row, grouped to keep [`draw_row`]'s
177/// argument count within clippy's limit.
178#[derive(Clone, Copy)]
179struct RowStyle {
180    /// The text (and, for the selected row, background) style.
181    style: Style,
182    /// When set, the whole row width is filled with this background first.
183    bg: Option<Color>,
184    /// The number of blank columns between cells.
185    column_spacing: u16,
186}
187
188/// Draw one table row of `column_spacing`-separated, per-column-clipped cells at row `y`.
189fn draw_row<B: Backend>(
190    term: &mut Terminal<B>,
191    area: Rect,
192    y: u16,
193    cells: &[&str],
194    widths: &[u16],
195    row_style: RowStyle,
196) {
197    let RowStyle {
198        style,
199        bg,
200        column_spacing,
201    } = row_style;
202    if let Some(bg) = bg {
203        fill_rect(
204            term,
205            Rect::new(area.left(), y, area.width(), 1),
206            ' ',
207            Style::new().bg(bg),
208        );
209    }
210    let mut x = area.left();
211    for (cell, &w) in cells.iter().zip(widths) {
212        if x >= area.right() {
213            break;
214        }
215        let avail = (area.right() - x).min(w) as usize;
216        let text = truncate_to_cols(cell, avail);
217        term.reset_style()
218            .fg(style.foreground())
219            .bg(style.background());
220        term.print(x, y, &text);
221        x = x.saturating_add(w + column_spacing);
222    }
223    term.reset_style();
224}
225
226#[cfg(test)]
227mod tests {
228    use retroglyph_core::Headless;
229
230    use super::*;
231
232    #[test]
233    fn table_widget_highlights_the_selected_row() {
234        let area = Rect::new(0, 0, 20, 3);
235        let headers = ["Name"];
236        let widths = [10u16];
237        let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
238        let table = Table::new(&headers, &widths, &rows);
239
240        let mut term = Terminal::new(Headless::new(20, 3));
241        let mut state = ListState::new();
242        state.select(Some(1));
243        table.render(area, &mut term, &mut state);
244
245        // Row 1 ("Bravo") is highlighted; row 0 ("Alpha") is not.
246        let highlighted_bg = term.grid().get(0, 2).style().background();
247        let plain_bg = term.grid().get(0, 1).style().background();
248        assert_ne!(highlighted_bg, plain_bg);
249    }
250
251    #[test]
252    fn table_widget_highlights_nothing_when_unselected() {
253        let area = Rect::new(0, 0, 20, 3);
254        let headers = ["Name"];
255        let widths = [10u16];
256        let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
257        let table = Table::new(&headers, &widths, &rows);
258
259        let mut term = Terminal::new(Headless::new(20, 3));
260        let mut state = ListState::new(); // nothing selected
261        table.render(area, &mut term, &mut state);
262
263        let row0_bg = term.grid().get(0, 1).style().background();
264        let row1_bg = term.grid().get(0, 2).style().background();
265        assert_eq!(row0_bg, row1_bg);
266    }
267
268    fn rows(names: &[&str]) -> Vec<Vec<String>> {
269        names.iter().map(|n| vec![(*n).to_string()]).collect()
270    }
271
272    #[test]
273    fn scroll_offset_renders_the_window_starting_at_offset() {
274        // 2 visible rows (area height 3, minus the header row).
275        let area = Rect::new(0, 0, 20, 3);
276        let headers = ["Name"];
277        let widths = [10u16];
278        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
279        let table = Table::new(&headers, &widths, &rows);
280
281        let mut term = Terminal::new(Headless::new(20, 3));
282        let mut state = ListState::new();
283        state.set_offset(2); // window is [Charlie, Delta]
284        table.render(area, &mut term, &mut state);
285
286        // Row 1 is "Charlie", row 2 is "Delta"; neither "Alpha" nor "Bravo"
287        // (offset 0/1) are drawn anywhere.
288        assert_eq!(term.grid().get(0, 1).glyph(), 'C');
289        assert_eq!(term.grid().get(0, 2).glyph(), 'D');
290    }
291
292    #[test]
293    fn selection_scrolled_out_of_view_highlights_nothing() {
294        let area = Rect::new(0, 0, 20, 3);
295        let headers = ["Name"];
296        let widths = [10u16];
297        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
298        let table = Table::new(&headers, &widths, &rows);
299
300        let mut term = Terminal::new(Headless::new(20, 3));
301        let mut state = ListState::new();
302        state.select(Some(0)); // "Alpha"
303        state.set_offset(2); // but the window starts at "Charlie"
304        table.render(area, &mut term, &mut state);
305
306        let row0_bg = term.grid().get(0, 1).style().background();
307        let row1_bg = term.grid().get(0, 2).style().background();
308        assert_eq!(row0_bg, row1_bg); // neither visible row is highlighted
309    }
310
311    #[test]
312    fn default_header_style_matches_previous_hardcoded_color() {
313        let area = Rect::new(0, 0, 20, 2);
314        let headers = ["Name"];
315        let widths = [10u16];
316        let rows: Vec<Vec<String>> = vec![];
317        let table = Table::new(&headers, &widths, &rows);
318
319        let mut term = Terminal::new(Headless::new(20, 2));
320        let mut state = ListState::new();
321        table.render(area, &mut term, &mut state);
322
323        let expected = Color::Rgb {
324            r: 210,
325            g: 210,
326            b: 230,
327        };
328        assert_eq!(term.grid().get(0, 0).style().foreground(), expected);
329    }
330
331    #[test]
332    fn header_style_can_be_overridden() {
333        let area = Rect::new(0, 0, 20, 2);
334        let headers = ["Name"];
335        let widths = [10u16];
336        let rows: Vec<Vec<String>> = vec![];
337        let custom = Style::new().fg(Color::RED);
338        let table = Table::new(&headers, &widths, &rows).header_style(custom);
339
340        let mut term = Terminal::new(Headless::new(20, 2));
341        let mut state = ListState::new();
342        table.render(area, &mut term, &mut state);
343
344        assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
345    }
346
347    #[test]
348    fn selected_style_can_be_overridden() {
349        let area = Rect::new(0, 0, 20, 3);
350        let headers = ["Name"];
351        let widths = [10u16];
352        let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
353        let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
354        let table = Table::new(&headers, &widths, &rows).selected_style(custom);
355
356        let mut term = Terminal::new(Headless::new(20, 3));
357        let mut state = ListState::new();
358        state.select(Some(1));
359        table.render(area, &mut term, &mut state);
360
361        assert_eq!(term.grid().get(0, 2).style().foreground(), Color::GREEN);
362        assert_eq!(term.grid().get(0, 2).style().background(), Color::BLUE);
363    }
364
365    #[test]
366    fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
367        let area = Rect::new(0, 0, 20, 3);
368        let headers = ["Name"];
369        let widths = [10u16];
370        let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
371        let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
372
373        let mut term = Terminal::new(Headless::new(20, 3));
374        let mut state = ListState::new();
375        state.select(Some(1));
376        table.render(area, &mut term, &mut state);
377
378        assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
379        assert_eq!(
380            term.grid().get(0, 0).style().background(),
381            Theme::DARK.panel_bg
382        );
383        assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.dim);
384        assert_eq!(
385            term.grid().get(0, 1).style().background(),
386            Theme::DARK.panel_bg
387        );
388        assert_eq!(term.grid().get(0, 2).style().foreground(), Theme::DARK.bg);
389        assert_eq!(
390            term.grid().get(0, 2).style().background(),
391            Theme::DARK.accent
392        );
393    }
394
395    #[test]
396    fn column_spacing_can_be_overridden() {
397        let area = Rect::new(0, 0, 20, 1);
398        let headers = ["A", "B"];
399        let widths = [1u16, 1u16];
400        let rows: Vec<Vec<String>> = vec![];
401        let table = Table::new(&headers, &widths, &rows).column_spacing(3);
402
403        let mut term = Terminal::new(Headless::new(20, 1));
404        let mut state = ListState::new();
405        table.render(area, &mut term, &mut state);
406
407        // Default spacing (1) would put "B" at column 2; spacing 3 pushes
408        // it out to column 4.
409        assert_eq!(term.grid().get(0, 0).glyph(), 'A');
410        assert_eq!(term.grid().get(4, 0).glyph(), 'B');
411    }
412
413    #[test]
414    fn ensure_visible_before_render_keeps_selection_on_screen() {
415        let area = Rect::new(0, 0, 20, 3); // 2 visible rows
416        let headers = ["Name"];
417        let widths = [10u16];
418        let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
419        let table = Table::new(&headers, &widths, &rows);
420
421        let mut term = Terminal::new(Headless::new(20, 3));
422        let mut state = ListState::new();
423        state.select(Some(3)); // "Delta", off the front of the default window
424        state.ensure_visible(2);
425        table.render(area, &mut term, &mut state);
426
427        // ensure_visible moved the window to [2, 4): "Charlie" then "Delta",
428        // with "Delta" (the selection) highlighted on the last visible row.
429        assert_eq!(term.grid().get(0, 2).glyph(), 'D');
430        let highlighted_bg = term.grid().get(0, 2).style().background();
431        let plain_bg = term.grid().get(0, 1).style().background();
432        assert_ne!(highlighted_bg, plain_bg);
433    }
434}