Skip to main content

photon_ui/components/
table.rs

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
20/// Callback type for table filter input.
21type FilterCallback = Box<dyn Fn(&str)>;
22
23/// A column definition for the [`Table`] component.
24pub struct Column {
25    /// Unique identifier for this column, used as a key into row data.
26    pub key: String,
27    /// Display label shown in the table header.
28    pub label: String,
29    /// Fixed width in columns, or `None` to distribute space automatically.
30    pub width: Option<u16>,
31    /// Whether the column can be sorted.
32    pub sortable: bool,
33}
34
35impl Column {
36    /// Create a new column with the given key and label.
37    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    /// Set a fixed width for this column.
47    pub fn width(mut self, w: u16) -> Self {
48        self.width = Some(w);
49        self
50    }
51
52    /// Mark this column as sortable.
53    pub fn sortable(mut self) -> Self {
54        self.sortable = true;
55        self
56    }
57}
58
59/// A single row of data in the [`Table`] component.
60pub struct Row {
61    cells: HashMap<String, String>,
62}
63
64impl Row {
65    /// Create a new row from a map of column key to cell value.
66    pub fn new(cells: HashMap<String, String>) -> Self {
67        Self { cells }
68    }
69
70    /// Get the cell value for the given column key.
71    pub fn get(&self, key: &str) -> Option<&str> {
72        self.cells.get(key).map(|s| s.as_str())
73    }
74}
75
76/// A table component with sortable columns, row selection, and interactive
77/// filtering.
78///
79/// Renders as a header row, a separator line, and data rows. The selected row
80/// shows a `> ` prefix and is highlighted with the theme's accent color when
81/// the table is focused.
82///
83/// # Hooks
84///
85/// - [`on_select`](Table::on_select) — fired when the user navigates rows.
86/// - [`on_sort`](Table::on_sort) — fired when sort column or direction changes.
87/// - [`on_filter`](Table::on_filter) — fired when the filter query changes.
88/// - [`on_filter_char`](Table::on_filter_char) — transforms or rejects filter
89///   characters during interactive filter mode.
90pub 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    /// Create a new table with the given columns and rows.
112    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    /// Index of the currently selected visible row.
136    pub fn selected(&self) -> usize {
137        self.selected
138    }
139
140    /// Set the selected row index (clamped to valid visible range).
141    pub fn set_selected(&mut self, index: usize) {
142        self.selected = index.min(self.display_indices.len().saturating_sub(1));
143    }
144
145    /// Set the column used for sorting display.
146    pub fn set_sort_column(&mut self, column: Option<usize>) {
147        self.sort_column = column;
148        self.recompute_display_indices();
149    }
150
151    /// Set whether the current sort is ascending.
152    pub fn set_sort_ascending(&mut self, ascending: bool) {
153        self.sort_ascending = ascending;
154        self.recompute_display_indices();
155    }
156
157    /// Sort by the given column index. Toggles direction if already sorting
158    /// by the same column. Does nothing if the column is not sortable.
159    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    /// Clear sorting and restore original row order.
179    pub fn clear_sort(&mut self) {
180        self.sort_column = None;
181        self.sort_ascending = true;
182        self.recompute_display_indices();
183    }
184
185    /// Set a filter query. Only rows with at least one cell containing the
186    /// query (case-insensitive) are displayed.
187    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    /// Clear the filter and show all rows.
199    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    /// Return the current filter query, if any.
209    pub fn filter_query(&self) -> Option<&str> {
210        self.filter_query.as_deref()
211    }
212
213    /// Return the current sort column, if any.
214    pub fn sort_column_index(&self) -> Option<usize> {
215        self.sort_column
216    }
217
218    /// Return whether the current sort is ascending.
219    pub fn sort_ascending(&self) -> bool {
220        self.sort_ascending
221    }
222
223    /// Return `true` if the table is currently accepting interactive filter
224    /// input.
225    pub fn in_filter_mode(&self) -> bool {
226        self.in_filter_mode
227    }
228
229    /// Return the number of rows currently displayed (after filtering).
230    pub fn displayed_row_count(&self) -> usize {
231        self.display_indices.len()
232    }
233
234    /// Return the original row index of the currently selected visible row.
235    pub fn selected_original_index(&self) -> Option<usize> {
236        self.display_indices.get(self.selected).copied()
237    }
238
239    /// Return a reference to the currently selected visible row.
240    pub fn selected_row(&self) -> Option<&Row> {
241        self.display_indices
242            .get(self.selected)
243            .map(|idx| &self.rows[*idx])
244    }
245
246    /// Replace the rows and recompute the display order.
247    pub fn set_rows(&mut self, rows: Vec<Row>) {
248        self.rows = rows;
249        self.recompute_display_indices();
250    }
251
252    /// Attach a callback invoked when the selected row changes via keyboard
253    /// navigation.
254    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    /// Attach a callback invoked when sort column or direction changes.
260    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    /// Attach a callback invoked when the filter query changes.
266    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    /// Attach a callback invoked for each character typed in interactive filter
272    /// mode. Return `Some(transformed)` to accept the character (possibly
273    /// modified) or `None` to reject it.
274    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    /// Set the key that enters interactive filter mode. Defaults to `'/'`.
280    pub fn filter_key(mut self, key: char) -> Self {
281        self.filter_key = key;
282        self
283    }
284
285    /// Set the indicator shown next to a column header when sorted ascending.
286    pub fn sort_indicator_asc(mut self, indicator: impl Into<String>) -> Self {
287        self.sort_indicator_asc = indicator.into();
288        self
289    }
290
291    /// Set the indicator shown next to a column header when sorted descending.
292    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        // Apply filter
319        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        // Apply sort
335        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        // If total exceeds budget, scale proportionally
401        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        // Filter input line (when in interactive filter mode)
448        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        // Header row
456        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        // Separator line
488        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        // Data rows
493        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); // header, sep, 1 row
674            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        // Enter filter mode
897        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        // Type a digit — should be rejected
903        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        // Type a letter — should be accepted
909        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        // Press '/' to enter filter mode
927        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        // Type 'a'
934        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        // Press Enter to exit filter mode
941        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        // Esc exits mode but keeps the applied filter
970        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        // Backspace on empty buffer exits and clears filter
995        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            // Filter line + header + sep + 1 row = 4 lines
1023            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            // Width is enough to pass min_width but columns scale to 0
1170            assert_eq!(rendered.lines.len(), 3); // header + sep + 1 row
1171            // Cells are empty because column widths are 0
1172            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        // Filter "Alice" still applies; only "Alice" matches
1194        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        // Already at top, Up should not move
1285        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        // Already at bottom (same row), Down should not move
1291        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); // header + sep only
1382    }
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            // Cells are empty because width is 0, but header/row structure still renders
1398            assert_eq!(rendered.lines.len(), 3); // header + sep + 1 row
1399            assert!(!rendered.lines[0].contains("Name"));
1400            assert!(!rendered.lines[2].contains("Alice"));
1401        });
1402    }
1403}