rusticity_term/ui/
table.rs

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