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 and row selection.
74///
75/// Renders as a header row, a separator line, and data rows. The selected row
76/// shows a `> ` prefix and is highlighted with the theme's accent color when
77/// the table is focused.
78pub struct Table {
79    columns: Vec<Column>,
80    rows: Vec<Row>,
81    selected: usize,
82    sort_column: Option<usize>,
83    sort_ascending: bool,
84    focused: bool,
85}
86
87impl Table {
88    /// Create a new table with the given columns and rows.
89    pub fn new(columns: Vec<Column>, rows: Vec<Row>) -> Self {
90        Self {
91            columns,
92            rows,
93            selected: 0,
94            sort_column: None,
95            sort_ascending: true,
96            focused: false,
97        }
98    }
99
100    /// Index of the currently selected row.
101    pub fn selected(&self) -> usize {
102        self.selected
103    }
104
105    /// Set the selected row index (clamped to valid range).
106    pub fn set_selected(&mut self, index: usize) {
107        self.selected = index.min(self.rows.len().saturating_sub(1));
108    }
109
110    /// Set the column used for sorting display.
111    pub fn set_sort_column(&mut self, column: Option<usize>) {
112        self.sort_column = column;
113    }
114
115    /// Set whether the current sort is ascending.
116    pub fn set_sort_ascending(&mut self, ascending: bool) {
117        self.sort_ascending = ascending;
118    }
119
120    fn compute_column_widths(&self, total_width: u16) -> Vec<u16> {
121        let num_cols = self.columns.len();
122        if num_cols == 0 {
123            return Vec::new();
124        }
125
126        let separator_width = (num_cols.saturating_sub(1)) as u16;
127        let prefix_width = 2u16;
128        let budget = total_width
129            .saturating_sub(prefix_width)
130            .saturating_sub(separator_width);
131
132        if budget == 0 {
133            return vec![0; num_cols];
134        }
135
136        let mut widths = Vec::with_capacity(num_cols);
137        let mut flex_indices = Vec::new();
138        let mut fixed_total = 0u16;
139
140        for (i, col) in self.columns.iter().enumerate() {
141            if let Some(w) = col.width {
142                let w = w.min(budget);
143                widths.push(w);
144                fixed_total += w;
145            } else {
146                widths.push(0);
147                flex_indices.push(i);
148            }
149        }
150
151        if !flex_indices.is_empty() {
152            let flex_budget = budget.saturating_sub(fixed_total);
153            let flex_width = if flex_budget > 0 {
154                flex_budget / flex_indices.len() as u16
155            } else {
156                1
157            };
158            for &i in &flex_indices {
159                widths[i] = flex_width.max(1);
160            }
161        }
162
163        // If total exceeds budget, scale proportionally
164        let total: u16 = widths.iter().sum();
165        if total > budget && budget > 0 {
166            for w in &mut widths {
167                *w = (*w as u32 * budget as u32 / total as u32) as u16;
168            }
169        }
170
171        widths
172    }
173}
174
175impl Focusable for Table {
176    fn focused(&self) -> bool {
177        self.focused
178    }
179
180    fn set_focused(&mut self, focused: bool) {
181        self.focused = focused;
182    }
183}
184
185impl Component for Table {
186    fn render(&self, width: u16) -> Result<Rendered, RenderError> {
187        let theme = Theme::current();
188
189        if self.columns.is_empty() {
190            return Ok(Rendered {
191                lines: Vec::new(),
192                cursor: None,
193                images: Vec::new(),
194            });
195        }
196
197        let separator_count = self.columns.len().saturating_sub(1) as u16;
198        let min_width = 2u16 + separator_count;
199        if width < min_width {
200            return Ok(Rendered {
201                lines: Vec::new(),
202                cursor: None,
203                images: Vec::new(),
204            });
205        }
206
207        let widths = self.compute_column_widths(width);
208        let mut lines = Vec::new();
209
210        // Header row
211        let header_style = Style::new().fg(theme.text_primary()).bold();
212        let mut header_parts = vec![stylize("  ", &header_style)];
213        for (i, col) in self.columns.iter().enumerate() {
214            let mut label = col.label.clone();
215            if let Some(sort_idx) = self.sort_column &&
216                sort_idx == i &&
217                col.sortable
218            {
219                let indicator = if self.sort_ascending { "▲" } else { "▼" };
220                label.push_str(indicator);
221            }
222
223            let cell_width = widths.get(i).copied().unwrap_or(0);
224            let cell = if cell_width == 0 {
225                String::new()
226            } else {
227                let truncated = crate::utils::truncate_to_width(&label, cell_width, "…");
228                format!("{:<width$}", truncated, width = cell_width as usize)
229            };
230            header_parts.push(stylize(&cell, &header_style));
231
232            if i + 1 < self.columns.len() {
233                header_parts.push(" ".to_string());
234            }
235        }
236        lines.push(header_parts.concat());
237
238        // Separator line
239        let sep_line = "─".repeat(width as usize);
240        let sep_style = Style::new().fg(theme.border_default());
241        lines.push(stylize(&sep_line, &sep_style));
242
243        // Data rows
244        let accent_style = Style::new().fg(theme.accent()).bold();
245        let text_style = Style::new().fg(theme.text_primary());
246
247        for (row_idx, row) in self.rows.iter().enumerate() {
248            let is_selected = row_idx == self.selected;
249            let row_style = if is_selected && self.focused {
250                &accent_style
251            } else {
252                &text_style
253            };
254
255            let prefix = if is_selected && self.focused {
256                stylize("> ", row_style)
257            } else {
258                "  ".to_string()
259            };
260
261            let mut row_parts = vec![prefix];
262            for (col_idx, col) in self.columns.iter().enumerate() {
263                let cell_width = widths.get(col_idx).copied().unwrap_or(0);
264                let cell_text = row.get(&col.key).unwrap_or("");
265                let cell = if cell_width == 0 {
266                    String::new()
267                } else {
268                    let truncated = crate::utils::truncate_to_width(cell_text, cell_width, "…");
269                    format!("{:<width$}", truncated, width = cell_width as usize)
270                };
271                row_parts.push(stylize(&cell, row_style));
272
273                if col_idx + 1 < self.columns.len() {
274                    row_parts.push(" ".to_string());
275                }
276            }
277            lines.push(row_parts.concat());
278        }
279
280        Ok(Rendered {
281            lines,
282            cursor: None,
283            images: Vec::new(),
284        })
285    }
286
287    fn handle_input(&mut self, event: &Event) -> InputResult {
288        use crossterm::event::KeyModifiers;
289        if let Event::Key(key) = event {
290            match key.code {
291                | KeyCode::Down => {
292                    if self.selected + 1 < self.rows.len() {
293                        self.selected += 1;
294                    }
295                    InputResult::Handled
296                },
297                | KeyCode::Up => {
298                    if self.selected > 0 {
299                        self.selected -= 1;
300                    }
301                    InputResult::Handled
302                },
303                | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
304                    if self.selected + 1 < self.rows.len() {
305                        self.selected += 1;
306                    }
307                    InputResult::Handled
308                },
309                | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
310                    if self.selected > 0 {
311                        self.selected -= 1;
312                    }
313                    InputResult::Handled
314                },
315                | _ => InputResult::Ignored,
316            }
317        } else {
318            InputResult::Ignored
319        }
320    }
321
322    fn as_focusable(&self) -> Option<&dyn Focusable> {
323        Some(self)
324    }
325
326    fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
327        Some(self)
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use std::collections::HashMap;
334
335    use crossterm::event::KeyCode;
336
337    use super::*;
338    use crate::Event;
339
340    #[test]
341    fn table_new() {
342        let cols = vec![Column::new("name", "Name")];
343        let rows = vec![Row::new(HashMap::from([(
344            "name".to_string(),
345            "Alice".to_string(),
346        )]))];
347        let table = Table::new(cols, rows);
348        assert_eq!(table.selected(), 0);
349    }
350
351    #[test]
352    fn table_set_selected_clamps() {
353        let cols = vec![Column::new("name", "Name")];
354        let rows = vec![
355            Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
356            Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
357        ];
358        let mut table = Table::new(cols, rows);
359        table.set_selected(100);
360        assert_eq!(table.selected(), 1);
361    }
362
363    #[test]
364    fn table_renders_header_and_rows() {
365        Theme::with(Theme::Light, || {
366            let cols = vec![Column::new("name", "Name")];
367            let rows = vec![Row::new(HashMap::from([(
368                "name".to_string(),
369                "Alice".to_string(),
370            )]))];
371            let table = Table::new(cols, rows);
372            let rendered = table.render(40).unwrap();
373            assert_eq!(rendered.lines.len(), 3); // header, sep, 1 row
374            assert!(rendered.lines[0].contains("Name"));
375        });
376    }
377
378    #[test]
379    fn table_selected_row_focused() {
380        Theme::with(Theme::Light, || {
381            let cols = vec![Column::new("name", "Name")];
382            let rows = vec![Row::new(HashMap::from([(
383                "name".to_string(),
384                "Alice".to_string(),
385            )]))];
386            let mut table = Table::new(cols, rows);
387            table.set_focused(true);
388            let rendered = table.render(40).unwrap();
389            assert!(rendered.lines[2].contains("> "));
390        });
391    }
392
393    #[test]
394    fn table_navigation() {
395        let cols = vec![Column::new("name", "Name")];
396        let rows = vec![
397            Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
398            Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
399        ];
400        let mut table = Table::new(cols, rows);
401        table.set_focused(true);
402        table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
403            KeyCode::Down,
404            crossterm::event::KeyModifiers::empty(),
405        )));
406        assert_eq!(table.selected(), 1);
407    }
408
409    #[test]
410    fn table_j_k_navigation() {
411        let cols = vec![Column::new("name", "Name")];
412        let rows = vec![
413            Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
414            Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
415        ];
416        let mut table = Table::new(cols, rows);
417        table.set_focused(true);
418        table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
419            KeyCode::Char('j'),
420            crossterm::event::KeyModifiers::empty(),
421        )));
422        assert_eq!(table.selected(), 1);
423        table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
424            KeyCode::Char('k'),
425            crossterm::event::KeyModifiers::empty(),
426        )));
427        assert_eq!(table.selected(), 0);
428    }
429
430    #[test]
431    fn table_sort_indicator() {
432        Theme::with(Theme::Light, || {
433            let cols = vec![Column::new("name", "Name").sortable()];
434            let rows = vec![Row::new(HashMap::from([(
435                "name".to_string(),
436                "Alice".to_string(),
437            )]))];
438            let mut table = Table::new(cols, rows);
439            table.set_sort_column(Some(0));
440            table.set_sort_ascending(true);
441            let rendered = table.render(40).unwrap();
442            assert!(rendered.lines[0].contains("▲"));
443        });
444    }
445
446    #[test]
447    fn table_empty_columns() {
448        let cols: Vec<Column> = vec![];
449        let rows: Vec<Row> = vec![];
450        let table = Table::new(cols, rows);
451        let rendered = table.render(40).unwrap();
452        assert!(rendered.lines.is_empty());
453    }
454
455    #[test]
456    fn table_unfocused_no_accent_prefix() {
457        Theme::with(Theme::Light, || {
458            let cols = vec![Column::new("name", "Name")];
459            let rows = vec![Row::new(HashMap::from([(
460                "name".to_string(),
461                "Alice".to_string(),
462            )]))];
463            let table = Table::new(cols, rows);
464            let rendered = table.render(40).unwrap();
465            assert!(!rendered.lines[2].contains("> "));
466        });
467    }
468}