1use ratatui::{prelude::*, widgets::*};
2
3use super::styles;
4use crate::common::t;
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: crate::common::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 header_cells = config.columns.iter().enumerate().map(|(i, col)| {
115 let mut name = col.name().to_string();
116 if !config.sort_column.is_empty() && config.sort_column == name {
117 let arrow = if config.sort_direction == crate::common::SortDirection::Asc {
118 " ↑"
119 } else {
120 " ↓"
121 };
122 name.push_str(arrow);
123 }
124 name = format_header_cell(&name, i);
125 Cell::from(name).style(Style::default().add_modifier(Modifier::BOLD))
126 });
127 let header = Row::new(header_cells)
128 .style(Style::default().bg(Color::White).fg(Color::Black))
129 .height(1);
130
131 let mut table_row_to_item_idx = Vec::new();
132 let item_rows = config.items.iter().enumerate().flat_map(|(idx, item)| {
133 let is_expanded = config.expanded_index == Some(idx);
134 let is_selected = idx == config.selected_index;
135 let mut rows = Vec::new();
136
137 let cells: Vec<Cell> = config
139 .columns
140 .iter()
141 .enumerate()
142 .map(|(i, col)| {
143 let (mut content, style) = col.render(item);
144
145 if i == 0 {
147 content = if is_expanded {
148 format!("{} {}", CURSOR_EXPANDED, content)
149 } else if is_selected {
150 format!("{} {}", CURSOR_COLLAPSED, content)
151 } else {
152 format!(" {}", content)
153 };
154 }
155
156 if i > 0 {
157 Cell::from(Line::from(vec![
158 Span::raw("⋮ "),
159 Span::styled(content, style),
160 ]))
161 } else {
162 Cell::from(content).style(style)
163 }
164 })
165 .collect();
166
167 table_row_to_item_idx.push(idx);
168 rows.push(Row::new(cells).height(1));
169
170 if is_expanded {
172 if let Some(ref get_content) = config.get_expanded_content {
173 let styled_lines = get_content(item);
174 let line_count = styled_lines.len();
175
176 for _ in 0..line_count {
177 let mut empty_cells = Vec::new();
178 for _ in 0..config.columns.len() {
179 empty_cells.push(Cell::from(""));
180 }
181 table_row_to_item_idx.push(idx);
182 rows.push(Row::new(empty_cells).height(1));
183 }
184 }
185 }
186
187 rows
188 });
189
190 let all_rows: Vec<Row> = item_rows.collect();
191
192 let mut table_state_index = 0;
193 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
194 if item_idx == config.selected_index {
195 table_state_index = i;
196 break;
197 }
198 }
199
200 let widths: Vec<Constraint> = config
201 .columns
202 .iter()
203 .map(|col| {
204 let min_width = col.name().len() + 2;
208 let width = col.width().max(min_width as u16);
209 Constraint::Length(width)
210 })
211 .collect();
212
213 let table = Table::new(all_rows, widths)
214 .header(header)
215 .block(
216 Block::default()
217 .title(config.title)
218 .borders(Borders::ALL)
219 .border_type(BorderType::Rounded)
220 .border_style(border_style)
221 .border_type(BorderType::Rounded),
222 )
223 .column_spacing(1)
224 .row_highlight_style(styles::highlight());
225
226 let mut state = TableState::default();
227 state.select(Some(table_state_index));
228
229 frame.render_stateful_widget(table, config.area, &mut state);
235
236 if let Some(expanded_idx) = config.expanded_index {
238 if let Some(ref get_content) = config.get_expanded_content {
239 if let Some(item) = config.items.get(expanded_idx) {
240 let styled_lines = get_content(item);
241
242 let mut row_y = 0;
244 for (i, &item_idx) in table_row_to_item_idx.iter().enumerate() {
245 if item_idx == expanded_idx {
246 row_y = i;
247 break;
248 }
249 }
250
251 let start_y = config.area.y + 2 + row_y as u16 + 1;
253 let visible_lines = styled_lines
254 .len()
255 .min((config.area.y + config.area.height - 1 - start_y) as usize);
256 if visible_lines > 0 {
257 let clear_area = Rect {
258 x: config.area.x + 1,
259 y: start_y,
260 width: config.area.width.saturating_sub(2),
261 height: visible_lines as u16,
262 };
263 frame.render_widget(Clear, clear_area);
264 }
265
266 for (line_idx, (line, line_style)) in styled_lines.iter().enumerate() {
267 let y = start_y + line_idx as u16;
268 if y >= config.area.y + config.area.height - 1 {
269 break; }
271
272 let line_area = Rect {
273 x: config.area.x + 1,
274 y,
275 width: config.area.width.saturating_sub(2),
276 height: 1,
277 };
278
279 let is_last_line = line_idx == styled_lines.len() - 1;
281 let is_field_start = line.contains(": ");
282 let indicator = if is_last_line {
283 "╰ "
284 } else if is_field_start {
285 "├ "
286 } else {
287 "│ "
288 };
289
290 let spans = if let Some(colon_pos) = line.find(": ") {
291 let col_name = &line[..colon_pos + 2];
292 let rest = &line[colon_pos + 2..];
293 vec![
294 Span::raw(indicator),
295 Span::styled(col_name.to_string(), styles::label()),
296 Span::styled(rest.to_string(), *line_style),
297 ]
298 } else {
299 vec![
300 Span::raw(indicator),
301 Span::styled(line.to_string(), *line_style),
302 ]
303 };
304
305 let paragraph = Paragraph::new(Line::from(spans));
306 frame.render_widget(paragraph, line_area);
307 }
308 }
309 }
310 }
311
312 if !config.items.is_empty() {
314 let scrollbar_area = config.area.inner(Margin {
315 vertical: 1,
316 horizontal: 0,
317 });
318 if config.items.len() > scrollbar_area.height as usize {
320 crate::common::render_scrollbar(
321 frame,
322 scrollbar_area,
323 config.items.len(),
324 config.selected_index,
325 );
326 }
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 const TIMESTAMP_LINE: &str = "Last state update: 2025-07-22 17:13:07 (UTC)";
335 const TRACK: &str = "│";
336 const THUMB: &str = "█";
337 const EXPAND_INTERMEDIATE: &str = "├ ";
338 const EXPAND_CONTINUATION: &str = "│ ";
339 const EXPAND_LAST: &str = "╰ ";
340
341 #[test]
342 fn test_expanded_content_overlay() {
343 assert!(TIMESTAMP_LINE.contains("(UTC)"));
344 assert!(!TIMESTAMP_LINE.contains("( UTC"));
345 assert_eq!(
346 "Name: TestAlarm\nState: OK\nLast state update: 2025-07-22 17:13:07 (UTC)"
347 .lines()
348 .count(),
349 3
350 );
351 }
352
353 #[test]
354 fn test_table_border_always_plain() {
355 assert_eq!(BorderType::Plain, BorderType::Plain);
356 }
357
358 #[test]
359 fn test_table_border_color_changes_when_active() {
360 let active = Style::default().fg(Color::Green);
361 let inactive = Style::default();
362 assert_eq!(active.fg, Some(Color::Green));
363 assert_eq!(inactive.fg, None);
364 }
365
366 #[test]
367 fn test_table_scrollbar_uses_solid_characters() {
368 assert_eq!(TRACK, "│");
369 assert_eq!(THUMB, "█");
370 assert_ne!(TRACK, "║");
371 }
372
373 #[test]
374 fn test_expansion_indicators() {
375 assert_eq!(EXPAND_INTERMEDIATE, "├ ");
376 assert_eq!(EXPAND_CONTINUATION, "│ ");
377 assert_eq!(EXPAND_LAST, "╰ ");
378 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_CONTINUATION);
379 assert_ne!(EXPAND_INTERMEDIATE, EXPAND_LAST);
380 assert_ne!(EXPAND_CONTINUATION, EXPAND_LAST);
381 }
382
383 #[test]
384 fn test_first_column_expansion_indicators() {
385 assert_eq!(CURSOR_COLLAPSED, "►");
387 assert_eq!(CURSOR_EXPANDED, "▼");
388
389 assert_ne!(CURSOR_COLLAPSED, CURSOR_EXPANDED);
391 }
392
393 #[test]
394 fn test_table_scrollbar_only_for_overflow() {
395 let (rows, height) = (50, 60u16);
396 let available = height.saturating_sub(3);
397 assert!(rows <= available as usize);
398 assert!(60 > available as usize);
399 }
400
401 #[test]
402 fn test_expansion_indicator_stripping() {
403 let value_with_right_arrow = "► my-stack";
404 let value_with_down_arrow = "▼ my-stack";
405 let value_without_indicator = "my-stack";
406
407 assert_eq!(
408 value_with_right_arrow
409 .trim_start_matches("► ")
410 .trim_start_matches("▼ "),
411 "my-stack"
412 );
413 assert_eq!(
414 value_with_down_arrow
415 .trim_start_matches("► ")
416 .trim_start_matches("▼ "),
417 "my-stack"
418 );
419 assert_eq!(
420 value_without_indicator
421 .trim_start_matches("► ")
422 .trim_start_matches("▼ "),
423 "my-stack"
424 );
425 }
426
427 #[test]
428 fn test_format_expandable_expanded() {
429 assert_eq!(format_expandable("test-item", true), "▼ test-item");
430 }
431
432 #[test]
433 fn test_format_expandable_not_expanded() {
434 assert_eq!(format_expandable("test-item", false), "► test-item");
435 }
436
437 #[test]
438 fn test_first_column_width_accounts_for_expansion_indicators() {
439 let selected_only = format_expandable_with_selection("test", false, true);
441 let expanded_only = format_expandable_with_selection("test", true, false);
442 let both = format_expandable_with_selection("test", true, true);
443 let neither = format_expandable_with_selection("test", false, false);
444
445 assert_eq!(selected_only.chars().count(), "test".chars().count() + 2);
447 assert_eq!(expanded_only.chars().count(), "test".chars().count() + 2);
448 assert_eq!(both.chars().count(), "test".chars().count() + 2);
450 assert_eq!(neither.chars().count(), "test".chars().count() + 2);
452 assert_eq!(neither, " test");
453 }
454
455 #[test]
456 fn test_format_header_cell_first_column() {
457 assert_eq!(format_header_cell("Name", 0), " Name");
458 }
459
460 #[test]
461 fn test_format_header_cell_other_columns() {
462 assert_eq!(format_header_cell("Region", 1), "⋮ Region");
463 assert_eq!(format_header_cell("Status", 2), "⋮ Status");
464 assert_eq!(format_header_cell("Created", 5), "⋮ Created");
465 }
466
467 #[test]
468 fn test_format_header_cell_with_sort_indicator() {
469 assert_eq!(format_header_cell("Name ↑", 0), " Name ↑");
470 assert_eq!(format_header_cell("Status ↓", 1), "⋮ Status ↓");
471 }
472}