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 [Vec<String>],
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 [Vec<String>]) -> 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(mut self, theme: Theme) -> Self {
122 self.header_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
123 self.row_style = Style::new().fg(theme.dim).bg(theme.panel_bg);
124 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
125 self
126 }
127}
128
129impl<B: Backend> StatefulWidget<B> for Table<'_> {
130 type State = ListState;
131
132 fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
133 if area.width() == 0 || area.height() == 0 {
134 return;
135 }
136 draw_row(
137 term,
138 area,
139 area.top(),
140 self.headers,
141 self.widths,
142 RowStyle {
143 style: self.header_style,
144 bg: None,
145 column_spacing: self.column_spacing,
146 },
147 );
148
149 let visible_rows = area.height_usize().saturating_sub(1);
150 let selected = state.selected();
151 for (row_index, row) in visible_window(self.rows, state.offset(), visible_rows) {
152 let y = area.top() + 1 + (row_index - state.offset()) as u16;
153 let (style, bg) = if Some(row_index) == selected {
154 (self.selected_style, Some(self.selected_style.background()))
155 } else {
156 (self.row_style, None)
157 };
158 let cells: Vec<&str> = row.iter().map(String::as_str).collect();
159 draw_row(
160 term,
161 area,
162 y,
163 &cells,
164 self.widths,
165 RowStyle {
166 style,
167 bg,
168 column_spacing: self.column_spacing,
169 },
170 );
171 }
172 term.reset_style();
173 }
174}
175
176#[derive(Clone, Copy)]
179struct RowStyle {
180 style: Style,
182 bg: Option<Color>,
184 column_spacing: u16,
186}
187
188fn draw_row<B: Backend>(
190 term: &mut Terminal<B>,
191 area: Rect,
192 y: u16,
193 cells: &[&str],
194 widths: &[u16],
195 row_style: RowStyle,
196) {
197 let RowStyle {
198 style,
199 bg,
200 column_spacing,
201 } = row_style;
202 if let Some(bg) = bg {
203 fill_rect(
204 term,
205 Rect::new(area.left(), y, area.width(), 1),
206 ' ',
207 Style::new().bg(bg),
208 );
209 }
210 let mut x = area.left();
211 for (cell, &w) in cells.iter().zip(widths) {
212 if x >= area.right() {
213 break;
214 }
215 let avail = (area.right() - x).min(w) as usize;
216 let text = truncate_to_cols(cell, avail);
217 term.reset_style()
218 .fg(style.foreground())
219 .bg(style.background());
220 term.print(x, y, &text);
221 x = x.saturating_add(w + column_spacing);
222 }
223 term.reset_style();
224}
225
226#[cfg(test)]
227mod tests {
228 use retroglyph_core::Headless;
229
230 use super::*;
231
232 #[test]
233 fn table_widget_highlights_the_selected_row() {
234 let area = Rect::new(0, 0, 20, 3);
235 let headers = ["Name"];
236 let widths = [10u16];
237 let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
238 let table = Table::new(&headers, &widths, &rows);
239
240 let mut term = Terminal::new(Headless::new(20, 3));
241 let mut state = ListState::new();
242 state.select(Some(1));
243 table.render(area, &mut term, &mut state);
244
245 let highlighted_bg = term.grid().get(0, 2).style().background();
247 let plain_bg = term.grid().get(0, 1).style().background();
248 assert_ne!(highlighted_bg, plain_bg);
249 }
250
251 #[test]
252 fn table_widget_highlights_nothing_when_unselected() {
253 let area = Rect::new(0, 0, 20, 3);
254 let headers = ["Name"];
255 let widths = [10u16];
256 let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
257 let table = Table::new(&headers, &widths, &rows);
258
259 let mut term = Terminal::new(Headless::new(20, 3));
260 let mut state = ListState::new(); table.render(area, &mut term, &mut state);
262
263 let row0_bg = term.grid().get(0, 1).style().background();
264 let row1_bg = term.grid().get(0, 2).style().background();
265 assert_eq!(row0_bg, row1_bg);
266 }
267
268 fn rows(names: &[&str]) -> Vec<Vec<String>> {
269 names.iter().map(|n| vec![(*n).to_string()]).collect()
270 }
271
272 #[test]
273 fn scroll_offset_renders_the_window_starting_at_offset() {
274 let area = Rect::new(0, 0, 20, 3);
276 let headers = ["Name"];
277 let widths = [10u16];
278 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
279 let table = Table::new(&headers, &widths, &rows);
280
281 let mut term = Terminal::new(Headless::new(20, 3));
282 let mut state = ListState::new();
283 state.set_offset(2); table.render(area, &mut term, &mut state);
285
286 assert_eq!(term.grid().get(0, 1).glyph(), 'C');
289 assert_eq!(term.grid().get(0, 2).glyph(), 'D');
290 }
291
292 #[test]
293 fn selection_scrolled_out_of_view_highlights_nothing() {
294 let area = Rect::new(0, 0, 20, 3);
295 let headers = ["Name"];
296 let widths = [10u16];
297 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
298 let table = Table::new(&headers, &widths, &rows);
299
300 let mut term = Terminal::new(Headless::new(20, 3));
301 let mut state = ListState::new();
302 state.select(Some(0)); state.set_offset(2); table.render(area, &mut term, &mut state);
305
306 let row0_bg = term.grid().get(0, 1).style().background();
307 let row1_bg = term.grid().get(0, 2).style().background();
308 assert_eq!(row0_bg, row1_bg); }
310
311 #[test]
312 fn default_header_style_matches_previous_hardcoded_color() {
313 let area = Rect::new(0, 0, 20, 2);
314 let headers = ["Name"];
315 let widths = [10u16];
316 let rows: Vec<Vec<String>> = vec![];
317 let table = Table::new(&headers, &widths, &rows);
318
319 let mut term = Terminal::new(Headless::new(20, 2));
320 let mut state = ListState::new();
321 table.render(area, &mut term, &mut state);
322
323 let expected = Color::Rgb {
324 r: 210,
325 g: 210,
326 b: 230,
327 };
328 assert_eq!(term.grid().get(0, 0).style().foreground(), expected);
329 }
330
331 #[test]
332 fn header_style_can_be_overridden() {
333 let area = Rect::new(0, 0, 20, 2);
334 let headers = ["Name"];
335 let widths = [10u16];
336 let rows: Vec<Vec<String>> = vec![];
337 let custom = Style::new().fg(Color::RED);
338 let table = Table::new(&headers, &widths, &rows).header_style(custom);
339
340 let mut term = Terminal::new(Headless::new(20, 2));
341 let mut state = ListState::new();
342 table.render(area, &mut term, &mut state);
343
344 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
345 }
346
347 #[test]
348 fn selected_style_can_be_overridden() {
349 let area = Rect::new(0, 0, 20, 3);
350 let headers = ["Name"];
351 let widths = [10u16];
352 let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
353 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
354 let table = Table::new(&headers, &widths, &rows).selected_style(custom);
355
356 let mut term = Terminal::new(Headless::new(20, 3));
357 let mut state = ListState::new();
358 state.select(Some(1));
359 table.render(area, &mut term, &mut state);
360
361 assert_eq!(term.grid().get(0, 2).style().foreground(), Color::GREEN);
362 assert_eq!(term.grid().get(0, 2).style().background(), Color::BLUE);
363 }
364
365 #[test]
366 fn theme_maps_named_roles_onto_header_row_and_selected_styles() {
367 let area = Rect::new(0, 0, 20, 3);
368 let headers = ["Name"];
369 let widths = [10u16];
370 let rows = vec![vec!["Alpha".to_string()], vec!["Bravo".to_string()]];
371 let table = Table::new(&headers, &widths, &rows).theme(Theme::DARK);
372
373 let mut term = Terminal::new(Headless::new(20, 3));
374 let mut state = ListState::new();
375 state.select(Some(1));
376 table.render(area, &mut term, &mut state);
377
378 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
379 assert_eq!(
380 term.grid().get(0, 0).style().background(),
381 Theme::DARK.panel_bg
382 );
383 assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.dim);
384 assert_eq!(
385 term.grid().get(0, 1).style().background(),
386 Theme::DARK.panel_bg
387 );
388 assert_eq!(term.grid().get(0, 2).style().foreground(), Theme::DARK.bg);
389 assert_eq!(
390 term.grid().get(0, 2).style().background(),
391 Theme::DARK.accent
392 );
393 }
394
395 #[test]
396 fn column_spacing_can_be_overridden() {
397 let area = Rect::new(0, 0, 20, 1);
398 let headers = ["A", "B"];
399 let widths = [1u16, 1u16];
400 let rows: Vec<Vec<String>> = vec![];
401 let table = Table::new(&headers, &widths, &rows).column_spacing(3);
402
403 let mut term = Terminal::new(Headless::new(20, 1));
404 let mut state = ListState::new();
405 table.render(area, &mut term, &mut state);
406
407 assert_eq!(term.grid().get(0, 0).glyph(), 'A');
410 assert_eq!(term.grid().get(4, 0).glyph(), 'B');
411 }
412
413 #[test]
414 fn ensure_visible_before_render_keeps_selection_on_screen() {
415 let area = Rect::new(0, 0, 20, 3); let headers = ["Name"];
417 let widths = [10u16];
418 let rows = rows(&["Alpha", "Bravo", "Charlie", "Delta"]);
419 let table = Table::new(&headers, &widths, &rows);
420
421 let mut term = Terminal::new(Headless::new(20, 3));
422 let mut state = ListState::new();
423 state.select(Some(3)); state.ensure_visible(2);
425 table.render(area, &mut term, &mut state);
426
427 assert_eq!(term.grid().get(0, 2).glyph(), 'D');
430 let highlighted_bg = term.grid().get(0, 2).style().background();
431 let plain_bg = term.grid().get(0, 1).style().background();
432 assert_ne!(highlighted_bg, plain_bg);
433 }
434}