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 fn render(&self, item: &T) -> (String, Style);
118}
119
120pub 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 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 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 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 if i == 0 {
188 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 if is_expanded {
213 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 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 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 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 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 if item_idx != usize::MAX && item_idx == config.selected_index {
285 table_state_index = i;
286 break;
287 }
288 }
289
290 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 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 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 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 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 let needed_offset = last_detail.saturating_sub(visible_rows - 1);
349 *state.offset_mut() = needed_offset;
350 }
351 }
352 }
353
354 frame.render_stateful_widget(table, config.area, &mut state);
360
361 let ratatui_scroll_offset = state.offset();
364
365 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 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 if abs_row_y < ratatui_scroll_offset {
383 } else {
385 let row_y = abs_row_y - ratatui_scroll_offset;
386
387 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; }
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 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 } }
458 }
459 }
460
461 if !config.items.is_empty() {
463 let scrollbar_area = config.area.inner(Margin {
464 vertical: 1,
465 horizontal: 0,
466 });
467 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 assert_eq!(CURSOR_COLLAPSED, "►");
536 assert_eq!(CURSOR_EXPANDED, "▼");
537
538 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 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 assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
596 assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
597 assert_eq!(both.chars().count(), "test".chars().count() + 2);
599 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 let header_first = format_header_cell("Name", 0);
626 assert_eq!(header_first.chars().count(), 6);
627
628 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 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); }
642
643 #[test]
644 fn test_utc_timestamp_column_width() {
645 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 assert!(UTC_TIMESTAMP_WIDTH > header_width);
657 }
658
659 #[test]
660 fn test_expanded_row_does_not_add_placeholder_rows_to_table() {
661 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), 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}