rusticity_term/ui/
table.rs

1use ratatui::{prelude::*, widgets::*};
2
3use super::{rounded_block, styles};
4use crate::common::{render_scrollbar, t, SortDirection};
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: 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    let title_style = if config.is_active {
114        Style::default().fg(Color::Green)
115    } else {
116        Style::default()
117    };
118
119    // Headers with sort indicators
120    let header_cells = config.columns.iter().enumerate().map(|(i, col)| {
121        let mut name = col.name().to_string();
122        if !config.sort_column.is_empty() && config.sort_column == name {
123            let arrow = if config.sort_direction == SortDirection::Asc {
124                " ↑"
125            } else {
126                " ↓"
127            };
128            name.push_str(arrow);
129        }
130        name = format_header_cell(&name, i);
131        Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
132    });
133    let header = Row::new(header_cells)
134        .style(Style::default().bg(Color::White).fg(Color::Black))
135        .height(1);
136
137    let mut table_row_to_item_idx = Vec::new();
138    let item_rows = config.items.iter().enumerate().flat_map(|(idx, item)| {
139        let is_expanded = config.expanded_index == Some(idx);
140        let is_selected = idx == config.selected_index;
141        let mut rows = Vec::new();
142
143        // Main row
144        let cells: Vec<Cell> = config
145            .columns
146            .iter()
147            .enumerate()
148            .map(|(i, col)| {
149                let (mut content, style) = col.render(item);
150
151                // Add expansion indicator to first column only
152                if i == 0 {
153                    content = if is_expanded {
154                        format!("{} {}", CURSOR_EXPANDED, content)
155                    } else if is_selected {
156                        format!("{} {}", CURSOR_COLLAPSED, content)
157                    } else {
158                        format!("  {}", content)
159                    };
160                }
161
162                if i > 0 {
163                    Cell::from(Line::from(vec![
164                        Span::raw("⋮ "),
165                        Span::styled(content, style),
166                    ]))
167                } else {
168                    Cell::from(content).style(style)
169                }
170            })
171            .collect();
172
173        table_row_to_item_idx.push(idx);
174        rows.push(Row::new(cells).height(1));
175
176        // Add empty rows for expanded content
177        if is_expanded {
178            if let Some(ref get_content) = config.get_expanded_content {
179                let styled_lines = get_content(item);
180                let line_count = styled_lines.len();
181
182                for _ in 0..line_count {
183                    let mut empty_cells = Vec::new();
184                    for _ in 0..config.columns.len() {
185                        empty_cells.push(Cell::from(""));
186                    }
187                    table_row_to_item_idx.push(idx);
188                    rows.push(Row::new(empty_cells).height(1));
189                }
190            }
191        }
192
193        rows
194    });
195
196    let all_rows: Vec<Row> = item_rows.collect();
197
198    let mut table_state_index = 0;
199    for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
200        if item_idx == config.selected_index {
201            table_state_index = i;
202            break;
203        }
204    }
205
206    let widths: Vec<Constraint> = config
207        .columns
208        .iter()
209        .enumerate()
210        .map(|(i, col)| {
211            // Calculate the actual formatted header width
212            let formatted_header = format_header_cell(col.name(), i);
213            let header_width = formatted_header.chars().count() as u16;
214            // Column width must be at least as wide as the formatted header
215            let width = col.width().max(header_width);
216            Constraint::Length(width)
217        })
218        .collect();
219
220    let table = Table::new(all_rows, widths)
221        .header(header)
222        .block(
223            rounded_block()
224                .title(Span::styled(config.title, title_style))
225                .border_style(border_style),
226        )
227        .column_spacing(1)
228        .row_highlight_style(styles::highlight());
229
230    let mut state = TableState::default();
231    state.select(Some(table_state_index));
232
233    // KNOWN ISSUE: ratatui 0.29 Table widget has built-in scrollbar that:
234    // 1. Uses ║ and █ characters that cannot be customized
235    // 2. Shows automatically when ratatui detects potential overflow
236    // 3. Cannot be disabled without upgrading ratatui or implementing custom table rendering
237    // The scrollbar may appear even when all paginated rows fit in the viewport
238    frame.render_stateful_widget(table, config.area, &mut state);
239
240    // Render expanded content as overlay if present
241    if let Some(expanded_idx) = config.expanded_index {
242        if let Some(ref get_content) = config.get_expanded_content {
243            if let Some(item) = config.items.get(expanded_idx) {
244                let styled_lines = get_content(item);
245
246                // Calculate position: find row index in rendered table
247                let mut row_y = 0;
248                for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
249                    if item_idx == expanded_idx {
250                        row_y = i;
251                        break;
252                    }
253                }
254
255                // Clear entire expanded area once
256                let start_y = config.area.y + 2 + row_y as u16 + 1;
257                let visible_lines = styled_lines
258                    .len()
259                    .min((config.area.y + config.area.height - 1 - start_y) as usize);
260                if visible_lines > 0 {
261                    let clear_area = Rect {
262                        x: config.area.x + 1,
263                        y: start_y,
264                        width: config.area.width.saturating_sub(2),
265                        height: visible_lines as u16,
266                    };
267                    frame.render_widget(Clear, clear_area);
268                }
269
270                for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
271                    let y = start_y + line_idx as u16;
272                    if y >= config.area.y + config.area.height - 1 {
273                        break; // Don't render past bottom border
274                    }
275
276                    let line_area = Rect {
277                        x: config.area.x + 1,
278                        y,
279                        width: config.area.width.saturating_sub(2),
280                        height: 1,
281                    };
282
283                    // Add expansion indicator on the left
284                    let is_last_line = line_idx == styled_lines.len() - 1;
285                    let is_field_start = line.contains(": ");
286                    let indicator = if is_last_line {
287                        "╰ "
288                    } else if is_field_start {
289                        "├ "
290                    } else {
291                        "│ "
292                    };
293
294                    let spans = if let Some(colon_pos) = line.find(": ") {
295                        let col_name = &line[..colon_pos + 2];
296                        let rest = &line[colon_pos + 2..];
297                        vec![
298                            Span::raw(indicator),
299                            Span::styled(col_name.to_string(), styles::label()),
300                            Span::styled(rest.to_string(), *line_style),
301                        ]
302                    } else {
303                        vec![
304                            Span::raw(indicator),
305                            Span::styled(line.to_string(), *line_style),
306                        ]
307                    };
308
309                    let paragraph = Paragraph::new(Line::from(spans));
310                    frame.render_widget(paragraph, line_area);
311                }
312            }
313        }
314    }
315
316    // Scrollbar - only show if items don't fit in viewport
317    if !config.items.is_empty() {
318        let scrollbar_area = config.area.inner(Margin {
319            vertical: 1,
320            horizontal: 0,
321        });
322        // Only show scrollbar if there are more items than can fit in the viewport
323        if config.items.len() > scrollbar_area.height as usize {
324            render_scrollbar(
325                frame,
326                scrollbar_area,
327                config.items.len(),
328                config.selected_index,
329            );
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
339    const TRACK: &str = "│";
340    const THUMB: &str = "█";
341    const EXPAND_INTERMEDIATE: &str = "├ ";
342    const EXPAND_CONTINUATION: &str = "│ ";
343    const EXPAND_LAST: &str = "╰ ";
344
345    #[test]
346    fn test_expanded_content_overlay() {
347        assert!(TIMESTAMP_LINE.contains("(UTC)"));
348        assert!(!TIMESTAMP_LINE.contains("( UTC"));
349        assert_eq!(
350            "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
351                .lines()
352                .count(),
353            3
354        );
355    }
356
357    #[test]
358    fn test_table_border_always_plain() {
359        assert_eq!(BorderType::Plain, BorderType::Plain);
360    }
361
362    #[test]
363    fn test_table_border_color_changes_when_active() {
364        let active = Style::default().fg(Color::Green);
365        let inactive = Style::default();
366        assert_eq!(active.fg, Some(Color::Green));
367        assert_eq!(inactive.fg, None);
368    }
369
370    #[test]
371    fn test_table_scrollbar_uses_solid_characters() {
372        assert_eq!(TRACK, "│");
373        assert_eq!(THUMB, "█");
374        assert_ne!(TRACK, "║");
375    }
376
377    #[test]
378    fn test_expansion_indicators() {
379        assert_eq!(EXPAND_INTERMEDIATE, "├ ");
380        assert_eq!(EXPAND_CONTINUATION, "│ ");
381        assert_eq!(EXPAND_LAST, "╰ ");
382        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
383        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
384        assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
385    }
386
387    #[test]
388    fn test_first_column_expansion_indicators() {
389        // Verify collapsed and expanded indicators
390        assert_eq!(CURSOR_COLLAPSED, "►");
391        assert_eq!(CURSOR_EXPANDED, "▼");
392
393        // Verify they're different
394        assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
395    }
396
397    #[test]
398    fn test_table_scrollbar_only_for_overflow() {
399        let (rows, height) = (50, 60u16);
400        let available = height.saturating_sub(3);
401        assert!(rows <= available as usize);
402        assert!(60 > available as usize);
403    }
404
405    #[test]
406    fn test_expansion_indicator_stripping() {
407        let value_with_right_arrow = "► my-stack";
408        let value_with_down_arrow = "▼ my-stack";
409        let value_without_indicator = "my-stack";
410
411        assert_eq!(
412            value_with_right_arrow
413                .trim_start_matches("► ")
414                .trim_start_matches("▼ "),
415            "my-stack"
416        );
417        assert_eq!(
418            value_with_down_arrow
419                .trim_start_matches("► ")
420                .trim_start_matches("▼ "),
421            "my-stack"
422        );
423        assert_eq!(
424            value_without_indicator
425                .trim_start_matches("► ")
426                .trim_start_matches("▼ "),
427            "my-stack"
428        );
429    }
430
431    #[test]
432    fn test_format_expandable_expanded() {
433        assert_eq!(format_expandable("test-item", true), "▼ test-item");
434    }
435
436    #[test]
437    fn test_format_expandable_not_expanded() {
438        assert_eq!(format_expandable("test-item", false), "► test-item");
439    }
440
441    #[test]
442    fn test_first_column_width_accounts_for_expansion_indicators() {
443        // Expansion indicators add 2 display characters (► or ▼ + space) when selected or expanded
444        let selected_only = format_expandable_with_selection("test", false, true);
445        let expanded_only = format_expandable_with_selection("test", true, false);
446        let both = format_expandable_with_selection("test", true, true);
447        let neither = format_expandable_with_selection("test", false, false);
448
449        // Selected or expanded should add 2 display characters (arrow + space)
450        assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
451        assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
452        // Both expanded and selected still shows only one indicator (expanded takes precedence)
453        assert_eq!(both.chars().count(), "test".chars().count() + 2);
454        // Neither should add 2 spaces for alignment
455        assert_eq!(neither.chars().count(), "test".chars().count() + 2);
456        assert_eq!(neither, "  test");
457    }
458
459    #[test]
460    fn test_format_header_cell_first_column() {
461        assert_eq!(format_header_cell("Name", 0), "  Name");
462    }
463
464    #[test]
465    fn test_format_header_cell_other_columns() {
466        assert_eq!(format_header_cell("Region", 1), "⋮ Region");
467        assert_eq!(format_header_cell("Status", 2), "⋮ Status");
468        assert_eq!(format_header_cell("Created", 5), "⋮ Created");
469    }
470
471    #[test]
472    fn test_format_header_cell_with_sort_indicator() {
473        assert_eq!(format_header_cell("Name ↑", 0), "  Name ↑");
474        assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
475    }
476
477    #[test]
478    fn test_column_width_never_narrower_than_header() {
479        // First column: "  Name" = 6 chars
480        let header_first = format_header_cell("Name", 0);
481        assert_eq!(header_first.chars().count(), 6);
482
483        // Other columns: "⋮ Launch time" = 13 chars
484        let header_other = format_header_cell("Launch time", 1);
485        assert_eq!(header_other.chars().count(), 13);
486    }
487
488    #[test]
489    fn test_formatted_header_width_calculation() {
490        // Test that formatted header width is correctly calculated
491        assert_eq!(format_header_cell("ID", 0).chars().count(), 4); // "  ID"
492        assert_eq!(format_header_cell("ID", 1).chars().count(), 4); // "⋮ ID"
493        assert_eq!(format_header_cell("Name", 0).chars().count(), 6); // "  Name"
494        assert_eq!(format_header_cell("Name", 1).chars().count(), 6); // "⋮ Name"
495        assert_eq!(format_header_cell("Launch time", 1).chars().count(), 13); // "⋮ Launch time"
496    }
497
498    #[test]
499    fn test_utc_timestamp_column_width() {
500        // UTC timestamp format: "2025-11-14 00:00:00.000 UTC" = 27 chars
501        // Header "Launch time" formatted as "⋮ Launch time" = 13 chars
502        // Column width should be max(27, 13) = 27
503        use crate::common::UTC_TIMESTAMP_WIDTH;
504        assert_eq!(UTC_TIMESTAMP_WIDTH, 27);
505
506        let header = format_header_cell("Launch time", 1);
507        let header_width = header.chars().count() as u16;
508        assert_eq!(header_width, 13);
509
510        // The column width should use UTC_TIMESTAMP_WIDTH (27), not header width (13)
511        assert!(UTC_TIMESTAMP_WIDTH > header_width);
512    }
513}