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;
7
8/// Column definition for a [`Table`].
9#[derive(Default)]
10pub struct TableColumn {
11    /// Header text displayed in the column header row.
12    pub title: String,
13    /// Fixed width in character cells.
14    pub width: u16,
15    /// Text alignment within the column. Default: `TextAlign::Left`.
16    pub align: crate::style::TextAlign,
17}
18
19/// A data table widget with column headers, row selection, and keyboard navigation.
20///
21/// Rows are provided as `Vec<Vec<String>>` — each inner `Vec` must match the
22/// number of columns. Use [`Table::columns`] and [`Table::rows`] for
23/// builder-style construction.
24///
25/// The table handles `Up`/`Down` keys internally for row selection. It is not
26/// focusable in the Tab chain — keyboard events reach it through a parent
27/// container's forwarding.
28pub struct Table {
29    columns: Vec<TableColumn>,
30    rows: Vec<Vec<String>>,
31    selected: Option<usize>,
32    scroll_offset: usize,
33    /// Current layout rect — updated on each layout pass.
34    rect: Rect,
35    style: Style,
36    header_style: Style,
37    select_style: Style,
38}
39
40impl Table {
41    /// Creates an empty table.
42    pub fn new() -> Self {
43        Self {
44            columns: Vec::new(),
45            rows: Vec::new(),
46            selected: None,
47            scroll_offset: 0,
48            rect: Rect::default(),
49            style: Style::default(),
50            header_style: Style::default().bold(),
51            select_style: Style::default(),
52        }
53    }
54
55    /// Sets the column definitions.
56    pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
57        self.columns = columns;
58        self
59    }
60
61    /// Sets the row data.
62    pub fn rows(mut self, rows: Vec<Vec<String>>) -> Self {
63        self.rows = rows;
64        self
65    }
66
67    /// Sets the default cell style.
68    pub fn style(mut self, style: Style) -> Self {
69        self.style = style;
70        self
71    }
72
73    /// Sets the header row style.
74    pub fn header_style(mut self, style: Style) -> Self {
75        self.header_style = style;
76        self
77    }
78
79    /// Sets the selected row style.
80    pub fn select_style(mut self, style: Style) -> Self {
81        self.select_style = style;
82        self
83    }
84
85    /// Returns the currently selected row index.
86    pub fn selected(&self) -> Option<usize> {
87        self.selected
88    }
89
90    /// Sets the selected row programmatically.
91    pub fn set_selected(&mut self, index: Option<usize>, cx: &mut EventCx) {
92        self.selected = index;
93        cx.invalidate_paint();
94    }
95
96    /// Returns the number of rows.
97    pub fn row_count(&self) -> usize {
98        self.rows.len()
99    }
100
101    /// Sorts rows by the given column index, in ascending order.
102    /// Invalid column index is a no-op.
103    pub fn sort_by_column(&mut self, col: usize, cx: &mut EventCx) {
104        if col < self.columns.len() {
105            self.rows.sort_by(|a, b| {
106                let ca = a.get(col).map(|s| s.as_str()).unwrap_or("");
107                let cb = b.get(col).map(|s| s.as_str()).unwrap_or("");
108                ca.cmp(cb)
109            });
110            cx.invalidate_paint();
111        }
112    }
113
114    /// Sets the width of a specific column. Minimum width is 3.
115    /// Invalid column index is a no-op.
116    pub fn set_column_width(&mut self, col: usize, width: u16, cx: &mut EventCx) {
117        if col < self.columns.len() {
118            self.columns[col].width = width.max(3);
119            cx.invalidate_layout();
120        }
121    }
122
123    /// Adjusts the width of a column by `delta` (positive = wider).
124    /// Invalid column index is a no-op.
125    pub fn adjust_column_width(&mut self, col: usize, delta: i16, cx: &mut EventCx) {
126        if col < self.columns.len() {
127            let new = (self.columns[col].width as i16 + delta).max(3) as u16;
128            self.columns[col].width = new;
129            cx.invalidate_layout();
130        }
131    }
132
133    /// Returns the number of visible rows that fit in `height`.
134    fn visible_rows(&self, height: u16) -> usize {
135        let usable = height.saturating_sub(2); // header + separator
136        usable as usize
137    }
138}
139
140impl Component for Table {
141    fn render(&self, cx: &mut RenderCx) {
142        let columns = &self.columns;
143        if columns.is_empty() {
144            return;
145        }
146
147        let col_count = columns.len();
148        let visible = self.visible_rows(cx.rect.height);
149        let start_row = self.scroll_offset;
150        let end_row = (start_row + visible).min(self.rows.len());
151
152        // --- header row ---
153        cx.set_style(self.header_style.clone());
154        for (i, col) in columns.iter().enumerate() {
155            let text = truncate_to_width(&col.title, col.width, col.align);
156            cx.text(&text);
157            if i < col_count - 1 {
158                cx.text("│");
159            }
160        }
161        cx.line("");
162
163        // --- separator ---
164        cx.set_style(self.style.clone());
165        for (i, col) in columns.iter().enumerate() {
166            let sep = "─".repeat(col.width as usize);
167            cx.text(&sep);
168            if i < col_count - 1 {
169                cx.text("┼");
170            }
171        }
172        cx.line("");
173
174        // --- data rows ---
175        for row_idx in start_row..end_row {
176            let is_selected = self.selected == Some(row_idx);
177            if is_selected {
178                cx.set_style(self.select_style.clone());
179            } else {
180                cx.set_style(self.style.clone());
181            }
182
183            let row = &self.rows[row_idx];
184            for (i, col) in columns.iter().enumerate() {
185                let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
186                let text = truncate_to_width(cell_text, col.width, col.align);
187                cx.text(&text);
188                if i < col_count - 1 {
189                    cx.text("│");
190                }
191            }
192            cx.line("");
193        }
194    }
195
196    fn measure(&self, _constraint: Constraint, _cx: &mut MeasureCx) -> Size {
197        let col_count = self.columns.len() as u16;
198        if col_count == 0 {
199            return Size { width: 0, height: 0 };
200        }
201
202        let width: u16 = self.columns.iter().map(|c| c.width).sum::<u16>()
203            + col_count.saturating_sub(1); // separators
204
205        let visible = self.rows.len().min(u16::MAX as usize) as u16;
206        let height = 2u16.saturating_add(visible); // header + separator + rows
207
208        Size { width, height }
209    }
210
211    fn event(&mut self, event: &Event, cx: &mut EventCx) {
212        if matches!(event, Event::Focus | Event::Blur | Event::Tick) {
213            return;
214        }
215
216        if self.rows.is_empty() {
217            return;
218        }
219
220        if let Event::Key(key_event) = event {
221            match &key_event.key {
222                crate::event::Key::Up => {
223                    let new_idx = match self.selected {
224                        Some(i) if i > 0 => i - 1,
225                        _ => 0,
226                    };
227                    self.selected = Some(new_idx);
228                    self.scroll_to_visible(new_idx);
229                    cx.invalidate_paint();
230                }
231                crate::event::Key::Down => {
232                    let max = self.rows.len() - 1;
233                    let new_idx = match self.selected {
234                        Some(i) if i < max => i + 1,
235                        Some(i) => i,
236                        None => 0,
237                    };
238                    self.selected = Some(new_idx);
239                    self.scroll_to_visible(new_idx);
240                    cx.invalidate_paint();
241                }
242                _ => {}
243            }
244        }
245    }
246
247    fn layout(&mut self, rect: Rect, _cx: &mut LayoutCx) {
248        self.rect = rect;
249    }
250
251    fn focusable(&self) -> bool {
252        false
253    }
254
255    fn style(&self) -> Style {
256        self.style.clone()
257    }
258}
259
260impl Table {
261    fn scroll_to_visible(&mut self, idx: usize) {
262        let visible = self.visible_rows(self.rect.height);
263        if visible == 0 {
264            return;
265        }
266        if idx < self.scroll_offset {
267            self.scroll_offset = idx;
268        } else if idx >= self.scroll_offset + visible {
269            self.scroll_offset = idx.saturating_sub(visible.saturating_sub(1));
270        }
271    }
272}
273
274/// Truncates and aligns `text` to fit within `max_width` character cells.
275///
276/// Takes Unicode width into account. Pads with spaces according to alignment.
277fn truncate_to_width(text: &str, max_width: u16, align: crate::style::TextAlign) -> String {
278    let mut result = String::new();
279    let mut used: u16 = 0;
280    for ch in text.chars() {
281        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
282        if used + w > max_width {
283            break;
284        }
285        used += w;
286        result.push(ch);
287    }
288    let padding = max_width.saturating_sub(used);
289    match align {
290        crate::style::TextAlign::Left => {
291            while used < max_width { result.push(' '); used += 1; }
292        }
293        crate::style::TextAlign::Center => {
294            let left = padding / 2;
295            let right = padding - left;
296            let mut s = String::new();
297            for _ in 0..left { s.push(' '); }
298            s.push_str(&result);
299            for _ in 0..right { s.push(' '); }
300            result = s;
301        }
302        crate::style::TextAlign::Right => {
303            let mut s = String::new();
304            for _ in 0..padding { s.push(' '); }
305            s.push_str(&result);
306            result = s;
307        }
308    }
309    result
310}