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