Skip to main content

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