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