Skip to main content

saorsa_core/widget/
data_table.rs

1//! Scrollable data table widget with columns and rows.
2//!
3//! Displays tabular data with column headers, row selection,
4//! vertical/horizontal scrolling, and keyboard navigation.
5
6use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::event::{Event, KeyCode, KeyEvent};
9use crate::geometry::Rect;
10use crate::style::Style;
11use crate::text::truncate_to_display_width;
12use crate::widget::label::Alignment;
13use unicode_width::UnicodeWidthStr;
14
15use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
16
17/// A column definition for a [`DataTable`].
18#[derive(Clone, Debug)]
19pub struct Column {
20    /// Column header text.
21    pub header: String,
22    /// Column width in characters.
23    pub width: u16,
24    /// Text alignment within the column.
25    pub alignment: Alignment,
26}
27
28impl Column {
29    /// Create a new column with left alignment.
30    pub fn new(header: &str, width: u16) -> Self {
31        Self {
32            header: header.to_string(),
33            width,
34            alignment: Alignment::Left,
35        }
36    }
37
38    /// Set the column alignment.
39    #[must_use]
40    pub fn with_alignment(mut self, alignment: Alignment) -> Self {
41        self.alignment = alignment;
42        self
43    }
44}
45
46/// A scrollable data table widget with columns and rows.
47///
48/// Supports keyboard navigation, row selection, and both vertical
49/// and horizontal scrolling.
50pub struct DataTable {
51    /// Column definitions.
52    columns: Vec<Column>,
53    /// Row data: each row is a `Vec<String>`, one per column.
54    rows: Vec<Vec<String>>,
55    /// Selected row index.
56    selected_row: usize,
57    /// Scroll offset (first visible row index).
58    row_offset: usize,
59    /// Horizontal scroll offset (pixel offset in characters).
60    col_offset: u16,
61    /// Style for headers.
62    header_style: Style,
63    /// Style for unselected rows.
64    row_style: Style,
65    /// Style for the selected row.
66    selected_style: Style,
67    /// Border style.
68    border: BorderStyle,
69    /// Sort state: (column_index, ascending).
70    sort_state: Option<(usize, bool)>,
71    /// Whether columns can be resized.
72    resizable_columns: bool,
73    /// Original row order (for restoring after clear_sort).
74    original_order: Vec<usize>,
75}
76
77impl DataTable {
78    /// Create a new data table with the given columns.
79    pub fn new(columns: Vec<Column>) -> Self {
80        Self {
81            columns,
82            rows: Vec::new(),
83            selected_row: 0,
84            row_offset: 0,
85            col_offset: 0,
86            header_style: Style::default().bold(true),
87            row_style: Style::default(),
88            selected_style: Style::default().reverse(true),
89            border: BorderStyle::None,
90            sort_state: None,
91            resizable_columns: false,
92            original_order: Vec::new(),
93        }
94    }
95
96    /// Set the header style.
97    #[must_use]
98    pub fn with_header_style(mut self, style: Style) -> Self {
99        self.header_style = style;
100        self
101    }
102
103    /// Set the row style.
104    #[must_use]
105    pub fn with_row_style(mut self, style: Style) -> Self {
106        self.row_style = style;
107        self
108    }
109
110    /// Set the selected row style.
111    #[must_use]
112    pub fn with_selected_style(mut self, style: Style) -> Self {
113        self.selected_style = style;
114        self
115    }
116
117    /// Set the border style.
118    #[must_use]
119    pub fn with_border(mut self, border: BorderStyle) -> Self {
120        self.border = border;
121        self
122    }
123
124    /// Add a row of data.
125    pub fn push_row(&mut self, row: Vec<String>) {
126        self.rows.push(row);
127    }
128
129    /// Set all rows at once, resetting selection.
130    pub fn set_rows(&mut self, rows: Vec<Vec<String>>) {
131        self.rows = rows;
132        self.selected_row = 0;
133        self.row_offset = 0;
134        self.sort_state = None;
135        self.original_order.clear();
136    }
137
138    /// Get the number of rows.
139    pub fn row_count(&self) -> usize {
140        self.rows.len()
141    }
142
143    /// Get the number of columns.
144    pub fn column_count(&self) -> usize {
145        self.columns.len()
146    }
147
148    /// Get the selected row index.
149    pub fn selected_row(&self) -> usize {
150        self.selected_row
151    }
152
153    /// Set the selected row (clamped to valid range).
154    pub fn set_selected_row(&mut self, idx: usize) {
155        if self.rows.is_empty() {
156            self.selected_row = 0;
157        } else {
158            self.selected_row = idx.min(self.rows.len().saturating_sub(1));
159        }
160    }
161
162    /// Get the data for the selected row.
163    pub fn selected_row_data(&self) -> Option<&[String]> {
164        self.rows.get(self.selected_row).map(|r| r.as_slice())
165    }
166
167    /// Get the column definitions.
168    pub fn columns(&self) -> &[Column] {
169        &self.columns
170    }
171
172    /// Get the horizontal scroll offset.
173    pub fn col_offset(&self) -> u16 {
174        self.col_offset
175    }
176
177    // --- Sorting API ---
178
179    /// Enable column resizing.
180    #[must_use]
181    pub fn with_resizable_columns(mut self, enabled: bool) -> Self {
182        self.resizable_columns = enabled;
183        self
184    }
185
186    /// Sort by the given column index (toggle ascending/descending).
187    ///
188    /// First call sorts ascending; repeated calls on the same column
189    /// toggle direction.
190    pub fn sort_by_column(&mut self, col_idx: usize) {
191        if col_idx >= self.columns.len() {
192            return;
193        }
194
195        // Save original order if not yet saved
196        if self.original_order.is_empty() {
197            self.original_order = (0..self.rows.len()).collect();
198        }
199
200        let ascending = match self.sort_state {
201            Some((prev_col, prev_asc)) if prev_col == col_idx => !prev_asc,
202            _ => true,
203        };
204
205        self.sort_state = Some((col_idx, ascending));
206
207        // Sort rows by the column value
208        let col = col_idx;
209        self.rows.sort_by(|a, b| {
210            let va = a.get(col).map(|s| s.as_str()).unwrap_or("");
211            let vb = b.get(col).map(|s| s.as_str()).unwrap_or("");
212            if ascending { va.cmp(vb) } else { vb.cmp(va) }
213        });
214
215        // Keep selection at row 0 after sort
216        self.selected_row = 0;
217        self.row_offset = 0;
218    }
219
220    /// Clear the sort and restore original order.
221    pub fn clear_sort(&mut self) {
222        if self.original_order.is_empty() || self.sort_state.is_none() {
223            self.sort_state = None;
224            return;
225        }
226
227        // Rebuild original order
228        let mut indexed: Vec<(usize, Vec<String>)> = self
229            .original_order
230            .iter()
231            .zip(self.rows.drain(..))
232            .map(|(&orig_idx, row)| (orig_idx, row))
233            .collect();
234        indexed.sort_by_key(|(idx, _)| *idx);
235        self.rows = indexed.into_iter().map(|(_, row)| row).collect();
236
237        self.sort_state = None;
238        self.original_order.clear();
239        self.selected_row = 0;
240        self.row_offset = 0;
241    }
242
243    /// Get the current sort state: (column_index, ascending).
244    pub fn sort_state(&self) -> Option<(usize, bool)> {
245        self.sort_state
246    }
247
248    /// Set the width of a column by index.
249    pub fn set_column_width(&mut self, col_idx: usize, width: u16) {
250        if let Some(col) = self.columns.get_mut(col_idx) {
251            col.width = width.clamp(3, 50);
252        }
253    }
254
255    /// Get the width of a column by index.
256    pub fn column_width(&self, col_idx: usize) -> Option<u16> {
257        self.columns.get(col_idx).map(|c| c.width)
258    }
259
260    /// Calculate total width of all columns (including separators).
261    fn total_columns_width(&self) -> u16 {
262        if self.columns.is_empty() {
263            return 0;
264        }
265        // Each column takes its width + 1 separator, except the last
266        let sum: u16 = self.columns.iter().map(|c| c.width).sum();
267        let separators = self.columns.len().saturating_sub(1) as u16;
268        sum.saturating_add(separators)
269    }
270
271    /// Render a row of text cells with alignment, truncation, and horizontal scroll.
272    fn render_row(
273        &self,
274        cells: &[String],
275        y: u16,
276        x_start: u16,
277        available_width: u16,
278        style: &Style,
279        buf: &mut ScreenBuffer,
280    ) {
281        let mut x_offset: u16 = 0;
282        let col_off = self.col_offset;
283
284        for (col_idx, col) in self.columns.iter().enumerate() {
285            let cell_text = cells.get(col_idx).map(|s| s.as_str()).unwrap_or("");
286            let col_w = col.width as usize;
287
288            // Column start position (before horizontal scroll)
289            let col_start = x_offset;
290            let col_end = x_offset.saturating_add(col.width);
291
292            // Add separator width (1 char between columns)
293            let next_x = if col_idx + 1 < self.columns.len() {
294                col_end.saturating_add(1)
295            } else {
296                col_end
297            };
298
299            // Check if this column is visible after horizontal scroll
300            if col_end > col_off && col_start < col_off.saturating_add(available_width) {
301                // Visible portion
302                let vis_start = col_start.saturating_sub(col_off);
303                let screen_x = x_start.saturating_add(vis_start);
304
305                let truncated = truncate_to_display_width(cell_text, col_w);
306                let text_width = UnicodeWidthStr::width(truncated);
307
308                // Apply alignment
309                let padding = col_w.saturating_sub(text_width);
310                let (left_pad, _right_pad) = match col.alignment {
311                    Alignment::Left => (0, padding),
312                    Alignment::Center => (padding / 2, padding.saturating_sub(padding / 2)),
313                    Alignment::Right => (padding, 0),
314                };
315
316                // Render the aligned text
317                let mut cx = screen_x;
318                // Left padding
319                for _ in 0..left_pad {
320                    if cx < x_start.saturating_add(available_width) {
321                        buf.set(cx, y, Cell::new(" ", style.clone()));
322                        cx += 1;
323                    }
324                }
325                // Text
326                for ch in truncated.chars() {
327                    let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
328                    if cx as usize + char_w > (x_start + available_width) as usize {
329                        break;
330                    }
331                    buf.set(cx, y, Cell::new(ch.to_string(), style.clone()));
332                    cx += char_w as u16;
333                }
334                // Fill remaining column width
335                while cx < screen_x.saturating_add(col.width) && cx < x_start + available_width {
336                    buf.set(cx, y, Cell::new(" ", style.clone()));
337                    cx += 1;
338                }
339
340                // Render separator
341                if col_idx + 1 < self.columns.len() && cx < x_start.saturating_add(available_width)
342                {
343                    buf.set(cx, y, Cell::new("\u{2502}", style.clone()));
344                }
345            }
346
347            x_offset = next_x;
348        }
349    }
350
351    /// Ensure the selected row is visible by adjusting row_offset.
352    fn ensure_selected_visible(&mut self, visible_height: usize) {
353        if visible_height == 0 {
354            return;
355        }
356        if self.selected_row < self.row_offset {
357            self.row_offset = self.selected_row;
358        }
359        if self.selected_row >= self.row_offset + visible_height {
360            self.row_offset = self
361                .selected_row
362                .saturating_sub(visible_height.saturating_sub(1));
363        }
364    }
365}
366
367impl Widget for DataTable {
368    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
369        if area.size.width == 0 || area.size.height == 0 {
370            return;
371        }
372
373        super::border::render_border(area, self.border, self.row_style.clone(), buf);
374
375        let inner = super::border::inner_area(area, self.border);
376        if inner.size.width == 0 || inner.size.height == 0 {
377            return;
378        }
379
380        let available_width = inner.size.width;
381        let total_height = inner.size.height as usize;
382
383        // First row: headers (with sort indicators)
384        if total_height > 0 {
385            let headers: Vec<String> = self
386                .columns
387                .iter()
388                .enumerate()
389                .map(|(idx, c)| {
390                    if let Some((sort_col, ascending)) = self.sort_state
391                        && sort_col == idx
392                    {
393                        let indicator = if ascending { "\u{2191}" } else { "\u{2193}" };
394                        return format!("{}{indicator}", c.header);
395                    }
396                    c.header.clone()
397                })
398                .collect();
399            self.render_row(
400                &headers,
401                inner.position.y,
402                inner.position.x,
403                available_width,
404                &self.header_style,
405                buf,
406            );
407        }
408
409        // Remaining rows: data
410        let data_height = total_height.saturating_sub(1);
411        if data_height == 0 {
412            return;
413        }
414
415        let max_offset = self.rows.len().saturating_sub(data_height.max(1));
416        let scroll = self.row_offset.min(max_offset);
417        let visible_end = (scroll + data_height).min(self.rows.len());
418
419        for (row_idx, data_idx) in (scroll..visible_end).enumerate() {
420            let y = inner.position.y + 1 + row_idx as u16;
421            if let Some(row_data) = self.rows.get(data_idx) {
422                let is_selected = data_idx == self.selected_row;
423                let style = if is_selected {
424                    &self.selected_style
425                } else {
426                    &self.row_style
427                };
428
429                // If selected, fill entire row first
430                if is_selected {
431                    for col in 0..available_width {
432                        buf.set(inner.position.x + col, y, Cell::new(" ", style.clone()));
433                    }
434                }
435
436                self.render_row(row_data, y, inner.position.x, available_width, style, buf);
437            }
438        }
439    }
440}
441
442impl InteractiveWidget for DataTable {
443    fn handle_event(&mut self, event: &Event) -> EventResult {
444        let Event::Key(KeyEvent { code, modifiers }) = event else {
445            return EventResult::Ignored;
446        };
447
448        match code {
449            KeyCode::Up => {
450                if self.selected_row > 0 {
451                    self.selected_row -= 1;
452                    self.ensure_selected_visible(20);
453                }
454                EventResult::Consumed
455            }
456            KeyCode::Down => {
457                if !self.rows.is_empty() && self.selected_row < self.rows.len().saturating_sub(1) {
458                    self.selected_row += 1;
459                    self.ensure_selected_visible(20);
460                }
461                EventResult::Consumed
462            }
463            KeyCode::Left => {
464                let has_ctrl = modifiers.contains(crate::event::Modifiers::CTRL);
465                let has_shift = modifiers.contains(crate::event::Modifiers::SHIFT);
466                if has_ctrl && has_shift && self.resizable_columns {
467                    // Ctrl+Shift+Left: decrease selected column width
468                    let max_col = self.columns.len().saturating_sub(1);
469                    let target = self.selected_row.min(max_col);
470                    if let Some(col) = self.columns.get_mut(target) {
471                        col.width = col.width.saturating_sub(1).max(3);
472                    }
473                } else if has_ctrl {
474                    self.col_offset = 0;
475                } else {
476                    self.col_offset = self.col_offset.saturating_sub(1);
477                }
478                EventResult::Consumed
479            }
480            KeyCode::Right => {
481                let has_ctrl = modifiers.contains(crate::event::Modifiers::CTRL);
482                let has_shift = modifiers.contains(crate::event::Modifiers::SHIFT);
483                if has_ctrl && has_shift && self.resizable_columns {
484                    // Ctrl+Shift+Right: increase selected column width
485                    let max_col = self.columns.len().saturating_sub(1);
486                    let target = self.selected_row.min(max_col);
487                    if let Some(col) = self.columns.get_mut(target) {
488                        col.width = (col.width + 1).min(50);
489                    }
490                } else if has_ctrl {
491                    self.col_offset = self.total_columns_width();
492                } else {
493                    self.col_offset = self.col_offset.saturating_add(1);
494                }
495                EventResult::Consumed
496            }
497            KeyCode::PageUp => {
498                let page = 20;
499                self.selected_row = self.selected_row.saturating_sub(page);
500                self.ensure_selected_visible(20);
501                EventResult::Consumed
502            }
503            KeyCode::PageDown => {
504                let page = 20;
505                if !self.rows.is_empty() {
506                    self.selected_row =
507                        (self.selected_row + page).min(self.rows.len().saturating_sub(1));
508                    self.ensure_selected_visible(20);
509                }
510                EventResult::Consumed
511            }
512            KeyCode::Home => {
513                self.selected_row = 0;
514                self.row_offset = 0;
515                EventResult::Consumed
516            }
517            KeyCode::End => {
518                if !self.rows.is_empty() {
519                    self.selected_row = self.rows.len().saturating_sub(1);
520                    self.ensure_selected_visible(20);
521                }
522                EventResult::Consumed
523            }
524            // Ctrl+0: clear sort
525            KeyCode::Char('0') if modifiers.contains(crate::event::Modifiers::CTRL) => {
526                self.clear_sort();
527                EventResult::Consumed
528            }
529            // Ctrl+1..9: sort by column 1-9
530            KeyCode::Char(ch)
531                if modifiers.contains(crate::event::Modifiers::CTRL)
532                    && ('1'..='9').contains(ch) =>
533            {
534                let col_idx = (*ch as usize) - ('1' as usize);
535                if col_idx < self.columns.len() {
536                    self.sort_by_column(col_idx);
537                }
538                EventResult::Consumed
539            }
540            _ => EventResult::Ignored,
541        }
542    }
543}
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used)]
547mod tests {
548    use super::*;
549    use crate::geometry::Size;
550
551    fn make_test_table() -> DataTable {
552        let cols = vec![
553            Column::new("Name", 10),
554            Column::new("Age", 5),
555            Column::new("City", 12),
556        ];
557        let mut table = DataTable::new(cols);
558        table.push_row(vec!["Alice".into(), "30".into(), "New York".into()]);
559        table.push_row(vec!["Bob".into(), "25".into(), "London".into()]);
560        table.push_row(vec!["Charlie".into(), "35".into(), "Tokyo".into()]);
561        table
562    }
563
564    #[test]
565    fn create_table_with_columns() {
566        let table = make_test_table();
567        assert_eq!(table.column_count(), 3);
568        assert_eq!(table.row_count(), 3);
569    }
570
571    #[test]
572    fn add_rows() {
573        let mut table = DataTable::new(vec![Column::new("X", 5)]);
574        assert_eq!(table.row_count(), 0);
575        table.push_row(vec!["a".into()]);
576        table.push_row(vec!["b".into()]);
577        assert_eq!(table.row_count(), 2);
578    }
579
580    #[test]
581    fn render_empty_table_shows_headers() {
582        let table = DataTable::new(vec![Column::new("Name", 10), Column::new("Age", 5)]);
583        let mut buf = ScreenBuffer::new(Size::new(20, 5));
584        table.render(Rect::new(0, 0, 20, 5), &mut buf);
585
586        // Header row should show "Name"
587        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("N"));
588        assert_eq!(buf.get(3, 0).map(|c| c.grapheme.as_str()), Some("e"));
589    }
590
591    #[test]
592    fn render_with_rows() {
593        let table = make_test_table();
594        let mut buf = ScreenBuffer::new(Size::new(30, 10));
595        table.render(Rect::new(0, 0, 30, 10), &mut buf);
596
597        // Row 0 = headers: "Name"
598        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("N"));
599        // Row 1 = first data row: "Alice" (selected)
600        assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("A"));
601        // Row 2 = second data row: "Bob"
602        assert_eq!(buf.get(0, 2).map(|c| c.grapheme.as_str()), Some("B"));
603    }
604
605    #[test]
606    fn selected_row_highlighted() {
607        let mut table = make_test_table();
608        table.selected_style = Style::default().bold(true);
609        table.row_style = Style::default();
610        table.set_selected_row(1); // "Bob"
611
612        let mut buf = ScreenBuffer::new(Size::new(30, 10));
613        table.render(Rect::new(0, 0, 30, 10), &mut buf);
614
615        // Row 2 = "Bob" (selected, bold)
616        let cell = buf.get(0, 2);
617        assert!(cell.is_some());
618        assert!(cell.map(|c| c.style.bold).unwrap_or(false));
619
620        // Row 1 = "Alice" (not selected)
621        let cell_a = buf.get(0, 1);
622        assert!(cell_a.is_some());
623        assert!(!cell_a.map(|c| c.style.bold).unwrap_or(true));
624    }
625
626    #[test]
627    fn column_alignment_left() {
628        let table = DataTable::new(vec![Column::new("H", 10).with_alignment(Alignment::Left)]);
629        let mut t = table;
630        t.push_row(vec!["Hi".into()]);
631
632        let mut buf = ScreenBuffer::new(Size::new(15, 5));
633        t.render(Rect::new(0, 0, 15, 5), &mut buf);
634
635        // "Hi" should be left-aligned: starts at position 0
636        assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("H"));
637        assert_eq!(buf.get(1, 1).map(|c| c.grapheme.as_str()), Some("i"));
638    }
639
640    #[test]
641    fn column_alignment_right() {
642        let col = Column::new("H", 10).with_alignment(Alignment::Right);
643        let mut table = DataTable::new(vec![col]);
644        table.push_row(vec!["Hi".into()]);
645
646        let mut buf = ScreenBuffer::new(Size::new(15, 5));
647        table.render(Rect::new(0, 0, 15, 5), &mut buf);
648
649        // "Hi" is 2 chars in 10-wide column, right-aligned: padding = 8
650        assert_eq!(buf.get(8, 1).map(|c| c.grapheme.as_str()), Some("H"));
651        assert_eq!(buf.get(9, 1).map(|c| c.grapheme.as_str()), Some("i"));
652    }
653
654    #[test]
655    fn column_alignment_center() {
656        let col = Column::new("H", 10).with_alignment(Alignment::Center);
657        let mut table = DataTable::new(vec![col]);
658        table.push_row(vec!["Hi".into()]);
659
660        let mut buf = ScreenBuffer::new(Size::new(15, 5));
661        table.render(Rect::new(0, 0, 15, 5), &mut buf);
662
663        // "Hi" is 2 chars, center in 10: left_pad = 4
664        assert_eq!(buf.get(4, 1).map(|c| c.grapheme.as_str()), Some("H"));
665        assert_eq!(buf.get(5, 1).map(|c| c.grapheme.as_str()), Some("i"));
666    }
667
668    #[test]
669    fn utf8_safe_truncation_in_cells() {
670        let col = Column::new("H", 5);
671        let mut table = DataTable::new(vec![col]);
672        table.push_row(vec!["你好世界人".into()]);
673
674        let mut buf = ScreenBuffer::new(Size::new(10, 5));
675        table.render(Rect::new(0, 0, 10, 5), &mut buf);
676
677        // Width 5 fits "你好" (4 chars) + maybe space, "世" would need 6
678        assert_eq!(buf.get(0, 1).map(|c| c.grapheme.as_str()), Some("你"));
679        assert_eq!(buf.get(2, 1).map(|c| c.grapheme.as_str()), Some("好"));
680    }
681
682    #[test]
683    fn vertical_scrolling_with_navigation() {
684        let mut table = make_test_table();
685
686        let down = Event::Key(KeyEvent {
687            code: KeyCode::Down,
688            modifiers: crate::event::Modifiers::NONE,
689        });
690        let up = Event::Key(KeyEvent {
691            code: KeyCode::Up,
692            modifiers: crate::event::Modifiers::NONE,
693        });
694
695        assert_eq!(table.selected_row(), 0);
696        table.handle_event(&down);
697        assert_eq!(table.selected_row(), 1);
698        table.handle_event(&down);
699        assert_eq!(table.selected_row(), 2);
700        table.handle_event(&down); // at end
701        assert_eq!(table.selected_row(), 2);
702        table.handle_event(&up);
703        assert_eq!(table.selected_row(), 1);
704    }
705
706    #[test]
707    fn horizontal_scrolling() {
708        let mut table = make_test_table();
709
710        let right = Event::Key(KeyEvent {
711            code: KeyCode::Right,
712            modifiers: crate::event::Modifiers::NONE,
713        });
714        let left = Event::Key(KeyEvent {
715            code: KeyCode::Left,
716            modifiers: crate::event::Modifiers::NONE,
717        });
718
719        assert_eq!(table.col_offset(), 0);
720        table.handle_event(&right);
721        assert_eq!(table.col_offset(), 1);
722        table.handle_event(&left);
723        assert_eq!(table.col_offset(), 0);
724        table.handle_event(&left); // can't go below 0
725        assert_eq!(table.col_offset(), 0);
726    }
727
728    #[test]
729    fn page_up_down() {
730        let cols = vec![Column::new("N", 5)];
731        let mut table = DataTable::new(cols);
732        for i in 0..50 {
733            table.push_row(vec![format!("Row {i}")]);
734        }
735
736        let page_down = Event::Key(KeyEvent {
737            code: KeyCode::PageDown,
738            modifiers: crate::event::Modifiers::NONE,
739        });
740        let page_up = Event::Key(KeyEvent {
741            code: KeyCode::PageUp,
742            modifiers: crate::event::Modifiers::NONE,
743        });
744
745        table.handle_event(&page_down);
746        assert_eq!(table.selected_row(), 20);
747        table.handle_event(&page_up);
748        assert_eq!(table.selected_row(), 0);
749    }
750
751    #[test]
752    fn home_end_navigation() {
753        let mut table = make_test_table();
754
755        let end = Event::Key(KeyEvent {
756            code: KeyCode::End,
757            modifiers: crate::event::Modifiers::NONE,
758        });
759        let home = Event::Key(KeyEvent {
760            code: KeyCode::Home,
761            modifiers: crate::event::Modifiers::NONE,
762        });
763
764        table.handle_event(&end);
765        assert_eq!(table.selected_row(), 2);
766        table.handle_event(&home);
767        assert_eq!(table.selected_row(), 0);
768    }
769
770    #[test]
771    fn render_with_border() {
772        let table = make_test_table();
773        let table = DataTable {
774            border: BorderStyle::Single,
775            ..table
776        };
777
778        let mut buf = ScreenBuffer::new(Size::new(35, 8));
779        table.render(Rect::new(0, 0, 35, 8), &mut buf);
780
781        assert_eq!(buf.get(0, 0).map(|c| c.grapheme.as_str()), Some("\u{250c}"));
782        // Header inside border
783        assert_eq!(buf.get(1, 1).map(|c| c.grapheme.as_str()), Some("N"));
784    }
785
786    #[test]
787    fn empty_table_with_columns() {
788        let table = DataTable::new(vec![Column::new("A", 5), Column::new("B", 5)]);
789        assert_eq!(table.row_count(), 0);
790        assert_eq!(table.column_count(), 2);
791        assert!(table.selected_row_data().is_none());
792
793        // Should not crash on render
794        let mut buf = ScreenBuffer::new(Size::new(15, 5));
795        table.render(Rect::new(0, 0, 15, 5), &mut buf);
796    }
797
798    #[test]
799    fn selected_row_data_access() {
800        let table = make_test_table();
801        match table.selected_row_data() {
802            Some(data) => {
803                assert_eq!(data.len(), 3);
804                assert_eq!(data[0], "Alice");
805            }
806            None => unreachable!("should have data"),
807        }
808    }
809
810    #[test]
811    fn set_rows_resets_selection() {
812        let mut table = make_test_table();
813        table.set_selected_row(2);
814        assert_eq!(table.selected_row(), 2);
815
816        table.set_rows(vec![vec!["X".into()]]);
817        assert_eq!(table.selected_row(), 0);
818        assert_eq!(table.row_count(), 1);
819    }
820
821    #[test]
822    fn builder_pattern() {
823        let table = DataTable::new(vec![Column::new("H", 10)])
824            .with_header_style(Style::default().bold(true))
825            .with_row_style(Style::default().dim(true))
826            .with_selected_style(Style::default().italic(true))
827            .with_border(BorderStyle::Rounded);
828
829        assert!(table.header_style.bold);
830        assert!(table.row_style.dim);
831        assert!(table.selected_style.italic);
832    }
833
834    #[test]
835    fn unhandled_event_ignored() {
836        let mut table = make_test_table();
837        let tab = Event::Key(KeyEvent {
838            code: KeyCode::Tab,
839            modifiers: crate::event::Modifiers::NONE,
840        });
841        assert_eq!(table.handle_event(&tab), EventResult::Ignored);
842    }
843
844    // --- Task 5: Sorting & Column Resize tests ---
845
846    #[test]
847    fn sort_by_column_ascending() {
848        let mut table = make_test_table();
849        // Rows: Alice, Bob, Charlie
850        table.sort_by_column(0); // Sort by Name ascending
851        assert_eq!(table.sort_state(), Some((0, true)));
852        match table.rows.first().map(|r| r[0].as_str()) {
853            Some("Alice") => {}
854            other => panic!("Expected Alice first, got {other:?}"),
855        }
856    }
857
858    #[test]
859    fn sort_toggle_descending() {
860        let mut table = make_test_table();
861        table.sort_by_column(0); // ascending
862        assert_eq!(table.sort_state(), Some((0, true)));
863        table.sort_by_column(0); // toggle to descending
864        assert_eq!(table.sort_state(), Some((0, false)));
865        match table.rows.first().map(|r| r[0].as_str()) {
866            Some("Charlie") => {}
867            other => panic!("Expected Charlie first (descending), got {other:?}"),
868        }
869    }
870
871    #[test]
872    fn sort_indicator_in_header() {
873        let mut table = make_test_table();
874        table.sort_by_column(0); // ascending
875
876        let mut buf = ScreenBuffer::new(Size::new(35, 10));
877        table.render(Rect::new(0, 0, 35, 10), &mut buf);
878
879        // Header should include "↑" after "Name"
880        // "Name↑" — '↑' is at position 4
881        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some("\u{2191}"));
882    }
883
884    #[test]
885    fn sort_descending_indicator() {
886        let mut table = make_test_table();
887        table.sort_by_column(0);
888        table.sort_by_column(0); // toggle descending
889
890        let mut buf = ScreenBuffer::new(Size::new(35, 10));
891        table.render(Rect::new(0, 0, 35, 10), &mut buf);
892
893        // "Name↓"
894        assert_eq!(buf.get(4, 0).map(|c| c.grapheme.as_str()), Some("\u{2193}"));
895    }
896
897    #[test]
898    fn clear_sort_restores_order() {
899        let mut table = make_test_table();
900        // Original: Alice, Bob, Charlie
901        table.sort_by_column(0); // ascending
902        table.sort_by_column(0); // descending: Charlie, Bob, Alice
903        table.clear_sort();
904        assert!(table.sort_state().is_none());
905    }
906
907    #[test]
908    fn column_resize_increase() {
909        let mut table = make_test_table();
910        let original_width = table.column_width(0);
911        assert_eq!(original_width, Some(10));
912
913        table.set_column_width(0, 15);
914        assert_eq!(table.column_width(0), Some(15));
915    }
916
917    #[test]
918    fn column_resize_clamping() {
919        let mut table = make_test_table();
920
921        // Below minimum (3)
922        table.set_column_width(0, 1);
923        assert_eq!(table.column_width(0), Some(3));
924
925        // Above maximum (50)
926        table.set_column_width(0, 100);
927        assert_eq!(table.column_width(0), Some(50));
928    }
929
930    #[test]
931    fn keyboard_sort_ctrl_1() {
932        let mut table = make_test_table();
933
934        let ctrl_1 = Event::Key(KeyEvent {
935            code: KeyCode::Char('1'),
936            modifiers: crate::event::Modifiers::CTRL,
937        });
938
939        assert_eq!(table.handle_event(&ctrl_1), EventResult::Consumed);
940        assert_eq!(table.sort_state(), Some((0, true)));
941    }
942
943    #[test]
944    fn keyboard_sort_ctrl_0_clears() {
945        let mut table = make_test_table();
946        table.sort_by_column(0);
947        assert!(table.sort_state().is_some());
948
949        let ctrl_0 = Event::Key(KeyEvent {
950            code: KeyCode::Char('0'),
951            modifiers: crate::event::Modifiers::CTRL,
952        });
953
954        assert_eq!(table.handle_event(&ctrl_0), EventResult::Consumed);
955        assert!(table.sort_state().is_none());
956    }
957
958    #[test]
959    fn keyboard_resize_ctrl_shift_right() {
960        let mut table = make_test_table().with_resizable_columns(true);
961
962        let original = table.column_width(0);
963        assert_eq!(original, Some(10));
964
965        let ctrl_shift_right = Event::Key(KeyEvent {
966            code: KeyCode::Right,
967            modifiers: crate::event::Modifiers::CTRL | crate::event::Modifiers::SHIFT,
968        });
969
970        table.handle_event(&ctrl_shift_right);
971        assert_eq!(table.column_width(0), Some(11));
972    }
973
974    #[test]
975    fn keyboard_resize_ctrl_shift_left() {
976        let mut table = make_test_table().with_resizable_columns(true);
977
978        let ctrl_shift_left = Event::Key(KeyEvent {
979            code: KeyCode::Left,
980            modifiers: crate::event::Modifiers::CTRL | crate::event::Modifiers::SHIFT,
981        });
982
983        table.handle_event(&ctrl_shift_left);
984        assert_eq!(table.column_width(0), Some(9));
985    }
986
987    #[test]
988    fn empty_table_sorting_no_crash() {
989        let mut table = DataTable::new(vec![Column::new("X", 5)]);
990        table.sort_by_column(0);
991        assert_eq!(table.sort_state(), Some((0, true)));
992        table.clear_sort();
993        assert!(table.sort_state().is_none());
994    }
995
996    #[test]
997    fn sort_by_column_resets_selection() {
998        let mut table = make_test_table();
999        table.set_selected_row(2);
1000        table.sort_by_column(0);
1001        assert_eq!(table.selected_row(), 0);
1002    }
1003}