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, PartialEq, Eq)]
11pub enum CursorType {
12 Row,
14 Cell,
16}
17
18#[derive(Debug, Clone, Copy)]
20pub enum ColumnWidth {
21 Fixed(u16),
23 Flex(u16),
25}
26
27pub struct TableColumn {
29 pub title: Text,
31 pub width: ColumnWidth,
33 pub align: crate::style::TextAlign,
35}
36
37pub struct TableCell {
39 pub content: Text,
41 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
57pub 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
73pub 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_type: CursorType,
86 selected_column: usize,
88 fixed_rows: usize,
90 column_highlight_style: Style,
92 cell_highlight_style: Style,
94}
95
96impl Table {
97 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 pub fn cursor_type(mut self, ct: CursorType) -> Self {
119 self.cursor_type = ct;
120 self
121 }
122
123 pub fn fixed_rows(mut self, n: usize) -> Self {
125 self.fixed_rows = n;
126 self
127 }
128
129 pub fn column_highlight_style(mut self, style: Style) -> Self {
131 self.column_highlight_style = style;
132 self
133 }
134
135 pub fn cell_highlight_style(mut self, style: Style) -> Self {
137 self.cell_highlight_style = style;
138 self
139 }
140
141 pub fn selected_column(&self) -> usize { self.selected_column }
143
144 pub fn columns(mut self, columns: Vec<TableColumn>) -> Self {
146 self.columns = columns;
147 self
148 }
149
150 pub fn rows(mut self, rows: Vec<TableRow>) -> Self {
152 self.rows = rows;
153 self
154 }
155
156 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 pub fn footer(mut self, row: TableRow) -> Self {
164 self.footer = Some(row);
165 self
166 }
167
168 pub fn style(mut self, style: Style) -> Self {
170 self.style = style;
171 self
172 }
173
174 pub fn header_style(mut self, style: Style) -> Self {
176 self.header_style = style;
177 self
178 }
179
180 pub fn select_style(mut self, style: Style) -> Self {
182 self.select_style = style;
183 self
184 }
185
186 pub fn selected(&self) -> usize { self.selected }
188
189 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 pub fn row_count(&self) -> usize {
199 self.rows.len()
200 }
201
202 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 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 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 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 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 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 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 fn visible_rows(&self, height: u16) -> usize {
282 let usable = height.saturating_sub(2); 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 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 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 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 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 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 }; 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
450fn 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); 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 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 assert!(tb.buffer.cells.iter().any(|c| c.symbol == "s"));
570 }
571}