1use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::Rect;
4
5use super::window::visible_window;
6use super::{Measure, StatefulWidget};
7use crate::Align;
8use crate::ListState;
9use crate::Surface;
10use crate::Theme;
11use crate::draw::fill_rect;
12use crate::text::draw_clipped;
13
14#[derive(Clone, Copy, Debug)]
54pub struct Table<'a> {
55 headers: &'a [&'a str],
56 widths: &'a [u16],
57 rows: &'a [&'a [&'a str]],
58 header_style: Style,
59 row_style: Style,
60 selected_style: Style,
61 column_spacing: u16,
62}
63
64impl<'a> Table<'a> {
65 #[must_use]
68 pub fn new(headers: &'a [&'a str], widths: &'a [u16], rows: &'a [&'a [&'a str]]) -> Self {
69 Self {
70 headers,
71 widths,
72 rows,
73 header_style: Style::new(),
74 row_style: Style::new(),
75 selected_style: Style::new(),
76 column_spacing: 1,
77 }
78 .theme(Theme::DARK)
79 }
80
81 #[must_use]
83 pub const fn header_style(mut self, style: Style) -> Self {
84 self.header_style = style;
85 self
86 }
87
88 #[must_use]
90 pub const fn row_style(mut self, style: Style) -> Self {
91 self.row_style = style;
92 self
93 }
94
95 #[must_use]
97 pub const fn selected_style(mut self, style: Style) -> Self {
98 self.selected_style = style;
99 self
100 }
101
102 #[must_use]
104 pub const fn column_spacing(mut self, spacing: u16) -> Self {
105 self.column_spacing = spacing;
106 self
107 }
108
109 #[must_use]
124 pub fn theme(self, theme: Theme) -> Self {
125 self.theme_on(theme, theme.panel_bg)
126 }
127
128 #[must_use]
133 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
134 self.header_style = Style::new().fg(theme.fg).bg(bg);
135 self.row_style = Style::new().fg(theme.dim).bg(bg);
136 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
137 self
138 }
139}
140
141impl StatefulWidget for Table<'_> {
142 type State = ListState;
143
144 fn render(&self, surface: &mut Surface<'_>, state: &mut Self::State) {
145 let (width, height) = (surface.width(), surface.height());
146 if width == 0 || height == 0 {
147 return;
148 }
149 draw_row(
150 surface,
151 width,
152 0,
153 self.headers,
154 self.widths,
155 RowStyle {
156 style: self.header_style,
157 bg: None,
158 column_spacing: self.column_spacing,
159 },
160 );
161
162 let visible_rows = usize::from(height).saturating_sub(1);
163 let selected = state.selected();
164 for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
165 #[allow(clippy::cast_possible_truncation)]
168 let row_offset = (row_index - state.offset()) as u16;
169 let y = 1 + row_offset;
170 let (style, bg) = if Some(row_index) == selected {
171 (self.selected_style, Some(self.selected_style.background()))
172 } else {
173 (self.row_style, None)
174 };
175 draw_row(
176 surface,
177 width,
178 y,
179 row,
180 self.widths,
181 RowStyle {
182 style,
183 bg,
184 column_spacing: self.column_spacing,
185 },
186 );
187 }
188 }
189}
190
191impl Measure for Table<'_> {
192 fn height_for(&self, _width: u16) -> u16 {
195 #[allow(clippy::cast_possible_truncation)]
196 let rows = self.rows.len().min(usize::from(u16::MAX)) as u16;
197 rows.saturating_add(1)
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(
216 surface: &mut Surface<'_>,
217 width: u16,
218 y: u16,
219 cells: &[&str],
220 widths: &[u16],
221 row_style: RowStyle,
222) {
223 let RowStyle {
224 style,
225 bg,
226 column_spacing,
227 } = row_style;
228 if let Some(bg) = bg {
229 fill_rect(surface, Rect::new(0, y, width, 1), ' ', Style::new().bg(bg));
230 }
231 let mut x = 0u16;
232 for (cell, &w) in cells.iter().zip(widths) {
233 if x >= width {
234 break;
235 }
236 let avail = (width - x).min(w);
237 let _ = draw_clipped(surface, (x, y), avail, cell, Align::Left, style);
238 x = x.saturating_add(w.saturating_add(column_spacing));
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use alloc::vec;
245 use alloc::vec::Vec;
246
247 use retroglyph_core::grid::{Grid, Pos};
248
249 use super::*;
250
251 #[test]
252 fn table_widget_highlights_the_selected_row() {
253 let area = Rect::new(0, 0, 20, 3);
254 let headers = ["Name"];
255 let widths = [10u16];
256 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
257 let table = Table::new(&headers, &widths, &rows);
258
259 let mut grid = Grid::new(20, 3);
260 let mut state = ListState::new();
261 state.select(Some(1));
262 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
263
264 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
266 let plain_bg = grid[Pos::new(0, 1)].style().background();
267 assert_ne!(highlighted_bg, plain_bg);
268 }
269
270 #[test]
271 fn table_widget_highlights_nothing_when_unselected() {
272 let area = Rect::new(0, 0, 20, 3);
273 let headers = ["Name"];
274 let widths = [10u16];
275 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
276 let table = Table::new(&headers, &widths, &rows);
277
278 let mut grid = Grid::new(20, 3);
279 let mut state = ListState::new(); table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
281
282 let row0_bg = grid[Pos::new(0, 1)].style().background();
283 let row1_bg = grid[Pos::new(0, 2)].style().background();
284 assert_eq!(row0_bg, row1_bg);
285 }
286
287 fn rows<'a>(names: &[&'a str]) -> Vec<[&'a str; 1]> {
288 names.iter().map(|n| [*n]).collect()
289 }
290
291 fn row_refs<'a>(rows: &'a [[&'a str; 1]]) -> Vec<&'a [&'a str]> {
292 rows.iter().map(<[&str; 1]>::as_slice).collect()
293 }
294
295 #[test]
296 fn scroll_offset_renders_the_window_starting_at_offset() {
297 let area = Rect::new(0, 0, 20, 3);
299 let headers = ["Name"];
300 let widths = [10u16];
301 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
302 let rows = row_refs(&rows);
303 let table = Table::new(&headers, &widths, &rows);
304
305 let mut grid = Grid::new(20, 3);
306 let mut state = ListState::new();
307 state.set_offset(2); table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
309
310 assert_eq!(grid[Pos::new(0, 1)].glyph(), 'C');
313 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
314 }
315
316 #[test]
317 fn selection_scrolled_out_of_view_highlights_nothing() {
318 let area = Rect::new(0, 0, 20, 3);
319 let headers = ["Name"];
320 let widths = [10u16];
321 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
322 let rows = row_refs(&rows);
323 let table = Table::new(&headers, &widths, &rows);
324
325 let mut grid = Grid::new(20, 3);
326 let mut state = ListState::new();
327 state.select(Some(0)); state.set_offset(2); table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
330
331 let row0_bg = grid[Pos::new(0, 1)].style().background();
332 let row1_bg = grid[Pos::new(0, 2)].style().background();
333 assert_eq!(row0_bg, row1_bg); }
335
336 #[test]
337 fn height_for_is_row_count_plus_the_header_row() {
338 let headers = ["Name"];
339 let widths = [10u16];
340 let rows = rows(&["Alpha", "Bravo", "Charlie"]);
341 let rows = row_refs(&rows);
342 let table = Table::new(&headers, &widths, &rows);
343
344 assert_eq!(table.height_for(80), 4); }
346
347 #[test]
348 fn default_header_style_matches_theme_dark() {
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(&mut Surface::new(&mut grid, area, 0), &mut state);
358
359 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
360 assert_eq!(
361 grid[Pos::new(0, 0)].style().background(),
362 Theme::DARK.panel_bg
363 );
364 }
365
366 #[test]
367 fn header_style_can_be_overridden() {
368 let area = Rect::new(0, 0, 20, 2);
369 let headers = ["Name"];
370 let widths = [10u16];
371 let rows: Vec<&[&str]> = vec![];
372 let custom = Style::new().fg(Color::RED);
373 let table = Table::new(&headers, &widths, &rows).header_style(custom);
374
375 let mut grid = Grid::new(20, 2);
376 let mut state = ListState::new();
377 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
378
379 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Color::RED);
380 }
381
382 #[test]
383 fn selected_style_can_be_overridden() {
384 let area = Rect::new(0, 0, 20, 3);
385 let headers = ["Name"];
386 let widths = [10u16];
387 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
388 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
389 let table = Table::new(&headers, &widths, &rows).selected_style(custom);
390
391 let mut grid = Grid::new(20, 3);
392 let mut state = ListState::new();
393 state.select(Some(1));
394 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
395
396 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Color::GREEN);
397 assert_eq!(grid[Pos::new(0, 2)].style().background(), Color::BLUE);
398 }
399
400 #[test]
401 fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
402 let area = Rect::new(0, 0, 20, 3);
403 let headers = ["Name"];
404 let widths = [10u16];
405 let rows: [&[&str]; 2] = [&["Alpha"], &["Bravo"]];
406 let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
407
408 let mut grid = Grid::new(20, 3);
409 let mut state = ListState::new();
410 state.select(Some(1));
411 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
412
413 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
414 assert_eq!(
415 grid[Pos::new(0, 0)].style().background(),
416 Theme::DARK.panel_bg
417 );
418 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
419 assert_eq!(
420 grid[Pos::new(0, 1)].style().background(),
421 Theme::DARK.panel_bg
422 );
423 assert_eq!(grid[Pos::new(0, 2)].style().foreground(), Theme::DARK.bg);
424 assert_eq!(
425 grid[Pos::new(0, 2)].style().background(),
426 Theme::DARK.accent
427 );
428 }
429
430 #[test]
431 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
432 let area = Rect::new(0, 0, 20, 2);
433 let headers = ["Name"];
434 let widths = [10u16];
435 let rows: [&[&str]; 1] = [&["Alpha"]];
436 let table = Table::new(&headers, &widths, &rows).theme_on(Theme::DARK, Color::Default);
437
438 let mut grid = Grid::new(20, 2);
439 let mut state = ListState::new();
440 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
441
442 assert_eq!(grid[Pos::new(0, 0)].style().foreground(), Theme::DARK.fg);
443 assert_eq!(grid[Pos::new(0, 0)].style().background(), Color::Default);
444 assert_eq!(grid[Pos::new(0, 1)].style().foreground(), Theme::DARK.dim);
445 assert_eq!(grid[Pos::new(0, 1)].style().background(), Color::Default);
446 }
447
448 #[test]
449 fn column_spacing_can_be_overridden() {
450 let area = Rect::new(0, 0, 20, 1);
451 let headers = ["A", "B"];
452 let widths = [1u16, 1u16];
453 let rows: Vec<&[&str]> = vec![];
454 let table = Table::new(&headers, &widths, &rows).column_spacing(3);
455
456 let mut grid = Grid::new(20, 1);
457 let mut state = ListState::new();
458 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
459
460 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
463 assert_eq!(grid[Pos::new(4, 0)].glyph(), 'B');
464 }
465
466 #[test]
467 fn draw_row_column_width_plus_spacing_saturates_instead_of_overflowing() {
468 let area = Rect::new(0, 0, 20, 1);
473 let cells: [&str; 2] = ["A", "B"];
474 let widths = [u16::MAX - 1, 1];
475 let row_style = RowStyle {
476 style: Style::new(),
477 bg: None,
478 column_spacing: 3,
479 };
480
481 let mut grid = Grid::new(20, 1);
482 draw_row(
483 &mut Surface::new(&mut grid, area, 0),
484 area.width(),
485 0,
486 &cells,
487 &widths,
488 row_style,
489 );
490
491 assert_eq!(grid[Pos::new(0, 0)].glyph(), 'A');
492 }
493
494 #[test]
495 fn ensure_visible_before_render_keeps_selection_on_screen() {
496 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
498 let widths = [10u16];
499 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
500 let rows = row_refs(&rows);
501 let table = Table::new(&headers, &widths, &rows);
502
503 let mut grid = Grid::new(20, 3);
504 let mut state = ListState::new();
505 state.select(Some(3)); state.ensure_visible(2);
507 table.render(&mut Surface::new(&mut grid, area, 0), &mut state);
508
509 assert_eq!(grid[Pos::new(0, 2)].glyph(), 'D');
512 let highlighted_bg = grid[Pos::new(0, 2)].style().background();
513 let plain_bg = grid[Pos::new(0, 1)].style().background();
514 assert_ne!(highlighted_bg, plain_bg);
515 }
516}