rusticity_term/ui/
table.rs

1use ratatui::{prelude::*, widgets::*};
2
3use super::styles;
4
5pub const CURSOR_COLLAPSED: &str = "►";
6pub const CURSOR_EXPANDED: &str = "▼";
7
8pub fn format_expandable(label: &str, is_expanded: bool) -> String {
9    if is_expanded {
10        format!("{} {}", CURSOR_EXPANDED, label)
11    } else {
12        format!("{} {}", CURSOR_COLLAPSED, label)
13    }
14}
15
16pub fn format_expandable_with_selection(
17    label: &str,
18    is_expanded: bool,
19    is_selected: bool,
20) -> String {
21    if is_expanded {
22        format!("{} {}", CURSOR_EXPANDED, label)
23    } else if is_selected {
24        format!("{} {}", CURSOR_COLLAPSED, label)
25    } else {
26        format!("  {}", label)
27    }
28}
29
30type ExpandedContentFn<'a, T> = Box<dyn Fn(&T) -> Vec<(String, Style)> + 'a>;
31
32// Helper to convert plain string to styled lines
33pub fn plain_expanded_content(content: String) -> Vec<(String, Style)> {
34    content
35        .lines()
36        .map(|line| (line.to_string(), Style::default()))
37        .collect()
38}
39
40pub struct TableConfig<'a, T> {
41    pub items: Vec<&'a T>,
42    pub selected_index: usize,
43    pub expanded_index: Option<usize>,
44    pub columns: &'a [Box<dyn Column<T>>],
45    pub sort_column: &'a str,
46    pub sort_direction: crate::common::SortDirection,
47    pub title: String,
48    pub area: Rect,
49    pub get_expanded_content: Option<ExpandedContentFn<'a, T>>,
50    pub is_active: bool,
51}
52
53pub fn format_header_cell(name: &str, column_index: usize) -> String {
54    if column_index == 0 {
55        format!("  {}", name)
56    } else {
57        format!("⋮ {}", name)
58    }
59}
60
61pub trait Column<T> {
62    fn name(&self) -> &str;
63    fn width(&self) -> u16;
64    fn render(&self, item: &T) -> (String, Style);
65}
66
67// Generate expanded content from visible columns
68pub fn expanded_from_columns<T>(columns: &[Box<dyn Column<T>>], item: &T) -> Vec<(String, Style)> {
69    columns
70        .iter()
71        .map(|col| {
72            let (value, style) = col.render(item);
73            // Strip expansion indicators (►, ▼, or spaces) from the value
74            let cleaned_value = value
75                .trim_start_matches("► ")
76                .trim_start_matches("▼ ")
77                .trim_start_matches("  ");
78            let display = if cleaned_value.is_empty() {
79                "-"
80            } else {
81                cleaned_value
82            };
83            (format!("{}: {}", col.name(), display), style)
84        })
85        .collect()
86}
87
88pub fn render_table<T>(frame: &mut Frame, config: TableConfig<T>) {
89    let border_style = if config.is_active {
90        styles::active_border()
91    } else {
92        Style::default()
93    };
94
95    // Headers with sort indicators
96    let header_cells = config.columns.iter().enumerate().map(|(i, col)| {
97        let mut name = col.name().to_string();
98        if !config.sort_column.is_empty() && config.sort_column == name {
99            let arrow = if config.sort_direction == crate::common::SortDirection::Asc {
100                " ↑"
101            } else {
102                " ↓"
103            };
104            name.push_str(arrow);
105        }
106        name = format_header_cell(&name, i);
107        Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
108    });
109    let header = Row::new(header_cells)
110        .style(Style::default().bg(Color::White).fg(Color::Black))
111        .height(1);
112
113    let mut table_row_to_item_idx = Vec::new();
114    let item_rows = config.items.iter().enumerate().flat_map(|(idx, item)| {
115        let is_expanded = config.expanded_index == Some(idx);
116        let is_selected = idx == config.selected_index;
117        let mut rows = Vec::new();
118
119        // Main row
120        let cells: Vec<Cell> = config
121            .columns
122            .iter()
123            .enumerate()
124            .map(|(i, col)| {
125                let (mut content, style) = col.render(item);
126
127                // Add expansion indicator to first column only
128                if i == 0 {
129                    content = if is_expanded {
130                        format!("{} {}", CURSOR_EXPANDED, content)
131                    } else if is_selected {
132                        format!("{} {}", CURSOR_COLLAPSED, content)
133                    } else {
134                        format!("  {}", content)
135                    };
136                }
137
138                if i > 0 {
139                    Cell::from(Line::from(vec![
140                        Span::raw("⋮ "),
141                        Span::styled(content, style),
142                    ]))
143                } else {
144                    Cell::from(content).style(style)
145                }
146            })
147            .collect();
148
149        table_row_to_item_idx.push(idx);
150        rows.push(Row::new(cells).height(1));
151
152        // Add empty rows for expanded content
153        if is_expanded {
154            if let Some(ref get_content) = config.get_expanded_content {
155                let styled_lines = get_content(item);
156                let line_count = styled_lines.len();
157
158                for _ in 0..line_count {
159                    let mut empty_cells = Vec::new();
160                    for _ in 0..config.columns.len() {
161                        empty_cells.push(Cell::from(""));
162                    }
163                    table_row_to_item_idx.push(idx);
164                    rows.push(Row::new(empty_cells).height(1));
165                }
166            }
167        }
168
169        rows
170    });
171
172    let all_rows: Vec<Row> = item_rows.collect();
173
174    let mut table_state_index = 0;
175    for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
176        if item_idx == config.selected_index {
177            table_state_index = i;
178            break;
179        }
180    }
181
182    let widths: Vec<Constraint> = config
183        .columns
184        .iter()
185        .map(|col| {
186            // First column needs +2 for expansion indicators and header padding
187            // Other columns need +2 for "⋮ " separator
188            // All columns need to be at least as wide as their name
189            let min_width = col.name().len() + 2;
190            let width = col.width().max(min_width as u16);
191            Constraint::Length(width)
192        })
193        .collect();
194
195    let table = Table::new(all_rows, widths)
196        .header(header)
197        .block(
198            Block::default()
199                .title(config.title)
200                .borders(Borders::ALL)
201                .border_style(border_style)
202                .border_type(BorderType::Plain),
203        )
204        .column_spacing(1)
205        .row_highlight_style(styles::highlight());
206
207    let mut state = TableState::default();
208    state.select(Some(table_state_index));
209
210    // KNOWN ISSUE: ratatui 0.29 Table widget has built-in scrollbar that:
211    // 1. Uses ║ and █ characters that cannot be customized
212    // 2. Shows automatically when ratatui detects potential overflow
213    // 3. Cannot be disabled without upgrading ratatui or implementing custom table rendering
214    // The scrollbar may appear even when all paginated rows fit in the viewport
215    frame.render_stateful_widget(table, config.area, &mut state);
216
217    // Render expanded content as overlay if present
218    if let Some(expanded_idx) = config.expanded_index {
219        if let Some(ref get_content) = config.get_expanded_content {
220            if let Some(item) = config.items.get(expanded_idx) {
221                let styled_lines = get_content(item);
222
223                // Calculate position: find row index in rendered table
224                let mut row_y = 0;
225                for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
226                    if item_idx == expanded_idx {
227                        row_y = i;
228                        break;
229                    }
230                }
231
232                // Clear entire expanded area once
233                let start_y = config.area.y + 2 + row_y as u16 + 1;
234                let visible_lines = styled_lines
235                    .len()
236                    .min((config.area.y + config.area.height - 1 - start_y) as usize);
237                if visible_lines > 0 {
238                    let clear_area = Rect {
239                        x: config.area.x + 1,
240                        y: start_y,
241                        width: config.area.width.saturating_sub(2),
242                        height: visible_lines as u16,
243                    };
244                    frame.render_widget(Clear, clear_area);
245                }
246
247                for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
248                    let y = start_y + line_idx as u16;
249                    if y >= config.area.y + config.area.height - 1 {
250                        break; // Don't render past bottom border
251                    }
252
253                    let line_area = Rect {
254                        x: config.area.x + 1,
255                        y,
256                        width: config.area.width.saturating_sub(2),
257                        height: 1,
258                    };
259
260                    // Add expansion indicator on the left
261                    let is_last_line = line_idx == styled_lines.len() - 1;
262                    let is_field_start = line.contains(": ");
263                    let indicator = if is_last_line {
264                        "╰ "
265                    } else if is_field_start {
266                        "├ "
267                    } else {
268                        "│ "
269                    };
270
271                    let spans = if let Some(colon_pos) = line.find(": ") {
272                        let col_name = &line[..colon_pos + 2];
273                        let rest = &line[colon_pos + 2..];
274                        vec![
275                            Span::raw(indicator),
276                            Span::styled(col_name.to_string(), styles::label()),
277                            Span::styled(rest.to_string(), *line_style),
278                        ]
279                    } else {
280                        vec![
281                            Span::raw(indicator),
282                            Span::styled(line.to_string(), *line_style),
283                        ]
284                    };
285
286                    let paragraph = Paragraph::new(Line::from(spans));
287                    frame.render_widget(paragraph, line_area);
288                }
289            }
290        }
291    }
292
293    // Scrollbar - only show if items don't fit in viewport
294    if !config.items.is_empty() {
295        let scrollbar_area = config.area.inner(Margin {
296            vertical: 1,
297            horizontal: 0,
298        });
299        // Only show scrollbar if there are more items than can fit in the viewport
300        if config.items.len() > scrollbar_area.height as usize {
301            crate::common::render_scrollbar(
302                frame,
303                scrollbar_area,
304                config.items.len(),
305                config.selected_index,
306            );
307        }
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
316    const TRACK: &str = "│";
317    const THUMB: &str = "█";
318    const EXPAND_INTERMEDIATE: &str = "├ ";
319    const EXPAND_CONTINUATION: &str = "│ ";
320    const EXPAND_LAST: &str = "╰ ";
321
322    #[test]
323    fn test_expanded_content_overlay() {
324        assert!(TIMESTAMP_LINE.contains("(UTC)"));
325        assert!(!TIMESTAMP_LINE.contains("( UTC"));
326        assert_eq!(
327            "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
328                .lines()
329                .count(),
330            3
331        );
332    }
333
334    #[test]
335    fn test_table_border_always_plain() {
336        assert_eq!(BorderType::Plain, BorderType::Plain);
337    }
338
339    #[test]
340    fn test_table_border_color_changes_when_active() {
341        let active = Style::default().fg(Color::Green);
342        let inactive = Style::default();
343        assert_eq!(active.fg, Some(Color::Green));
344        assert_eq!(inactive.fg, None);
345    }
346
347    #[test]
348    fn test_table_scrollbar_uses_solid_characters() {
349        assert_eq!(TRACK, "│");
350        assert_eq!(THUMB, "█");
351        assert_ne!(TRACK, "║");
352    }
353
354    #[test]
355    fn test_expansion_indicators() {
356        assert_eq!(EXPAND_INTERMEDIATE, "├ ");
357        assert_eq!(EXPAND_CONTINUATION, "│ ");
358        assert_eq!(EXPAND_LAST, "╰ ");
359        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
360        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
361        assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
362    }
363
364    #[test]
365    fn test_first_column_expansion_indicators() {
366        // Verify collapsed and expanded indicators
367        assert_eq!(CURSOR_COLLAPSED, "►");
368        assert_eq!(CURSOR_EXPANDED, "▼");
369
370        // Verify they're different
371        assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
372    }
373
374    #[test]
375    fn test_table_scrollbar_only_for_overflow() {
376        let (rows, height) = (50, 60u16);
377        let available = height.saturating_sub(3);
378        assert!(rows <= available as usize);
379        assert!(60 > available as usize);
380    }
381
382    #[test]
383    fn test_expansion_indicator_stripping() {
384        let value_with_right_arrow = "► my-stack";
385        let value_with_down_arrow = "▼ my-stack";
386        let value_without_indicator = "my-stack";
387
388        assert_eq!(
389            value_with_right_arrow
390                .trim_start_matches("► ")
391                .trim_start_matches("▼ "),
392            "my-stack"
393        );
394        assert_eq!(
395            value_with_down_arrow
396                .trim_start_matches("► ")
397                .trim_start_matches("▼ "),
398            "my-stack"
399        );
400        assert_eq!(
401            value_without_indicator
402                .trim_start_matches("► ")
403                .trim_start_matches("▼ "),
404            "my-stack"
405        );
406    }
407
408    #[test]
409    fn test_format_expandable_expanded() {
410        assert_eq!(format_expandable("test-item", true), "▼ test-item");
411    }
412
413    #[test]
414    fn test_format_expandable_not_expanded() {
415        assert_eq!(format_expandable("test-item", false), "► test-item");
416    }
417
418    #[test]
419    fn test_first_column_width_accounts_for_expansion_indicators() {
420        // Expansion indicators add 2 display characters (► or ▼ + space) when selected or expanded
421        let selected_only = format_expandable_with_selection("test", false, true);
422        let expanded_only = format_expandable_with_selection("test", true, false);
423        let both = format_expandable_with_selection("test", true, true);
424        let neither = format_expandable_with_selection("test", false, false);
425
426        // Selected or expanded should add 2 display characters (arrow + space)
427        assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
428        assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
429        // Both expanded and selected still shows only one indicator (expanded takes precedence)
430        assert_eq!(both.chars().count(), "test".chars().count() + 2);
431        // Neither should add 2 spaces for alignment
432        assert_eq!(neither.chars().count(), "test".chars().count() + 2);
433        assert_eq!(neither, "  test");
434    }
435
436    #[test]
437    fn test_format_header_cell_first_column() {
438        assert_eq!(format_header_cell("Name", 0), "  Name");
439    }
440
441    #[test]
442    fn test_format_header_cell_other_columns() {
443        assert_eq!(format_header_cell("Region", 1), "⋮ Region");
444        assert_eq!(format_header_cell("Status", 2), "⋮ Status");
445        assert_eq!(format_header_cell("Created", 5), "⋮ Created");
446    }
447
448    #[test]
449    fn test_format_header_cell_with_sort_indicator() {
450        assert_eq!(format_header_cell("Name ↑", 0), "  Name ↑");
451        assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
452    }
453}