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 content = if is_expanded {
189 format!("{} {}", CURSOR_EXPANDED, content)
190 } else if is_selected {
191 format!("{} {}", CURSOR_COLLAPSED, content)
192 } else {
193 format!(" {}", content)
194 };
195 }
196
197 if i > 0 {
198 Cell::from(Line::from(vec![
199 Span::raw("⋮ "),
200 Span::styled(content, style),
201 ]))
202 } else {
203 if is_expanded {
205 let text = content
206 .strip_prefix(&format!("{} ", CURSOR_EXPANDED))
207 .unwrap_or(&content);
208 Cell::from(Line::from(vec![
209 Span::styled(
210 format!("{} ", CURSOR_EXPANDED),
211 Style::default().fg(Color::White),
212 ),
213 Span::styled(text.to_string(), style),
214 ]))
215 } else if is_selected {
216 let text = content
217 .strip_prefix(&format!("{} ", CURSOR_COLLAPSED))
218 .unwrap_or(&content);
219 Cell::from(Line::from(vec![
220 Span::styled(
221 format!("{} ", CURSOR_COLLAPSED),
222 Style::default().fg(Color::White),
223 ),
224 Span::styled(text.to_string(), style),
225 ]))
226 } else {
227 let text = content.strip_prefix(" ").unwrap_or(&content);
228 Cell::from(Line::from(vec![
229 Span::raw(" "),
230 Span::styled(text.to_string(), style),
231 ]))
232 }
233 }
234 })
235 .collect();
236
237 table_row_to_item_idx.push(idx);
238 rows.push(Row::new(cells).height(1));
239
240 if is_expanded {
242 if let Some(ref get_content) = config.get_expanded_content {
243 let styled_lines = get_content(item);
244 let line_count = styled_lines.len();
245
246 for _ in 0..line_count {
247 let mut empty_cells = Vec::new();
248 for _ in 0..config.columns.len() {
249 empty_cells.push(Cell::from(""));
250 }
251 table_row_to_item_idx.push(idx);
252 rows.push(Row::new(empty_cells).height(1));
253 }
254 }
255 }
256
257 rows
258 });
259
260 let all_rows: Vec<Row> = item_rows.collect();
261
262 let mut table_state_index = 0;
263 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
264 if item_idx == config.selected_index {
265 table_state_index = i;
266 break;
267 }
268 }
269
270 let widths: Vec<Constraint> = config
271 .columns
272 .iter()
273 .enumerate()
274 .map(|(i, col)| {
275 let formatted_header = format_header_cell(col.name(), i);
277 let header_width = formatted_header.chars().count() as u16;
278 let width = col.width().max(header_width);
280 Constraint::Length(width)
281 })
282 .collect();
283
284 let table = Table::new(all_rows, widths)
285 .header(header)
286 .block(
287 rounded_block()
288 .title(Span::styled(config.title, title_style))
289 .border_style(border_style),
290 )
291 .column_spacing(1)
292 .row_highlight_style(styles::highlight());
293
294 let mut state = TableState::default();
295 state.select(Some(table_state_index));
296
297 frame.render_stateful_widget(table, config.area, &mut state);
303
304 if let Some(expanded_idx) = config.expanded_index {
306 if let Some(ref get_content) = config.get_expanded_content {
307 if let Some(item) = config.items.get(expanded_idx) {
308 let styled_lines = get_content(item);
309
310 let mut row_y = 0;
312 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
313 if item_idx == expanded_idx {
314 row_y = i;
315 break;
316 }
317 }
318
319 let start_y = config.area.y + 2 + row_y as u16 + 1;
321 let visible_lines = styled_lines
322 .len()
323 .min((config.area.y + config.area.height - 1 - start_y) as usize);
324 if visible_lines > 0 {
325 let clear_area = Rect {
326 x: config.area.x + 1,
327 y: start_y,
328 width: config.area.width.saturating_sub(2),
329 height: visible_lines as u16,
330 };
331 frame.render_widget(Clear, clear_area);
332 }
333
334 for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
335 let y = start_y + line_idx as u16;
336 if y >= config.area.y + config.area.height - 1 {
337 break; }
339
340 let line_area = Rect {
341 x: config.area.x + 1,
342 y,
343 width: config.area.width.saturating_sub(2),
344 height: 1,
345 };
346
347 let is_last_line = line_idx == styled_lines.len() - 1;
349 let is_field_start = line.contains(": ");
350 let indicator = if is_last_line {
351 "╰ "
352 } else if is_field_start {
353 "├ "
354 } else {
355 "│ "
356 };
357
358 let spans = if let Some(colon_pos) = line.find(": ") {
359 let col_name = &line[..colon_pos + 2];
360 let rest = &line[colon_pos + 2..];
361 vec![
362 Span::raw(indicator),
363 Span::styled(col_name.to_string(), styles::label()),
364 Span::styled(rest.to_string(), *line_style),
365 ]
366 } else {
367 vec![
368 Span::raw(indicator),
369 Span::styled(line.to_string(), *line_style),
370 ]
371 };
372
373 let paragraph = Paragraph::new(Line::from(spans));
374 frame.render_widget(paragraph, line_area);
375 }
376 }
377 }
378 }
379
380 if !config.items.is_empty() {
382 let scrollbar_area = config.area.inner(Margin {
383 vertical: 1,
384 horizontal: 0,
385 });
386 if config.items.len() > scrollbar_area.height as usize {
388 render_scrollbar(
389 frame,
390 scrollbar_area,
391 config.items.len(),
392 config.selected_index,
393 );
394 }
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
403 const TRACK: &str = "│";
404 const THUMB: &str = "█";
405 const EXPAND_INTERMEDIATE: &str = "├ ";
406 const EXPAND_CONTINUATION: &str = "│ ";
407 const EXPAND_LAST: &str = "╰ ";
408
409 #[test]
410 fn test_expanded_content_overlay() {
411 assert!(TIMESTAMP_LINE.contains("(UTC)"));
412 assert!(!TIMESTAMP_LINE.contains("( UTC"));
413 assert_eq!(
414 "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
415 .lines()
416 .count(),
417 3
418 );
419 }
420
421 #[test]
422 fn test_table_border_always_plain() {
423 assert_eq!(BorderType::Plain, BorderType::Plain);
424 }
425
426 #[test]
427 fn test_table_border_color_changes_when_active() {
428 let active = Style::default().fg(Color::Green);
429 let inactive = Style::default();
430 assert_eq!(active.fg, Some(Color::Green));
431 assert_eq!(inactive.fg, None);
432 }
433
434 #[test]
435 fn test_table_scrollbar_uses_solid_characters() {
436 assert_eq!(TRACK, "│");
437 assert_eq!(THUMB, "█");
438 assert_ne!(TRACK, "║");
439 }
440
441 #[test]
442 fn test_expansion_indicators() {
443 assert_eq!(EXPAND_INTERMEDIATE, "├ ");
444 assert_eq!(EXPAND_CONTINUATION, "│ ");
445 assert_eq!(EXPAND_LAST, "╰ ");
446 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
447 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
448 assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
449 }
450
451 #[test]
452 fn test_first_column_expansion_indicators() {
453 assert_eq!(CURSOR_COLLAPSED, "►");
455 assert_eq!(CURSOR_EXPANDED, "▼");
456
457 assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
459 }
460
461 #[test]
462 fn test_table_scrollbar_only_for_overflow() {
463 let (rows, height) = (50, 60u16);
464 let available = height.saturating_sub(3);
465 assert!(rows <= available as usize);
466 assert!(60 > available as usize);
467 }
468
469 #[test]
470 fn test_expansion_indicator_stripping() {
471 let value_with_right_arrow = "► my-stack";
472 let value_with_down_arrow = "▼ my-stack";
473 let value_without_indicator = "my-stack";
474
475 assert_eq!(
476 value_with_right_arrow
477 .trim_start_matches("► ")
478 .trim_start_matches("▼ "),
479 "my-stack"
480 );
481 assert_eq!(
482 value_with_down_arrow
483 .trim_start_matches("► ")
484 .trim_start_matches("▼ "),
485 "my-stack"
486 );
487 assert_eq!(
488 value_without_indicator
489 .trim_start_matches("► ")
490 .trim_start_matches("▼ "),
491 "my-stack"
492 );
493 }
494
495 #[test]
496 fn test_format_expandable_expanded() {
497 assert_eq!(format_expandable("test-item", true), "▼ test-item");
498 }
499
500 #[test]
501 fn test_format_expandable_not_expanded() {
502 assert_eq!(format_expandable("test-item", false), "► test-item");
503 }
504
505 #[test]
506 fn test_first_column_width_accounts_for_expansion_indicators() {
507 let selected_only = format_expandable_with_selection("test", false, true);
509 let expanded_only = format_expandable_with_selection("test", true, false);
510 let both = format_expandable_with_selection("test", true, true);
511 let neither = format_expandable_with_selection("test", false, false);
512
513 assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
515 assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
516 assert_eq!(both.chars().count(), "test".chars().count() + 2);
518 assert_eq!(neither.chars().count(), "test".chars().count() + 2);
520 assert_eq!(neither, " test");
521 }
522
523 #[test]
524 fn test_format_header_cell_first_column() {
525 assert_eq!(format_header_cell("Name", 0), " Name");
526 }
527
528 #[test]
529 fn test_format_header_cell_other_columns() {
530 assert_eq!(format_header_cell("Region", 1), "⋮ Region");
531 assert_eq!(format_header_cell("Status", 2), "⋮ Status");
532 assert_eq!(format_header_cell("Created", 5), "⋮ Created");
533 }
534
535 #[test]
536 fn test_format_header_cell_with_sort_indicator() {
537 assert_eq!(format_header_cell("Name ↑", 0), " Name ↑");
538 assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
539 }
540
541 #[test]
542 fn test_column_width_never_narrower_than_header() {
543 let header_first = format_header_cell("Name", 0);
545 assert_eq!(header_first.chars().count(), 6);
546
547 let header_other = format_header_cell("Launch time", 1);
549 assert_eq!(header_other.chars().count(), 13);
550 }
551
552 #[test]
553 fn test_formatted_header_width_calculation() {
554 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); }
561
562 #[test]
563 fn test_utc_timestamp_column_width() {
564 use crate::common::UTC_TIMESTAMP_WIDTH;
568 assert_eq!(UTC_TIMESTAMP_WIDTH, 27);
569
570 let header = format_header_cell("Launch time", 1);
571 let header_width = header.chars().count() as u16;
572 assert_eq!(header_width, 13);
573
574 assert!(UTC_TIMESTAMP_WIDTH > header_width);
576 }
577}