1use 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#[derive(Clone, Copy, Debug)]
35pub struct Table<'a> {
36 headers: &'a [&'a str],
37 widths: &'a [u16],
38 rows: &'a [&'a [&'a str]],
39 header_style: Style,
40 row_style: Style,
41 selected_style: Style,
42 column_spacing: u16,
43}
44
45impl<'a> Table<'a> {
46 #[must_use]
49 pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> 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 #[must_use]
75 pub const fn header_style(mut self, style: Style) -> Self {
76 self.header_style = style;
77 self
78 }
79
80 #[must_use]
82 pub const fn row_style(mut self, style: Style) -> Self {
83 self.row_style = style;
84 self
85 }
86
87 #[must_use]
89 pub const fn selected_style(mut self, style: Style) -> Self {
90 self.selected_style = style;
91 self
92 }
93
94 #[must_use]
96 pub const fn column_spacing(mut self, spacing: u16) -> Self {
97 self.column_spacing = spacing;
98 self
99 }
100
101 #[must_use]
121 pub fn theme(self, theme: Theme) -> Self {
122 self.theme_on(theme, theme.panel_bg)
123 }
124
125 #[must_use]
130 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
131 self.header_style = Style::new().fg(theme.fg).bg(bg);
132 self.row_style = Style::new().fg(theme.dim).bg(bg);
133 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
134 self
135 }
136}
137
138impl<B: Backend> StatefulWidget<B> for Table<'_> {
139 type State = ListState;
140
141 fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
142 if area.width() == 0 || area.height() == 0 {
143 return;
144 }
145 draw_row(
146 term,
147 area,
148 area.top(),
149 self.headers,
150 self.widths,
151 RowStyle {
152 style: self.header_style,
153 bg: None,
154 column_spacing: self.column_spacing,
155 },
156 );
157
158 let visible_rows = area.height_usize().saturating_sub(1);
159 let selected = state.selected();
160 for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
161 let y = area.top() + 1 + (row_index - state.offset()) as u16;
162 let (style, bg) = if Some(row_index) == selected {
163 (self.selected_style, Some(self.selected_style.background()))
164 } else {
165 (self.row_style, None)
166 };
167 draw_row(
168 term,
169 area,
170 y,
171 row,
172 self.widths,
173 RowStyle {
174 style,
175 bg,
176 column_spacing: self.column_spacing,
177 },
178 );
179 }
180 term.reset_style();
181 }
182}
183
184#[derive(Clone, Copy)]
187struct RowStyle {
188 style: Style,
190 bg: Option<Color>,
192 column_spacing: u16,
194}
195
196fn draw_row<B: Backend>(
198 term: &mut Terminal<B>,
199 area: Rect,
200 y: u16,
201 cells: &[&str],
202 widths: &[u16],
203 row_style: RowStyle,
204) {
205 let RowStyle {
206 style,
207 bg,
208 column_spacing,
209 } = row_style;
210 if let Some(bg) = bg {
211 fill_rect(
212 term,
213 Rect::new(area.left(), y, area.width(), 1),
214 ' ',
215 Style::new().bg(bg),
216 );
217 }
218 let mut x = area.left();
219 for (cell, &w) in cells.iter().zip(widths) {
220 if x >= area.right() {
221 break;
222 }
223 let avail = (area.right() - x).min(w) as usize;
224 let text = truncate_to_cols(cell, avail);
225 term.reset_style()
226 .fg(style.foreground())
227 .bg(style.background());
228 term.print(x, y, text);
229 x = x.saturating_add(w.saturating_add(column_spacing));
230 }
231 term.reset_style();
232}
233
234#[cfg(test)]
235mod tests {
236 use retroglyph_core::Headless;
237
238 use super::*;
239
240 #[test]
241 fn table_widget_highlights_the_selected_row() {
242 let area = Rect::new(0, 0, 20, 3);
243 let headers = ["Name"];
244 let widths = [10u16];
245 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
246 let table = Table::new(&headers, &widths, &rows);
247
248 let mut term = Terminal::new(Headless::new(20, 3));
249 let mut state = ListState::new();
250 state.select(Some(1));
251 table.render(area, &mut term, &mut state);
252
253 let highlighted_bg = term.grid().get(0, 2).style().background();
255 let plain_bg = term.grid().get(0, 1).style().background();
256 assert_ne!(highlighted_bg, plain_bg);
257 }
258
259 #[test]
260 fn table_widget_highlights_nothing_when_unselected() {
261 let area = Rect::new(0, 0, 20, 3);
262 let headers = ["Name"];
263 let widths = [10u16];
264 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
265 let table = Table::new(&headers, &widths, &rows);
266
267 let mut term = Terminal::new(Headless::new(20, 3));
268 let mut state = ListState::new(); table.render(area, &mut term, &mut state);
270
271 let row0_bg = term.grid().get(0, 1).style().background();
272 let row1_bg = term.grid().get(0, 2).style().background();
273 assert_eq!(row0_bg, row1_bg);
274 }
275
276 fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
277 names.iter().map(|n| [*n]).collect()
278 }
279
280 fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
281 rows.iter().map(<[&str; 1]>::as_slice).collect()
282 }
283
284 #[test]
285 fn scroll_offset_renders_the_window_starting_at_offset() {
286 let area = Rect::new(0, 0, 20, 3);
288 let headers = ["Name"];
289 let widths = [10u16];
290 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
291 let rows = row_refs(&rows);
292 let table = Table::new(&headers, &widths, &rows);
293
294 let mut term = Terminal::new(Headless::new(20, 3));
295 let mut state = ListState::new();
296 state.set_offset(2); table.render(area, &mut term, &mut state);
298
299 assert_eq!(term.grid().get(0, 1).glyph(), 'C');
302 assert_eq!(term.grid().get(0, 2).glyph(), 'D');
303 }
304
305 #[test]
306 fn selection_scrolled_out_of_view_highlights_nothing() {
307 let area = Rect::new(0, 0, 20, 3);
308 let headers = ["Name"];
309 let widths = [10u16];
310 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
311 let rows = row_refs(&rows);
312 let table = Table::new(&headers, &widths, &rows);
313
314 let mut term = Terminal::new(Headless::new(20, 3));
315 let mut state = ListState::new();
316 state.select(Some(0)); state.set_offset(2); table.render(area, &mut term, &mut state);
319
320 let row0_bg = term.grid().get(0, 1).style().background();
321 let row1_bg = term.grid().get(0, 2).style().background();
322 assert_eq!(row0_bg, row1_bg); }
324
325 #[test]
326 fn default_header_style_matches_previous_hardcoded_color() {
327 let area = Rect::new(0, 0, 20, 2);
328 let headers = ["Name"];
329 let widths = [10u16];
330 let rows: Vec<&[&str]> = vec![];
331 let table = Table::new(&headers, &widths, &rows);
332
333 let mut term = Terminal::new(Headless::new(20, 2));
334 let mut state = ListState::new();
335 table.render(area, &mut term, &mut state);
336
337 let expected = Color::Rgb {
338 r: 210,
339 g: 210,
340 b: 230,
341 };
342 assert_eq!(term.grid().get(0, 0).style().foreground(), expected);
343 }
344
345 #[test]
346 fn header_style_can_be_overridden() {
347 let area = Rect::new(0, 0, 20, 2);
348 let headers = ["Name"];
349 let widths = [10u16];
350 let rows: Vec<&[&str]> = vec![];
351 let custom = Style::new().fg(Color::RED);
352 let table = Table::new(&headers, &widths, &rows).header_style(custom);
353
354 let mut term = Terminal::new(Headless::new(20, 2));
355 let mut state = ListState::new();
356 table.render(area, &mut term, &mut state);
357
358 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
359 }
360
361 #[test]
362 fn selected_style_can_be_overridden() {
363 let area = Rect::new(0, 0, 20, 3);
364 let headers = ["Name"];
365 let widths = [10u16];
366 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
367 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
368 let table = Table::new(&headers, &widths, &rows).selected_style(custom);
369
370 let mut term = Terminal::new(Headless::new(20, 3));
371 let mut state = ListState::new();
372 state.select(Some(1));
373 table.render(area, &mut term, &mut state);
374
375 assert_eq!(term.grid().get(0, 2).style().foreground(), Color::GREEN);
376 assert_eq!(term.grid().get(0, 2).style().background(), Color::BLUE);
377 }
378
379 #[test]
380 fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
381 let area = Rect::new(0, 0, 20, 3);
382 let headers = ["Name"];
383 let widths = [10u16];
384 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
385 let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
386
387 let mut term = Terminal::new(Headless::new(20, 3));
388 let mut state = ListState::new();
389 state.select(Some(1));
390 table.render(area, &mut term, &mut state);
391
392 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
393 assert_eq!(
394 term.grid().get(0, 0).style().background(),
395 Theme::DARK.panel_bg
396 );
397 assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.dim);
398 assert_eq!(
399 term.grid().get(0, 1).style().background(),
400 Theme::DARK.panel_bg
401 );
402 assert_eq!(term.grid().get(0, 2).style().foreground(), Theme::DARK.bg);
403 assert_eq!(
404 term.grid().get(0, 2).style().background(),
405 Theme::DARK.accent
406 );
407 }
408
409 #[test]
410 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
411 let area = Rect::new(0, 0, 20, 2);
412 let headers = ["Name"];
413 let widths = [10u16];
414 let rows: [&[&str]; 1] = [&["Alpha"]];
415 let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
416
417 let mut term = Terminal::new(Headless::new(20, 2));
418 let mut state = ListState::new();
419 table.render(area, &mut term, &mut state);
420
421 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
422 assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
423 assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.dim);
424 assert_eq!(term.grid().get(0, 1).style().background(), Color::Default);
425 }
426
427 #[test]
428 fn column_spacing_can_be_overridden() {
429 let area = Rect::new(0, 0, 20, 1);
430 let headers = ["A", "B"];
431 let widths = [1u16, 1u16];
432 let rows: Vec<&[&str]> = vec![];
433 let table = Table::new(&headers, &widths, &rows).column_spacing(3);
434
435 let mut term = Terminal::new(Headless::new(20, 1));
436 let mut state = ListState::new();
437 table.render(area, &mut term, &mut state);
438
439 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
442 assert_eq!(term.grid().get(4, 0).glyph(), 'B');
443 }
444
445 #[test]
446 fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
447 let area = Rect::new(0, 0, 20, 1);
452 let cells: [&str; 2] = ["A", "B"];
453 let widths = [u16::MAX - 1, 1];
454 let row_style = RowStyle {
455 style: Style::new(),
456 bg: None,
457 column_spacing: 3,
458 };
459
460 let mut term = Terminal::new(Headless::new(20, 1));
461 draw_row(&mut term, area, 0, &cells, &widths, row_style);
462
463 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
464 }
465
466 #[test]
467 fn ensure_visible_before_render_keeps_selection_on_screen() {
468 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
470 let widths = [10u16];
471 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
472 let rows = row_refs(&rows);
473 let table = Table::new(&headers, &widths, &rows);
474
475 let mut term = Terminal::new(Headless::new(20, 3));
476 let mut state = ListState::new();
477 state.select(Some(3)); state.ensure_visible(2);
479 table.render(area, &mut term, &mut state);
480
481 assert_eq!(term.grid().get(0, 2).glyph(), 'D');
484 let highlighted_bg = term.grid().get(0, 2).style().background();
485 let plain_bg = term.grid().get(0, 1).style().background();
486 assert_ne!(highlighted_bg, plain_bg);
487 }
488}