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                    // If content has a leading indent (e.g. "  name" for nested items),
189                    // move it before the indicator so it renders as "  ► name" not "►   name"
190                    let leading = if content.starts_with("  ") { "  " } else { "" };
191                    let trimmed = if leading.is_empty() {
192                        content.as_str()
193                    } else {
194                        content.strip_prefix("  ").unwrap_or(&content)
195                    };
196                    content = if is_expanded {
197                        format!("{}{} {}", leading, CURSOR_EXPANDED, trimmed)
198                    } else if is_selected {
199                        format!("{}{} {}", leading, CURSOR_COLLAPSED, trimmed)
200                    } else {
201                        format!("{}  {}", leading, trimmed)
202                    };
203                }
204
205                if i > 0 {
206                    Cell::from(Line::from(vec![
207                        Span::raw("⋮ "),
208                        Span::styled(content, style),
209                    ]))
210                } else {
211                    // First column: render indent + cursor in white, rest in style
212                    if is_expanded {
213                        // content = "{indent}▼ {text}"
214                        let cursor_str = format!("{} ", CURSOR_EXPANDED);
215                        let (indent_cursor, text) = if let Some(pos) = content.find(&cursor_str) {
216                            let (prefix, rest) = content.split_at(pos + cursor_str.len());
217                            (prefix, rest)
218                        } else {
219                            ("", content.as_str())
220                        };
221                        Cell::from(Line::from(vec![
222                            Span::styled(
223                                indent_cursor.to_string(),
224                                Style::default().fg(Color::White),
225                            ),
226                            Span::styled(text.to_string(), style),
227                        ]))
228                    } else if is_selected {
229                        // content = "{indent}► {text}"
230                        let cursor_str = format!("{} ", CURSOR_COLLAPSED);
231                        let (indent_cursor, text) = if let Some(pos) = content.find(&cursor_str) {
232                            let (prefix, rest) = content.split_at(pos + cursor_str.len());
233                            (prefix, rest)
234                        } else {
235                            ("", content.as_str())
236                        };
237                        Cell::from(Line::from(vec![
238                            Span::styled(
239                                indent_cursor.to_string(),
240                                Style::default().fg(Color::White),
241                            ),
242                            Span::styled(text.to_string(), style),
243                        ]))
244                    } else {
245                        // content = "{indent}  {text}" — split at first non-space after indent
246                        let non_space = content.find(|c: char| c != ' ').unwrap_or(0);
247                        let (spaces, text) = content.split_at(non_space);
248                        Cell::from(Line::from(vec![
249                            Span::raw(spaces.to_string()),
250                            Span::styled(text.to_string(), style),
251                        ]))
252                    }
253                }
254            })
255            .collect();
256
257        table_row_to_item_idx.push(idx);
258        rows.push(Row::new(cells).height(1));
259
260        // Add placeholder rows for expanded content so subsequent rows are pushed down
261        // and the scrollbar reflects the true content height. We track which are
262        // placeholder rows so table_state_index is never set to one.
263        if is_expanded {
264            if let Some(ref get_content) = config.get_expanded_content {
265                let styled_lines = get_content(item);
266                for _ in 0..styled_lines.len() {
267                    let empty_cells: Vec<Cell> =
268                        (0..config.columns.len()).map(|_| Cell::from("")).collect();
269                    // Use usize::MAX as a sentinel meaning "placeholder, not selectable"
270                    table_row_to_item_idx.push(usize::MAX);
271                    rows.push(Row::new(empty_cells).height(1));
272                }
273            }
274        }
275
276        rows
277    });
278
279    let all_rows: Vec<Row> = item_rows.collect();
280
281    let mut table_state_index = 0;
282    for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
283        // Skip placeholder rows (sentineled with usize::MAX)
284        if item_idx != usize::MAX && item_idx == config.selected_index {
285            table_state_index = i;
286            break;
287        }
288    }
289
290    // Find the widest column — it becomes the stretch column that absorbs remaining space.
291    let max_declared_width = config
292        .columns
293        .iter()
294        .map(|col| col.width())
295        .max()
296        .unwrap_or(0);
297
298    let widths: Vec<Constraint> = config
299        .columns
300        .iter()
301        .enumerate()
302        .map(|(i, col)| {
303            // Calculate the actual formatted header width
304            let formatted_header = format_header_cell(col.name(), i);
305            let header_width = formatted_header.chars().count() as u16;
306            let width = col.width().max(header_width);
307            // The widest column (typically Message) stretches to fill available space.
308            if col.width() == max_declared_width {
309                Constraint::Fill(1)
310            } else {
311                Constraint::Length(width)
312            }
313        })
314        .collect();
315
316    let table = Table::new(all_rows, widths)
317        .header(header)
318        .block(
319            rounded_block()
320                .title(Span::styled(config.title, title_style))
321                .border_style(border_style),
322        )
323        .column_spacing(1)
324        .row_highlight_style(styles::highlight());
325
326    let mut state = TableState::default();
327    state.select(Some(table_state_index));
328
329    // If the selected item is expanded, pre-set the offset so ratatui shows all detail rows.
330    // Count placeholder rows after the selected item.
331    let detail_row_count = if config.expanded_index == Some(config.selected_index) {
332        table_row_to_item_idx
333            .iter()
334            .skip(table_state_index + 1)
335            .take_while(|&&idx| idx == usize::MAX)
336            .count()
337    } else {
338        0
339    };
340    if detail_row_count > 0 {
341        // The item is at table_state_index and needs detail_row_count rows below.
342        // visible_rows = area.height - 3 (borders + header).
343        let visible_rows = config.area.height.saturating_sub(3) as usize;
344        if visible_rows > 0 {
345            let last_detail = table_state_index + detail_row_count;
346            if last_detail >= visible_rows {
347                // Scroll so that last detail row is the last visible row
348                let needed_offset = last_detail.saturating_sub(visible_rows - 1);
349                *state.offset_mut() = needed_offset;
350            }
351        }
352    }
353
354    // KNOWN ISSUE: ratatui 0.29 Table widget has built-in scrollbar that:
355    // 1. Uses ║ and █ characters that cannot be customized
356    // 2. Shows automatically when ratatui detects potential overflow
357    // 3. Cannot be disabled without upgrading ratatui or implementing custom table rendering
358    // The scrollbar may appear even when all paginated rows fit in the viewport
359    frame.render_stateful_widget(table, config.area, &mut state);
360
361    // After rendering, state.offset() gives ratatui's internal scroll offset.
362    // We use this to compute the correct visual position for the expanded content overlay.
363    let ratatui_scroll_offset = state.offset();
364
365    // Render expanded content as overlay if present
366    if let Some(expanded_idx) = config.expanded_index {
367        if let Some(ref get_content) = config.get_expanded_content {
368            if let Some(item) = config.items.get(expanded_idx) {
369                let styled_lines = get_content(item);
370
371                // Calculate position: find absolute row index for the expanded item,
372                // then subtract ratatui's scroll offset to get the VISUAL row position.
373                let mut abs_row_y = 0;
374                for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
375                    if item_idx == expanded_idx {
376                        abs_row_y = i;
377                        break;
378                    }
379                }
380                // Visual row = absolute row - ratatui's scroll offset
381                // If the expanded item is scrolled off the top, don't render overlay.
382                if abs_row_y < ratatui_scroll_offset {
383                    // Expanded item is above the visible area; skip overlay
384                } else {
385                    let row_y = abs_row_y - ratatui_scroll_offset;
386
387                    // Clear entire expanded area once
388                    let start_y = config.area.y + 2 + row_y as u16 + 1;
389                    let bottom = config.area.y + config.area.height - 1;
390                    let visible_lines = if start_y >= bottom {
391                        0
392                    } else {
393                        styled_lines.len().min((bottom - start_y) as usize)
394                    };
395                    if visible_lines > 0 {
396                        let clear_area = Rect {
397                            x: config.area.x + 1,
398                            y: start_y,
399                            width: config.area.width.saturating_sub(2),
400                            height: visible_lines as u16,
401                        };
402                        frame.render_widget(Clear, clear_area);
403                    }
404
405                    for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
406                        let y = start_y + line_idx as u16;
407                        if y >= config.area.y + config.area.height - 1 {
408                            break; // Don't render past bottom border
409                        }
410
411                        let line_area = Rect {
412                            x: config.area.x + 1,
413                            y,
414                            width: config.area.width.saturating_sub(2),
415                            height: 1,
416                        };
417
418                        // Add expansion indicator on the left.
419                        // If the line starts with spaces (indent prefix for nested items),
420                        // strip it from the content and prepend it before the indicator.
421                        let indent = if line.starts_with("  ") { "  " } else { "" };
422                        let line_content = if indent.is_empty() {
423                            line.as_str()
424                        } else {
425                            line.strip_prefix("  ").unwrap_or(line.as_str())
426                        };
427
428                        let is_last_line = line_idx == styled_lines.len() - 1;
429                        let is_field_start = line_content.contains(": ");
430                        let indicator = if is_last_line {
431                            "╰ "
432                        } else if is_field_start {
433                            "├ "
434                        } else {
435                            "│ "
436                        };
437
438                        let spans = if let Some(colon_pos) = line_content.find(": ") {
439                            let col_name = &line_content[..colon_pos + 2];
440                            let rest = &line_content[colon_pos + 2..];
441                            vec![
442                                Span::raw(format!("{}{}", indent, indicator)),
443                                Span::styled(col_name.to_string(), styles::label()),
444                                Span::styled(rest.to_string(), *line_style),
445                            ]
446                        } else {
447                            vec![
448                                Span::raw(format!("{}{}", indent, indicator)),
449                                Span::styled(line_content.to_string(), *line_style),
450                            ]
451                        };
452
453                        let paragraph = Paragraph::new(Line::from(spans));
454                        frame.render_widget(paragraph, line_area);
455                    }
456                } // end else (expanded item is visible)
457            }
458        }
459    }
460
461    // Scrollbar - only show if items don't fit in viewport
462    if !config.items.is_empty() {
463        let scrollbar_area = config.area.inner(Margin {
464            vertical: 1,
465            horizontal: 0,
466        });
467        // Only show scrollbar if there are more items than can fit in the viewport
468        if config.items.len() > scrollbar_area.height as usize {
469            render_scrollbar(
470                frame,
471                scrollbar_area,
472                config.items.len(),
473                config.selected_index,
474            );
475        }
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
484    const TRACK: &str = "│";
485    const THUMB: &str = "█";
486    const EXPAND_INTERMEDIATE: &str = "├ ";
487    const EXPAND_CONTINUATION: &str = "│ ";
488    const EXPAND_LAST: &str = "╰ ";
489
490    #[test]
491    fn test_expanded_content_overlay() {
492        assert!(TIMESTAMP_LINE.contains("(UTC)"));
493        assert!(!TIMESTAMP_LINE.contains("( UTC"));
494        assert_eq!(
495            "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
496                .lines()
497                .count(),
498            3
499        );
500    }
501
502    #[test]
503    fn test_table_border_always_plain() {
504        assert_eq!(BorderType::Plain, BorderType::Plain);
505    }
506
507    #[test]
508    fn test_table_border_color_changes_when_active() {
509        let active = Style::default().fg(Color::Green);
510        let inactive = Style::default();
511        assert_eq!(active.fg, Some(Color::Green));
512        assert_eq!(inactive.fg, None);
513    }
514
515    #[test]
516    fn test_table_scrollbar_uses_solid_characters() {
517        assert_eq!(TRACK, "│");
518        assert_eq!(THUMB, "█");
519        assert_ne!(TRACK, "║");
520    }
521
522    #[test]
523    fn test_expansion_indicators() {
524        assert_eq!(EXPAND_INTERMEDIATE, "├ ");
525        assert_eq!(EXPAND_CONTINUATION, "│ ");
526        assert_eq!(EXPAND_LAST, "╰ ");
527        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
528        assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
529        assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
530    }
531
532    #[test]
533    fn test_first_column_expansion_indicators() {
534        // Verify collapsed and expanded indicators
535        assert_eq!(CURSOR_COLLAPSED, "►");
536        assert_eq!(CURSOR_EXPANDED, "▼");
537
538        // Verify they're different
539        assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
540    }
541
542    #[test]
543    fn test_table_scrollbar_only_for_overflow() {
544        let (rows, height) = (50, 60u16);
545        let available = height.saturating_sub(3);
546        assert!(rows <= available as usize);
547        assert!(60 > available as usize);
548    }
549
550    #[test]
551    fn test_expansion_indicator_stripping() {
552        let value_with_right_arrow = "► my-stack";
553        let value_with_down_arrow = "▼ my-stack";
554        let value_without_indicator = "my-stack";
555
556        assert_eq!(
557            value_with_right_arrow
558                .trim_start_matches("► ")
559                .trim_start_matches("▼ "),
560            "my-stack"
561        );
562        assert_eq!(
563            value_with_down_arrow
564                .trim_start_matches("► ")
565                .trim_start_matches("▼ "),
566            "my-stack"
567        );
568        assert_eq!(
569            value_without_indicator
570                .trim_start_matches("► ")
571                .trim_start_matches("▼ "),
572            "my-stack"
573        );
574    }
575
576    #[test]
577    fn test_format_expandable_expanded() {
578        assert_eq!(format_expandable("test-item", true), "▼ test-item");
579    }
580
581    #[test]
582    fn test_format_expandable_not_expanded() {
583        assert_eq!(format_expandable("test-item", false), "► test-item");
584    }
585
586    #[test]
587    fn test_first_column_width_accounts_for_expansion_indicators() {
588        // Expansion indicators add 2 display characters (► or ▼ + space) when selected or expanded
589        let selected_only = format_expandable_with_selection("test", false, true);
590        let expanded_only = format_expandable_with_selection("test", true, false);
591        let both = format_expandable_with_selection("test", true, true);
592        let neither = format_expandable_with_selection("test", false, false);
593
594        // Selected or expanded should add 2 display characters (arrow + space)
595        assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
596        assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
597        // Both expanded and selected still shows only one indicator (expanded takes precedence)
598        assert_eq!(both.chars().count(), "test".chars().count() + 2);
599        // Neither should add 2 spaces for alignment
600        assert_eq!(neither.chars().count(), "test".chars().count() + 2);
601        assert_eq!(neither, "  test");
602    }
603
604    #[test]
605    fn test_format_header_cell_first_column() {
606        assert_eq!(format_header_cell("Name", 0), "  Name");
607    }
608
609    #[test]
610    fn test_format_header_cell_other_columns() {
611        assert_eq!(format_header_cell("Region", 1), "⋮ Region");
612        assert_eq!(format_header_cell("Status", 2), "⋮ Status");
613        assert_eq!(format_header_cell("Created", 5), "⋮ Created");
614    }
615
616    #[test]
617    fn test_format_header_cell_with_sort_indicator() {
618        assert_eq!(format_header_cell("Name ↑", 0), "  Name ↑");
619        assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
620    }
621
622    #[test]
623    fn test_column_width_never_narrower_than_header() {
624        // First column: "  Name" = 6 chars
625        let header_first = format_header_cell("Name", 0);
626        assert_eq!(header_first.chars().count(), 6);
627
628        // Other columns: "⋮ Launch time" = 13 chars
629        let header_other = format_header_cell("Launch time", 1);
630        assert_eq!(header_other.chars().count(), 13);
631    }
632
633    #[test]
634    fn test_formatted_header_width_calculation() {
635        // Test that formatted header width is correctly calculated
636        assert_eq!(format_header_cell("ID", 0).chars().count(), 4); // "  ID"
637        assert_eq!(format_header_cell("ID", 1).chars().count(), 4); // "⋮ ID"
638        assert_eq!(format_header_cell("Name", 0).chars().count(), 6); // "  Name"
639        assert_eq!(format_header_cell("Name", 1).chars().count(), 6); // "⋮ Name"
640        assert_eq!(format_header_cell("Launch time", 1).chars().count(), 13); // "⋮ Launch time"
641    }
642
643    #[test]
644    fn test_utc_timestamp_column_width() {
645        // UTC timestamp format: "2025-11-14 00:00:00.000 UTC" = 27 chars
646        // Header "Launch time" formatted as "⋮ Launch time" = 13 chars
647        // Column width should be max(27, 13) = 27
648        use crate::common::UTC_TIMESTAMP_WIDTH;
649        assert_eq!(UTC_TIMESTAMP_WIDTH, 27);
650
651        let header = format_header_cell("Launch time", 1);
652        let header_width = header.chars().count() as u16;
653        assert_eq!(header_width, 13);
654
655        // The column width should use UTC_TIMESTAMP_WIDTH (27), not header width (13)
656        assert!(UTC_TIMESTAMP_WIDTH > header_width);
657    }
658
659    #[test]
660    fn test_expanded_row_does_not_add_placeholder_rows_to_table() {
661        // Regression: expanded content used to add N empty placeholder rows to the ratatui
662        // Table widget, causing it to scroll internally when the expanded item was near
663        // the bottom ("sticky" overlay bug). Now expanded content is a pure overlay only.
664        // We verify: TableConfig.items.len() == page_size regardless of expansion.
665        struct TestCol;
666        impl Column<String> for TestCol {
667            fn id(&self) -> &'static str {
668                "test"
669            }
670            fn default_name(&self) -> &'static str {
671                "Name"
672            }
673            fn width(&self) -> u16 {
674                10
675            }
676            fn render(&self, item: &String) -> (String, ratatui::style::Style) {
677                (item.clone(), ratatui::style::Style::default())
678            }
679        }
680
681        let items: Vec<String> = vec!["alpha".into(), "beta".into(), "gamma".into()];
682        let columns: Vec<Box<dyn Column<String>>> = vec![Box::new(TestCol)];
683
684        let config = TableConfig {
685            items: items.iter().collect(),
686            selected_index: 2,
687            expanded_index: Some(2), // last item expanded
688            columns: &columns,
689            sort_column: "",
690            sort_direction: crate::common::SortDirection::Asc,
691            title: "Test".to_string(),
692            area: ratatui::layout::Rect::new(0, 0, 80, 10),
693            get_expanded_content: Some(Box::new(|_item: &String| {
694                vec![
695                    ("f1: v1".to_string(), ratatui::style::Style::default()),
696                    ("f2: v2".to_string(), ratatui::style::Style::default()),
697                ]
698            })),
699            is_active: true,
700        };
701
702        assert_eq!(
703            config.items.len(),
704            3,
705            "items.len() must equal page_size — no placeholder rows added for expanded content"
706        );
707    }
708}