1use ratatui::{
51 layout::{Constraint, Rect},
52 style::{Color, Modifier, Style},
53 widgets::{Block, Borders, Cell, Row, Table},
54 Frame,
55};
56
57pub struct ColumnDef<T> {
62 pub header: String,
64 pub width: Constraint,
66 pub render: Box<dyn Fn(&T) -> Cell<'static>>,
68}
69
70impl<T> ColumnDef<T> {
71 pub fn new(
73 header: impl Into<String>,
74 width: Constraint,
75 render: impl Fn(&T) -> Cell<'static> + 'static,
76 ) -> Self {
77 Self {
78 header: header.into(),
79 width,
80 render: Box::new(render),
81 }
82 }
83}
84
85pub struct TableData<T> {
90 pub rows: Vec<T>,
92 pub columns: Vec<ColumnDef<T>>,
94 pub title: Option<String>,
96 pub borders: bool,
98 pub highlight_symbol: Option<String>,
100 pub highlight: bool,
102}
103
104impl<T> TableData<T> {
105 pub fn new() -> Self {
107 Self {
108 rows: Vec::new(),
109 columns: Vec::new(),
110 title: None,
111 borders: true,
112 highlight_symbol: Some(">> ".to_string()),
113 highlight: true,
114 }
115 }
116
117 pub fn title(mut self, title: impl Into<String>) -> Self {
119 self.title = Some(title.into());
120 self
121 }
122
123 pub fn column(mut self, def: ColumnDef<T>) -> Self {
125 self.columns.push(def);
126 self
127 }
128
129 pub fn rows(mut self, rows: Vec<T>) -> Self {
131 self.rows = rows;
132 self
133 }
134
135 pub fn borders(mut self, borders: bool) -> Self {
137 self.borders = borders;
138 self
139 }
140
141 pub fn highlight_symbol(mut self, symbol: impl Into<String>) -> Self {
143 self.highlight_symbol = Some(symbol.into());
144 self
145 }
146
147 pub fn highlight(mut self, highlight: bool) -> Self {
149 self.highlight = highlight;
150 self
151 }
152
153 pub fn row(mut self, row: T) -> Self {
155 self.rows.push(row);
156 self
157 }
158}
159
160impl<T> Default for TableData<T> {
161 fn default() -> Self {
162 Self::new()
163 }
164}
165
166pub fn render_table<T: Clone>(frame: &mut Frame, area: Rect, data: &TableData<T>) {
182 let header_cells: Vec<Cell> = data
184 .columns
185 .iter()
186 .map(|col| Cell::from(col.header.clone()).style(Style::default().fg(Color::Yellow)))
187 .collect();
188
189 let header = Row::new(header_cells).height(1);
190
191 let rows = data.rows.iter().map(|row_data| {
193 let cells: Vec<Cell> = data
194 .columns
195 .iter()
196 .map(|col| (col.render)(row_data))
197 .collect();
198 Row::new(cells)
199 });
200
201 let widths: Vec<Constraint> = data.columns.iter().map(|col| col.width).collect();
203
204 let mut table = Table::new(rows, widths).header(header);
206
207 if data.borders {
209 let mut block = Block::default().borders(Borders::ALL);
210 if let Some(ref title) = data.title {
211 block = block.title(title.clone());
212 }
213 table = table.block(block);
214 }
215
216 if data.highlight {
218 table = table.highlight_style(Style::default().add_modifier(Modifier::BOLD));
219 if let Some(ref symbol) = data.highlight_symbol {
220 table = table.highlight_symbol(symbol.clone());
221 }
222 }
223
224 frame.render_widget(table, area);
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use ratatui::backend::TestBackend;
231 use ratatui::Terminal;
232
233 #[derive(Clone, Debug, PartialEq)]
234 struct TestData {
235 name: String,
236 value: i32,
237 status: String,
238 }
239
240 #[test]
241 fn test_table_data_construction() {
242 let table = TableData::<TestData>::new()
243 .title("Test Table")
244 .column(ColumnDef::new(
245 "Name",
246 Constraint::Percentage(40),
247 |d: &TestData| Cell::from(d.name.clone()),
248 ))
249 .column(ColumnDef::new(
250 "Value",
251 Constraint::Percentage(30),
252 |d: &TestData| Cell::from(d.value.to_string()),
253 ))
254 .column(ColumnDef::new(
255 "Status",
256 Constraint::Percentage(30),
257 |d: &TestData| Cell::from(d.status.clone()),
258 ))
259 .rows(vec![
260 TestData {
261 name: "Item 1".to_string(),
262 value: 100,
263 status: "Active".to_string(),
264 },
265 TestData {
266 name: "Item 2".to_string(),
267 value: 200,
268 status: "Inactive".to_string(),
269 },
270 ]);
271
272 assert_eq!(table.title, Some("Test Table".to_string()));
273 assert_eq!(table.columns.len(), 3);
274 assert_eq!(table.rows.len(), 2);
275 assert!(table.borders);
276 assert!(table.highlight);
277 }
278
279 #[test]
280 fn test_column_def_creation() {
281 let col = ColumnDef::new("Test Column", Constraint::Length(20), |d: &TestData| {
282 Cell::from(d.name.clone())
283 });
284
285 assert_eq!(col.header, "Test Column");
286 assert_eq!(col.width, Constraint::Length(20));
287
288 let test_data = TestData {
290 name: "Test".to_string(),
291 value: 42,
292 status: "OK".to_string(),
293 };
294 let cell = (col.render)(&test_data);
295 drop(cell);
297 }
298
299 #[test]
300 fn test_builder_pattern() {
301 let table = TableData::<TestData>::new()
302 .title("My Table")
303 .borders(false)
304 .highlight(false)
305 .highlight_symbol("->")
306 .row(TestData {
307 name: "First".to_string(),
308 value: 1,
309 status: "OK".to_string(),
310 })
311 .row(TestData {
312 name: "Second".to_string(),
313 value: 2,
314 status: "OK".to_string(),
315 });
316
317 assert_eq!(table.title, Some("My Table".to_string()));
318 assert!(!table.borders);
319 assert!(!table.highlight);
320 assert_eq!(table.highlight_symbol, Some("->".to_string()));
321 assert_eq!(table.rows.len(), 2);
322 }
323
324 #[test]
325 fn test_default_values() {
326 let table = TableData::<TestData>::default();
327
328 assert_eq!(table.rows.len(), 0);
329 assert_eq!(table.columns.len(), 0);
330 assert_eq!(table.title, None);
331 assert!(table.borders);
332 assert!(table.highlight);
333 assert_eq!(table.highlight_symbol, Some(">> ".to_string()));
334 }
335
336 #[test]
337 fn test_render_table() {
338 let backend = TestBackend::new(80, 20);
340 let mut terminal = Terminal::new(backend).unwrap();
341
342 let table = TableData::new()
344 .title("Test Table")
345 .column(ColumnDef::new(
346 "Name",
347 Constraint::Percentage(50),
348 |d: &TestData| Cell::from(d.name.clone()),
349 ))
350 .column(ColumnDef::new(
351 "Value",
352 Constraint::Percentage(50),
353 |d: &TestData| {
354 let color = if d.value > 100 {
355 Color::Green
356 } else {
357 Color::Red
358 };
359 Cell::from(d.value.to_string()).style(Style::default().fg(color))
360 },
361 ))
362 .rows(vec![
363 TestData {
364 name: "High".to_string(),
365 value: 200,
366 status: "Active".to_string(),
367 },
368 TestData {
369 name: "Low".to_string(),
370 value: 50,
371 status: "Inactive".to_string(),
372 },
373 ]);
374
375 terminal
377 .draw(|f| {
378 let area = f.area();
379 render_table(f, area, &table);
380 })
381 .unwrap();
382
383 let buffer = terminal.backend().buffer();
385 assert!(buffer.area.width > 0);
386 assert!(buffer.area.height > 0);
387 }
388
389 #[test]
390 fn test_render_table_without_borders() {
391 let backend = TestBackend::new(80, 20);
392 let mut terminal = Terminal::new(backend).unwrap();
393
394 let table = TableData::new()
395 .borders(false)
396 .column(ColumnDef::new(
397 "Test",
398 Constraint::Percentage(100),
399 |d: &TestData| Cell::from(d.name.clone()),
400 ))
401 .row(TestData {
402 name: "Item".to_string(),
403 value: 0,
404 status: "OK".to_string(),
405 });
406
407 terminal
408 .draw(|f| {
409 let area = f.area();
410 render_table(f, area, &table);
411 })
412 .unwrap();
413
414 let buffer = terminal.backend().buffer();
416 assert!(buffer.area.width > 0);
417 }
418
419 #[test]
420 fn test_multiple_column_widths() {
421 let table = TableData::<TestData>::new()
422 .column(ColumnDef::new(
423 "Fixed",
424 Constraint::Length(10),
425 |d: &TestData| Cell::from(d.name.clone()),
426 ))
427 .column(ColumnDef::new(
428 "Percentage",
429 Constraint::Percentage(50),
430 |d: &TestData| Cell::from(d.value.to_string()),
431 ))
432 .column(ColumnDef::new("Min", Constraint::Min(5), |d: &TestData| {
433 Cell::from(d.status.clone())
434 }))
435 .column(ColumnDef::new(
436 "Max",
437 Constraint::Max(20),
438 |_d: &TestData| Cell::from("X"),
439 ));
440
441 assert_eq!(table.columns.len(), 4);
442 assert_eq!(table.columns[0].width, Constraint::Length(10));
443 assert_eq!(table.columns[1].width, Constraint::Percentage(50));
444 assert_eq!(table.columns[2].width, Constraint::Min(5));
445 assert_eq!(table.columns[3].width, Constraint::Max(20));
446 }
447}