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 let filter_style = Style::new().fg(theme.text_muted());
449 let filter_text = format!("/{}", self.filter_buffer);
450 let filter_line = crate::utils::truncate_to_width(&filter_text, width, "…");
451 lines.push(stylize(&filter_line, &filter_style));
452 }
453
454 let header_style = Style::new().fg(theme.text()).bold();
456 let mut header_parts = vec![stylize(" ", &header_style)];
457 for (i, col) in self.columns.iter().enumerate() {
458 let mut label = col.label.clone();
459 if let Some(sort_idx) = self.sort_column &&
460 sort_idx == i &&
461 col.sortable
462 {
463 let indicator = if self.sort_ascending {
464 &self.sort_indicator_asc
465 } else {
466 &self.sort_indicator_desc
467 };
468 label.push_str(indicator);
469 }
470
471 let cell_width = widths.get(i).copied().unwrap_or(0);
472 let cell = if cell_width == 0 {
473 String::new()
474 } else {
475 let truncated = crate::utils::truncate_to_width(&label, cell_width, "…");
476 format!("{:<width$}", truncated, width = cell_width as usize)
477 };
478 header_parts.push(stylize(&cell, &header_style));
479
480 if i + 1 < self.columns.len() {
481 header_parts.push(" ".to_string());
482 }
483 }
484 lines.push(header_parts.concat());
485
486 let sep_line = "─".repeat(width as usize);
488 let sep_style = Style::new().fg(theme.border());
489 lines.push(stylize(&sep_line, &sep_style));
490
491 let accent_style = Style::new().fg(theme.accent()).bold();
493 let text_style = Style::new().fg(theme.text());
494
495 for (visible_idx, &row_idx) in self.display_indices.iter().enumerate() {
496 let is_selected = visible_idx == self.selected;
497 let row_style = if is_selected && self.focused {
498 &accent_style
499 } else {
500 &text_style
501 };
502
503 let prefix = if is_selected && self.focused {
504 stylize("> ", row_style)
505 } else {
506 " ".to_string()
507 };
508
509 let row = &self.rows[row_idx];
510 let mut row_parts = vec![prefix];
511 for (col_idx, col) in self.columns.iter().enumerate() {
512 let cell_width = widths.get(col_idx).copied().unwrap_or(0);
513 let cell_text = row.get(&col.key).unwrap_or("");
514 let cell = if cell_width == 0 {
515 String::new()
516 } else {
517 let truncated = crate::utils::truncate_to_width(cell_text, cell_width, "…");
518 format!("{:<width$}", truncated, width = cell_width as usize)
519 };
520 row_parts.push(stylize(&cell, row_style));
521
522 if col_idx + 1 < self.columns.len() {
523 row_parts.push(" ".to_string());
524 }
525 }
526 lines.push(row_parts.concat());
527 }
528
529 Ok(Rendered {
530 lines,
531 cursor: None,
532 images: Vec::new(),
533 })
534 }
535
536 fn handle_input(&mut self, event: &Event) -> InputResult {
537 use crossterm::event::KeyModifiers;
538 if let Event::Key(key) = event {
539 if self.in_filter_mode {
540 match key.code {
541 | KeyCode::Esc => {
542 self.in_filter_mode = false;
543 self.filter_buffer.clear();
544 InputResult::Handled
545 },
546 | KeyCode::Enter => {
547 self.in_filter_mode = false;
548 if self.filter_buffer.is_empty() {
549 self.clear_filter();
550 }
551 InputResult::Handled
552 },
553 | KeyCode::Backspace => {
554 if self.filter_buffer.is_empty() {
555 self.in_filter_mode = false;
556 self.clear_filter();
557 } else {
558 self.filter_buffer.pop();
559 let buf = self.filter_buffer.clone();
560 self.set_filter(&buf);
561 }
562 InputResult::Handled
563 },
564 | KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
565 let ch = if let Some(ref cb) = self.on_filter_char {
566 match cb(c) {
567 | Some(transformed) => transformed,
568 | None => return InputResult::Handled,
569 }
570 } else {
571 c
572 };
573 self.filter_buffer.push(ch);
574 let buf = self.filter_buffer.clone();
575 self.set_filter(&buf);
576 InputResult::Handled
577 },
578 | _ => InputResult::Ignored,
579 }
580 } else {
581 match key.code {
582 | KeyCode::Down => {
583 self.move_selection_down();
584 InputResult::Handled
585 },
586 | KeyCode::Up => {
587 self.move_selection_up();
588 InputResult::Handled
589 },
590 | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
591 self.move_selection_down();
592 InputResult::Handled
593 },
594 | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
595 self.move_selection_up();
596 InputResult::Handled
597 },
598 | KeyCode::Char(c)
599 if c == self.filter_key &&
600 !key.modifiers.contains(KeyModifiers::CONTROL) =>
601 {
602 self.in_filter_mode = true;
603 self.filter_buffer = self.filter_query.clone().unwrap_or_default();
604 InputResult::Handled
605 },
606 | _ => InputResult::Ignored,
607 }
608 }
609 } else {
610 InputResult::Ignored
611 }
612 }
613
614 fn as_focusable(&self) -> Option<&dyn Focusable> {
615 Some(self)
616 }
617
618 fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
619 Some(self)
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use std::{
626 cell::{
627 Cell,
628 RefCell,
629 },
630 collections::HashMap,
631 rc::Rc,
632 };
633
634 use crossterm::event::KeyCode;
635
636 use super::*;
637 use crate::Event;
638
639 #[test]
640 fn table_new() {
641 let cols = vec![Column::new("name", "Name")];
642 let rows = vec![Row::new(HashMap::from([(
643 "name".to_string(),
644 "Alice".to_string(),
645 )]))];
646 let table = Table::new(cols, rows);
647 assert_eq!(table.selected(), 0);
648 }
649
650 #[test]
651 fn table_set_selected_clamps() {
652 let cols = vec![Column::new("name", "Name")];
653 let rows = vec![
654 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
655 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
656 ];
657 let mut table = Table::new(cols, rows);
658 table.set_selected(100);
659 assert_eq!(table.selected(), 1);
660 }
661
662 #[test]
663 fn table_renders_header_and_rows() {
664 Theme::with(Theme::Light, || {
665 let cols = vec![Column::new("name", "Name")];
666 let rows = vec![Row::new(HashMap::from([(
667 "name".to_string(),
668 "Alice".to_string(),
669 )]))];
670 let table = Table::new(cols, rows);
671 let rendered = table.render(40).unwrap();
672 assert_eq!(rendered.lines.len(), 3); assert!(rendered.lines[0].contains("Name"));
674 });
675 }
676
677 #[test]
678 fn table_selected_row_focused() {
679 Theme::with(Theme::Light, || {
680 let cols = vec![Column::new("name", "Name")];
681 let rows = vec![Row::new(HashMap::from([(
682 "name".to_string(),
683 "Alice".to_string(),
684 )]))];
685 let mut table = Table::new(cols, rows);
686 table.set_focused(true);
687 let rendered = table.render(40).unwrap();
688 assert!(rendered.lines[2].contains("> "));
689 });
690 }
691
692 #[test]
693 fn table_navigation() {
694 let cols = vec![Column::new("name", "Name")];
695 let rows = vec![
696 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
697 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
698 ];
699 let mut table = Table::new(cols, rows);
700 table.set_focused(true);
701 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
702 KeyCode::Down,
703 crossterm::event::KeyModifiers::empty(),
704 )));
705 assert_eq!(table.selected(), 1);
706 }
707
708 #[test]
709 fn table_j_k_navigation() {
710 let cols = vec![Column::new("name", "Name")];
711 let rows = vec![
712 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
713 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
714 ];
715 let mut table = Table::new(cols, rows);
716 table.set_focused(true);
717 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
718 KeyCode::Char('j'),
719 crossterm::event::KeyModifiers::empty(),
720 )));
721 assert_eq!(table.selected(), 1);
722 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
723 KeyCode::Char('k'),
724 crossterm::event::KeyModifiers::empty(),
725 )));
726 assert_eq!(table.selected(), 0);
727 }
728
729 #[test]
730 fn table_sort_indicator() {
731 Theme::with(Theme::Light, || {
732 let cols = vec![Column::new("name", "Name").sortable()];
733 let rows = vec![Row::new(HashMap::from([(
734 "name".to_string(),
735 "Alice".to_string(),
736 )]))];
737 let mut table = Table::new(cols, rows);
738 table.set_sort_column(Some(0));
739 table.set_sort_ascending(true);
740 let rendered = table.render(40).unwrap();
741 assert!(rendered.lines[0].contains("▲"));
742 });
743 }
744
745 #[test]
746 fn table_empty_columns() {
747 let cols: Vec<Column> = vec![];
748 let rows: Vec<Row> = vec![];
749 let table = Table::new(cols, rows);
750 let rendered = table.render(40).unwrap();
751 assert!(rendered.lines.is_empty());
752 }
753
754 #[test]
755 fn table_unfocused_no_accent_prefix() {
756 Theme::with(Theme::Light, || {
757 let cols = vec![Column::new("name", "Name")];
758 let rows = vec![Row::new(HashMap::from([(
759 "name".to_string(),
760 "Alice".to_string(),
761 )]))];
762 let table = Table::new(cols, rows);
763 let rendered = table.render(40).unwrap();
764 assert!(!rendered.lines[2].contains("> "));
765 });
766 }
767
768 #[test]
769 fn table_sort_by_reorders_rows() {
770 let cols = vec![Column::new("name", "Name").sortable()];
771 let rows = vec![
772 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
773 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
774 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
775 ];
776 let mut table = Table::new(cols, rows);
777 table.sort_by(0);
778 assert_eq!(table.displayed_row_count(), 3);
779 let rendered = table.render(40).unwrap();
780 assert!(rendered.lines[2].contains("Alice"));
781 assert!(rendered.lines[3].contains("Bob"));
782 assert!(rendered.lines[4].contains("Charlie"));
783 }
784
785 #[test]
786 fn table_filter_rows() {
787 let cols = vec![Column::new("name", "Name")];
788 let rows = vec![
789 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
790 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
791 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
792 ];
793 let mut table = Table::new(cols, rows);
794 table.set_filter("a");
795 assert_eq!(table.displayed_row_count(), 2);
796 }
797
798 #[test]
799 fn table_clear_filter() {
800 let cols = vec![Column::new("name", "Name")];
801 let rows = vec![
802 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
803 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
804 ];
805 let mut table = Table::new(cols, rows);
806 table.set_filter("Alice");
807 assert_eq!(table.displayed_row_count(), 1);
808 table.clear_filter();
809 assert_eq!(table.displayed_row_count(), 2);
810 }
811
812 #[test]
813 fn table_selected_row_returns_correct_data() {
814 let cols = vec![Column::new("name", "Name").sortable()];
815 let rows = vec![
816 Row::new(HashMap::from([("name".to_string(), "Charlie".to_string())])),
817 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
818 ];
819 let mut table = Table::new(cols, rows);
820 table.sort_by(0);
821 let row = table.selected_row();
822 assert!(row.is_some());
823 assert_eq!(row.unwrap().get("name"), Some("Alice"));
824 assert_eq!(table.selected_original_index(), Some(1));
825 }
826
827 #[test]
828 fn table_on_select_fires() {
829 let cols = vec![Column::new("name", "Name")];
830 let rows = vec![
831 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
832 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
833 ];
834 let selected = Rc::new(Cell::new(99usize));
835 let sc = selected.clone();
836 let mut table = Table::new(cols, rows).on_select(move |idx| {
837 sc.set(idx);
838 });
839 table.set_focused(true);
840 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
841 KeyCode::Down,
842 crossterm::event::KeyModifiers::empty(),
843 )));
844 assert_eq!(selected.get(), 1);
845 }
846
847 #[test]
848 fn table_on_sort_fires() {
849 let cols = vec![Column::new("name", "Name").sortable()];
850 let rows = vec![Row::new(HashMap::from([(
851 "name".to_string(),
852 "Alice".to_string(),
853 )]))];
854 let sort_col = Rc::new(Cell::new(99usize));
855 let sort_asc = Rc::new(Cell::new(false));
856 let sc = sort_col.clone();
857 let sa = sort_asc.clone();
858 let mut table = Table::new(cols, rows).on_sort(move |col, asc| {
859 sc.set(col);
860 sa.set(asc);
861 });
862 table.sort_by(0);
863 assert_eq!(sort_col.get(), 0);
864 assert!(sort_asc.get());
865 }
866
867 #[test]
868 fn table_on_filter_fires() {
869 let cols = vec![Column::new("name", "Name")];
870 let rows = vec![Row::new(HashMap::from([(
871 "name".to_string(),
872 "Alice".to_string(),
873 )]))];
874 let filter = Rc::new(RefCell::new(String::new()));
875 let fi = filter.clone();
876 let mut table = Table::new(cols, rows).on_filter(move |q| {
877 *fi.borrow_mut() = q.to_string();
878 });
879 table.set_filter("Alice");
880 assert_eq!(filter.borrow().as_str(), "Alice");
881 table.clear_filter();
882 assert_eq!(filter.borrow().as_str(), "");
883 }
884
885 #[test]
886 fn table_on_filter_char_rejects() {
887 let cols = vec![Column::new("name", "Name")];
888 let rows = vec![Row::new(HashMap::from([(
889 "name".to_string(),
890 "Alice".to_string(),
891 )]))];
892 let mut table = Table::new(cols, rows)
893 .on_filter_char(|c| if c.is_alphabetic() { Some(c) } else { None });
894 table.set_focused(true);
895 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
897 KeyCode::Char('/'),
898 crossterm::event::KeyModifiers::empty(),
899 )));
900 assert!(table.in_filter_mode());
901 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
903 KeyCode::Char('1'),
904 crossterm::event::KeyModifiers::empty(),
905 )));
906 assert_eq!(table.filter_query(), None);
907 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
909 KeyCode::Char('a'),
910 crossterm::event::KeyModifiers::empty(),
911 )));
912 assert_eq!(table.filter_query(), Some("a"));
913 }
914
915 #[test]
916 fn table_filter_mode_enter_and_exit() {
917 let cols = vec![Column::new("name", "Name")];
918 let rows = vec![
919 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
920 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
921 ];
922 let mut table = Table::new(cols, rows);
923 table.set_focused(true);
924
925 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
927 KeyCode::Char('/'),
928 crossterm::event::KeyModifiers::empty(),
929 )));
930 assert!(table.in_filter_mode());
931
932 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
934 KeyCode::Char('a'),
935 crossterm::event::KeyModifiers::empty(),
936 )));
937 assert_eq!(table.filter_query(), Some("a"));
938
939 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
941 KeyCode::Enter,
942 crossterm::event::KeyModifiers::empty(),
943 )));
944 assert!(!table.in_filter_mode());
945 assert_eq!(table.filter_query(), Some("a"));
946 }
947
948 #[test]
949 fn table_filter_mode_esc_clears_buffer() {
950 let cols = vec![Column::new("name", "Name")];
951 let rows = vec![Row::new(HashMap::from([(
952 "name".to_string(),
953 "Alice".to_string(),
954 )]))];
955 let mut table = Table::new(cols, rows);
956 table.set_focused(true);
957
958 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
959 KeyCode::Char('/'),
960 crossterm::event::KeyModifiers::empty(),
961 )));
962 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
963 KeyCode::Char('a'),
964 crossterm::event::KeyModifiers::empty(),
965 )));
966 assert_eq!(table.filter_query(), Some("a"));
967
968 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
970 KeyCode::Esc,
971 crossterm::event::KeyModifiers::empty(),
972 )));
973 assert!(!table.in_filter_mode());
974 assert_eq!(table.filter_query(), Some("a"));
975 }
976
977 #[test]
978 fn table_filter_mode_backspace_exits_when_empty() {
979 let cols = vec![Column::new("name", "Name")];
980 let rows = vec![Row::new(HashMap::from([(
981 "name".to_string(),
982 "Alice".to_string(),
983 )]))];
984 let mut table = Table::new(cols, rows);
985 table.set_focused(true);
986
987 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
988 KeyCode::Char('/'),
989 crossterm::event::KeyModifiers::empty(),
990 )));
991 assert!(table.in_filter_mode());
992
993 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
995 KeyCode::Backspace,
996 crossterm::event::KeyModifiers::empty(),
997 )));
998 assert!(!table.in_filter_mode());
999 assert_eq!(table.filter_query(), None);
1000 }
1001
1002 #[test]
1003 fn table_filter_mode_renders_input_line() {
1004 Theme::with(Theme::Light, || {
1005 let cols = vec![Column::new("name", "Name").width(10)];
1006 let rows = vec![Row::new(HashMap::from([(
1007 "name".to_string(),
1008 "Alice".to_string(),
1009 )]))];
1010 let mut table = Table::new(cols, rows);
1011 table.set_focused(true);
1012 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1013 KeyCode::Char('/'),
1014 crossterm::event::KeyModifiers::empty(),
1015 )));
1016 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1017 KeyCode::Char('a'),
1018 crossterm::event::KeyModifiers::empty(),
1019 )));
1020 let rendered = table.render(40).unwrap();
1021 assert_eq!(rendered.lines.len(), 4);
1023 assert!(rendered.lines[0].contains("/a"));
1024 });
1025 }
1026
1027 #[test]
1028 fn table_custom_filter_key() {
1029 let cols = vec![Column::new("name", "Name")];
1030 let rows = vec![Row::new(HashMap::from([(
1031 "name".to_string(),
1032 "Alice".to_string(),
1033 )]))];
1034 let mut table = Table::new(cols, rows).filter_key('f');
1035 table.set_focused(true);
1036
1037 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1038 KeyCode::Char('f'),
1039 crossterm::event::KeyModifiers::empty(),
1040 )));
1041 assert!(table.in_filter_mode());
1042 }
1043
1044 #[test]
1045 fn table_custom_sort_indicators() {
1046 Theme::with(Theme::Light, || {
1047 let cols = vec![Column::new("name", "Name").sortable().width(10)];
1048 let rows = vec![Row::new(HashMap::from([(
1049 "name".to_string(),
1050 "Alice".to_string(),
1051 )]))];
1052 let mut table = Table::new(cols, rows)
1053 .sort_indicator_asc("^")
1054 .sort_indicator_desc("v");
1055 table.set_sort_column(Some(0));
1056 table.set_sort_ascending(true);
1057 let rendered = table.render(40).unwrap();
1058 assert!(rendered.lines[0].contains("^"));
1059 assert!(!rendered.lines[0].contains("▲"));
1060
1061 table.set_sort_ascending(false);
1062 let rendered = table.render(40).unwrap();
1063 assert!(rendered.lines[0].contains("v"));
1064 assert!(!rendered.lines[0].contains("▼"));
1065 });
1066 }
1067
1068 #[test]
1069 fn table_sort_by_out_of_bounds() {
1070 let cols = vec![Column::new("name", "Name").sortable()];
1071 let rows = vec![Row::new(HashMap::from([(
1072 "name".to_string(),
1073 "Alice".to_string(),
1074 )]))];
1075 let mut table = Table::new(cols, rows);
1076 table.sort_by(99);
1077 assert_eq!(table.sort_column_index(), None);
1078 }
1079
1080 #[test]
1081 fn table_sort_by_unsortable_column() {
1082 let cols = vec![
1083 Column::new("name", "Name").sortable(),
1084 Column::new("status", "Status"),
1085 ];
1086 let rows = vec![Row::new(HashMap::from([(
1087 "name".to_string(),
1088 "Alice".to_string(),
1089 )]))];
1090 let mut table = Table::new(cols, rows);
1091 table.sort_by(1);
1092 assert_eq!(table.sort_column_index(), None);
1093 }
1094
1095 #[test]
1096 fn table_clear_filter_fires_hook() {
1097 let filter = Rc::new(RefCell::new(String::from("init")));
1098 let fi = filter.clone();
1099 let cols = vec![Column::new("name", "Name")];
1100 let rows = vec![Row::new(HashMap::from([(
1101 "name".to_string(),
1102 "Alice".to_string(),
1103 )]))];
1104 let mut table = Table::new(cols, rows).on_filter(move |q| {
1105 *fi.borrow_mut() = q.to_string();
1106 });
1107 table.set_filter("Alice");
1108 table.clear_filter();
1109 assert_eq!(filter.borrow().as_str(), "");
1110 }
1111
1112 #[test]
1113 fn table_getters() {
1114 let cols = vec![Column::new("name", "Name").sortable()];
1115 let rows = vec![
1116 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1117 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1118 ];
1119 let mut table = Table::new(cols, rows);
1120 assert_eq!(table.sort_column_index(), None);
1121 assert!(table.sort_ascending());
1122 assert!(!table.in_filter_mode());
1123 assert_eq!(table.displayed_row_count(), 2);
1124 assert_eq!(table.selected_original_index(), Some(0));
1125
1126 table.sort_by(0);
1127 assert_eq!(table.sort_column_index(), Some(0));
1128 assert_eq!(table.selected_original_index(), Some(0));
1129
1130 table.set_filter("Bob");
1131 assert_eq!(table.displayed_row_count(), 1);
1132 assert_eq!(table.selected_original_index(), Some(1));
1133 }
1134
1135 #[test]
1136 fn table_selected_row_none_when_empty() {
1137 let cols = vec![Column::new("name", "Name")];
1138 let rows: Vec<Row> = vec![];
1139 let table = Table::new(cols, rows);
1140 assert!(table.selected_row().is_none());
1141 }
1142
1143 #[test]
1144 fn table_render_tiny_width_returns_empty() {
1145 let cols = vec![Column::new("a", "A"), Column::new("b", "B")];
1146 let rows = vec![Row::new(HashMap::from([(
1147 "a".to_string(),
1148 "x".to_string(),
1149 )]))];
1150 let table = Table::new(cols, rows);
1151 let rendered = table.render(1).unwrap();
1152 assert!(rendered.lines.is_empty());
1153 }
1154
1155 #[test]
1156 fn table_render_zero_budget_columns() {
1157 Theme::with(Theme::Light, || {
1158 let cols = vec![
1159 Column::new("a", "A").width(5),
1160 Column::new("b", "B").width(5),
1161 ];
1162 let rows = vec![Row::new(HashMap::from([(
1163 "a".to_string(),
1164 "x".to_string(),
1165 )]))];
1166 let table = Table::new(cols, rows);
1167 let rendered = table.render(4).unwrap();
1168 assert_eq!(rendered.lines.len(), 3); assert!(!rendered.lines[0].contains("A"));
1172 });
1173 }
1174
1175 #[test]
1176 fn table_set_rows_with_active_filter() {
1177 let cols = vec![Column::new("name", "Name")];
1178 let rows = vec![
1179 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1180 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1181 ];
1182 let mut table = Table::new(cols, rows);
1183 table.set_filter("Alice");
1184 assert_eq!(table.displayed_row_count(), 1);
1185
1186 let new_rows = vec![
1187 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1188 Row::new(HashMap::from([("name".to_string(), "Alison".to_string())])),
1189 Row::new(HashMap::from([("name".to_string(), "Alex".to_string())])),
1190 ];
1191 table.set_rows(new_rows);
1192 assert_eq!(table.displayed_row_count(), 1);
1194 }
1195
1196 #[test]
1197 fn table_filter_mode_enter_empty_buffer() {
1198 let cols = vec![Column::new("name", "Name")];
1199 let rows = vec![Row::new(HashMap::from([(
1200 "name".to_string(),
1201 "Alice".to_string(),
1202 )]))];
1203 let mut table = Table::new(cols, rows);
1204 table.set_focused(true);
1205 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1206 KeyCode::Char('/'),
1207 crossterm::event::KeyModifiers::empty(),
1208 )));
1209 assert!(table.in_filter_mode());
1210 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1211 KeyCode::Enter,
1212 crossterm::event::KeyModifiers::empty(),
1213 )));
1214 assert!(!table.in_filter_mode());
1215 assert_eq!(table.filter_query(), None);
1216 }
1217
1218 #[test]
1219 fn table_filter_mode_unhandled_key_ignored() {
1220 let cols = vec![Column::new("name", "Name")];
1221 let rows = vec![Row::new(HashMap::from([(
1222 "name".to_string(),
1223 "Alice".to_string(),
1224 )]))];
1225 let mut table = Table::new(cols, rows);
1226 table.set_focused(true);
1227 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1228 KeyCode::Char('/'),
1229 crossterm::event::KeyModifiers::empty(),
1230 )));
1231 let result = table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1232 KeyCode::Tab,
1233 crossterm::event::KeyModifiers::empty(),
1234 )));
1235 assert_eq!(result, InputResult::Ignored);
1236 assert!(table.in_filter_mode());
1237 }
1238
1239 #[test]
1240 fn table_non_key_event_ignored() {
1241 let cols = vec![Column::new("name", "Name")];
1242 let rows = vec![Row::new(HashMap::from([(
1243 "name".to_string(),
1244 "Alice".to_string(),
1245 )]))];
1246 let mut table = Table::new(cols, rows);
1247 let result = table.handle_input(&Event::Resize(80, 24));
1248 assert_eq!(result, InputResult::Ignored);
1249 }
1250
1251 #[test]
1252 fn table_as_focusable_returns_some() {
1253 let cols = vec![Column::new("name", "Name")];
1254 let rows = vec![Row::new(HashMap::from([(
1255 "name".to_string(),
1256 "Alice".to_string(),
1257 )]))];
1258 let mut table = Table::new(cols, rows);
1259 assert!(table.as_focusable().is_some());
1260 assert!(table.as_focusable_mut().is_some());
1261 table.set_focused(true);
1262 assert!(table.focused());
1263 }
1264
1265 #[test]
1266 fn table_set_selected_empty_rows() {
1267 let cols = vec![Column::new("name", "Name")];
1268 let rows: Vec<Row> = vec![];
1269 let mut table = Table::new(cols, rows);
1270 table.set_selected(5);
1271 assert_eq!(table.selected(), 0);
1272 }
1273
1274 #[test]
1275 fn table_navigation_clamped_at_edges() {
1276 let cols = vec![Column::new("name", "Name")];
1277 let rows = vec![Row::new(HashMap::from([(
1278 "name".to_string(),
1279 "Alice".to_string(),
1280 )]))];
1281 let mut table = Table::new(cols, rows);
1282 table.set_focused(true);
1283 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1285 KeyCode::Up,
1286 crossterm::event::KeyModifiers::empty(),
1287 )));
1288 assert_eq!(table.selected(), 0);
1289 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1291 KeyCode::Down,
1292 crossterm::event::KeyModifiers::empty(),
1293 )));
1294 assert_eq!(table.selected(), 0);
1295 }
1296
1297 #[test]
1298 fn table_filter_char_transforms() {
1299 let cols = vec![Column::new("name", "Name")];
1300 let rows = vec![Row::new(HashMap::from([(
1301 "name".to_string(),
1302 "Alice".to_string(),
1303 )]))];
1304 let mut table = Table::new(cols, rows).on_filter_char(|c| Some(c.to_ascii_uppercase()));
1305 table.set_focused(true);
1306 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1307 KeyCode::Char('/'),
1308 crossterm::event::KeyModifiers::empty(),
1309 )));
1310 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1311 KeyCode::Char('a'),
1312 crossterm::event::KeyModifiers::empty(),
1313 )));
1314 assert_eq!(table.filter_query(), Some("A"));
1315 }
1316
1317 #[test]
1318 fn table_set_sort_column_and_ascending() {
1319 let cols = vec![Column::new("name", "Name").sortable()];
1320 let rows = vec![
1321 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1322 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1323 ];
1324 let mut table = Table::new(cols, rows);
1325 table.set_sort_column(Some(0));
1326 table.set_sort_ascending(false);
1327 assert_eq!(table.sort_column_index(), Some(0));
1328 assert!(!table.sort_ascending());
1329 let rendered = table.render(40).unwrap();
1330 assert!(rendered.lines[2].contains("Bob"));
1331 }
1332
1333 #[test]
1334 fn table_filter_mode_ctrl_char_ignored() {
1335 let cols = vec![Column::new("name", "Name")];
1336 let rows = vec![Row::new(HashMap::from([(
1337 "name".to_string(),
1338 "Alice".to_string(),
1339 )]))];
1340 let mut table = Table::new(cols, rows);
1341 table.set_focused(true);
1342 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1343 KeyCode::Char('/'),
1344 crossterm::event::KeyModifiers::empty(),
1345 )));
1346 let result = table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
1347 KeyCode::Char('c'),
1348 crossterm::event::KeyModifiers::CONTROL,
1349 )));
1350 assert_eq!(result, InputResult::Ignored);
1351 }
1352
1353 #[test]
1354 fn table_sort_toggle_same_column() {
1355 let cols = vec![Column::new("name", "Name").sortable()];
1356 let rows = vec![
1357 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1358 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1359 ];
1360 let mut table = Table::new(cols, rows);
1361 table.sort_by(0);
1362 assert!(table.sort_ascending());
1363 table.sort_by(0);
1364 assert!(!table.sort_ascending());
1365 table.sort_by(0);
1366 assert!(table.sort_ascending());
1367 }
1368
1369 #[test]
1370 fn table_filter_rejects_no_matches() {
1371 let cols = vec![Column::new("name", "Name")];
1372 let rows = vec![
1373 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
1374 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
1375 ];
1376 let mut table = Table::new(cols, rows);
1377 table.set_filter("zzz");
1378 assert_eq!(table.displayed_row_count(), 0);
1379 let rendered = table.render(40).unwrap();
1380 assert_eq!(rendered.lines.len(), 2); }
1382
1383 #[test]
1384 fn table_cell_width_zero_in_header_and_row() {
1385 Theme::with(Theme::Light, || {
1386 let cols = vec![
1387 Column::new("name", "Name").width(0),
1388 Column::new("status", "Status").width(0),
1389 ];
1390 let rows = vec![Row::new(HashMap::from([
1391 ("name".to_string(), "Alice".to_string()),
1392 ("status".to_string(), "Active".to_string()),
1393 ]))];
1394 let table = Table::new(cols, rows);
1395 let rendered = table.render(40).unwrap();
1396 assert_eq!(rendered.lines.len(), 3); assert!(!rendered.lines[0].contains("Name"));
1399 assert!(!rendered.lines[2].contains("Alice"));
1400 });
1401 }
1402}