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