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
31type ExpandedContentFn<'a, T> = Box<dyn Fn(&T) -> Vec<(String, Style)> + 'a>;
32
33pub fn plain_expanded_content(content: String) -> Vec<(String, Style)> {
35 content
36 .lines()
37 .map(|line| (line.to_string(), Style::default()))
38 .collect()
39}
40
41pub struct TableConfig<'a, T> {
42 pub items: Vec<&'a T>,
43 pub selected_index: usize,
44 pub expanded_index: Option<usize>,
45 pub columns: &'a [Box<dyn Column<T>>],
46 pub sort_column: &'a str,
47 pub sort_direction: SortDirection,
48 pub title: String,
49 pub area: Rect,
50 pub get_expanded_content: Option<ExpandedContentFn<'a, T>>,
51 pub is_active: bool,
52}
53
54pub fn format_header_cell(name: &str, column_index: usize) -> String {
55 if column_index == 0 {
56 format!(" {}", name)
57 } else {
58 format!("⋮ {}", name)
59 }
60}
61
62pub trait Column<T> {
63 fn id(&self) -> &'static str {
64 unimplemented!("id() must be implemented if using default name() implementation")
65 }
66
67 fn default_name(&self) -> &'static str {
68 unimplemented!("default_name() must be implemented if using default name() implementation")
69 }
70
71 fn name(&self) -> &str {
72 let id = self.id();
73 let translated = t(id);
74 if translated == id {
75 self.default_name()
76 } else {
77 Box::leak(translated.into_boxed_str())
78 }
79 }
80
81 fn width(&self) -> u16;
82 fn render(&self, item: &T) -> (String, Style);
83}
84
85pub fn expanded_from_columns<T>(columns: &[Box<dyn Column<T>>], item: &T) -> Vec<(String, Style)> {
87 columns
88 .iter()
89 .map(|col| {
90 let (value, style) = col.render(item);
91 let cleaned_value = value
93 .trim_start_matches("► ")
94 .trim_start_matches("▼ ")
95 .trim_start_matches(" ");
96 let display = if cleaned_value.is_empty() {
97 "-"
98 } else {
99 cleaned_value
100 };
101 (format!("{}: {}", col.name(), display), style)
102 })
103 .collect()
104}
105
106pub fn render_table<T>(frame: &mut Frame, config: TableConfig<T>) {
107 let border_style = if config.is_active {
108 styles::active_border()
109 } else {
110 Style::default()
111 };
112
113 let title_style = if config.is_active {
114 Style::default().fg(Color::Green)
115 } else {
116 Style::default()
117 };
118
119 let header_cells = config.columns.iter().enumerate().map(|(i, col)| {
121 let mut name = col.name().to_string();
122 if !config.sort_column.is_empty() && config.sort_column == name {
123 let arrow = if config.sort_direction == SortDirection::Asc {
124 " ↑"
125 } else {
126 " ↓"
127 };
128 name.push_str(arrow);
129 }
130 name = format_header_cell(&name, i);
131 Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
132 });
133 let header = Row::new(header_cells)
134 .style(Style::default().bg(Color::White).fg(Color::Black))
135 .height(1);
136
137 let mut table_row_to_item_idx = Vec::new();
138 let item_rows = config.items.iter().enumerate().flat_map(|(idx, item)| {
139 let is_expanded = config.expanded_index == Some(idx);
140 let is_selected = idx == config.selected_index;
141 let mut rows = Vec::new();
142
143 let cells: Vec<Cell> = config
145 .columns
146 .iter()
147 .enumerate()
148 .map(|(i, col)| {
149 let (mut content, style) = col.render(item);
150
151 if i == 0 {
153 content = if is_expanded {
154 format!("{} {}", CURSOR_EXPANDED, content)
155 } else if is_selected {
156 format!("{} {}", CURSOR_COLLAPSED, content)
157 } else {
158 format!(" {}", content)
159 };
160 }
161
162 if i > 0 {
163 Cell::from(Line::from(vec![
164 Span::raw("⋮ "),
165 Span::styled(content, style),
166 ]))
167 } else {
168 Cell::from(content).style(style)
169 }
170 })
171 .collect();
172
173 table_row_to_item_idx.push(idx);
174 rows.push(Row::new(cells).height(1));
175
176 if is_expanded {
178 if let Some(ref get_content) = config.get_expanded_content {
179 let styled_lines = get_content(item);
180 let line_count = styled_lines.len();
181
182 for _ in 0..line_count {
183 let mut empty_cells = Vec::new();
184 for _ in 0..config.columns.len() {
185 empty_cells.push(Cell::from(""));
186 }
187 table_row_to_item_idx.push(idx);
188 rows.push(Row::new(empty_cells).height(1));
189 }
190 }
191 }
192
193 rows
194 });
195
196 let all_rows: Vec<Row> = item_rows.collect();
197
198 let mut table_state_index = 0;
199 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
200 if item_idx == config.selected_index {
201 table_state_index = i;
202 break;
203 }
204 }
205
206 let widths: Vec<Constraint> = config
207 .columns
208 .iter()
209 .enumerate()
210 .map(|(i, col)| {
211 let formatted_header = format_header_cell(col.name(), i);
213 let header_width = formatted_header.chars().count() as u16;
214 let width = col.width().max(header_width);
216 Constraint::Length(width)
217 })
218 .collect();
219
220 let table = Table::new(all_rows, widths)
221 .header(header)
222 .block(
223 rounded_block()
224 .title(Span::styled(config.title, title_style))
225 .border_style(border_style),
226 )
227 .column_spacing(1)
228 .row_highlight_style(styles::highlight());
229
230 let mut state = TableState::default();
231 state.select(Some(table_state_index));
232
233 frame.render_stateful_widget(table, config.area, &mut state);
239
240 if let Some(expanded_idx) = config.expanded_index {
242 if let Some(ref get_content) = config.get_expanded_content {
243 if let Some(item) = config.items.get(expanded_idx) {
244 let styled_lines = get_content(item);
245
246 let mut row_y = 0;
248 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
249 if item_idx == expanded_idx {
250 row_y = i;
251 break;
252 }
253 }
254
255 let start_y = config.area.y + 2 + row_y as u16 + 1;
257 let visible_lines = styled_lines
258 .len()
259 .min((config.area.y + config.area.height - 1 - start_y) as usize);
260 if visible_lines > 0 {
261 let clear_area = Rect {
262 x: config.area.x + 1,
263 y: start_y,
264 width: config.area.width.saturating_sub(2),
265 height: visible_lines as u16,
266 };
267 frame.render_widget(Clear, clear_area);
268 }
269
270 for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
271 let y = start_y + line_idx as u16;
272 if y >= config.area.y + config.area.height - 1 {
273 break; }
275
276 let line_area = Rect {
277 x: config.area.x + 1,
278 y,
279 width: config.area.width.saturating_sub(2),
280 height: 1,
281 };
282
283 let is_last_line = line_idx == styled_lines.len() - 1;
285 let is_field_start = line.contains(": ");
286 let indicator = if is_last_line {
287 "╰ "
288 } else if is_field_start {
289 "├ "
290 } else {
291 "│ "
292 };
293
294 let spans = if let Some(colon_pos) = line.find(": ") {
295 let col_name = &line[..colon_pos + 2];
296 let rest = &line[colon_pos + 2..];
297 vec![
298 Span::raw(indicator),
299 Span::styled(col_name.to_string(), styles::label()),
300 Span::styled(rest.to_string(), *line_style),
301 ]
302 } else {
303 vec![
304 Span::raw(indicator),
305 Span::styled(line.to_string(), *line_style),
306 ]
307 };
308
309 let paragraph = Paragraph::new(Line::from(spans));
310 frame.render_widget(paragraph, line_area);
311 }
312 }
313 }
314 }
315
316 if !config.items.is_empty() {
318 let scrollbar_area = config.area.inner(Margin {
319 vertical: 1,
320 horizontal: 0,
321 });
322 if config.items.len() > scrollbar_area.height as usize {
324 render_scrollbar(
325 frame,
326 scrollbar_area,
327 config.items.len(),
328 config.selected_index,
329 );
330 }
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
339 const TRACK: &str = "│";
340 const THUMB: &str = "█";
341 const EXPAND_INTERMEDIATE: &str = "├ ";
342 const EXPAND_CONTINUATION: &str = "│ ";
343 const EXPAND_LAST: &str = "╰ ";
344
345 #[test]
346 fn test_expanded_content_overlay() {
347 assert!(TIMESTAMP_LINE.contains("(UTC)"));
348 assert!(!TIMESTAMP_LINE.contains("( UTC"));
349 assert_eq!(
350 "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
351 .lines()
352 .count(),
353 3
354 );
355 }
356
357 #[test]
358 fn test_table_border_always_plain() {
359 assert_eq!(BorderType::Plain, BorderType::Plain);
360 }
361
362 #[test]
363 fn test_table_border_color_changes_when_active() {
364 let active = Style::default().fg(Color::Green);
365 let inactive = Style::default();
366 assert_eq!(active.fg, Some(Color::Green));
367 assert_eq!(inactive.fg, None);
368 }
369
370 #[test]
371 fn test_table_scrollbar_uses_solid_characters() {
372 assert_eq!(TRACK, "│");
373 assert_eq!(THUMB, "█");
374 assert_ne!(TRACK, "║");
375 }
376
377 #[test]
378 fn test_expansion_indicators() {
379 assert_eq!(EXPAND_INTERMEDIATE, "├ ");
380 assert_eq!(EXPAND_CONTINUATION, "│ ");
381 assert_eq!(EXPAND_LAST, "╰ ");
382 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
383 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
384 assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
385 }
386
387 #[test]
388 fn test_first_column_expansion_indicators() {
389 assert_eq!(CURSOR_COLLAPSED, "►");
391 assert_eq!(CURSOR_EXPANDED, "▼");
392
393 assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
395 }
396
397 #[test]
398 fn test_table_scrollbar_only_for_overflow() {
399 let (rows, height) = (50, 60u16);
400 let available = height.saturating_sub(3);
401 assert!(rows <= available as usize);
402 assert!(60 > available as usize);
403 }
404
405 #[test]
406 fn test_expansion_indicator_stripping() {
407 let value_with_right_arrow = "► my-stack";
408 let value_with_down_arrow = "▼ my-stack";
409 let value_without_indicator = "my-stack";
410
411 assert_eq!(
412 value_with_right_arrow
413 .trim_start_matches("► ")
414 .trim_start_matches("▼ "),
415 "my-stack"
416 );
417 assert_eq!(
418 value_with_down_arrow
419 .trim_start_matches("► ")
420 .trim_start_matches("▼ "),
421 "my-stack"
422 );
423 assert_eq!(
424 value_without_indicator
425 .trim_start_matches("► ")
426 .trim_start_matches("▼ "),
427 "my-stack"
428 );
429 }
430
431 #[test]
432 fn test_format_expandable_expanded() {
433 assert_eq!(format_expandable("test-item", true), "▼ test-item");
434 }
435
436 #[test]
437 fn test_format_expandable_not_expanded() {
438 assert_eq!(format_expandable("test-item", false), "► test-item");
439 }
440
441 #[test]
442 fn test_first_column_width_accounts_for_expansion_indicators() {
443 let selected_only = format_expandable_with_selection("test", false, true);
445 let expanded_only = format_expandable_with_selection("test", true, false);
446 let both = format_expandable_with_selection("test", true, true);
447 let neither = format_expandable_with_selection("test", false, false);
448
449 assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
451 assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
452 assert_eq!(both.chars().count(), "test".chars().count() + 2);
454 assert_eq!(neither.chars().count(), "test".chars().count() + 2);
456 assert_eq!(neither, " test");
457 }
458
459 #[test]
460 fn test_format_header_cell_first_column() {
461 assert_eq!(format_header_cell("Name", 0), " Name");
462 }
463
464 #[test]
465 fn test_format_header_cell_other_columns() {
466 assert_eq!(format_header_cell("Region", 1), "⋮ Region");
467 assert_eq!(format_header_cell("Status", 2), "⋮ Status");
468 assert_eq!(format_header_cell("Created", 5), "⋮ Created");
469 }
470
471 #[test]
472 fn test_format_header_cell_with_sort_indicator() {
473 assert_eq!(format_header_cell("Name ↑", 0), " Name ↑");
474 assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
475 }
476
477 #[test]
478 fn test_column_width_never_narrower_than_header() {
479 let header_first = format_header_cell("Name", 0);
481 assert_eq!(header_first.chars().count(), 6);
482
483 let header_other = format_header_cell("Launch time", 1);
485 assert_eq!(header_other.chars().count(), 13);
486 }
487
488 #[test]
489 fn test_formatted_header_width_calculation() {
490 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); }
497
498 #[test]
499 fn test_utc_timestamp_column_width() {
500 use crate::common::UTC_TIMESTAMP_WIDTH;
504 assert_eq!(UTC_TIMESTAMP_WIDTH, 27);
505
506 let header = format_header_cell("Launch time", 1);
507 let header_width = header.chars().count() as u16;
508 assert_eq!(header_width, 13);
509
510 assert!(UTC_TIMESTAMP_WIDTH > header_width);
512 }
513}