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#[derive(Debug, Clone, Copy)]
11pub enum ColumnWidth {
12 Fixed(u16),
14 Flex(u16),
16}
17
18pub struct TableColumn {
20 pub title: Text,
22 pub width: ColumnWidth,
24 pub align: crate::style::TextAlign,
26}
27
28pub struct TableCell {
30 pub content: Text,
32 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
48pub 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
64pub 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 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 pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
95 self.columns = columns;
96 self
97 }
98
99 pub fn rows(mut self, rows: Vec<TableRow>) -> Self {
101 self.rows = rows;
102 self
103 }
104
105 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 pub fn footer(mut self, row: TableRow) -> Self {
113 self.footer = Some(row);
114 self
115 }
116
117 pub fn style(mut self, style: Style) -> Self {
119 self.style = style;
120 self
121 }
122
123 pub fn header_style(mut self, style: Style) -> Self {
125 self.header_style = style;
126 self
127 }
128
129 pub fn select_style(mut self, style: Style) -> Self {
131 self.select_style = style;
132 self
133 }
134
135 pub fn selected(&self) -> usize { self.selected }
137
138 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 pub fn row_count(&self) -> usize {
148 self.rows.len()
149 }
150
151 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 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 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 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 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 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 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 fn visible_rows(&self, height: u16) -> usize {
231 let usable = height.saturating_sub(2); 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 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 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 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 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 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 }; 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
375fn 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); 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 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 assert!(tb.buffer.cells.iter().any(|c| c.symbol == "s"));
495 }
496}