1use retroglyph_core::{Color, Rect, Style};
3
4use super::StatefulWidget;
5use super::window::visible_window;
6use crate::ListState;
7use crate::Surface;
8use crate::Theme;
9use crate::draw::fill_rect;
10use crate::text::truncate as truncate_to_cols;
11
12#[derive(Clone, Copy, Debug)]
58pub struct Table<'a> {
59 headers: &'a [&'a str],
60 widths: &'a [u16],
61 rows: &'a [&'a [&'a str]],
62 header_style: Style,
63 row_style: Style,
64 selected_style: Style,
65 column_spacing: u16,
66}
67
68impl<'a> Table<'a> {
69 #[must_use]
72 pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> Self {
73 Self {
74 headers,
75 widths,
76 rows,
77 header_style: Style::new().fg(Color::Rgb {
78 r: 210,
79 g: 210,
80 b: 230,
81 }),
82 row_style: Style::new().fg(Color::Rgb {
83 r: 170,
84 g: 175,
85 b: 190,
86 }),
87 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
88 r: 40,
89 g: 60,
90 b: 90,
91 }),
92 column_spacing: 1,
93 }
94 }
95
96 #[must_use]
98 pub const fn header_style(mut self, style: Style) -> Self {
99 self.header_style = style;
100 self
101 }
102
103 #[must_use]
105 pub const fn row_style(mut self, style: Style) -> Self {
106 self.row_style = style;
107 self
108 }
109
110 #[must_use]
112 pub const fn selected_style(mut self, style: Style) -> Self {
113 self.selected_style = style;
114 self
115 }
116
117 #[must_use]
119 pub const fn column_spacing(mut self, spacing: u16) -> Self {
120 self.column_spacing = spacing;
121 self
122 }
123
124 #[must_use]
144 pub fn theme(self, theme: Theme) -> Self {
145 self.theme_on(theme, theme.panel_bg)
146 }
147
148 #[must_use]
153 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
154 self.header_style = Style::new().fg(theme.fg).bg(bg);
155 self.row_style = Style::new().fg(theme.dim).bg(bg);
156 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
157 self
158 }
159}
160
161impl StatefulWidget for Table<'_> {
162 type State = ListState;
163
164 fn render(&self, area: Rect, surface: &mut Surface<'_>, state: &mut Self::State) {
165 if area.width() == 0 || area.height() == 0 {
166 return;
167 }
168 draw_row(
169 surface,
170 area,
171 area.top(),
172 self.headers,
173 self.widths,
174 RowStyle {
175 style: self.header_style,
176 bg: None,
177 column_spacing: self.column_spacing,
178 },
179 );
180
181 let visible_rows = area.height_usize().saturating_sub(1);
182 let selected = state.selected();
183 for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
184 #[allow(clippy::cast_possible_truncation)]
187 let row_offset = (row_index - state.offset()) as u16;
188 let y = area.top() + 1 + row_offset;
189 let (style, bg) = if Some(row_index) == selected {
190 (self.selected_style, Some(self.selected_style.background()))
191 } else {
192 (self.row_style, None)
193 };
194 draw_row(
195 surface,
196 area,
197 y,
198 row,
199 self.widths,
200 RowStyle {
201 style,
202 bg,
203 column_spacing: self.column_spacing,
204 },
205 );
206 }
207 }
208}
209
210#[derive(Clone, Copy)]
213struct RowStyle {
214 style: Style,
216 bg: Option<Color>,
218 column_spacing: u16,
220}
221
222fn draw_row(
224 surface: &mut Surface<'_>,
225 area: Rect,
226 y: u16,
227 cells: &[&str],
228 widths: &[u16],
229 row_style: RowStyle,
230) {
231 let RowStyle {
232 style,
233 bg,
234 column_spacing,
235 } = row_style;
236 if let Some(bg) = bg {
237 fill_rect(
238 surface,
239 Rect::new(area.left(), y, area.width(), 1),
240 ' ',
241 Style::new().bg(bg),
242 );
243 }
244 let mut x = area.left();
245 for (cell, &w) in cells.iter().zip(widths) {
246 if x >= area.right() {
247 break;
248 }
249 let avail = (area.right() - x).min(w) as usize;
250 let text = truncate_to_cols(cell, avail);
251 surface.print((x, y), text, style);
252 x = x.saturating_add(w.saturating_add(column_spacing));
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use retroglyph_core::{Grid, Pos};
259
260 use super::*;
261
262 #[test]
263 fn table_widget_highlights_the_selected_row() {
264 let area = Rect::new(0, 0, 20, 3);
265 let headers = ["Name"];
266 let widths = [10u16];
267 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
268 let table = Table::new(&headers, &widths, &rows);
269
270 let mut grid = Grid::new(20, 3);
271 let mut state = ListState::new();
272 state.select(Some(1));
273 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
274
275 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
277 let plain_bg = grid[Pos::new(0, 1)].style().background();
278 assert_ne!(highlighted_bg, plain_bg);
279 }
280
281 #[test]
282 fn table_widget_highlights_nothing_when_unselected() {
283 let area = Rect::new(0, 0, 20, 3);
284 let headers = ["Name"];
285 let widths = [10u16];
286 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
287 let table = Table::new(&headers, &widths, &rows);
288
289 let mut grid = Grid::new(20, 3);
290 let mut state = ListState::new(); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
292
293 let row0_bg = grid[Pos::new(0, 1)].style().background();
294 let row1_bg = grid[Pos::new(0, 2)].style().background();
295 assert_eq!(row0_bg, row1_bg);
296 }
297
298 fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
299 names.iter().map(|n| [*n]).collect()
300 }
301
302 fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
303 rows.iter().map(<[&str; 1]>::as_slice).collect()
304 }
305
306 #[test]
307 fn scroll_offset_renders_the_window_starting_at_offset() {
308 let area = Rect::new(0, 0, 20, 3);
310 let headers = ["Name"];
311 let widths = [10u16];
312 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
313 let rows = row_refs(&rows);
314 let table = Table::new(&headers, &widths, &rows);
315
316 let mut grid = Grid::new(20, 3);
317 let mut state = ListState::new();
318 state.set_offset(2); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
320
321 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
324 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
325 }
326
327 #[test]
328 fn selection_scrolled_out_of_view_highlights_nothing() {
329 let area = Rect::new(0, 0, 20, 3);
330 let headers = ["Name"];
331 let widths = [10u16];
332 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
333 let rows = row_refs(&rows);
334 let table = Table::new(&headers, &widths, &rows);
335
336 let mut grid = Grid::new(20, 3);
337 let mut state = ListState::new();
338 state.select(Some(0)); state.set_offset(2); table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
341
342 let row0_bg = grid[Pos::new(0, 1)].style().background();
343 let row1_bg = grid[Pos::new(0, 2)].style().background();
344 assert_eq!(row0_bg, row1_bg); }
346
347 #[test]
348 fn default_header_style_matches_previous_hardcoded_color() {
349 let area = Rect::new(0, 0, 20, 2);
350 let headers = ["Name"];
351 let widths = [10u16];
352 let rows: Vec<&[&str]> = vec![];
353 let table = Table::new(&headers, &widths, &rows);
354
355 let mut grid = Grid::new(20, 2);
356 let mut state = ListState::new();
357 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
358
359 let expected = Color::Rgb {
360 r: 210,
361 g: 210,
362 b: 230,
363 };
364 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), expected);
365 }
366
367 #[test]
368 fn header_style_can_be_overridden() {
369 let area = Rect::new(0, 0, 20, 2);
370 let headers = ["Name"];
371 let widths = [10u16];
372 let rows: Vec<&[&str]> = vec![];
373 let custom = Style::new().fg(Color::RED);
374 let table = Table::new(&headers, &widths, &rows).header_style(custom);
375
376 let mut grid = Grid::new(20, 2);
377 let mut state = ListState::new();
378 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
379
380 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
381 }
382
383 #[test]
384 fn selected_style_can_be_overridden() {
385 let area = Rect::new(0, 0, 20, 3);
386 let headers = ["Name"];
387 let widths = [10u16];
388 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
389 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
390 let table = Table::new(&headers, &widths, &rows).selected_style(custom);
391
392 let mut grid = Grid::new(20, 3);
393 let mut state = ListState::new();
394 state.select(Some(1));
395 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
396
397 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Color::GREEN);
398 assert_eq!(grid[Pos::new(0, 2)].style().background(), Color::BLUE);
399 }
400
401 #[test]
402 fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
403 let area = Rect::new(0, 0, 20, 3);
404 let headers = ["Name"];
405 let widths = [10u16];
406 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
407 let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
408
409 let mut grid = Grid::new(20, 3);
410 let mut state = ListState::new();
411 state.select(Some(1));
412 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
413
414 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
415 assert_eq!(
416 grid[Pos::new(0, 0)].style().background(),
417 Theme::DARK.panel_bg
418 );
419 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
420 assert_eq!(
421 grid[Pos::new(0, 1)].style().background(),
422 Theme::DARK.panel_bg
423 );
424 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Theme::DARK.bg);
425 assert_eq!(
426 grid[Pos::new(0, 2)].style().background(),
427 Theme::DARK.accent
428 );
429 }
430
431 #[test]
432 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
433 let area = Rect::new(0, 0, 20, 2);
434 let headers = ["Name"];
435 let widths = [10u16];
436 let rows: [&[&str]; 1] = [&["Alpha"]];
437 let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
438
439 let mut grid = Grid::new(20, 2);
440 let mut state = ListState::new();
441 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
442
443 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
444 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
445 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
446 assert_eq!(grid[Pos::new(0, 1)].style().background(), Color::Default);
447 }
448
449 #[test]
450 fn column_spacing_can_be_overridden() {
451 let area = Rect::new(0, 0, 20, 1);
452 let headers = ["A", "B"];
453 let widths = [1u16, 1u16];
454 let rows: Vec<&[&str]> = vec![];
455 let table = Table::new(&headers, &widths, &rows).column_spacing(3);
456
457 let mut grid = Grid::new(20, 1);
458 let mut state = ListState::new();
459 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
460
461 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
464 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
465 }
466
467 #[test]
468 fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
469 let area = Rect::new(0, 0, 20, 1);
474 let cells: [&str; 2] = ["A", "B"];
475 let widths = [u16::MAX - 1, 1];
476 let row_style = RowStyle {
477 style: Style::new(),
478 bg: None,
479 column_spacing: 3,
480 };
481
482 let mut grid = Grid::new(20, 1);
483 draw_row(
484 &mut Surface::new(&mut grid, area, 0),
485 area,
486 0,
487 &cells,
488 &widths,
489 row_style,
490 );
491
492 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
493 }
494
495 #[test]
496 fn ensure_visible_before_render_keeps_selection_on_screen() {
497 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
499 let widths = [10u16];
500 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
501 let rows = row_refs(&rows);
502 let table = Table::new(&headers, &widths, &rows);
503
504 let mut grid = Grid::new(20, 3);
505 let mut state = ListState::new();
506 state.select(Some(3)); state.ensure_visible(2);
508 table.render(area, &mut Surface::new(&mut grid, area, 0), &mut state);
509
510 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
513 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
514 let plain_bg = grid[Pos::new(0, 1)].style().background();
515 assert_ne!(highlighted_bg, plain_bg);
516 }
517}