1use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::Rect;
4
5use super::window::visible_window;
6use super::{InteractiveWidget, Measure, StatefulWidget};
7use crate::Surface;
8use crate::align::Align;
9use crate::draw::fill_rect;
10use crate::interact::Response;
11use crate::interact::Sense;
12use crate::state::ListState;
13use crate::text::draw_clipped;
14use crate::theme::Theme;
15
16#[derive(Clone, Copy, Debug)]
64pub struct Table<'a> {
65 headers: &'a [&'a str],
66 widths: &'a [u16],
67 rows: &'a [&'a [&'a str]],
68 header_style: Style,
69 row_style: Style,
70 selected_style: Style,
71 column_spacing: u16,
72}
73
74impl<'a> Table<'a> {
75 #[must_use]
78 pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> Self {
79 Self {
80 headers,
81 widths,
82 rows,
83 header_style: Style::new(),
84 row_style: Style::new(),
85 selected_style: Style::new(),
86 column_spacing: 1,
87 }
88 .theme(Theme::DARK)
89 }
90
91 #[must_use]
93 pub const fn header_style(mut self, style: Style) -> Self {
94 self.header_style = style;
95 self
96 }
97
98 #[must_use]
100 pub const fn row_style(mut self, style: Style) -> Self {
101 self.row_style = style;
102 self
103 }
104
105 #[must_use]
107 pub const fn selected_style(mut self, style: Style) -> Self {
108 self.selected_style = style;
109 self
110 }
111
112 #[must_use]
114 pub const fn column_spacing(mut self, spacing: u16) -> Self {
115 self.column_spacing = spacing;
116 self
117 }
118
119 #[must_use]
134 pub fn theme(self, theme: Theme) -> Self {
135 self.theme_on(theme, theme.panel_bg)
136 }
137
138 #[must_use]
143 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
144 self.header_style = Style::new().fg(theme.fg).bg(bg);
145 self.row_style = Style::new().fg(theme.dim).bg(bg);
146 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
147 self
148 }
149}
150
151impl Table<'_> {
152 fn draw(&self, surface: &mut Surface<'_>, state: &ListState) {
155 let (width, height) = (surface.width(), surface.height());
156 if width == 0 || height == 0 {
157 return;
158 }
159 draw_row(
160 surface,
161 width,
162 0,
163 self.headers,
164 self.widths,
165 RowStyle {
166 style: self.header_style,
167 bg: None,
168 column_spacing: self.column_spacing,
169 },
170 );
171
172 let visible_rows = usize::from(height).saturating_sub(1);
173 let selected = state.selected();
174 for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
175 #[allow(clippy::cast_possible_truncation)]
178 let row_offset = (row_index - state.offset()) as u16;
179 let y = 1 + row_offset;
180 let (style, bg) = if Some(row_index) == selected {
181 (self.selected_style, Some(self.selected_style.background()))
182 } else {
183 (self.row_style, None)
184 };
185 draw_row(
186 surface,
187 width,
188 y,
189 row,
190 self.widths,
191 RowStyle {
192 style,
193 bg,
194 column_spacing: self.column_spacing,
195 },
196 );
197 }
198 }
199
200 fn index_at(
203 &self,
204 area: Rect,
205 state: &ListState,
206 pos: retroglyph_core::grid::Pos,
207 ) -> Option<usize> {
208 let row = pos.y.checked_sub(area.top())?.checked_sub(1)?; let index = state.offset() + usize::from(row);
210 (index < self.rows.len()).then_some(index)
211 }
212}
213
214impl StatefulWidget for Table<'_> {
215 type State = ListState;
216
217 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
218 self.draw(surface, state);
219 }
220}
221
222impl<Id> InteractiveWidget<Id> for Table<'_> {
223 type State = ListState;
224
225 fn sense(&self) -> Sense {
229 Sense::click() | Sense::SCROLL | Sense::HOVER
230 }
231
232 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State, response: Response<Id>) {
233 let area = surface.area();
234
235 if response.clicked()
238 && let Some(pos) = response.pointer_pos()
239 && let Some(index) = self.index_at(area, state, pos)
240 {
241 state.select(Some(index));
242 }
243 let scroll_delta = response.scroll_delta();
244 if scroll_delta != 0 {
245 state.scroll_by(scroll_delta);
246 }
247
248 self.draw(surface, state);
249 }
250}
251
252impl Measure for Table<'_> {
253 fn height_for(&self, _width: u16) -> u16 {
256 #[allow(clippy::cast_possible_truncation)]
257 let rows = self.rows.len().min(usize::from(u16::MAX)) as u16;
258 rows.saturating_add(1)
259 }
260}
261
262#[derive(Clone, Copy)]
265struct RowStyle {
266 style: Style,
268 bg: Option<Color>,
270 column_spacing: u16,
272}
273
274fn draw_row(
277 surface: &mut Surface<'_>,
278 width: u16,
279 y: u16,
280 cells: &[&str],
281 widths: &[u16],
282 row_style: RowStyle,
283) {
284 let RowStyle {
285 style,
286 bg,
287 column_spacing,
288 } = row_style;
289 if let Some(bg) = bg {
290 fill_rect(surface, Rect::new(0, y, width, 1), ' ', Style::new().bg(bg));
291 }
292 let mut x = 0u16;
293 for (cell, &w) in cells.iter().zip(widths) {
294 if x >= width {
295 break;
296 }
297 let avail = (width - x).min(w);
298 let _ = draw_clipped(surface, (x, y), avail, cell, Align::Left, style);
299 x = x.saturating_add(w.saturating_add(column_spacing));
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use alloc::vec;
306 use alloc::vec::Vec;
307
308 use retroglyph_core::grid::{Grid, Pos};
309
310 use super::*;
311
312 #[test]
313 fn table_widget_highlights_the_selected_row() {
314 let area = Rect::new(0, 0, 20, 3);
315 let headers = ["Name"];
316 let widths = [10u16];
317 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
318 let table = Table::new(&headers, &widths, &rows);
319
320 let mut grid = Grid::new(20, 3);
321 let mut state = ListState::new();
322 state.select(Some(1));
323 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
324
325 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
327 let plain_bg = grid[Pos::new(0, 1)].style().background();
328 assert_ne!(highlighted_bg, plain_bg);
329 }
330
331 #[test]
332 fn table_widget_highlights_nothing_when_unselected() {
333 let area = Rect::new(0, 0, 20, 3);
334 let headers = ["Name"];
335 let widths = [10u16];
336 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
337 let table = Table::new(&headers, &widths, &rows);
338
339 let mut grid = Grid::new(20, 3);
340 let mut state = ListState::new(); StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
342
343 let row0_bg = grid[Pos::new(0, 1)].style().background();
344 let row1_bg = grid[Pos::new(0, 2)].style().background();
345 assert_eq!(row0_bg, row1_bg);
346 }
347
348 fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
349 names.iter().map(|n| [*n]).collect()
350 }
351
352 fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
353 rows.iter().map(<[&str; 1]>::as_slice).collect()
354 }
355
356 #[test]
357 fn scroll_offset_renders_the_window_starting_at_offset() {
358 let area = Rect::new(0, 0, 20, 3);
360 let headers = ["Name"];
361 let widths = [10u16];
362 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
363 let rows = row_refs(&rows);
364 let table = Table::new(&headers, &widths, &rows);
365
366 let mut grid = Grid::new(20, 3);
367 let mut state = ListState::new();
368 state.set_offset(2); StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
370
371 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
374 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
375 }
376
377 #[test]
378 fn selection_scrolled_out_of_view_highlights_nothing() {
379 let area = Rect::new(0, 0, 20, 3);
380 let headers = ["Name"];
381 let widths = [10u16];
382 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
383 let rows = row_refs(&rows);
384 let table = Table::new(&headers, &widths, &rows);
385
386 let mut grid = Grid::new(20, 3);
387 let mut state = ListState::new();
388 state.select(Some(0)); state.set_offset(2); StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
391
392 let row0_bg = grid[Pos::new(0, 1)].style().background();
393 let row1_bg = grid[Pos::new(0, 2)].style().background();
394 assert_eq!(row0_bg, row1_bg); }
396
397 #[test]
398 fn height_for_is_row_count_plus_the_header_row() {
399 let headers = ["Name"];
400 let widths = [10u16];
401 let rows = rows(&["Alpha", "Bravo", "Charlie"]);
402 let rows = row_refs(&rows);
403 let table = Table::new(&headers, &widths, &rows);
404
405 assert_eq!(table.height_for(80), 4); }
407
408 #[test]
409 fn default_header_style_matches_theme_dark() {
410 let area = Rect::new(0, 0, 20, 2);
411 let headers = ["Name"];
412 let widths = [10u16];
413 let rows: Vec<&[&str]> = vec![];
414 let table = Table::new(&headers, &widths, &rows);
415
416 let mut grid = Grid::new(20, 2);
417 let mut state = ListState::new();
418 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
419
420 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
421 assert_eq!(
422 grid[Pos::new(0, 0)].style().background(),
423 Theme::DARK.panel_bg
424 );
425 }
426
427 #[test]
428 fn header_style_can_be_overridden() {
429 let area = Rect::new(0, 0, 20, 2);
430 let headers = ["Name"];
431 let widths = [10u16];
432 let rows: Vec<&[&str]> = vec![];
433 let custom = Style::new().fg(Color::RED);
434 let table = Table::new(&headers, &widths, &rows).header_style(custom);
435
436 let mut grid = Grid::new(20, 2);
437 let mut state = ListState::new();
438 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
439
440 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
441 }
442
443 #[test]
444 fn selected_style_can_be_overridden() {
445 let area = Rect::new(0, 0, 20, 3);
446 let headers = ["Name"];
447 let widths = [10u16];
448 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
449 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
450 let table = Table::new(&headers, &widths, &rows).selected_style(custom);
451
452 let mut grid = Grid::new(20, 3);
453 let mut state = ListState::new();
454 state.select(Some(1));
455 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
456
457 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Color::GREEN);
458 assert_eq!(grid[Pos::new(0, 2)].style().background(), Color::BLUE);
459 }
460
461 #[test]
462 fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
463 let area = Rect::new(0, 0, 20, 3);
464 let headers = ["Name"];
465 let widths = [10u16];
466 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
467 let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
468
469 let mut grid = Grid::new(20, 3);
470 let mut state = ListState::new();
471 state.select(Some(1));
472 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
473
474 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
475 assert_eq!(
476 grid[Pos::new(0, 0)].style().background(),
477 Theme::DARK.panel_bg
478 );
479 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
480 assert_eq!(
481 grid[Pos::new(0, 1)].style().background(),
482 Theme::DARK.panel_bg
483 );
484 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Theme::DARK.bg);
485 assert_eq!(
486 grid[Pos::new(0, 2)].style().background(),
487 Theme::DARK.accent
488 );
489 }
490
491 #[test]
492 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
493 let area = Rect::new(0, 0, 20, 2);
494 let headers = ["Name"];
495 let widths = [10u16];
496 let rows: [&[&str]; 1] = [&["Alpha"]];
497 let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
498
499 let mut grid = Grid::new(20, 2);
500 let mut state = ListState::new();
501 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
502
503 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
504 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
505 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
506 assert_eq!(grid[Pos::new(0, 1)].style().background(), Color::Default);
507 }
508
509 #[test]
510 fn column_spacing_can_be_overridden() {
511 let area = Rect::new(0, 0, 20, 1);
512 let headers = ["A", "B"];
513 let widths = [1u16, 1u16];
514 let rows: Vec<&[&str]> = vec![];
515 let table = Table::new(&headers, &widths, &rows).column_spacing(3);
516
517 let mut grid = Grid::new(20, 1);
518 let mut state = ListState::new();
519 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
520
521 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
524 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
525 }
526
527 #[test]
528 fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
529 let area = Rect::new(0, 0, 20, 1);
534 let cells: [&str; 2] = ["A", "B"];
535 let widths = [u16::MAX - 1, 1];
536 let row_style = RowStyle {
537 style: Style::new(),
538 bg: None,
539 column_spacing: 3,
540 };
541
542 let mut grid = Grid::new(20, 1);
543 draw_row(
544 &mut Surface::new(&mut grid, area, 0),
545 area.width(),
546 0,
547 &cells,
548 &widths,
549 row_style,
550 );
551
552 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
553 }
554
555 #[test]
556 fn ensure_visible_before_render_keeps_selection_on_screen() {
557 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
559 let widths = [10u16];
560 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
561 let rows = row_refs(&rows);
562 let table = Table::new(&headers, &widths, &rows);
563
564 let mut grid = Grid::new(20, 3);
565 let mut state = ListState::new();
566 state.select(Some(3)); state.ensure_visible(2);
568 StatefulWidget::render(&table, &mut Surface::new(&mut grid, area, 0), &mut state);
569
570 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
573 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
574 let plain_bg = grid[Pos::new(0, 1)].style().background();
575 assert_ne!(highlighted_bg, plain_bg);
576 }
577
578 #[test]
579 fn click_selects_the_row_under_the_pointer() {
580 let area = Rect::new(0, 0, 20, 3);
581 let headers = ["Name"];
582 let widths = [10u16];
583 let rows = rows(&["Alpha", "Bravo"]);
584 let rows = row_refs(&rows);
585 let table = Table::new(&headers, &widths, &rows);
586 let mut state = ListState::new();
587
588 let response: Response<()> = Response {
589 hovered: true,
590 clicked: true,
591 pointer_pos: Some(Pos::new(2, 2)), ..Response::default()
593 };
594 let mut grid = Grid::new(20, 3);
595 InteractiveWidget::render(
596 &table,
597 &mut Surface::new(&mut grid, area, 0),
598 &mut state,
599 response,
600 );
601 assert_eq!(state.selected(), Some(1));
602 }
603
604 #[test]
605 fn click_selects_the_row_under_the_pointer_with_a_scroll_offset() {
606 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
608 let widths = [10u16];
609 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
610 let rows = row_refs(&rows);
611 let table = Table::new(&headers, &widths, &rows);
612 let mut state = ListState::new();
613 state.set_offset(2); let response: Response<()> = Response {
616 hovered: true,
617 clicked: true,
618 pointer_pos: Some(Pos::new(2, 2)), ..Response::default()
620 };
621 let mut grid = Grid::new(20, 3);
622 InteractiveWidget::render(
623 &table,
624 &mut Surface::new(&mut grid, area, 0),
625 &mut state,
626 response,
627 );
628 assert_eq!(state.selected(), Some(3));
629 }
630
631 #[test]
632 fn click_on_the_header_row_selects_nothing() {
633 let area = Rect::new(0, 0, 20, 3);
634 let headers = ["Name"];
635 let widths = [10u16];
636 let rows = rows(&["Alpha", "Bravo"]);
637 let rows = row_refs(&rows);
638 let table = Table::new(&headers, &widths, &rows);
639 let mut state = ListState::new();
640 state.select(Some(0));
641
642 let response: Response<()> = Response {
643 hovered: true,
644 clicked: true,
645 pointer_pos: Some(Pos::new(2, 0)), ..Response::default()
647 };
648 let mut grid = Grid::new(20, 3);
649 InteractiveWidget::render(
650 &table,
651 &mut Surface::new(&mut grid, area, 0),
652 &mut state,
653 response,
654 );
655 assert_eq!(state.selected(), Some(0)); }
657
658 #[test]
659 fn wheel_scroll_moves_the_offset() {
660 let area = Rect::new(0, 0, 20, 3);
661 let headers = ["Name"];
662 let widths = [10u16];
663 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
664 let rows = row_refs(&rows);
665 let table = Table::new(&headers, &widths, &rows);
666 let mut state = ListState::new();
667
668 let response: Response<()> = Response {
669 scroll_delta: 2,
670 ..Response::default()
671 };
672 let mut grid = Grid::new(20, 3);
673 InteractiveWidget::render(
674 &table,
675 &mut Surface::new(&mut grid, area, 0),
676 &mut state,
677 response,
678 );
679 assert_eq!(state.offset(), 2);
680 }
681}