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
68pub 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 fn fit_content(&self) -> bool {
122 false
123 }
124
125 fn render(&self, item: &T) -> (String, Style);
126}
127
128pub 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 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 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 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 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 if i == 0 {
250 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 if is_expanded {
275 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 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 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 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 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 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 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 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 let needed_offset = last_detail.saturating_sub(visible_rows - 1);
392 *state.offset_mut() = needed_offset;
393 }
394 }
395 }
396
397 frame.render_stateful_widget(table, config.area, &mut state);
403
404 let ratatui_scroll_offset = state.offset();
407
408 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 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 if abs_row_y < ratatui_scroll_offset {
426 } else {
428 let row_y = abs_row_y - ratatui_scroll_offset;
429
430 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; }
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 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 } }
501 }
502 }
503
504 if !config.items.is_empty() {
506 let scrollbar_area = config.area.inner(Margin {
507 vertical: 1,
508 horizontal: 0,
509 });
510 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 assert_eq!(CURSOR_COLLAPSED, "►");
579 assert_eq!(CURSOR_EXPANDED, "▼");
580
581 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 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 assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
639 assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
640 assert_eq!(both.chars().count(), "test".chars().count() + 2);
642 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 let header_first = format_header_cell("Name", 0);
669 assert_eq!(header_first.chars().count(), 6);
670
671 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 assert_eq!(format_header_cell("ID", 0).chars().count(), 4); assert_eq!(format_header_cell("ID", 1).chars().count(), 4); assert_eq!(format_header_cell("Name", 0).chars().count(), 6); assert_eq!(format_header_cell("Name", 1).chars().count(), 6); assert_eq!(format_header_cell("Launch time", 1).chars().count(), 13); }
685
686 #[test]
687 fn test_utc_timestamp_column_width() {
688 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 assert!(UTC_TIMESTAMP_WIDTH > header_width);
700 }
701
702 #[test]
703 fn test_expanded_row_does_not_add_placeholder_rows_to_table() {
704 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), 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(); 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}