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