fusabi_tui/widgets/
table.rs

1//! Generic table widget for rendering tabular data
2//!
3//! This module provides a flexible table widget that can render any type of data
4//! with custom column definitions and styling. It's extracted from Hibana's table
5//! rendering logic and made generic for reuse across different data types.
6//!
7//! ## Example
8//!
9//! ```rust
10//! use fusabi_tui::widgets::table::{TableData, ColumnDef};
11//! use ratatui::widgets::Cell;
12//! use ratatui::layout::Constraint;
13//! use ratatui::style::{Color, Style};
14//!
15//! #[derive(Clone)]
16//! struct User {
17//!     name: String,
18//!     age: u32,
19//!     status: String,
20//! }
21//!
22//! let users = vec![
23//!     User { name: "Alice".to_string(), age: 30, status: "Active".to_string() },
24//!     User { name: "Bob".to_string(), age: 25, status: "Inactive".to_string() },
25//! ];
26//!
27//! let table = TableData::new()
28//!     .title("Users")
29//!     .column(ColumnDef {
30//!         header: "Name".to_string(),
31//!         width: Constraint::Percentage(40),
32//!         render: Box::new(|user: &User| Cell::from(user.name.clone())),
33//!     })
34//!     .column(ColumnDef {
35//!         header: "Age".to_string(),
36//!         width: Constraint::Percentage(30),
37//!         render: Box::new(|user: &User| Cell::from(user.age.to_string())),
38//!     })
39//!     .column(ColumnDef {
40//!         header: "Status".to_string(),
41//!         width: Constraint::Percentage(30),
42//!         render: Box::new(|user: &User| {
43//!             let color = if user.status == "Active" { Color::Green } else { Color::Red };
44//!             Cell::from(user.status.clone()).style(Style::default().fg(color))
45//!         }),
46//!     })
47//!     .rows(users);
48//! ```
49
50use ratatui::{
51    layout::{Constraint, Rect},
52    style::{Color, Modifier, Style},
53    widgets::{Block, Borders, Cell, Row, Table},
54    Frame,
55};
56
57/// Column definition for a table
58///
59/// Defines how a column should be rendered, including its header, width constraint,
60/// and a rendering function that converts the row data into a Cell.
61pub struct ColumnDef<T> {
62    /// Column header text
63    pub header: String,
64    /// Width constraint for this column
65    pub width: Constraint,
66    /// Function to render a cell from row data
67    pub render: Box<dyn Fn(&T) -> Cell<'static>>,
68}
69
70impl<T> ColumnDef<T> {
71    /// Create a new column definition
72    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
85/// Generic table data structure
86///
87/// Holds the data and configuration for rendering a table widget.
88/// Use the builder pattern methods to construct a table incrementally.
89pub struct TableData<T> {
90    /// Rows of data to display
91    pub rows: Vec<T>,
92    /// Column definitions
93    pub columns: Vec<ColumnDef<T>>,
94    /// Optional table title
95    pub title: Option<String>,
96    /// Whether to show borders
97    pub borders: bool,
98    /// Highlight symbol for selection
99    pub highlight_symbol: Option<String>,
100    /// Whether to highlight on hover/selection
101    pub highlight: bool,
102}
103
104impl<T> TableData<T> {
105    /// Create a new empty table
106    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    /// Set the table title
118    pub fn title(mut self, title: impl Into<String>) -> Self {
119        self.title = Some(title.into());
120        self
121    }
122
123    /// Add a column definition
124    pub fn column(mut self, def: ColumnDef<T>) -> Self {
125        self.columns.push(def);
126        self
127    }
128
129    /// Set multiple rows of data
130    pub fn rows(mut self, rows: Vec<T>) -> Self {
131        self.rows = rows;
132        self
133    }
134
135    /// Set whether to show borders (default: true)
136    pub fn borders(mut self, borders: bool) -> Self {
137        self.borders = borders;
138        self
139    }
140
141    /// Set the highlight symbol (default: ">> ")
142    pub fn highlight_symbol(mut self, symbol: impl Into<String>) -> Self {
143        self.highlight_symbol = Some(symbol.into());
144        self
145    }
146
147    /// Set whether to enable highlighting (default: true)
148    pub fn highlight(mut self, highlight: bool) -> Self {
149        self.highlight = highlight;
150        self
151    }
152
153    /// Add a single row
154    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
166/// Render a generic table to a frame
167///
168/// This function extracts the rendering logic from Hibana's `draw_components_table`
169/// and makes it generic over any data type T. It:
170///
171/// 1. Creates a header row with yellow styling
172/// 2. Applies column render functions to each row
173/// 3. Constructs a Ratatui Table widget with borders
174/// 4. Renders to the provided frame area
175///
176/// ## Arguments
177///
178/// * `frame` - The Ratatui frame to render to
179/// * `area` - The rectangular area to render the table in
180/// * `data` - The table data containing rows and column definitions
181pub fn render_table<T: Clone>(frame: &mut Frame, area: Rect, data: &TableData<T>) {
182    // Build header cells with yellow styling (from Hibana's pattern)
183    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    // Build data rows by applying each column's render function
192    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    // Collect column widths
202    let widths: Vec<Constraint> = data.columns.iter().map(|col| col.width).collect();
203
204    // Build the table widget
205    let mut table = Table::new(rows, widths).header(header);
206
207    // Add block with borders and optional title
208    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    // Add highlight styling if enabled
217    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        // Test render function
289        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        // Cell doesn't expose content directly, but we can verify it doesn't panic
296        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        // Create a test backend
339        let backend = TestBackend::new(80, 20);
340        let mut terminal = Terminal::new(backend).unwrap();
341
342        // Create test data
343        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        // Render the table
376        terminal
377            .draw(|f| {
378                let area = f.area();
379                render_table(f, area, &table);
380            })
381            .unwrap();
382
383        // Verify rendering didn't panic and produced output
384        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        // Should render without panicking
415        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}