Skip to main content

lv_tui/widgets/
table.rs

1use crate::component::{Component, EventCx, LayoutCx, MeasureCx};
2use crate::event::Event;
3use crate::geom::{Rect, Size};
4use crate::layout::Constraint;
5use crate::render::RenderCx;
6use crate::style::Style;
7use crate::text::Text;
8
9/// Column width constraint.
10#[derive(Debug, Clone, Copy)]
11pub enum ColumnWidth {
12    /// Exact width in cells.
13    Fixed(u16),
14    /// Fill remaining space proportionally (weight).
15    Flex(u16),
16}
17
18/// Column definition for a [`Table`].
19pub struct TableColumn {
20    /// Header text displayed in the column header row.
21    pub title: Text,
22    /// Width constraint for this column.
23    pub width: ColumnWidth,
24    /// Text alignment within the column. Default: `TextAlign::Left`.
25    pub align: crate::style::TextAlign,
26}
27
28/// A single cell in a table row.
29pub struct TableCell {
30    /// Cell content.
31    pub content: Text,
32    /// Optional per-cell style override.
33    pub style: Option<Style>,
34}
35
36impl From<&str> for TableCell {
37    fn from(s: &str) -> Self { Self { content: Text::from(s), style: None } }
38}
39
40impl From<String> for TableCell {
41    fn from(s: String) -> Self { Self { content: Text::from(s), style: None } }
42}
43
44impl From<Text> for TableCell {
45    fn from(content: Text) -> Self { Self { content, style: None } }
46}
47
48/// A row in a table, with optional per-row style and height.
49pub struct TableRow {
50    pub cells: Vec<TableCell>,
51    pub height: u16,
52    pub style: Option<Style>,
53}
54
55impl TableRow {
56    pub fn new(cells: Vec<impl Into<TableCell>>) -> Self {
57        Self { cells: cells.into_iter().map(|c| c.into()).collect(), height: 1, style: None }
58    }
59
60    pub fn height(mut self, height: u16) -> Self { self.height = height.max(1); self }
61    pub fn style(mut self, style: Style) -> Self { self.style = Some(style); self }
62}
63
64/// A data table widget with column headers, row selection, and keyboard navigation.
65pub struct Table {
66    columns: Vec<TableColumn>,
67    rows: Vec<TableRow>,
68    footer: Option<TableRow>,
69    selected: usize,
70    scroll_offset: usize,
71    rect: Rect,
72    style: Style,
73    header_style: Style,
74    select_style: Style,
75}
76
77impl Table {
78    /// Creates an empty table.
79    pub fn new() -> Self {
80        Self {
81            columns: Vec::new(),
82            rows: Vec::new(),
83            selected: 0,
84            scroll_offset: 0,
85            rect: Rect::default(),
86            style: Style::default(),
87            header_style: Style::default().bold(),
88            select_style: Style::default(),
89            footer: None,
90        }
91    }
92
93    /// Sets the column definitions.
94    pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
95        self.columns = columns;
96        self
97    }
98
99    /// Sets the row data.
100    pub fn rows(mut self, rows: Vec<TableRow>) -> Self {
101        self.rows = rows;
102        self
103    }
104
105    /// Convenience: construct rows from Vec<Vec<impl Into<TableCell>>>.
106    pub fn rows_simple(mut self, rows: Vec<Vec<impl Into<TableCell>>>) -> Self {
107        self.rows = rows.into_iter().map(|r| TableRow::new(r)).collect();
108        self
109    }
110
111    /// Sets a footer row displayed below all data rows.
112    pub fn footer(mut self, row: TableRow) -> Self {
113        self.footer = Some(row);
114        self
115    }
116
117    /// Sets the default cell style.
118    pub fn style(mut self, style: Style) -> Self {
119        self.style = style;
120        self
121    }
122
123    /// Sets the header row style.
124    pub fn header_style(mut self, style: Style) -> Self {
125        self.header_style = style;
126        self
127    }
128
129    /// Sets the selected row style.
130    pub fn select_style(mut self, style: Style) -> Self {
131        self.select_style = style;
132        self
133    }
134
135    /// Returns the currently selected row index.
136    pub fn selected(&self) -> usize { self.selected }
137
138    /// Sets the selected row programmatically.
139    pub fn set_selected(&mut self, index: usize, cx: &mut EventCx) {
140        if index < self.rows.len() {
141            self.selected = index;
142            cx.invalidate_paint();
143        }
144    }
145
146    /// Returns the number of rows.
147    pub fn row_count(&self) -> usize {
148        self.rows.len()
149    }
150
151    /// Sorts rows by the given column index, in ascending order.
152    /// Invalid column index is a no-op.
153    pub fn sort_by_column(&mut self, col: usize, cx: &mut EventCx) {
154        if col < self.columns.len() {
155            self.rows.sort_by(|a, b| {
156                let ca = a.cells.get(col).map(|c| c.content.first_text()).unwrap_or("");
157                let cb = b.cells.get(col).map(|c| c.content.first_text()).unwrap_or("");
158                ca.cmp(cb)
159            });
160            cx.invalidate_paint();
161        }
162    }
163
164    /// Sets the width of a specific column. Minimum width is 3.
165    pub fn set_column_width(&mut self, col: usize, width: u16, cx: &mut EventCx) {
166        if col < self.columns.len() {
167            self.columns[col].width = ColumnWidth::Fixed(width.max(3));
168            cx.invalidate_layout();
169        }
170    }
171
172    /// Adjusts the width of a column by `delta` (positive = wider).
173    pub fn adjust_column_width(&mut self, col: usize, delta: i16, cx: &mut EventCx) {
174        if col < self.columns.len() {
175            if let ColumnWidth::Fixed(w) = self.columns[col].width {
176                self.columns[col].width = ColumnWidth::Fixed((w as i16 + delta).max(3) as u16);
177                cx.invalidate_layout();
178            }
179        }
180    }
181
182    /// Resolve column widths from constraints given the available total width.
183    fn resolved_widths(&self, available: u16) -> Vec<u16> {
184        let col_count = self.columns.len();
185        if col_count == 0 { return Vec::new(); }
186
187        let sep_w = (col_count.saturating_sub(1)) as u16;
188        let usable = available.saturating_sub(sep_w);
189
190        let mut widths = vec![0u16; col_count];
191        let mut flex_total: u16 = 0;
192
193        // First pass: assign fixed widths
194        for (i, col) in self.columns.iter().enumerate() {
195            if let ColumnWidth::Fixed(w) = col.width {
196                widths[i] = w;
197            } else if let ColumnWidth::Flex(w) = col.width {
198                flex_total += w;
199            }
200        }
201
202        let fixed_sum: u16 = widths.iter().sum();
203        let flex_space = usable.saturating_sub(fixed_sum);
204
205        // Second pass: assign flex widths
206        if flex_total > 0 {
207            let per_flex = flex_space / flex_total;
208            let mut allocated: u16 = 0;
209            for (i, col) in self.columns.iter().enumerate() {
210                if let ColumnWidth::Flex(w) = col.width {
211                    widths[i] = w.saturating_mul(per_flex).max(3);
212                    allocated += widths[i];
213                }
214            }
215            // Distribute remainder to last flex column
216            if allocated < flex_space {
217                for i in (0..col_count).rev() {
218                    if matches!(self.columns[i].width, ColumnWidth::Flex(_)) {
219                        widths[i] += flex_space - allocated;
220                        break;
221                    }
222                }
223            }
224        }
225
226        widths
227    }
228
229    /// Returns the number of visible rows that fit in `height`.
230    fn visible_rows(&self, height: u16) -> usize {
231        let usable = height.saturating_sub(2); // header + separator
232        usable as usize
233    }
234}
235
236impl Component for Table {
237    fn render(&self, cx: &mut RenderCx) {
238        let columns = &self.columns;
239        if columns.is_empty() { return; }
240
241        let col_count = columns.len();
242        let widths = self.resolved_widths(cx.rect.width);
243        let visible = self.visible_rows(cx.rect.height);
244        let start_row = self.scroll_offset;
245        let end_row = (start_row + visible).min(self.rows.len());
246
247        // --- header row ---
248        cx.set_style(self.header_style.clone());
249        for (i, col) in columns.iter().enumerate() {
250            let text = truncate_to_width(col.title.first_text(), widths[i], col.align);
251            cx.text(&text);
252            if i < col_count - 1 {
253                cx.text("│");
254            }
255        }
256        cx.line("");
257
258        // --- separator ---
259        cx.set_style(self.style.clone());
260        for (i, _col) in columns.iter().enumerate() {
261            cx.text(&"─".repeat(widths[i] as usize));
262            if i < col_count - 1 { cx.text("┼"); }
263        }
264        cx.line("");
265
266        // --- data rows ---
267        for row_idx in start_row..end_row {
268            let is_selected = self.selected == row_idx;
269
270            let row = &self.rows[row_idx];
271            let row_style = row.style.clone().unwrap_or(self.style.clone());
272            for (i, _col) in columns.iter().enumerate() {
273                let cell_text = row.cells.get(i).map(|c| c.content.first_text()).unwrap_or("");
274                let cell_style = row.cells.get(i).and_then(|c| c.style.clone()).unwrap_or(row_style.clone());
275                // Layer: base → row/cell style → selection highlight
276                let final_style = if is_selected {
277                    crate::style_parser::merge_styles(cell_style, &self.select_style)
278                } else {
279                    cell_style
280                };
281                cx.set_style(final_style);
282                let text = truncate_to_width(cell_text, widths[i], columns[i].align);
283                cx.text(&text);
284                if i < col_count - 1 {
285                    cx.text("│");
286                }
287            }
288            cx.line("");
289        }
290
291        // --- footer ---
292        if let Some(footer_row) = &self.footer {
293            cx.set_style(self.style.clone());
294            for (i, _col) in columns.iter().enumerate() {
295                cx.text(&"─".repeat(widths[i] as usize));
296                if i < col_count - 1 { cx.text("┼"); }
297            }
298            cx.line("");
299
300            for (i, col) in columns.iter().enumerate() {
301                let cell_text = footer_row.cells.get(i).map(|c| c.content.first_text()).unwrap_or("");
302                let text = truncate_to_width(cell_text, widths[i], col.align);
303                cx.text(&text);
304                if i < col_count - 1 { cx.text("│"); }
305            }
306            cx.line("");
307        }
308    }
309
310    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
311        if self.columns.is_empty() { return Size { width: 0, height: 0 }; }
312        let widths = self.resolved_widths(80);
313        let width: u16 = widths.iter().sum::<u16>()
314            + (self.columns.len() as u16).saturating_sub(1);
315
316        let footer_height = if self.footer.is_some() { 2 } else { 0 }; // separator + footer
317        let visible = self.rows.len().min(u16::MAX as usize) as u16;
318        let height = 2u16.saturating_add(visible).saturating_add(footer_height);
319
320        Size { width, height }
321    }
322
323    fn event(&mut self, event: &Event, cx: &mut EventCx) {
324        if matches!(event, Event::Focus | Event::Blur | Event::Tick) { return; }
325        if self.rows.is_empty() { return; }
326
327        if let Event::Key(key_event) = event {
328            match &key_event.key {
329                crate::event::Key::Up => {
330                    if self.selected > 0 {
331                        self.selected -= 1;
332                        self.scroll_to_visible(self.selected);
333                        cx.invalidate_paint();
334                    }
335                }
336                crate::event::Key::Down => {
337                    if self.selected + 1 < self.rows.len() {
338                        self.selected += 1;
339                        self.scroll_to_visible(self.selected);
340                        cx.invalidate_paint();
341                    }
342                }
343                _ => {}
344            }
345        }
346    }
347
348    fn layout(&mut self, rect: Rect, _cx: &mut LayoutCx) {
349        self.rect = rect;
350    }
351
352    fn focusable(&self) -> bool {
353        false
354    }
355
356    fn style(&self) -> Style {
357        self.style.clone()
358    }
359}
360
361impl Table {
362    fn scroll_to_visible(&mut self, idx: usize) {
363        let visible = self.visible_rows(self.rect.height);
364        if visible == 0 {
365            return;
366        }
367        if idx < self.scroll_offset {
368            self.scroll_offset = idx;
369        } else if idx >= self.scroll_offset + visible {
370            self.scroll_offset = idx.saturating_sub(visible.saturating_sub(1));
371        }
372    }
373}
374
375/// Truncates and aligns `text` to fit within `max_width` character cells.
376///
377/// Takes Unicode width into account. Pads with spaces according to alignment.
378fn truncate_to_width(text: &str, max_width: u16, align: crate::style::TextAlign) -> String {
379    let mut result = String::new();
380    let mut used: u16 = 0;
381    for ch in text.chars() {
382        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
383        if used + w > max_width {
384            break;
385        }
386        used += w;
387        result.push(ch);
388    }
389    let padding = max_width.saturating_sub(used);
390    match align {
391        crate::style::TextAlign::Left => {
392            while used < max_width { result.push(' '); used += 1; }
393        }
394        crate::style::TextAlign::Center => {
395            let left = padding / 2;
396            let right = padding - left;
397            let mut s = String::new();
398            for _ in 0..left { s.push(' '); }
399            s.push_str(&result);
400            for _ in 0..right { s.push(' '); }
401            result = s;
402        }
403        crate::style::TextAlign::Right => {
404            let mut s = String::new();
405            for _ in 0..padding { s.push(' '); }
406            s.push_str(&result);
407            result = s;
408        }
409    }
410    result
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::style::{Color, TextAlign};
417    use crate::testbuffer::TestBuffer;
418
419    #[test]
420    fn test_table_headers() {
421        let mut tb = TestBuffer::new(30, 3);
422        let cols = vec![TableColumn { title: Text::from("Name"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }];
423        let rows = vec![TableRow::new(vec![TableCell::from("val")])];
424        tb.render(&Table::new().columns(cols).rows(rows));
425        assert!(tb.buffer.cells.iter().any(|c| c.symbol == "N"));
426    }
427
428    #[test]
429    fn test_column_width_flex() {
430        let table = Table::new().columns(vec![
431            TableColumn { title: Text::from("A"), width: ColumnWidth::Flex(1), align: TextAlign::Left },
432            TableColumn { title: Text::from("B"), width: ColumnWidth::Flex(1), align: TextAlign::Left },
433        ]);
434        let widths = table.resolved_widths(25); // 25 - 1 sep = 24, split 12/12
435        assert_eq!(widths.len(), 2);
436        assert!(widths[0] >= 10);
437        assert!(widths[1] >= 10);
438    }
439
440    #[test]
441    fn test_cell_style() {
442        let mut tb = TestBuffer::new(40, 3);
443        let table = Table::new()
444            .columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
445            .rows(vec![TableRow::new(vec![TableCell { content: Text::from("hi"), style: Some(Style::default().fg(Color::Cyan)) }])]);
446        tb.render(&table);
447        assert_eq!(tb.cell_fg(0, 2), Some(Color::Cyan));
448    }
449
450    #[test]
451    fn test_merge_style_preserves_select_bg() {
452        let base = Style::default();
453        let sel = Style::default().bg(Color::White).fg(Color::Black);
454        let merged = crate::style_parser::merge_styles(base, &sel);
455        assert_eq!(merged.bg, Some(Color::White));
456        assert_eq!(merged.fg, Some(Color::Black));
457    }
458
459    #[test]
460    fn test_render_with_style() {
461        use crate::render::RenderCx;
462        let mut buf = crate::buffer::Buffer::new(crate::geom::Size { width: 10, height: 1 });
463        let rect = crate::geom::Rect { x: 0, y: 0, width: 10, height: 1 };
464        let mut cx = RenderCx::new(rect, &mut buf, Style::default());
465        cx.set_style(Style::default().bg(Color::White).fg(Color::Black));
466        cx.text("test");
467        assert_eq!(buf.cells[0].style.bg, Some(Color::White), "render bg");
468    }
469
470    #[test]
471    fn test_selection_highlight() {
472        let mut tb = TestBuffer::new(40, 5);
473        let mut table = Table::new()
474            .columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
475            .rows(vec![TableRow::new(vec!["row0"]), TableRow::new(vec!["row1"])])
476            .select_style(Style::default().bg(Color::White).fg(Color::Black));
477        table.selected = 0;
478        tb.render(&table);
479        // Data cell (0,2) symbol and bg
480        let cell = &tb.buffer.cells[2 * 40 + 0];
481        eprintln!("cell(0,2): sym={:?} fg={:?} bg={:?}", cell.symbol, cell.style.fg, cell.style.bg);
482        assert_eq!(tb.cell_bg(0, 2), Some(Color::White), "selected row should have white bg, got {:?}", tb.cell_bg(0, 2));
483    }
484
485    #[test]
486    fn test_footer_renders() {
487        let mut tb = TestBuffer::new(40, 5);
488        let table = Table::new()
489            .columns(vec![TableColumn { title: Text::from("X"), width: ColumnWidth::Fixed(10), align: TextAlign::Left }])
490            .rows(vec![TableRow::new(vec!["data"])])
491            .footer(TableRow::new(vec![TableCell { content: Text::from("sum"), style: Some(Style::default().bold()) }]));
492        tb.render(&table);
493        // Footer text should appear somewhere in the buffer
494        assert!(tb.buffer.cells.iter().any(|c| c.symbol == "s"));
495    }
496}