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