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