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