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