1use std::collections::HashMap;
2
3use crossterm::event::KeyCode;
4
5use crate::{
6 Component,
7 Event,
8 Focusable,
9 InputResult,
10 RenderError,
11 Rendered,
12 theme::{
13 Style,
14 Theme,
15 stylize,
16 },
17};
18
19type FilterCallback = Box<dyn Fn(&str)>;
21
22pub struct Column {
24 pub key: String,
26 pub label: String,
28 pub width: Option<u16>,
30 pub sortable: bool,
32}
33
34impl Column {
35 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
37 Self {
38 key: key.into(),
39 label: label.into(),
40 width: None,
41 sortable: false,
42 }
43 }
44
45 pub fn width(mut self, w: u16) -> Self {
47 self.width = Some(w);
48 self
49 }
50
51 pub fn sortable(mut self) -> Self {
53 self.sortable = true;
54 self
55 }
56}
57
58pub struct Row {
60 cells: HashMap<String, String>,
61}
62
63impl Row {
64 pub fn new(cells: HashMap<String, String>) -> Self {
66 Self { cells }
67 }
68
69 pub fn get(&self, key: &str) -> Option<&str> {
71 self.cells.get(key).map(|s| s.as_str())
72 }
73}
74
75pub struct Table {
90 columns: Vec<Column>,
91 rows: Vec<Row>,
92 selected: usize,
93 sort_column: Option<usize>,
94 sort_ascending: bool,
95 focused: bool,
96 filter_query: Option<String>,
97 filter_buffer: String,
98 in_filter_mode: bool,
99 filter_key: char,
100 sort_indicator_asc: String,
101 sort_indicator_desc: String,
102 display_indices: Vec<usize>,
103 on_select: Option<Box<dyn Fn(usize)>>,
104 on_sort: Option<Box<dyn Fn(usize, bool)>>,
105 on_filter: Option<FilterCallback>,
106 on_filter_char: Option<Box<dyn Fn(char) -> Option<char>>>,
107}
108
109impl Table {
110 pub fn new(columns: Vec<Column>, rows: Vec<Row>) -> Self {
112 let display_indices: Vec<usize> = (0..rows.len()).collect();
113 Self {
114 columns,
115 rows,
116 selected: 0,
117 sort_column: None,
118 sort_ascending: true,
119 focused: false,
120 filter_query: None,
121 filter_buffer: String::new(),
122 in_filter_mode: false,
123 filter_key: '/',
124 sort_indicator_asc: "▲".to_string(),
125 sort_indicator_desc: "▼".to_string(),
126 display_indices,
127 on_select: None,
128 on_sort: None,
129 on_filter: None,
130 on_filter_char: None,
131 }
132 }
133
134 pub fn selected(&self) -> usize {
136 self.selected
137 }
138
139 pub fn set_selected(&mut self, index: usize) {
141 self.selected = index.min(self.display_indices.len().saturating_sub(1));
142 }
143
144 pub fn set_sort_column(&mut self, column: Option<usize>) {
146 self.sort_column = column;
147 self.recompute_display_indices();
148 }
149
150 pub fn set_sort_ascending(&mut self, ascending: bool) {
152 self.sort_ascending = ascending;
153 self.recompute_display_indices();
154 }
155
156 pub fn sort_by(&mut self, column: usize) {
159 if column >= self.columns.len() {
160 return;
161 }
162 if !self.columns[column].sortable {
163 return;
164 }
165 if self.sort_column == Some(column) {
166 self.sort_ascending = !self.sort_ascending;
167 } else {
168 self.sort_column = Some(column);
169 self.sort_ascending = true;
170 }
171 self.recompute_display_indices();
172 if let Some(ref cb) = self.on_sort {
173 cb(column, self.sort_ascending);
174 }
175 }
176
177 pub fn clear_sort(&mut self) {
179 self.sort_column = None;
180 self.sort_ascending = true;
181 self.recompute_display_indices();
182 }
183
184 pub fn set_filter(&mut self, query: impl Into<String>) {
187 let q = query.into();
188 self.filter_query = if q.is_empty() { None } else { Some(q) };
189 self.filter_buffer = self.filter_query.clone().unwrap_or_default();
190 self.recompute_display_indices();
191 if let Some(ref cb) = self.on_filter {
192 let query_str = self.filter_query.as_deref().unwrap_or("");
193 cb(query_str);
194 }
195 }
196
197 pub fn clear_filter(&mut self) {
199 self.filter_query = None;
200 self.filter_buffer.clear();
201 self.recompute_display_indices();
202 if let Some(ref cb) = self.on_filter {
203 cb("");
204 }
205 }
206
207 pub fn filter_query(&self) -> Option<&str> {
209 self.filter_query.as_deref()
210 }
211
212 pub fn sort_column_index(&self) -> Option<usize> {
214 self.sort_column
215 }
216
217 pub fn sort_ascending(&self) -> bool {
219 self.sort_ascending
220 }
221
222 pub fn in_filter_mode(&self) -> bool {
225 self.in_filter_mode
226 }
227
228 pub fn displayed_row_count(&self) -> usize {
230 self.display_indices.len()
231 }
232
233 pub fn selected_original_index(&self) -> Option<usize> {
235 self.display_indices.get(self.selected).copied()
236 }
237
238 pub fn selected_row(&self) -> Option<&Row> {
240 self.display_indices
241 .get(self.selected)
242 .map(|idx| &self.rows[*idx])
243 }
244
245 pub fn set_rows(&mut self, rows: Vec<Row>) {
247 self.rows = rows;
248 self.recompute_display_indices();
249 }
250
251 pub fn on_select(mut self, cb: impl Fn(usize) + 'static) -> Self {
254 self.on_select = Some(Box::new(cb));
255 self
256 }
257
258 pub fn on_sort(mut self, cb: impl Fn(usize, bool) + 'static) -> Self {
260 self.on_sort = Some(Box::new(cb));
261 self
262 }
263
264 pub fn on_filter(mut self, cb: impl Fn(&str) + 'static) -> Self {
266 self.on_filter = Some(Box::new(cb));
267 self
268 }
269
270 pub fn on_filter_char(mut self, cb: impl Fn(char) -> Option<char> + 'static) -> Self {
274 self.on_filter_char = Some(Box::new(cb));
275 self
276 }
277
278 pub fn filter_key(mut self, key: char) -> Self {
280 self.filter_key = key;
281 self
282 }
283
284 pub fn sort_indicator_asc(mut self, indicator: impl Into<String>) -> Self {
286 self.sort_indicator_asc = indicator.into();
287 self
288 }
289
290 pub fn sort_indicator_desc(mut self, indicator: impl Into<String>) -> Self {
292 self.sort_indicator_desc = indicator.into();
293 self
294 }
295
296 fn move_selection_down(&mut self) {
297 if self.selected + 1 < self.display_indices.len() {
298 self.selected += 1;
299 if let Some(ref cb) = self.on_select {
300 cb(self.selected);
301 }
302 }
303 }
304
305 fn move_selection_up(&mut self) {
306 if self.selected > 0 {
307 self.selected -= 1;
308 if let Some(ref cb) = self.on_select {
309 cb(self.selected);
310 }
311 }
312 }
313
314 fn recompute_display_indices(&mut self) {
315 let mut indices: Vec<usize> = (0..self.rows.len()).collect();
316
317 if let Some(ref query) = self.filter_query {
319 let query_lower = query.to_lowercase();
320 indices.retain(|idx| {
321 let row = &self.rows[*idx];
322 for col in &self.columns {
323 if let Some(val) = row.get(&col.key) &&
324 val.to_lowercase().contains(&query_lower)
325 {
326 return true;
327 }
328 }
329 false
330 });
331 }
332
333 if let Some(sort_col) = self.sort_column &&
335 sort_col < self.columns.len() &&
336 self.columns[sort_col].sortable
337 {
338 let key = &self.columns[sort_col].key;
339 let ascending = self.sort_ascending;
340 indices.sort_by(|a, b| {
341 let val_a = self.rows[*a].get(key).unwrap_or("");
342 let val_b = self.rows[*b].get(key).unwrap_or("");
343 match ascending {
344 | true => val_a.cmp(val_b),
345 | false => val_b.cmp(val_a),
346 }
347 });
348 }
349
350 self.display_indices = indices;
351 self.selected = self
352 .selected
353 .min(self.display_indices.len().saturating_sub(1));
354 }
355
356 fn compute_column_widths(&self, total_width: u16) -> Vec<u16> {
357 let num_cols = self.columns.len();
358 if num_cols == 0 {
359 return Vec::new();
360 }
361
362 let separator_width = (num_cols.saturating_sub(1)) as u16;
363 let prefix_width = 2u16;
364 let budget = total_width
365 .saturating_sub(prefix_width)
366 .saturating_sub(separator_width);
367
368 if budget == 0 {
369 return vec![0; num_cols];
370 }
371
372 let mut widths = Vec::with_capacity(num_cols);
373 let mut flex_indices = Vec::new();
374 let mut fixed_total = 0u16;
375
376 for (i, col) in self.columns.iter().enumerate() {
377 if let Some(w) = col.width {
378 let w = w.min(budget);
379 widths.push(w);
380 fixed_total += w;
381 } else {
382 widths.push(0);
383 flex_indices.push(i);
384 }
385 }
386
387 if !flex_indices.is_empty() {
388 let flex_budget = budget.saturating_sub(fixed_total);
389 let flex_width = if flex_budget > 0 {
390 flex_budget / flex_indices.len() as u16
391 } else {
392 1
393 };
394 for &i in &flex_indices {
395 widths[i] = flex_width.max(1);
396 }
397 }
398
399 let total: u16 = widths.iter().sum();
401 if total > budget && budget > 0 {
402 for w in &mut widths {
403 *w = (*w as u32 * budget as u32 / total as u32) as u16;
404 }
405 }
406
407 widths
408 }
409}
410
411impl Focusable for Table {
412 fn focused(&self) -> bool {
413 self.focused
414 }
415
416 fn set_focused(&mut self, focused: bool) {
417 self.focused = focused;
418 }
419}
420
421impl Component for Table {
422 fn render(&self, width: u16) -> Result<Rendered, RenderError> {
423 let theme = Theme::palette();
424
425 if self.columns.is_empty() {
426 return Ok(Rendered {
427 lines: Vec::new(),
428 cursor: None,
429 images: Vec::new(),
430 });
431 }
432
433 let separator_count = self.columns.len().saturating_sub(1) as u16;
434 let min_width = 2u16 + separator_count;
435 if width < min_width {
436 return Ok(Rendered {
437 lines: Vec::new(),
438 cursor: None,
439 images: Vec::new(),
440 });
441 }
442
443 let widths = self.compute_column_widths(width);
444 let mut lines = Vec::new();
445
446 if self.in_filter_mode {
448 lines.push(crate::utils::render_editable_line(
449 "/",
450 &self.filter_buffer,
451 width,
452 &*theme,
453 ));
454 }
455
456 let header_style = Style::new().fg(theme.text()).bold();
458 let mut header_parts = vec![stylize(" ", &header_style)];
459 for (i, col) in self.columns.iter().enumerate() {
460 let mut label = col.label.clone();
461 if let Some(sort_idx) = self.sort_column &&
462 sort_idx == i &&
463 col.sortable
464 {
465 let indicator = if self.sort_ascending {
466 &self.sort_indicator_asc
467 } else {
468 &self.sort_indicator_desc
469 };
470 label.push_str(indicator);
471 }
472
473 let cell_width = widths.get(i).copied().unwrap_or(0);
474 let cell = if cell_width == 0 {
475 String::new()
476 } else {
477 let truncated = crate::utils::truncate_to_width(&label, cell_width, "…");
478 format!("{:<width$}", truncated, width = cell_width as usize)
479 };
480 header_parts.push(stylize(&cell, &header_style));
481
482 if i + 1 < self.columns.len() {
483 header_parts.push(" ".to_string());
484 }
485 }
486 lines.push(header_parts.concat());
487
488 let sep_line = "─".repeat(width as usize);
490 let sep_style = Style::new().fg(theme.border());
491 lines.push(stylize(&sep_line, &sep_style));
492
493 let accent_style = Style::new().fg(theme.accent()).bold();
495 let text_style = Style::new().fg(theme.text());
496
497 for (visible_idx, &row_idx) in self.display_indices.iter().enumerate() {
498 let is_selected = visible_idx == self.selected;
499 let row_style = if is_selected && self.focused {
500 &accent_style
501 } else {
502 &text_style
503 };
504
505 let prefix = if is_selected && self.focused {
506 stylize("> ", row_style)
507 } else {
508 " ".to_string()
509 };
510
511 let row = &self.rows[row_idx];
512 let mut row_parts = vec![prefix];
513 for (col_idx, col) in self.columns.iter().enumerate() {
514 let cell_width = widths.get(col_idx).copied().unwrap_or(0);
515 let cell_text = row.get(&col.key).unwrap_or("");
516 let cell = if cell_width == 0 {
517 String::new()
518 } else {
519 let truncated = crate::utils::truncate_to_width(cell_text, cell_width, "…");
520 format!("{:<width$}", truncated, width = cell_width as usize)
521 };
522 row_parts.push(stylize(&cell, row_style));
523
524 if col_idx + 1 < self.columns.len() {
525 row_parts.push(" ".to_string());
526 }
527 }
528 lines.push(row_parts.concat());
529 }
530
531 Ok(Rendered {
532 lines,
533 cursor: None,
534 images: Vec::new(),
535 })
536 }
537
538 fn handle_input(&mut self, event: &Event) -> InputResult {
539 use crossterm::event::KeyModifiers;
540 if let Event::Key(key) = event {
541 if self.in_filter_mode {
542 match key.code {
543 | KeyCode::Esc => {
544 self.in_filter_mode = false;
545 self.filter_buffer.clear();
546 InputResult::Handled
547 },
548 | KeyCode::Enter => {
549 self.in_filter_mode = false;
550 if self.filter_buffer.is_empty() {
551 self.clear_filter();
552 }
553 InputResult::Handled
554 },
555 | KeyCode::Backspace => {
556 if self.filter_buffer.is_empty() {
557 self.in_filter_mode = false;
558 self.clear_filter();
559 } else {
560 self.filter_buffer.pop();
561 let buf = self.filter_buffer.clone();
562 self.set_filter(&buf);
563 }
564 InputResult::Handled
565 },
566 | KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
567 let ch = if let Some(ref cb) = self.on_filter_char {
568 match cb(c) {
569 | Some(transformed) => transformed,
570 | None => return InputResult::Handled,
571 }
572 } else {
573 c
574 };
575 self.filter_buffer.push(ch);
576 let buf = self.filter_buffer.clone();
577 self.set_filter(&buf);
578 InputResult::Handled
579 },
580 | _ => InputResult::Ignored,
581 }
582 } else {
583 match key.code {
584 | KeyCode::Down => {
585 self.move_selection_down();
586 InputResult::Handled
587 },
588 | KeyCode::Up => {
589 self.move_selection_up();
590 InputResult::Handled
591 },
592 | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
593 self.move_selection_down();
594 InputResult::Handled
595 },
596 | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
597 self.move_selection_up();
598 InputResult::Handled
599 },
600 | KeyCode::Char(c)
601 if c == self.filter_key &&
602 !key.modifiers.contains(KeyModifiers::CONTROL) =>
603 {
604 self.in_filter_mode = true;
605 self.filter_buffer = self.filter_query.clone().unwrap_or_default();
606 InputResult::Handled
607 },
608 | _ => InputResult::Ignored,
609 }
610 }
611 } else {
612 InputResult::Ignored
613 }
614 }
615
616 fn as_focusable(&self) -> Option<&dyn Focusable> {
617 Some(self)
618 }
619
620 fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
621 Some(self)
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use std::{
628 cell::{
629 Cell,
630 RefCell,
631 },
632 collections::HashMap,
633 rc::Rc,
634 };
635
636 use crossterm::event::KeyCode;
637
638 use super::*;
639 use crate::Event;
640
641 #[test]
642 fn table_new() {
643 let cols = vec![Column::new("name", "Name")];
644 let rows = vec![Row::new(HashMap::from([(
645 "name".to_string(),
646 "Alice".to_string(),
647 )]))];
648 let table = Table::new(cols, rows);
649 assert_eq!(table.selected(), 0);
650 }
651
652 #[test]
653 fn table_set_selected_clamps() {
654 let cols = vec![Column::new("name", "Name")];
655 let rows = vec![
656 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
657 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
658 ];
659 let mut table = Table::new(cols, rows);
660 table.set_selected(100);
661 assert_eq!(table.selected(), 1);
662 }
663
664 #[test]
665 fn table_renders_header_and_rows() {
666 Theme::with(Theme::Light, || {
667 let cols = vec![Column::new("name", "Name")];
668 let rows = vec![Row::new(HashMap::from([(
669 "name".to_string(),
670 "Alice".to_string(),
671 )]))];
672 let table = Table::new(cols, rows);
673 let rendered = table.render(40).unwrap();
674 assert_eq!(rendered.lines.len(), 3); assert!(rendered.lines[0].contains("Name"));
676 });
677 }
678
679 #[test]
680 fn table_selected_row_focused() {
681 Theme::with(Theme::Light, || {
682 let cols = vec![Column::new("name", "Name")];
683 let rows = vec![Row::new(HashMap::from([(
684 "name".to_string(),
685 "Alice".to_string(),
686 )]))];
687 let mut table = Table::new(cols, rows);
688 table.set_focused(true);
689 let rendered = table.render(40).unwrap();
690 assert!(rendered.lines[2].contains("> "));
691 });
692 }
693
694 #[test]
695 fn table_navigation() {
696 let cols = vec![Column::new("name", "Name")];
697 let rows = vec![
698 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
699 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
700 ];
701 let mut table = Table::new(cols, rows);
702 table.set_focused(true);
703 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
704 KeyCode::Down,
705 crossterm::event::KeyModifiers::empty(),
706 )));
707 assert_eq!(table.selected(), 1);
708 }
709
710 #[test]
711 fn table_j_k_navigation() {
712 let cols = vec![Column::new("name", "Name")];
713 let rows = vec![
714 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
715 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
716 ];
717 let mut table = Table::new(cols, rows);
718 table.set_focused(true);
719 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
720 KeyCode::Char('j'),
721 crossterm::event::KeyModifiers::empty(),
722 )));
723 assert_eq!(table.selected(), 1);
724 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
725 KeyCode::Char('k'),
726 crossterm::event::KeyModifiers::empty(),
727 )));
728 assert_eq!(table.selected(), 0);
729 }
730
731 #[test]
732 fn table_sort_indicator() {
733 Theme::with(Theme::Light, || {
734 let cols = vec![Column::new("name", "Name").sortable()];
735 let rows = vec![Row::new(HashMap::from([(
736 "name".to_string(),
737 "Alice".to_string(),
738 )]))];
739 let mut table = Table::new(cols, rows);
740 table.set_sort_column(Some(0));
741 table.set_sort_ascending(true);
742 let rendered = table.render(40).unwrap();
743 assert!(rendered.lines[0].contains("▲"));
744 });
745 }
746
747 #[test]
748 fn table_empty_columns() {
749 let cols: Vec<Column> = vec![];
750 let rows: Vec<Row> = vec![];
751 let table = Table::new(cols, rows);
752 let rendered = table.render(40).unwrap();
753 assert!(rendered.lines.is_empty());
754 }
755
756 #[test]
757 fn table_unfocused_no_accent_prefix() {
758 Theme::with(Theme::Light, || {
759 let cols = vec![Column::new("name", "Name")];
760 let rows = vec![Row::new(HashMap::from([(
761 "name".to_string(),
762 "Alice".to_string(),
763 )]))];
764 let table = Table::new(cols, rows);
765 let rendered = table.render(40).unwrap();
766 assert!(!rendered.lines[2].contains("> "));
767 });
768 }
769
770 #[test]
771 fn table_sort_by_reorders_rows() {
772 let cols = vec![Column::new("name", "Name").sortable()];
773 let rows = vec![
774 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
775 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
776 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
777 ];
778 let mut table = Table::new(cols, rows);
779 table.sort_by(0);
780 assert_eq!(table.displayed_row_count(), 3);
781 let rendered = table.render(40).unwrap();
782 assert!(rendered.lines[2].contains("Alice"));
783 assert!(rendered.lines[3].contains("Bob"));
784 assert!(rendered.lines[4].contains("Charlie"));
785 }
786
787 #[test]
788 fn table_filter_rows() {
789 let cols = vec![Column::new("name", "Name")];
790 let rows = vec![
791 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
792 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
793 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
794 ];
795 let mut table = Table::new(cols, rows);
796 table.set_filter("a");
797 assert_eq!(table.displayed_row_count(), 2);
798 }
799
800 #[test]
801 fn table_clear_filter() {
802 let cols = vec![Column::new("name", "Name")];
803 let rows = vec![
804 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
805 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
806 ];
807 let mut table = Table::new(cols, rows);
808 table.set_filter("Alice");
809 assert_eq!(table.displayed_row_count(), 1);
810 table.clear_filter();
811 assert_eq!(table.displayed_row_count(), 2);
812 }
813
814 #[test]
815 fn table_selected_row_returns_correct_data() {
816 let cols = vec![Column::new("name", "Name").sortable()];
817 let rows = vec![
818 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
819 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
820 ];
821 let mut table = Table::new(cols, rows);
822 table.sort_by(0);
823 let row = table.selected_row();
824 assert!(row.is_some());
825 assert_eq!(row.unwrap().get("name"), Some("Alice"));
826 assert_eq!(table.selected_original_index(), Some(1));
827 }
828
829 #[test]
830 fn table_on_select_fires() {
831 let cols = vec![Column::new("name", "Name")];
832 let rows = vec![
833 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
834 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
835 ];
836 let selected = Rc::new(Cell::new(99usize));
837 let sc = selected.clone();
838 let mut table = Table::new(cols, rows).on_select(move |idx| {
839 sc.set(idx);
840 });
841 table.set_focused(true);
842 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
843 KeyCode::Down,
844 crossterm::event::KeyModifiers::empty(),
845 )));
846 assert_eq!(selected.get(), 1);
847 }
848
849 #[test]
850 fn table_on_sort_fires() {
851 let cols = vec![Column::new("name", "Name").sortable()];
852 let rows = vec![Row::new(HashMap::from([(
853 "name".to_string(),
854 "Alice".to_string(),
855 )]))];
856 let sort_col = Rc::new(Cell::new(99usize));
857 let sort_asc = Rc::new(Cell::new(false));
858 let sc = sort_col.clone();
859 let sa = sort_asc.clone();
860 let mut table = Table::new(cols, rows).on_sort(move |col, asc| {
861 sc.set(col);
862 sa.set(asc);
863 });
864 table.sort_by(0);
865 assert_eq!(sort_col.get(), 0);
866 assert!(sort_asc.get());
867 }
868
869 #[test]
870 fn table_on_filter_fires() {
871 let cols = vec![Column::new("name", "Name")];
872 let rows = vec![Row::new(HashMap::from([(
873 "name".to_string(),
874 "Alice".to_string(),
875 )]))];
876 let filter = Rc::new(RefCell::new(String::new()));
877 let fi = filter.clone();
878 let mut table = Table::new(cols, rows).on_filter(move |q| {
879 *fi.borrow_mut() = q.to_string();
880 });
881 table.set_filter("Alice");
882 assert_eq!(filter.borrow().as_str(), "Alice");
883 table.clear_filter();
884 assert_eq!(filter.borrow().as_str(), "");
885 }
886
887 #[test]
888 fn table_on_filter_char_rejects() {
889 let cols = vec![Column::new("name", "Name")];
890 let rows = vec![Row::new(HashMap::from([(
891 "name".to_string(),
892 "Alice".to_string(),
893 )]))];
894 let mut table = Table::new(cols, rows)
895 .on_filter_char(|c| if c.is_alphabetic() { Some(c) } else { None });
896 table.set_focused(true);
897 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
899 KeyCode::Char('/'),
900 crossterm::event::KeyModifiers::empty(),
901 )));
902 assert!(table.in_filter_mode());
903 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
905 KeyCode::Char('1'),
906 crossterm::event::KeyModifiers::empty(),
907 )));
908 assert_eq!(table.filter_query(), None);
909 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
911 KeyCode::Char('a'),
912 crossterm::event::KeyModifiers::empty(),
913 )));
914 assert_eq!(table.filter_query(), Some("a"));
915 }
916
917 #[test]
918 fn table_filter_mode_enter_and_exit() {
919 let cols = vec![Column::new("name", "Name")];
920 let rows = vec![
921 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
922 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
923 ];
924 let mut table = Table::new(cols, rows);
925 table.set_focused(true);
926
927 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
929 KeyCode::Char('/'),
930 crossterm::event::KeyModifiers::empty(),
931 )));
932 assert!(table.in_filter_mode());
933
934 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
936 KeyCode::Char('a'),
937 crossterm::event::KeyModifiers::empty(),
938 )));
939 assert_eq!(table.filter_query(), Some("a"));
940
941 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
943 KeyCode::Enter,
944 crossterm::event::KeyModifiers::empty(),
945 )));
946 assert!(!table.in_filter_mode());
947 assert_eq!(table.filter_query(), Some("a"));
948 }
949
950 #[test]
951 fn table_filter_mode_esc_clears_buffer() {
952 let cols = vec![Column::new("name", "Name")];
953 let rows = vec![Row::new(HashMap::from([(
954 "name".to_string(),
955 "Alice".to_string(),
956 )]))];
957 let mut table = Table::new(cols, rows);
958 table.set_focused(true);
959
960 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
961 KeyCode::Char('/'),
962 crossterm::event::KeyModifiers::empty(),
963 )));
964 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
965 KeyCode::Char('a'),
966 crossterm::event::KeyModifiers::empty(),
967 )));
968 assert_eq!(table.filter_query(), Some("a"));
969
970 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
972 KeyCode::Esc,
973 crossterm::event::KeyModifiers::empty(),
974 )));
975 assert!(!table.in_filter_mode());
976 assert_eq!(table.filter_query(), Some("a"));
977 }
978
979 #[test]
980 fn table_filter_mode_backspace_exits_when_empty() {
981 let cols = vec![Column::new("name", "Name")];
982 let rows = vec![Row::new(HashMap::from([(
983 "name".to_string(),
984 "Alice".to_string(),
985 )]))];
986 let mut table = Table::new(cols, rows);
987 table.set_focused(true);
988
989 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
990 KeyCode::Char('/'),
991 crossterm::event::KeyModifiers::empty(),
992 )));
993 assert!(table.in_filter_mode());
994
995 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
997 KeyCode::Backspace,
998 crossterm::event::KeyModifiers::empty(),
999 )));
1000 assert!(!table.in_filter_mode());
1001 assert_eq!(table.filter_query(), None);
1002 }
1003
1004 #[test]
1005 fn table_filter_mode_renders_input_line() {
1006 Theme::with(Theme::Light, || {
1007 let cols = vec![Column::new("name", "Name").width(10)];
1008 let rows = vec![Row::new(HashMap::from([(
1009 "name".to_string(),
1010 "Alice".to_string(),
1011 )]))];
1012 let mut table = Table::new(cols, rows);
1013 table.set_focused(true);
1014 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1015 KeyCode::Char('/'),
1016 crossterm::event::KeyModifiers::empty(),
1017 )));
1018 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1019 KeyCode::Char('a'),
1020 crossterm::event::KeyModifiers::empty(),
1021 )));
1022 let rendered = table.render(40).unwrap();
1023 assert_eq!(rendered.lines.len(), 4);
1025 assert!(rendered.lines[0].contains('/'));
1026 assert!(rendered.lines[0].contains('a'));
1027 assert!(rendered.lines[0].contains(crate::utils::EDIT_CURSOR));
1028 assert!(rendered.lines[0].contains("\x1b[48;"));
1029 assert_eq!(rendered.cursor, None);
1030 });
1031 }
1032
1033 #[test]
1034 fn table_filter_mode_cursor_visible_when_empty() {
1035 Theme::with(Theme::Light, || {
1036 let cols = vec![Column::new("name", "Name").width(10)];
1037 let rows = vec![Row::new(HashMap::from([(
1038 "name".to_string(),
1039 "Alice".to_string(),
1040 )]))];
1041 let mut table = Table::new(cols, rows);
1042 table.set_focused(true);
1043 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1044 KeyCode::Char('/'),
1045 crossterm::event::KeyModifiers::empty(),
1046 )));
1047 let rendered = table.render(40).unwrap();
1048 assert!(rendered.lines[0].contains(crate::utils::EDIT_CURSOR));
1049 assert!(rendered.lines[0].contains("\x1b[48;"));
1050 assert_eq!(rendered.cursor, None);
1051 });
1052 }
1053
1054 #[test]
1055 fn table_filter_mode_cursor_uses_accent_colour() {
1056 Theme::with(Theme::Light, || {
1057 let cols = vec![Column::new("name", "Name").width(10)];
1058 let rows = vec![Row::new(HashMap::from([(
1059 "name".to_string(),
1060 "Alice".to_string(),
1061 )]))];
1062 let mut table = Table::new(cols, rows);
1063 table.set_focused(true);
1064 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1065 KeyCode::Char('/'),
1066 crossterm::event::KeyModifiers::empty(),
1067 )));
1068 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1069 KeyCode::Char('a'),
1070 crossterm::event::KeyModifiers::empty(),
1071 )));
1072 let rendered = table.render(40).unwrap();
1073 assert!(rendered.lines[0].contains("\x1b[38;2;250;82;15m"));
1075 });
1076 }
1077
1078 #[test]
1079 fn table_custom_filter_key() {
1080 let cols = vec![Column::new("name", "Name")];
1081 let rows = vec![Row::new(HashMap::from([(
1082 "name".to_string(),
1083 "Alice".to_string(),
1084 )]))];
1085 let mut table = Table::new(cols, rows).filter_key('f');
1086 table.set_focused(true);
1087
1088 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1089 KeyCode::Char('f'),
1090 crossterm::event::KeyModifiers::empty(),
1091 )));
1092 assert!(table.in_filter_mode());
1093 }
1094
1095 #[test]
1096 fn table_custom_sort_indicators() {
1097 Theme::with(Theme::Light, || {
1098 let cols = vec![Column::new("name", "Name").sortable().width(10)];
1099 let rows = vec![Row::new(HashMap::from([(
1100 "name".to_string(),
1101 "Alice".to_string(),
1102 )]))];
1103 let mut table = Table::new(cols, rows)
1104 .sort_indicator_asc("^")
1105 .sort_indicator_desc("v");
1106 table.set_sort_column(Some(0));
1107 table.set_sort_ascending(true);
1108 let rendered = table.render(40).unwrap();
1109 assert!(rendered.lines[0].contains("^"));
1110 assert!(!rendered.lines[0].contains("▲"));
1111
1112 table.set_sort_ascending(false);
1113 let rendered = table.render(40).unwrap();
1114 assert!(rendered.lines[0].contains("v"));
1115 assert!(!rendered.lines[0].contains("▼"));
1116 });
1117 }
1118
1119 #[test]
1120 fn table_sort_by_out_of_bounds() {
1121 let cols = vec![Column::new("name", "Name").sortable()];
1122 let rows = vec![Row::new(HashMap::from([(
1123 "name".to_string(),
1124 "Alice".to_string(),
1125 )]))];
1126 let mut table = Table::new(cols, rows);
1127 table.sort_by(99);
1128 assert_eq!(table.sort_column_index(), None);
1129 }
1130
1131 #[test]
1132 fn table_sort_by_unsortable_column() {
1133 let cols = vec![
1134 Column::new("name", "Name").sortable(),
1135 Column::new("status", "Status"),
1136 ];
1137 let rows = vec![Row::new(HashMap::from([(
1138 "name".to_string(),
1139 "Alice".to_string(),
1140 )]))];
1141 let mut table = Table::new(cols, rows);
1142 table.sort_by(1);
1143 assert_eq!(table.sort_column_index(), None);
1144 }
1145
1146 #[test]
1147 fn table_clear_filter_fires_hook() {
1148 let filter = Rc::new(RefCell::new(String::from("init")));
1149 let fi = filter.clone();
1150 let cols = vec![Column::new("name", "Name")];
1151 let rows = vec![Row::new(HashMap::from([(
1152 "name".to_string(),
1153 "Alice".to_string(),
1154 )]))];
1155 let mut table = Table::new(cols, rows).on_filter(move |q| {
1156 *fi.borrow_mut() = q.to_string();
1157 });
1158 table.set_filter("Alice");
1159 table.clear_filter();
1160 assert_eq!(filter.borrow().as_str(), "");
1161 }
1162
1163 #[test]
1164 fn table_getters() {
1165 let cols = vec![Column::new("name", "Name").sortable()];
1166 let rows = vec![
1167 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1168 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1169 ];
1170 let mut table = Table::new(cols, rows);
1171 assert_eq!(table.sort_column_index(), None);
1172 assert!(table.sort_ascending());
1173 assert!(!table.in_filter_mode());
1174 assert_eq!(table.displayed_row_count(), 2);
1175 assert_eq!(table.selected_original_index(), Some(0));
1176
1177 table.sort_by(0);
1178 assert_eq!(table.sort_column_index(), Some(0));
1179 assert_eq!(table.selected_original_index(), Some(0));
1180
1181 table.set_filter("Bob");
1182 assert_eq!(table.displayed_row_count(), 1);
1183 assert_eq!(table.selected_original_index(), Some(1));
1184 }
1185
1186 #[test]
1187 fn table_selected_row_none_when_empty() {
1188 let cols = vec![Column::new("name", "Name")];
1189 let rows: Vec<Row> = vec![];
1190 let table = Table::new(cols, rows);
1191 assert!(table.selected_row().is_none());
1192 }
1193
1194 #[test]
1195 fn table_render_tiny_width_returns_empty() {
1196 let cols = vec![Column::new("a", "A"), Column::new("b", "B")];
1197 let rows = vec![Row::new(HashMap::from([(
1198 "a".to_string(),
1199 "x".to_string(),
1200 )]))];
1201 let table = Table::new(cols, rows);
1202 let rendered = table.render(1).unwrap();
1203 assert!(rendered.lines.is_empty());
1204 }
1205
1206 #[test]
1207 fn table_render_zero_budget_columns() {
1208 Theme::with(Theme::Light, || {
1209 let cols = vec![
1210 Column::new("a", "A").width(5),
1211 Column::new("b", "B").width(5),
1212 ];
1213 let rows = vec![Row::new(HashMap::from([(
1214 "a".to_string(),
1215 "x".to_string(),
1216 )]))];
1217 let table = Table::new(cols, rows);
1218 let rendered = table.render(4).unwrap();
1219 assert_eq!(rendered.lines.len(), 3); assert!(!rendered.lines[0].contains("A"));
1223 });
1224 }
1225
1226 #[test]
1227 fn table_set_rows_with_active_filter() {
1228 let cols = vec![Column::new("name", "Name")];
1229 let rows = vec![
1230 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1231 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1232 ];
1233 let mut table = Table::new(cols, rows);
1234 table.set_filter("Alice");
1235 assert_eq!(table.displayed_row_count(), 1);
1236
1237 let new_rows = vec![
1238 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1239 Row::new(HashMap::from([("name".to_string(), "Alison".to_string())])),
1240 Row::new(HashMap::from([("name".to_string(), "Alex".to_string())])),
1241 ];
1242 table.set_rows(new_rows);
1243 assert_eq!(table.displayed_row_count(), 1);
1245 }
1246
1247 #[test]
1248 fn table_filter_mode_enter_empty_buffer() {
1249 let cols = vec![Column::new("name", "Name")];
1250 let rows = vec![Row::new(HashMap::from([(
1251 "name".to_string(),
1252 "Alice".to_string(),
1253 )]))];
1254 let mut table = Table::new(cols, rows);
1255 table.set_focused(true);
1256 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1257 KeyCode::Char('/'),
1258 crossterm::event::KeyModifiers::empty(),
1259 )));
1260 assert!(table.in_filter_mode());
1261 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1262 KeyCode::Enter,
1263 crossterm::event::KeyModifiers::empty(),
1264 )));
1265 assert!(!table.in_filter_mode());
1266 assert_eq!(table.filter_query(), None);
1267 }
1268
1269 #[test]
1270 fn table_filter_mode_unhandled_key_ignored() {
1271 let cols = vec![Column::new("name", "Name")];
1272 let rows = vec![Row::new(HashMap::from([(
1273 "name".to_string(),
1274 "Alice".to_string(),
1275 )]))];
1276 let mut table = Table::new(cols, rows);
1277 table.set_focused(true);
1278 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1279 KeyCode::Char('/'),
1280 crossterm::event::KeyModifiers::empty(),
1281 )));
1282 let result = table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1283 KeyCode::Tab,
1284 crossterm::event::KeyModifiers::empty(),
1285 )));
1286 assert_eq!(result, InputResult::Ignored);
1287 assert!(table.in_filter_mode());
1288 }
1289
1290 #[test]
1291 fn table_non_key_event_ignored() {
1292 let cols = vec![Column::new("name", "Name")];
1293 let rows = vec![Row::new(HashMap::from([(
1294 "name".to_string(),
1295 "Alice".to_string(),
1296 )]))];
1297 let mut table = Table::new(cols, rows);
1298 let result = table.handle_input(&Event::Resize(80, 24));
1299 assert_eq!(result, InputResult::Ignored);
1300 }
1301
1302 #[test]
1303 fn table_as_focusable_returns_some() {
1304 let cols = vec![Column::new("name", "Name")];
1305 let rows = vec![Row::new(HashMap::from([(
1306 "name".to_string(),
1307 "Alice".to_string(),
1308 )]))];
1309 let mut table = Table::new(cols, rows);
1310 assert!(table.as_focusable().is_some());
1311 assert!(table.as_focusable_mut().is_some());
1312 table.set_focused(true);
1313 assert!(table.focused());
1314 }
1315
1316 #[test]
1317 fn table_set_selected_empty_rows() {
1318 let cols = vec![Column::new("name", "Name")];
1319 let rows: Vec<Row> = vec![];
1320 let mut table = Table::new(cols, rows);
1321 table.set_selected(5);
1322 assert_eq!(table.selected(), 0);
1323 }
1324
1325 #[test]
1326 fn table_navigation_clamped_at_edges() {
1327 let cols = vec![Column::new("name", "Name")];
1328 let rows = vec![Row::new(HashMap::from([(
1329 "name".to_string(),
1330 "Alice".to_string(),
1331 )]))];
1332 let mut table = Table::new(cols, rows);
1333 table.set_focused(true);
1334 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1336 KeyCode::Up,
1337 crossterm::event::KeyModifiers::empty(),
1338 )));
1339 assert_eq!(table.selected(), 0);
1340 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1342 KeyCode::Down,
1343 crossterm::event::KeyModifiers::empty(),
1344 )));
1345 assert_eq!(table.selected(), 0);
1346 }
1347
1348 #[test]
1349 fn table_filter_char_transforms() {
1350 let cols = vec![Column::new("name", "Name")];
1351 let rows = vec![Row::new(HashMap::from([(
1352 "name".to_string(),
1353 "Alice".to_string(),
1354 )]))];
1355 let mut table = Table::new(cols, rows).on_filter_char(|c| Some(c.to_ascii_uppercase()));
1356 table.set_focused(true);
1357 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1358 KeyCode::Char('/'),
1359 crossterm::event::KeyModifiers::empty(),
1360 )));
1361 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1362 KeyCode::Char('a'),
1363 crossterm::event::KeyModifiers::empty(),
1364 )));
1365 assert_eq!(table.filter_query(), Some("A"));
1366 }
1367
1368 #[test]
1369 fn table_set_sort_column_and_ascending() {
1370 let cols = vec![Column::new("name", "Name").sortable()];
1371 let rows = vec![
1372 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1373 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1374 ];
1375 let mut table = Table::new(cols, rows);
1376 table.set_sort_column(Some(0));
1377 table.set_sort_ascending(false);
1378 assert_eq!(table.sort_column_index(), Some(0));
1379 assert!(!table.sort_ascending());
1380 let rendered = table.render(40).unwrap();
1381 assert!(rendered.lines[2].contains("Bob"));
1382 }
1383
1384 #[test]
1385 fn table_filter_mode_ctrl_char_ignored() {
1386 let cols = vec![Column::new("name", "Name")];
1387 let rows = vec![Row::new(HashMap::from([(
1388 "name".to_string(),
1389 "Alice".to_string(),
1390 )]))];
1391 let mut table = Table::new(cols, rows);
1392 table.set_focused(true);
1393 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1394 KeyCode::Char('/'),
1395 crossterm::event::KeyModifiers::empty(),
1396 )));
1397 let result = table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1398 KeyCode::Char('c'),
1399 crossterm::event::KeyModifiers::CONTROL,
1400 )));
1401 assert_eq!(result, InputResult::Ignored);
1402 }
1403
1404 #[test]
1405 fn table_sort_toggle_same_column() {
1406 let cols = vec![Column::new("name", "Name").sortable()];
1407 let rows = vec![
1408 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1409 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1410 ];
1411 let mut table = Table::new(cols, rows);
1412 table.sort_by(0);
1413 assert!(table.sort_ascending());
1414 table.sort_by(0);
1415 assert!(!table.sort_ascending());
1416 table.sort_by(0);
1417 assert!(table.sort_ascending());
1418 }
1419
1420 #[test]
1421 fn table_filter_rejects_no_matches() {
1422 let cols = vec![Column::new("name", "Name")];
1423 let rows = vec![
1424 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1425 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1426 ];
1427 let mut table = Table::new(cols, rows);
1428 table.set_filter("zzz");
1429 assert_eq!(table.displayed_row_count(), 0);
1430 let rendered = table.render(40).unwrap();
1431 assert_eq!(rendered.lines.len(), 2); }
1433
1434 #[test]
1435 fn table_cell_width_zero_in_header_and_row() {
1436 Theme::with(Theme::Light, || {
1437 let cols = vec![
1438 Column::new("name", "Name").width(0),
1439 Column::new("status", "Status").width(0),
1440 ];
1441 let rows = vec![Row::new(HashMap::from([
1442 ("name".to_string(), "Alice".to_string()),
1443 ("status".to_string(), "Active".to_string()),
1444 ]))];
1445 let table = Table::new(cols, rows);
1446 let rendered = table.render(40).unwrap();
1447 assert_eq!(rendered.lines.len(), 3); assert!(!rendered.lines[0].contains("Name"));
1450 assert!(!rendered.lines[2].contains("Alice"));
1451 });
1452 }
1453}