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
118    /// Opt into compact sizing based on the rendered rows. width() caps data
119    /// width, including the two-cell prefix; the full header always takes priority.
120    /// Other columns retain the existing widest-column auto-fill behavior.
121    fn fit_content(&self) -> bool {
122        false
123    }
124
125    fn render(&self, item: &T) -> (String, Style);
126}
127
128// Generate expanded content from visible columns
129pub fn expanded_from_columns<T>(columns: &[Box<dyn Column<T>>], item: &T) -> Vec<(String, Style)> {
130    columns
131        .iter()
132        .map(|col| {
133            let (value, style) = col.render(item);
134            // Strip expansion indicators (►, ▼, or spaces) from the value
135            let cleaned_value = value
136                .trim_start_matches("► ")
137                .trim_start_matches("▼ ")
138                .trim_start_matches("  ");
139            let display = if cleaned_value.is_empty() {
140                "-"
141            } else {
142                cleaned_value
143            };
144            (format!("{}: {}", col.name(), display), style)
145        })
146        .collect()
147}
148
149fn formatted_column_header(
150    name: &str,
151    index: usize,
152    sort_column: &str,
153    direction: SortDirection,
154) -> String {
155    let name = if !sort_column.is_empty() && sort_column == name {
156        format!(
157            "{} {}",
158            name,
159            if direction == SortDirection::Asc {
160                "↑"
161            } else {
162                "↓"
163            }
164        )
165    } else {
166        name.to_string()
167    };
168    format_header_cell(&name, index)
169}
170
171fn column_constraints<T>(
172    columns: &[Box<dyn Column<T>>],
173    items: &[&T],
174    sort_column: &str,
175    direction: SortDirection,
176) -> Vec<Constraint> {
177    let stretch_width = columns
178        .iter()
179        .filter(|c| !c.fit_content())
180        .map(|c| c.width())
181        .max();
182    columns
183        .iter()
184        .enumerate()
185        .map(|(index, column)| {
186            if column.fit_content() {
187                let header = formatted_column_header(column.name(), index, sort_column, direction);
188                let header_width = Line::raw(header).width();
189                let data_width = items
190                    .iter()
191                    .map(|item| Line::raw(column.render(item).0).width().saturating_add(2))
192                    .max()
193                    .unwrap_or(0);
194                let width = header_width.max(data_width.min(column.width() as usize));
195                Constraint::Length(width.min(u16::MAX as usize) as u16)
196            } else if Some(column.width()) == stretch_width {
197                Constraint::Fill(1)
198            } else {
199                // Preserve existing layouts for columns that have not opted in.
200                let header_width = format_header_cell(column.name(), index).chars().count() as u16;
201                Constraint::Length(column.width().max(header_width))
202            }
203        })
204        .collect()
205}
206
207pub fn render_table<T>(frame: &mut Frame, config: TableConfig<T>) {
208    let border_style = if config.is_active {
209        styles::active_border()
210    } else {
211        Style::default()
212    };
213
214    let title_style = if config.is_active {
215        Style::default().fg(Color::Green)
216    } else {
217        Style::default()
218    };
219
220    // Headers with sort indicators
221    let header_cells = config.columns.iter().enumerate().map(|(index, column)| {
222        Cell::from(formatted_column_header(
223            column.name(),
224            index,
225            config.sort_column,
226            config.sort_direction,
227        ))
228        .style(Style::default().add_modifier(Modifier::BOLD))
229    });
230    let header = Row::new(header_cells)
231        .style(Style::default().bg(Color::White).fg(Color::Black))
232        .height(1);
233
234    let mut table_row_to_item_idx = Vec::new();
235    let item_rows = config.items.iter().enumerate().flat_map(|(idx, item)| {
236        let is_expanded = config.expanded_index == Some(idx);
237        let is_selected = idx == config.selected_index;
238        let mut rows = Vec::new();
239
240        // Main row
241        let cells: Vec<Cell> = config
242            .columns
243            .iter()
244            .enumerate()
245            .map(|(i, col)| {
246                let (mut content, style) = col.render(item);
247
248                // Add expansion indicator to first column only
249                if i == 0 {
250                    // If content has a leading indent (e.g. "  name" for nested items),
251                    // move it before the indicator so it renders as "  ► name" not "►   name"
252                    let leading = if content.starts_with("  ") { "  " } else { "" };
253                    let trimmed = if leading.is_empty() {
254                        content.as_str()
255                    } else {
256                        content.strip_prefix("  ").unwrap_or(&content)
257                    };
258                    content = if is_expanded {
259                        format!("{}{} {}", leading, CURSOR_EXPANDED, trimmed)
260                    } else if is_selected {
261                        format!("{}{} {}", leading, CURSOR_COLLAPSED, trimmed)
262                    } else {
263                        format!("{}  {}", leading, trimmed)
264                    };
265                }
266
267                if i > 0 {
268                    Cell::from(Line::from(vec![
269                        Span::raw("⋮ "),
270                        Span::styled(content, style),
271                    ]))
272                } else {
273                    // First column: render indent + cursor in white, rest in style
274                    if is_expanded {
275                        // content = "{indent}▼ {text}"
276                        let cursor_str = format!("{} ", CURSOR_EXPANDED);
277                        let (indent_cursor, text) = if let Some(pos) = content.find(&cursor_str) {
278                            let (prefix, rest) = content.split_at(pos + cursor_str.len());
279                            (prefix, rest)
280                        } else {
281                            ("", content.as_str())
282                        };
283                        Cell::from(Line::from(vec![
284                            Span::styled(
285                                indent_cursor.to_string(),
286                                Style::default().fg(Color::White),
287                            ),
288                            Span::styled(text.to_string(), style),
289                        ]))
290                    } else if is_selected {
291                        // content = "{indent}► {text}"
292                        let cursor_str = format!("{} ", CURSOR_COLLAPSED);
293                        let (indent_cursor, text) = if let Some(pos) = content.find(&cursor_str) {
294                            let (prefix, rest) = content.split_at(pos + cursor_str.len());
295                            (prefix, rest)
296                        } else {
297                            ("", content.as_str())
298                        };
299                        Cell::from(Line::from(vec![
300                            Span::styled(
301                                indent_cursor.to_string(),
302                                Style::default().fg(Color::White),
303                            ),
304                            Span::styled(text.to_string(), style),
305                        ]))
306                    } else {
307                        // content = "{indent}  {text}" — split at first non-space after indent
308                        let non_space = content.find(|c: char| c != ' ').unwrap_or(0);
309                        let (spaces, text) = content.split_at(non_space);
310                        Cell::from(Line::from(vec![
311                            Span::raw(spaces.to_string()),
312                            Span::styled(text.to_string(), style),
313                        ]))
314                    }
315                }
316            })
317            .collect();
318
319        table_row_to_item_idx.push(idx);
320        rows.push(Row::new(cells).height(1));
321
322        // Add placeholder rows for expanded content so subsequent rows are pushed down
323        // and the scrollbar reflects the true content height. We track which are
324        // placeholder rows so table_state_index is never set to one.
325        if is_expanded {
326            if let Some(ref get_content) = config.get_expanded_content {
327                let styled_lines = get_content(item);
328                for _ in 0..styled_lines.len() {
329                    let empty_cells: Vec<Cell> =
330                        (0..config.columns.len()).map(|_| Cell::from("")).collect();
331                    // Use usize::MAX as a sentinel meaning "placeholder, not selectable"
332                    table_row_to_item_idx.push(usize::MAX);
333                    rows.push(Row::new(empty_cells).height(1));
334                }
335            }
336        }
337
338        rows
339    });
340
341    let all_rows: Vec<Row> = item_rows.collect();
342
343    let mut table_state_index = 0;
344    for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
345        // Skip placeholder rows (sentineled with usize::MAX)
346        if item_idx != usize::MAX && item_idx == config.selected_index {
347            table_state_index = i;
348            break;
349        }
350    }
351
352    let widths = column_constraints(
353        config.columns,
354        &config.items,
355        config.sort_column,
356        config.sort_direction,
357    );
358
359    let table = Table::new(all_rows, widths)
360        .header(header)
361        .block(
362            rounded_block()
363                .title(Span::styled(config.title, title_style))
364                .border_style(border_style),
365        )
366        .column_spacing(1)
367        .row_highlight_style(styles::highlight());
368
369    let mut state = TableState::default();
370    state.select(Some(table_state_index));
371
372    // If the selected item is expanded, pre-set the offset so ratatui shows all detail rows.
373    // Count placeholder rows after the selected item.
374    let detail_row_count = if config.expanded_index == Some(config.selected_index) {
375        table_row_to_item_idx
376            .iter()
377            .skip(table_state_index + 1)
378            .take_while(|&&idx| idx == usize::MAX)
379            .count()
380    } else {
381        0
382    };
383    if detail_row_count > 0 {
384        // The item is at table_state_index and needs detail_row_count rows below.
385        // visible_rows = area.height - 3 (borders + header).
386        let visible_rows = config.area.height.saturating_sub(3) as usize;
387        if visible_rows > 0 {
388            let last_detail = table_state_index + detail_row_count;
389            if last_detail >= visible_rows {
390                // Scroll so that last detail row is the last visible row
391                let needed_offset = last_detail.saturating_sub(visible_rows - 1);
392                *state.offset_mut() = needed_offset;
393            }
394        }
395    }
396
397    // KNOWN ISSUE: ratatui 0.29 Table widget has built-in scrollbar that:
398    // 1. Uses ║ and █ characters that cannot be customized
399    // 2. Shows automatically when ratatui detects potential overflow
400    // 3. Cannot be disabled without upgrading ratatui or implementing custom table rendering
401    // The scrollbar may appear even when all paginated rows fit in the viewport
402    frame.render_stateful_widget(table, config.area, &mut state);
403
404    // After rendering, state.offset() gives ratatui's internal scroll offset.
405    // We use this to compute the correct visual position for the expanded content overlay.
406    let ratatui_scroll_offset = state.offset();
407
408    // Render expanded content as overlay if present
409    if let Some(expanded_idx) = config.expanded_index {
410        if let Some(ref get_content) = config.get_expanded_content {
411            if let Some(item) = config.items.get(expanded_idx) {
412                let styled_lines = get_content(item);
413
414                // Calculate position: find absolute row index for the expanded item,
415                // then subtract ratatui's scroll offset to get the VISUAL row position.
416                let mut abs_row_y = 0;
417                for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
418                    if item_idx == expanded_idx {
419                        abs_row_y = i;
420                        break;
421                    }
422                }
423                // Visual row = absolute row - ratatui's scroll offset
424                // If the expanded item is scrolled off the top, don't render overlay.
425                if abs_row_y < ratatui_scroll_offset {
426                    // Expanded item is above the visible area; skip overlay
427                } else {
428                    let row_y = abs_row_y - ratatui_scroll_offset;
429
430                    // Clear entire expanded area once
431                    let start_y = config.area.y + 2 + row_y as u16 + 1;
432                    let bottom = config.area.y + config.area.height - 1;
433                    let visible_lines = if start_y >= bottom {
434                        0
435                    } else {
436                        styled_lines.len().min((bottom - start_y) as usize)
437                    };
438                    if visible_lines > 0 {
439                        let clear_area = Rect {
440                            x: config.area.x + 1,
441                            y: start_y,
442                            width: config.area.width.saturating_sub(2),
443                            height: visible_lines as u16,
444                        };
445                        frame.render_widget(Clear, clear_area);
446                    }
447
448                    for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
449                        let y = start_y + line_idx as u16;
450                        if y >= config.area.y + config.area.height - 1 {
451                            break; // Don't render past bottom border
452                        }
453
454                        let line_area = Rect {
455                            x: config.area.x + 1,
456                            y,
457                            width: config.area.width.saturating_sub(2),
458                            height: 1,
459                        };
460
461                        // Add expansion indicator on the left.
462                        // If the line starts with spaces (indent prefix for nested items),
463                        // strip it from the content and prepend it before the indicator.
464                        let indent = if line.starts_with("  ") { "  " } else { "" };
465                        let line_content = if indent.is_empty() {
466                            line.as_str()
467                        } else {
468                            line.strip_prefix("  ").unwrap_or(line.as_str())
469                        };
470
471                        let is_last_line = line_idx == styled_lines.len() - 1;
472                        let is_field_start = line_content.contains(": ");
473                        let indicator = if is_last_line {
474                            "╰ "
475                        } else if is_field_start {
476                            "├ "
477                        } else {
478                            "│ "
479                        };
480
481                        let spans = if let Some(colon_pos) = line_content.find(": ") {
482                            let col_name = &line_content[..colon_pos + 2];
483                            let rest = &line_content[colon_pos + 2..];
484                            vec![
485                                Span::raw(format!("{}{}", indent, indicator)),
486                                Span::styled(col_name.to_string(), styles::label()),
487                                Span::styled(rest.to_string(), *line_style),
488                            ]
489                        } else {
490                            vec![
491                                Span::raw(format!("{}{}", indent, indicator)),
492                                Span::styled(line_content.to_string(), *line_style),
493                            ]
494                        };
495
496                        let paragraph = Paragraph::new(Line::from(spans));
497                        frame.render_widget(paragraph, line_area);
498                    }
499                } // end else (expanded item is visible)
500            }
501        }
502    }
503
504    // Scrollbar - only show if items don't fit in viewport
505    if !config.items.is_empty() {
506        let scrollbar_area = config.area.inner(Margin {
507            vertical: 1,
508            horizontal: 0,
509        });
510        // Only show scrollbar if there are more items than can fit in the viewport
511        if config.items.len() > scrollbar_area.height as usize {
512            render_scrollbar(
513                frame,
514                scrollbar_area,
515                config.items.len(),
516                config.selected_index,
517            );
518        }
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
527    const TRACK: &str = "│";
528    const THUMB: &str = "█";
529    const EXPAND_INTERMEDIATE: &str = "├ ";
530    const EXPAND_CONTINUATION: &str = "│ ";
531    const EXPAND_LAST: &str = "╰ ";
532
533    #[test]
534    fn test_expanded_content_overlay() {
535        assert!(TIMESTAMP_LINE.contains("(UTC)"));
536        assert!(!TIMESTAMP_LINE.contains("( UTC"));
537        assert_eq!(
538            "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
539                .lines()
540                .count(),
541            3
542        );
543    }
544
545    #[test]
546    fn test_table_border_always_plain() {
547        assert_eq!(BorderType::Plain, BorderType::Plain);
548    }
549
550    #[test]
551    fn test_table_border_color_changes_when_active() {
552        let active = Style::default().fg(Color::Green);
553        let inactive = Style::default();
554        assert_eq!(active.fg, Some(Color::Green));
555        assert_eq!(inactive.fg, None);
556    }
557
558    #[test]
559    fn test_table_scrollbar_uses_solid_characters() {
560        assert_eq!(TRACK, "│");
561        assert_eq!(THUMB, "█");
562        assert_ne!(TRACK, "║");
563    }
564
565    #[test]
566    fn test_expansion_indicators() {
567        assert_eq!(EXPAND_INTERMEDIATE, "├ ");
568        assert_eq!(EXPAND_CONTINUATION, "│ ");
569        assert_eq!(EXPAND_LAST, "╰ ");
570        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
571        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
572        assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
573    }
574
575    #[test]
576    fn test_first_column_expansion_indicators() {
577        // Verify collapsed and expanded indicators
578        assert_eq!(CURSOR_COLLAPSED, "►");
579        assert_eq!(CURSOR_EXPANDED, "▼");
580
581        // Verify they're different
582        assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
583    }
584
585    #[test]
586    fn test_table_scrollbar_only_for_overflow() {
587        let (rows, height) = (50, 60u16);
588        let available = height.saturating_sub(3);
589        assert!(rows <= available as usize);
590        assert!(60 > available as usize);
591    }
592
593    #[test]
594    fn test_expansion_indicator_stripping() {
595        let value_with_right_arrow = "► my-stack";
596        let value_with_down_arrow = "▼ my-stack";
597        let value_without_indicator = "my-stack";
598
599        assert_eq!(
600            value_with_right_arrow
601                .trim_start_matches("► ")
602                .trim_start_matches("▼ "),
603            "my-stack"
604        );
605        assert_eq!(
606            value_with_down_arrow
607                .trim_start_matches("► ")
608                .trim_start_matches("▼ "),
609            "my-stack"
610        );
611        assert_eq!(
612            value_without_indicator
613                .trim_start_matches("► ")
614                .trim_start_matches("▼ "),
615            "my-stack"
616        );
617    }
618
619    #[test]
620    fn test_format_expandable_expanded() {
621        assert_eq!(format_expandable("test-item", true), "▼ test-item");
622    }
623
624    #[test]
625    fn test_format_expandable_not_expanded() {
626        assert_eq!(format_expandable("test-item", false), "► test-item");
627    }
628
629    #[test]
630    fn test_first_column_width_accounts_for_expansion_indicators() {
631        // Expansion indicators add 2 display characters (► or ▼ + space) when selected or expanded
632        let selected_only = format_expandable_with_selection("test", false, true);
633        let expanded_only = format_expandable_with_selection("test", true, false);
634        let both = format_expandable_with_selection("test", true, true);
635        let neither = format_expandable_with_selection("test", false, false);
636
637        // Selected or expanded should add 2 display characters (arrow + space)
638        assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
639        assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
640        // Both expanded and selected still shows only one indicator (expanded takes precedence)
641        assert_eq!(both.chars().count(), "test".chars().count() + 2);
642        // Neither should add 2 spaces for alignment
643        assert_eq!(neither.chars().count(), "test".chars().count() + 2);
644        assert_eq!(neither, "  test");
645    }
646
647    #[test]
648    fn test_format_header_cell_first_column() {
649        assert_eq!(format_header_cell("Name", 0), "  Name");
650    }
651
652    #[test]
653    fn test_format_header_cell_other_columns() {
654        assert_eq!(format_header_cell("Region", 1), "⋮ Region");
655        assert_eq!(format_header_cell("Status", 2), "⋮ Status");
656        assert_eq!(format_header_cell("Created", 5), "⋮ Created");
657    }
658
659    #[test]
660    fn test_format_header_cell_with_sort_indicator() {
661        assert_eq!(format_header_cell("Name ↑", 0), "  Name ↑");
662        assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
663    }
664
665    #[test]
666    fn test_column_width_never_narrower_than_header() {
667        // First column: "  Name" = 6 chars
668        let header_first = format_header_cell("Name", 0);
669        assert_eq!(header_first.chars().count(), 6);
670
671        // Other columns: "⋮ Launch time" = 13 chars
672        let header_other = format_header_cell("Launch time", 1);
673        assert_eq!(header_other.chars().count(), 13);
674    }
675
676    #[test]
677    fn test_formatted_header_width_calculation() {
678        // Test that formatted header width is correctly calculated
679        assert_eq!(format_header_cell("ID", 0).chars().count(), 4); // "  ID"
680        assert_eq!(format_header_cell("ID", 1).chars().count(), 4); // "⋮ ID"
681        assert_eq!(format_header_cell("Name", 0).chars().count(), 6); // "  Name"
682        assert_eq!(format_header_cell("Name", 1).chars().count(), 6); // "⋮ Name"
683        assert_eq!(format_header_cell("Launch time", 1).chars().count(), 13); // "⋮ Launch time"
684    }
685
686    #[test]
687    fn test_utc_timestamp_column_width() {
688        // UTC timestamp format: "2025-11-14 00:00:00.000 UTC" = 27 chars
689        // Header "Launch time" formatted as "⋮ Launch time" = 13 chars
690        // Column width should be max(27, 13) = 27
691        use crate::common::UTC_TIMESTAMP_WIDTH;
692        assert_eq!(UTC_TIMESTAMP_WIDTH, 27);
693
694        let header = format_header_cell("Launch time", 1);
695        let header_width = header.chars().count() as u16;
696        assert_eq!(header_width, 13);
697
698        // The column width should use UTC_TIMESTAMP_WIDTH (27), not header width (13)
699        assert!(UTC_TIMESTAMP_WIDTH > header_width);
700    }
701
702    #[test]
703    fn test_expanded_row_does_not_add_placeholder_rows_to_table() {
704        // Regression: expanded content used to add N empty placeholder rows to the ratatui
705        // Table widget, causing it to scroll internally when the expanded item was near
706        // the bottom ("sticky" overlay bug). Now expanded content is a pure overlay only.
707        // We verify: TableConfig.items.len() == page_size regardless of expansion.
708        struct TestCol;
709        impl Column<String> for TestCol {
710            fn id(&self) -> &'static str {
711                "test"
712            }
713            fn default_name(&self) -> &'static str {
714                "Name"
715            }
716            fn width(&self) -> u16 {
717                10
718            }
719            fn render(&self, item: &String) -> (String, ratatui::style::Style) {
720                (item.clone(), ratatui::style::Style::default())
721            }
722        }
723
724        let items: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()];
725        let columns: Vec<Box<dyn Column<String>>> = vec![Box::new(TestCol)];
726
727        let config = TableConfig {
728            items: items.iter().collect(),
729            selected_index: 2,
730            expanded_index: Some(2), // last item expanded
731            columns: &columns,
732            sort_column: "",
733            sort_direction: crate::common::SortDirection::Asc,
734            title: "Test".to_string(),
735            area: ratatui::layout::Rect::new(0, 0, 80, 10),
736            get_expanded_content: Some(Box::new(|_item: &String| {
737                vec![
738                    ("f1: v1".to_string(), ratatui::style::Style::default()),
739                    ("f2: v2".to_string(), ratatui::style::Style::default()),
740                ]
741            })),
742            is_active: true,
743        };
744
745        assert_eq!(
746            config.items.len(),
747            3,
748            "items.len() must equal page_size — no placeholder rows added for expanded content"
749        );
750    }
751}
752
753#[cfg(test)]
754mod compact_column_tests {
755    use super::*;
756
757    struct TestColumn {
758        label: &'static str,
759        width: u16,
760        compact: bool,
761    }
762    impl Column<String> for TestColumn {
763        fn name(&self) -> &str {
764            self.label
765        }
766        fn width(&self) -> u16 {
767            self.width
768        }
769        fn fit_content(&self) -> bool {
770            self.compact
771        }
772        fn render(&self, item: &String) -> (String, Style) {
773            (item.clone(), Style::default())
774        }
775    }
776    fn col(label: &'static str, width: u16, compact: bool) -> Box<dyn Column<String>> {
777        Box::new(TestColumn {
778            label,
779            width,
780            compact,
781        })
782    }
783
784    #[test]
785    fn legacy_columns_keep_widest_auto_fill_and_ties() {
786        let columns = vec![
787            col("ID", 20, false),
788            col("Message", 80, false),
789            col("Other", 80, false),
790        ];
791        assert_eq!(
792            column_constraints(&columns, &[], "ID", SortDirection::Asc),
793            vec![
794                Constraint::Length(20),
795                Constraint::Fill(1),
796                Constraint::Fill(1)
797            ]
798        );
799    }
800
801    #[test]
802    fn compact_columns_do_not_compete_for_auto_fill() {
803        let columns = vec![
804            col("ID", 20, false),
805            col("IPv6 CIDR", 45, true),
806            col("Text", 30, false),
807        ];
808        let value = "-".to_string();
809        assert_eq!(
810            column_constraints(&columns, &[&value], "", SortDirection::Asc),
811            vec![
812                Constraint::Length(20),
813                Constraint::Length(11),
814                Constraint::Fill(1)
815            ]
816        );
817    }
818
819    #[test]
820    fn compact_width_counts_display_cells_prefix_and_sort_arrow() {
821        let columns = vec![col("ID", 20, true)];
822        let value = "界界e\u{301}".to_string(); // Five display cells, not six scalars or eleven bytes.
823        assert_eq!(
824            column_constraints(&columns, &[&value], "ID", SortDirection::Asc),
825            vec![Constraint::Length(7)]
826        );
827        assert_eq!(
828            column_constraints(&columns, &[], "ID", SortDirection::Desc),
829            vec![Constraint::Length(6)]
830        );
831    }
832
833    #[test]
834    fn compact_data_is_capped_but_headers_are_not() {
835        let columns = vec![col("Value", 12, true), col("A long header", 5, true)];
836        let value = "x".repeat(200);
837        assert_eq!(
838            column_constraints(&columns, &[&value], "A long header", SortDirection::Asc),
839            vec![Constraint::Length(12), Constraint::Length(17)]
840        );
841        assert!(column_constraints::<String>(&[], &[], "", SortDirection::Asc).is_empty());
842    }
843}