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