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