Skip to main content

bubbles/
table.rs

1//! Table component for displaying tabular data.
2//!
3//! This module provides a table widget with keyboard navigation for TUI
4//! applications.
5//!
6//! # Example
7//!
8//! ```rust
9//! use bubbles::table::{Table, Column};
10//!
11//! let columns = vec![
12//!     Column::new("ID", 10),
13//!     Column::new("Name", 20),
14//!     Column::new("Status", 15),
15//! ];
16//!
17//! let rows = vec![
18//!     vec!["1".into(), "Alice".into(), "Active".into()],
19//!     vec!["2".into(), "Bob".into(), "Inactive".into()],
20//! ];
21//!
22//! let table = Table::new()
23//!     .columns(columns)
24//!     .rows(rows);
25//! ```
26
27use crate::key::{Binding, matches};
28use crate::viewport::Viewport;
29use bubbletea::{Cmd, KeyMsg, Message, Model, MouseAction, MouseButton, MouseMsg};
30use lipgloss::{Color, Style};
31
32/// A single column definition for the table.
33#[derive(Debug, Clone)]
34pub struct Column {
35    /// Column title displayed in the header.
36    pub title: String,
37    /// Width of the column in characters.
38    pub width: usize,
39}
40
41impl Column {
42    /// Creates a new column with the given title and width.
43    #[must_use]
44    pub fn new(title: impl Into<String>, width: usize) -> Self {
45        Self {
46            title: title.into(),
47            width,
48        }
49    }
50}
51
52/// A row in the table (vector of cell values).
53pub type Row = Vec<String>;
54
55/// Key bindings for table navigation.
56#[derive(Debug, Clone)]
57pub struct KeyMap {
58    /// Move up one line.
59    pub line_up: Binding,
60    /// Move down one line.
61    pub line_down: Binding,
62    /// Page up.
63    pub page_up: Binding,
64    /// Page down.
65    pub page_down: Binding,
66    /// Half page up.
67    pub half_page_up: Binding,
68    /// Half page down.
69    pub half_page_down: Binding,
70    /// Go to top.
71    pub goto_top: Binding,
72    /// Go to bottom.
73    pub goto_bottom: Binding,
74}
75
76impl Default for KeyMap {
77    fn default() -> Self {
78        Self {
79            line_up: Binding::new().keys(&["up", "k"]).help("↑/k", "up"),
80            line_down: Binding::new().keys(&["down", "j"]).help("↓/j", "down"),
81            page_up: Binding::new()
82                .keys(&["b", "pgup"])
83                .help("b/pgup", "page up"),
84            page_down: Binding::new()
85                .keys(&["f", "pgdown", " "])
86                .help("f/pgdn", "page down"),
87            half_page_up: Binding::new().keys(&["u", "ctrl+u"]).help("u", "½ page up"),
88            half_page_down: Binding::new()
89                .keys(&["d", "ctrl+d"])
90                .help("d", "½ page down"),
91            goto_top: Binding::new()
92                .keys(&["home", "g"])
93                .help("g/home", "go to start"),
94            goto_bottom: Binding::new()
95                .keys(&["end", "G"])
96                .help("G/end", "go to end"),
97        }
98    }
99}
100
101/// Styles for the table.
102#[derive(Debug, Clone)]
103pub struct Styles {
104    /// Style for the header row.
105    pub header: Style,
106    /// Style for normal cells.
107    pub cell: Style,
108    /// Style for the selected row.
109    pub selected: Style,
110}
111
112impl Default for Styles {
113    fn default() -> Self {
114        Self {
115            header: Style::new().bold().padding_left(1).padding_right(1),
116            cell: Style::new().padding_left(1).padding_right(1),
117            selected: Style::new().bold().foreground_color(Color::from("212")),
118        }
119    }
120}
121
122/// Table model for displaying tabular data with keyboard navigation.
123#[derive(Debug, Clone)]
124pub struct Table {
125    /// Key bindings for navigation.
126    pub key_map: KeyMap,
127    /// Styles for rendering.
128    pub styles: Styles,
129    /// Whether mouse wheel scrolling is enabled.
130    pub mouse_wheel_enabled: bool,
131    /// Number of rows to scroll per mouse wheel tick.
132    pub mouse_wheel_delta: usize,
133    /// Whether mouse click selection is enabled.
134    pub mouse_click_enabled: bool,
135    /// Column definitions.
136    columns: Vec<Column>,
137    /// Table rows (data).
138    rows: Vec<Row>,
139    /// Currently selected row index.
140    cursor: usize,
141    /// Whether the table is focused.
142    focus: bool,
143    /// Internal viewport for scrolling.
144    viewport: Viewport,
145    /// Start index for rendered rows.
146    start: usize,
147    /// End index for rendered rows.
148    end: usize,
149}
150
151impl Default for Table {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157impl Table {
158    /// Creates a new empty table.
159    #[must_use]
160    pub fn new() -> Self {
161        Self {
162            key_map: KeyMap::default(),
163            styles: Styles::default(),
164            mouse_wheel_enabled: true,
165            mouse_wheel_delta: 3,
166            mouse_click_enabled: true,
167            columns: Vec::new(),
168            rows: Vec::new(),
169            cursor: 0,
170            focus: false,
171            viewport: Viewport::new(0, 20),
172            start: 0,
173            end: 0,
174        }
175    }
176
177    /// Sets the columns (builder pattern).
178    #[must_use]
179    pub fn columns(mut self, columns: Vec<Column>) -> Self {
180        self.columns = columns;
181        self.update_viewport();
182        self
183    }
184
185    /// Sets the rows (builder pattern).
186    #[must_use]
187    pub fn rows(mut self, rows: Vec<Row>) -> Self {
188        self.rows = rows;
189        self.update_viewport();
190        self
191    }
192
193    /// Sets the height (builder pattern).
194    #[must_use]
195    pub fn height(mut self, h: usize) -> Self {
196        let header_height = 1; // Single header row
197        self.viewport.height = h.saturating_sub(header_height);
198        self.update_viewport();
199        self
200    }
201
202    /// Sets the width (builder pattern).
203    #[must_use]
204    pub fn width(mut self, w: usize) -> Self {
205        self.viewport.width = w;
206        self.update_viewport();
207        self
208    }
209
210    /// Sets the focused state (builder pattern).
211    #[must_use]
212    pub fn focused(mut self, f: bool) -> Self {
213        self.focus = f;
214        self.update_viewport();
215        self
216    }
217
218    /// Sets the styles (builder pattern).
219    #[must_use]
220    pub fn with_styles(mut self, styles: Styles) -> Self {
221        self.styles = styles;
222        self.update_viewport();
223        self
224    }
225
226    /// Sets the key map (builder pattern).
227    #[must_use]
228    pub fn with_key_map(mut self, key_map: KeyMap) -> Self {
229        self.key_map = key_map;
230        self
231    }
232
233    /// Enables or disables mouse wheel scrolling (builder pattern).
234    #[must_use]
235    pub fn mouse_wheel(mut self, enabled: bool) -> Self {
236        self.mouse_wheel_enabled = enabled;
237        self
238    }
239
240    /// Sets the number of rows to scroll per mouse wheel tick (builder pattern).
241    #[must_use]
242    pub fn mouse_wheel_delta(mut self, delta: usize) -> Self {
243        self.mouse_wheel_delta = delta;
244        self
245    }
246
247    /// Enables or disables mouse click row selection (builder pattern).
248    #[must_use]
249    pub fn mouse_click(mut self, enabled: bool) -> Self {
250        self.mouse_click_enabled = enabled;
251        self
252    }
253
254    /// Returns whether the table is focused.
255    #[must_use]
256    pub fn is_focused(&self) -> bool {
257        self.focus
258    }
259
260    /// Focuses the table.
261    pub fn focus(&mut self) {
262        self.focus = true;
263        self.update_viewport();
264    }
265
266    /// Blurs (unfocuses) the table.
267    pub fn blur(&mut self) {
268        self.focus = false;
269        self.update_viewport();
270    }
271
272    /// Returns the columns.
273    #[must_use]
274    pub fn get_columns(&self) -> &[Column] {
275        &self.columns
276    }
277
278    /// Returns the rows.
279    #[must_use]
280    pub fn get_rows(&self) -> &[Row] {
281        &self.rows
282    }
283
284    /// Sets the columns.
285    pub fn set_columns(&mut self, columns: Vec<Column>) {
286        self.columns = columns;
287        self.update_viewport();
288    }
289
290    /// Sets the rows.
291    pub fn set_rows(&mut self, rows: Vec<Row>) {
292        self.rows = rows;
293        if self.cursor > self.rows.len().saturating_sub(1) {
294            self.cursor = self.rows.len().saturating_sub(1);
295        }
296        self.update_viewport();
297    }
298
299    /// Sets the width.
300    pub fn set_width(&mut self, w: usize) {
301        self.viewport.width = w;
302        self.update_viewport();
303    }
304
305    /// Sets the height.
306    pub fn set_height(&mut self, h: usize) {
307        let header_height = 1;
308        self.viewport.height = h.saturating_sub(header_height);
309        self.update_viewport();
310    }
311
312    /// Returns the viewport height.
313    #[must_use]
314    pub fn get_height(&self) -> usize {
315        self.viewport.height
316    }
317
318    /// Returns the viewport width.
319    #[must_use]
320    pub fn get_width(&self) -> usize {
321        self.viewport.width
322    }
323
324    /// Returns the currently selected row, if any.
325    #[must_use]
326    pub fn selected_row(&self) -> Option<&Row> {
327        self.rows.get(self.cursor)
328    }
329
330    /// Returns the cursor position (selected row index).
331    #[must_use]
332    pub fn cursor(&self) -> usize {
333        self.cursor
334    }
335
336    /// Sets the cursor position.
337    pub fn set_cursor(&mut self, n: usize) {
338        self.cursor = n.min(self.rows.len().saturating_sub(1));
339        self.update_viewport();
340    }
341
342    /// Moves the selection up by n rows.
343    pub fn move_up(&mut self, n: usize) {
344        if self.rows.is_empty() {
345            return;
346        }
347        self.cursor = self.cursor.saturating_sub(n);
348        self.update_viewport();
349    }
350
351    /// Moves the selection down by n rows.
352    pub fn move_down(&mut self, n: usize) {
353        if self.rows.is_empty() {
354            return;
355        }
356        self.cursor = (self.cursor + n).min(self.rows.len().saturating_sub(1));
357        self.update_viewport();
358    }
359
360    /// Moves to the first row.
361    pub fn goto_top(&mut self) {
362        self.cursor = 0;
363        self.update_viewport();
364    }
365
366    /// Moves to the last row.
367    pub fn goto_bottom(&mut self) {
368        if !self.rows.is_empty() {
369            self.cursor = self.rows.len() - 1;
370        }
371        self.update_viewport();
372    }
373
374    /// Parses rows from a string value with the given separator.
375    pub fn from_values(&mut self, value: &str, separator: &str) {
376        let rows: Vec<Row> = value
377            .lines()
378            .map(|line| line.split(separator).map(String::from).collect())
379            .collect();
380        self.set_rows(rows);
381    }
382
383    /// Updates the viewport to reflect current state.
384    fn update_viewport(&mut self) {
385        if self.rows.is_empty() {
386            self.start = 0;
387            self.end = 0;
388            self.viewport.set_content("");
389            return;
390        }
391
392        let height = self.viewport.height;
393        if height == 0 {
394            self.start = 0;
395            self.end = 0;
396            self.viewport.set_content("");
397            return;
398        }
399
400        // Keep cursor visible - adjust start window if cursor moves out of view
401        if self.cursor < self.start {
402            // Cursor moved above visible window
403            self.start = self.cursor;
404        } else if self.cursor >= self.start + height {
405            // Cursor moved below visible window
406            self.start = self.cursor - height + 1;
407        }
408
409        // Calculate end to show exactly height rows (or fewer if not enough data)
410        self.end = (self.start + height).min(self.rows.len());
411
412        // If we're near the end and have room, fill the viewport
413        if self.end - self.start < height && self.start > 0 {
414            self.start = self.end.saturating_sub(height);
415        }
416
417        // Render only the visible rows
418        let rendered: Vec<String> = (self.start..self.end).map(|i| self.render_row(i)).collect();
419
420        self.viewport.set_content(&rendered.join("\n"));
421    }
422
423    /// Renders the header row.
424    fn headers_view(&self) -> String {
425        let cells: Vec<String> = self
426            .columns
427            .iter()
428            .filter(|col| col.width > 0)
429            .map(|col| {
430                let truncated = truncate_string(&col.title, col.width);
431                let padded = pad_string(&truncated, col.width);
432                self.styles.header.render(&padded)
433            })
434            .collect();
435
436        cells.join("")
437    }
438
439    /// Renders a single row.
440    fn render_row(&self, row_idx: usize) -> String {
441        let row = &self.rows[row_idx];
442
443        let cells: Vec<String> = self
444            .columns
445            .iter()
446            .enumerate()
447            .filter(|(_, col)| col.width > 0)
448            .map(|(i, col)| {
449                let value = row.get(i).map(String::as_str).unwrap_or("");
450                let truncated = truncate_string(value, col.width);
451                let padded = pad_string(&truncated, col.width);
452                self.styles.cell.render(&padded)
453            })
454            .collect();
455
456        let row_str = cells.join("");
457
458        if row_idx == self.cursor {
459            self.styles.selected.render(&row_str)
460        } else {
461            row_str
462        }
463    }
464
465    /// Updates the table based on key/mouse input.
466    pub fn update(&mut self, msg: &Message) {
467        if !self.focus {
468            return;
469        }
470
471        if let Some(key) = msg.downcast_ref::<KeyMsg>() {
472            let key_str = key.to_string();
473
474            if matches(&key_str, &[&self.key_map.line_up]) {
475                self.move_up(1);
476            } else if matches(&key_str, &[&self.key_map.line_down]) {
477                self.move_down(1);
478            } else if matches(&key_str, &[&self.key_map.page_up]) {
479                self.move_up(self.viewport.height);
480            } else if matches(&key_str, &[&self.key_map.page_down]) {
481                self.move_down(self.viewport.height);
482            } else if matches(&key_str, &[&self.key_map.half_page_up]) {
483                self.move_up(self.viewport.height / 2);
484            } else if matches(&key_str, &[&self.key_map.half_page_down]) {
485                self.move_down(self.viewport.height / 2);
486            } else if matches(&key_str, &[&self.key_map.goto_top]) {
487                self.goto_top();
488            } else if matches(&key_str, &[&self.key_map.goto_bottom]) {
489                self.goto_bottom();
490            }
491        }
492
493        // Handle mouse events
494        if let Some(mouse) = msg.downcast_ref::<MouseMsg>() {
495            // Only respond to press events
496            if mouse.action != MouseAction::Press {
497                return;
498            }
499
500            match mouse.button {
501                // Wheel scrolling
502                MouseButton::WheelUp if self.mouse_wheel_enabled => {
503                    self.move_up(self.mouse_wheel_delta);
504                }
505                MouseButton::WheelDown if self.mouse_wheel_enabled => {
506                    self.move_down(self.mouse_wheel_delta);
507                }
508                // Click to select row
509                MouseButton::Left if self.mouse_click_enabled => {
510                    // y=0 is the header row, data rows start at y=1
511                    // Convert click y to row index, accounting for viewport scroll
512                    let header_height = 1usize;
513                    let click_y = mouse.y as usize;
514
515                    if click_y >= header_height {
516                        // Calculate which visible row was clicked
517                        let visible_row = click_y - header_height;
518                        // Convert to actual row index using viewport offset
519                        let row_index = self.start + visible_row;
520
521                        // Only select if within bounds
522                        if row_index < self.rows.len() {
523                            self.cursor = row_index;
524                            self.update_viewport();
525                        }
526                    }
527                }
528                _ => {}
529            }
530        }
531    }
532
533    /// Renders the table.
534    #[must_use]
535    pub fn view(&self) -> String {
536        format!("{}\n{}", self.headers_view(), self.viewport.view())
537    }
538}
539
540/// Pads a string to the given width with spaces.
541fn pad_string(s: &str, width: usize) -> String {
542    use unicode_width::UnicodeWidthStr;
543    let current_width = UnicodeWidthStr::width(s);
544    if current_width >= width {
545        s.to_string()
546    } else {
547        let padding = width - current_width;
548        format!("{}{}", s, " ".repeat(padding))
549    }
550}
551
552/// Truncates a string to the given width, adding ellipsis if needed.
553fn truncate_string(s: &str, width: usize) -> String {
554    use unicode_width::UnicodeWidthStr;
555
556    if UnicodeWidthStr::width(s) <= width {
557        return s.to_string();
558    }
559
560    if width == 0 {
561        return String::new();
562    }
563
564    // We need to truncate to width - 1 (for ellipsis)
565    let target_width = width.saturating_sub(1);
566    let mut current_width = 0;
567    let mut result = String::new();
568
569    for c in s.chars() {
570        let w = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
571        if current_width + w > target_width {
572            break;
573        }
574        result.push(c);
575        current_width += w;
576    }
577
578    format!("{}…", result)
579}
580
581impl Model for Table {
582    /// Initialize the table.
583    ///
584    /// Tables don't require initialization commands.
585    fn init(&self) -> Option<Cmd> {
586        None
587    }
588
589    /// Update the table state based on incoming messages.
590    fn update(&mut self, msg: Message) -> Option<Cmd> {
591        self.update(&msg);
592        None
593    }
594
595    /// Render the table.
596    fn view(&self) -> String {
597        Table::view(self)
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_column_new() {
607        let col = Column::new("Name", 20);
608        assert_eq!(col.title, "Name");
609        assert_eq!(col.width, 20);
610    }
611
612    #[test]
613    fn test_table_new() {
614        let table = Table::new();
615        assert!(table.get_columns().is_empty());
616        assert!(table.get_rows().is_empty());
617        assert!(!table.is_focused());
618    }
619
620    #[test]
621    fn test_table_builder() {
622        let columns = vec![Column::new("ID", 10), Column::new("Name", 20)];
623        let rows = vec![
624            vec!["1".into(), "Alice".into()],
625            vec!["2".into(), "Bob".into()],
626        ];
627
628        let table = Table::new()
629            .columns(columns)
630            .rows(rows)
631            .height(10)
632            .focused(true);
633
634        assert_eq!(table.get_columns().len(), 2);
635        assert_eq!(table.get_rows().len(), 2);
636        assert!(table.is_focused());
637    }
638
639    #[test]
640    fn test_table_navigation() {
641        let rows = vec![
642            vec!["1".into()],
643            vec!["2".into()],
644            vec!["3".into()],
645            vec!["4".into()],
646            vec!["5".into()],
647        ];
648
649        let mut table = Table::new().rows(rows).height(10);
650
651        assert_eq!(table.cursor(), 0);
652
653        table.move_down(1);
654        assert_eq!(table.cursor(), 1);
655
656        table.move_down(2);
657        assert_eq!(table.cursor(), 3);
658
659        table.move_up(1);
660        assert_eq!(table.cursor(), 2);
661
662        table.goto_bottom();
663        assert_eq!(table.cursor(), 4);
664
665        table.goto_top();
666        assert_eq!(table.cursor(), 0);
667    }
668
669    #[test]
670    fn test_table_selected_row() {
671        let rows = vec![
672            vec!["1".into(), "Alice".into()],
673            vec!["2".into(), "Bob".into()],
674        ];
675
676        let mut table = Table::new().rows(rows);
677
678        assert_eq!(
679            table.selected_row(),
680            Some(&vec!["1".into(), "Alice".into()])
681        );
682
683        table.move_down(1);
684        assert_eq!(table.selected_row(), Some(&vec!["2".into(), "Bob".into()]));
685    }
686
687    #[test]
688    fn test_table_focus_blur() {
689        let mut table = Table::new();
690        assert!(!table.is_focused());
691
692        table.focus();
693        assert!(table.is_focused());
694
695        table.blur();
696        assert!(!table.is_focused());
697    }
698
699    #[test]
700    fn test_table_set_cursor() {
701        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
702
703        let mut table = Table::new().rows(rows);
704
705        table.set_cursor(2);
706        assert_eq!(table.cursor(), 2);
707
708        // Should clamp to last row
709        table.set_cursor(100);
710        assert_eq!(table.cursor(), 2);
711    }
712
713    #[test]
714    fn test_table_from_values() {
715        let mut table = Table::new();
716        table.from_values("a,b,c\n1,2,3\nx,y,z", ",");
717
718        assert_eq!(table.get_rows().len(), 3);
719        assert_eq!(table.get_rows()[0], vec!["a", "b", "c"]);
720        assert_eq!(table.get_rows()[1], vec!["1", "2", "3"]);
721    }
722
723    #[test]
724    fn test_table_view() {
725        let columns = vec![Column::new("ID", 5), Column::new("Name", 10)];
726        let rows = vec![
727            vec!["1".into(), "Alice".into()],
728            vec!["2".into(), "Bob".into()],
729        ];
730
731        let table = Table::new().columns(columns).rows(rows).height(5);
732        let view = table.view();
733
734        assert!(view.contains("ID"));
735        assert!(view.contains("Name"));
736    }
737
738    #[test]
739    fn test_truncate_string() {
740        assert_eq!(truncate_string("Hello", 10), "Hello");
741        assert_eq!(truncate_string("Hello World", 5), "Hell…");
742        assert_eq!(truncate_string("Hi", 2), "Hi");
743        assert_eq!(truncate_string("", 5), "");
744    }
745
746    #[test]
747    fn test_table_empty() {
748        let table = Table::new();
749        assert!(table.selected_row().is_none());
750        assert_eq!(table.cursor(), 0);
751    }
752
753    #[test]
754    fn test_keymap_default() {
755        let km = KeyMap::default();
756        assert!(!km.line_up.get_keys().is_empty());
757        assert!(!km.goto_bottom.get_keys().is_empty());
758    }
759
760    // Model trait implementation tests
761    #[test]
762    fn test_model_init() {
763        let table = Table::new();
764        // Tables don't require init commands
765        let cmd = Model::init(&table);
766        assert!(cmd.is_none());
767    }
768
769    #[test]
770    fn test_model_view() {
771        let columns = vec![Column::new("ID", 5)];
772        let rows = vec![vec!["1".into()]];
773        let table = Table::new().columns(columns).rows(rows);
774        // Model::view should return same result as Table::view
775        let model_view = Model::view(&table);
776        let table_view = Table::view(&table);
777        assert_eq!(model_view, table_view);
778    }
779
780    #[test]
781    fn test_model_update_handles_navigation() {
782        use bubbletea::{KeyMsg, KeyType, Message};
783
784        let columns = vec![Column::new("ID", 5), Column::new("Name", 20)];
785        let rows = vec![
786            vec!["1".into(), "First".into()],
787            vec!["2".into(), "Second".into()],
788            vec!["3".into(), "Third".into()],
789        ];
790        let mut table = Table::new().columns(columns).rows(rows).focused(true);
791        assert_eq!(table.cursor(), 0);
792
793        // Press down arrow
794        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
795        let _ = Model::update(&mut table, down_msg);
796
797        assert_eq!(table.cursor(), 1, "Table should navigate down on Down key");
798    }
799
800    #[test]
801    fn test_model_update_unfocused_ignores_input() {
802        use bubbletea::{KeyMsg, KeyType, Message};
803
804        let columns = vec![Column::new("ID", 5)];
805        let rows = vec![vec!["1".into()], vec!["2".into()]];
806        let mut table = Table::new().columns(columns).rows(rows);
807        // Table is not focused by default
808        assert!(!table.focus);
809        assert_eq!(table.cursor(), 0);
810
811        // Press down arrow
812        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
813        let _ = Model::update(&mut table, down_msg);
814
815        assert_eq!(table.cursor(), 0, "Unfocused table should ignore key input");
816    }
817
818    #[test]
819    fn test_model_update_goto_bottom() {
820        use bubbletea::{KeyMsg, KeyType, Message};
821
822        let columns = vec![Column::new("ID", 5)];
823        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
824        let mut table = Table::new().columns(columns).rows(rows).focused(true);
825        assert_eq!(table.cursor(), 0);
826
827        // Press End to go to bottom
828        let end_msg = Message::new(KeyMsg::from_type(KeyType::End));
829        let _ = Model::update(&mut table, end_msg);
830
831        assert_eq!(table.cursor(), 2, "Table should go to bottom on End key");
832    }
833
834    #[test]
835    fn test_table_satisfies_model_bounds() {
836        fn requires_model<T: Model + Send + 'static>() {}
837        requires_model::<Table>();
838    }
839
840    #[test]
841    fn test_model_update_page_down() {
842        use bubbletea::{KeyMsg, KeyType, Message};
843
844        let columns = vec![Column::new("ID", 5)];
845        // Create 20 rows to test page navigation
846        let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
847        let mut table = Table::new()
848            .columns(columns)
849            .rows(rows)
850            .focused(true)
851            .height(5); // 5 visible rows
852
853        assert_eq!(table.cursor(), 0);
854
855        // Press PageDown
856        let msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
857        let _ = Model::update(&mut table, msg);
858
859        // Should move down by height (5 rows)
860        assert!(
861            table.cursor() > 0,
862            "Table should navigate down on PageDown key"
863        );
864    }
865
866    #[test]
867    fn test_model_update_page_up() {
868        use bubbletea::{KeyMsg, KeyType, Message};
869
870        let columns = vec![Column::new("ID", 5)];
871        let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
872        let mut table = Table::new()
873            .columns(columns)
874            .rows(rows)
875            .focused(true)
876            .height(5);
877
878        // Start at row 10
879        table.set_cursor(10);
880        assert_eq!(table.cursor(), 10);
881
882        // Press PageUp
883        let msg = Message::new(KeyMsg::from_type(KeyType::PgUp));
884        let _ = Model::update(&mut table, msg);
885
886        // Should move up
887        assert!(
888            table.cursor() < 10,
889            "Table should navigate up on PageUp key"
890        );
891    }
892
893    #[test]
894    fn test_model_update_goto_top() {
895        use bubbletea::{KeyMsg, KeyType, Message};
896
897        let columns = vec![Column::new("ID", 5)];
898        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
899        let mut table = Table::new().columns(columns).rows(rows).focused(true);
900
901        // Start at last row
902        table.set_cursor(2);
903        assert_eq!(table.cursor(), 2);
904
905        // Press Home to go to top
906        let msg = Message::new(KeyMsg::from_type(KeyType::Home));
907        let _ = Model::update(&mut table, msg);
908
909        assert_eq!(table.cursor(), 0, "Table should go to top on Home key");
910    }
911
912    #[test]
913    fn test_table_set_rows_replaces_data() {
914        let columns = vec![Column::new("Name", 10)];
915        let initial_rows = vec![vec!["Alice".into()], vec!["Bob".into()]];
916        let mut table = Table::new().columns(columns).rows(initial_rows);
917
918        assert_eq!(table.rows.len(), 2);
919        assert_eq!(table.rows[0][0], "Alice");
920
921        // Replace rows
922        let new_rows = vec![
923            vec!["Charlie".into()],
924            vec!["Diana".into()],
925            vec!["Eve".into()],
926        ];
927        table.set_rows(new_rows);
928
929        assert_eq!(table.rows.len(), 3);
930        assert_eq!(table.rows[0][0], "Charlie");
931        assert_eq!(table.rows[1][0], "Diana");
932        assert_eq!(table.rows[2][0], "Eve");
933    }
934
935    #[test]
936    fn test_table_set_columns_updates_headers() {
937        let initial_cols = vec![Column::new("A", 5), Column::new("B", 5)];
938        let rows = vec![vec!["1".into(), "2".into()]];
939        let mut table = Table::new().columns(initial_cols).rows(rows);
940
941        assert_eq!(table.columns.len(), 2);
942        assert_eq!(table.columns[0].title, "A");
943
944        // Update columns
945        let new_cols = vec![
946            Column::new("X", 10),
947            Column::new("Y", 10),
948            Column::new("Z", 10),
949        ];
950        table.set_columns(new_cols);
951
952        assert_eq!(table.columns.len(), 3);
953        assert_eq!(table.columns[0].title, "X");
954        assert_eq!(table.columns[1].title, "Y");
955        assert_eq!(table.columns[2].title, "Z");
956    }
957
958    #[test]
959    fn test_table_single_row() {
960        use bubbletea::{KeyMsg, KeyType, Message};
961
962        let columns = vec![Column::new("Item", 10)];
963        let rows = vec![vec!["Only One".into()]];
964        let mut table = Table::new().columns(columns).rows(rows).focused(true);
965
966        assert_eq!(table.cursor(), 0);
967        assert_eq!(table.rows.len(), 1);
968
969        // Try to navigate down - should stay at 0
970        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
971        let _ = Model::update(&mut table, down_msg);
972        assert_eq!(table.cursor(), 0, "Single row table should not move down");
973
974        // Try to navigate up - should stay at 0
975        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
976        let _ = Model::update(&mut table, up_msg);
977        assert_eq!(table.cursor(), 0, "Single row table should not move up");
978
979        // Selected row should work
980        assert!(table.selected_row().is_some());
981        assert_eq!(table.selected_row().unwrap()[0], "Only One");
982    }
983
984    #[test]
985    fn test_table_single_column() {
986        let columns = vec![Column::new("Solo", 15)];
987        let rows = vec![
988            vec!["Row 1".into()],
989            vec!["Row 2".into()],
990            vec!["Row 3".into()],
991        ];
992        let table = Table::new().columns(columns).rows(rows);
993
994        assert_eq!(table.columns.len(), 1);
995        assert_eq!(table.columns[0].title, "Solo");
996        assert_eq!(table.columns[0].width, 15);
997
998        // View should still render correctly
999        let view = table.view();
1000        assert!(!view.is_empty());
1001        assert!(
1002            view.contains("Solo") || view.contains("Row"),
1003            "Single column table should render"
1004        );
1005    }
1006
1007    // ========================================================================
1008    // Additional Model trait tests for bead charmed_rust-zg4
1009    // ========================================================================
1010
1011    #[test]
1012    fn test_table_empty_navigation() {
1013        use bubbletea::{KeyMsg, KeyType, Message};
1014
1015        let mut table = Table::new().focused(true);
1016        assert!(table.rows.is_empty());
1017        assert_eq!(table.cursor(), 0);
1018
1019        // Navigation on empty table should not panic
1020        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1021        let _ = Model::update(&mut table, down_msg);
1022        assert_eq!(table.cursor(), 0, "Empty table cursor should stay at 0");
1023
1024        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1025        let _ = Model::update(&mut table, up_msg);
1026        assert_eq!(table.cursor(), 0, "Empty table cursor should stay at 0");
1027
1028        let end_msg = Message::new(KeyMsg::from_type(KeyType::End));
1029        let _ = Model::update(&mut table, end_msg);
1030        assert_eq!(
1031            table.cursor(),
1032            0,
1033            "Empty table goto_bottom should stay at 0"
1034        );
1035
1036        let home_msg = Message::new(KeyMsg::from_type(KeyType::Home));
1037        let _ = Model::update(&mut table, home_msg);
1038        assert_eq!(table.cursor(), 0, "Empty table goto_top should stay at 0");
1039    }
1040
1041    #[test]
1042    fn test_table_view_empty() {
1043        let table = Table::new();
1044        let view = table.view();
1045        // Empty table should still produce a view (may be empty or minimal)
1046        // Just verify it doesn't panic
1047        let _ = view;
1048    }
1049
1050    #[test]
1051    fn test_table_view_renders_column_widths() {
1052        let columns = vec![Column::new("Short", 5), Column::new("LongerColumn", 15)];
1053        let rows = vec![vec!["A".into(), "B".into()]];
1054        let table = Table::new().columns(columns).rows(rows);
1055        let view = table.view();
1056
1057        // View should be non-empty and contain column headers
1058        assert!(!view.is_empty());
1059        // The view includes headers that should be visible
1060        assert!(view.contains("Short") || view.contains("Longer"));
1061    }
1062
1063    #[test]
1064    fn test_model_update_navigate_up() {
1065        use bubbletea::{KeyMsg, KeyType, Message};
1066
1067        let columns = vec![Column::new("ID", 5)];
1068        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
1069        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1070        table.set_cursor(2);
1071        assert_eq!(table.cursor(), 2);
1072
1073        // Press Up arrow
1074        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1075        let _ = Model::update(&mut table, up_msg);
1076
1077        assert_eq!(table.cursor(), 1, "Table should navigate up on Up key");
1078    }
1079
1080    #[test]
1081    fn test_table_view_with_long_content() {
1082        let columns = vec![Column::new("Name", 5)];
1083        let rows = vec![vec!["VeryLongNameThatExceedsColumnWidth".into()]];
1084        let table = Table::new().columns(columns).rows(rows);
1085        let view = table.view();
1086
1087        // Content should be truncated in view (not crash)
1088        assert!(!view.is_empty());
1089    }
1090
1091    #[test]
1092    fn test_table_cursor_boundary_top() {
1093        use bubbletea::{KeyMsg, KeyType, Message};
1094
1095        let columns = vec![Column::new("ID", 5)];
1096        let rows = vec![vec!["1".into()], vec!["2".into()]];
1097        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1098        assert_eq!(table.cursor(), 0);
1099
1100        // Try to move up from top - should stay at 0
1101        let up_msg = Message::new(KeyMsg::from_type(KeyType::Up));
1102        let _ = Model::update(&mut table, up_msg);
1103        assert_eq!(table.cursor(), 0, "Cursor should not go below 0");
1104    }
1105
1106    #[test]
1107    fn test_table_cursor_boundary_bottom() {
1108        use bubbletea::{KeyMsg, KeyType, Message};
1109
1110        let columns = vec![Column::new("ID", 5)];
1111        let rows = vec![vec!["1".into()], vec!["2".into()]];
1112        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1113        table.set_cursor(1);
1114        assert_eq!(table.cursor(), 1);
1115
1116        // Try to move down from bottom - should stay at 1
1117        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1118        let _ = Model::update(&mut table, down_msg);
1119        assert_eq!(table.cursor(), 1, "Cursor should not exceed row count");
1120    }
1121
1122    #[test]
1123    fn test_table_update_with_j_k_keys() {
1124        use bubbletea::{KeyMsg, Message};
1125
1126        let columns = vec![Column::new("ID", 5)];
1127        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
1128        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1129        assert_eq!(table.cursor(), 0);
1130
1131        // Test 'j' key for down
1132        let j_msg = Message::new(KeyMsg::from_char('j'));
1133        let _ = Model::update(&mut table, j_msg);
1134        assert_eq!(table.cursor(), 1, "'j' should move cursor down");
1135
1136        // Test 'k' key for up
1137        let k_msg = Message::new(KeyMsg::from_char('k'));
1138        let _ = Model::update(&mut table, k_msg);
1139        assert_eq!(table.cursor(), 0, "'k' should move cursor up");
1140    }
1141
1142    #[test]
1143    fn test_table_update_with_g_and_shift_g_keys() {
1144        use bubbletea::{KeyMsg, Message};
1145
1146        let columns = vec![Column::new("ID", 5)];
1147        let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
1148        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1149        assert_eq!(table.cursor(), 0);
1150
1151        // Test 'G' key for goto bottom
1152        let g_upper_msg = Message::new(KeyMsg::from_char('G'));
1153        let _ = Model::update(&mut table, g_upper_msg);
1154        assert_eq!(table.cursor(), 2, "'G' should go to bottom");
1155
1156        // Test 'g' key for goto top
1157        let g_msg = Message::new(KeyMsg::from_char('g'));
1158        let _ = Model::update(&mut table, g_msg);
1159        assert_eq!(table.cursor(), 0, "'g' should go to top");
1160    }
1161
1162    #[test]
1163    fn test_table_height_affects_pagination() {
1164        use bubbletea::{KeyMsg, KeyType, Message};
1165
1166        let columns = vec![Column::new("ID", 5)];
1167        // 20 rows
1168        let rows: Vec<Row> = (1..=20).map(|i| vec![i.to_string()]).collect();
1169        let mut table = Table::new()
1170            .columns(columns)
1171            .rows(rows)
1172            .focused(true)
1173            .height(3); // Small viewport
1174
1175        assert_eq!(table.cursor(), 0);
1176
1177        // PageDown should move by height
1178        let pgdown_msg = Message::new(KeyMsg::from_type(KeyType::PgDown));
1179        let _ = Model::update(&mut table, pgdown_msg);
1180
1181        // Cursor should move down (exact amount depends on implementation)
1182        assert!(table.cursor() > 0, "PageDown should move cursor down");
1183    }
1184
1185    #[test]
1186    fn test_table_selected_row_after_navigation() {
1187        use bubbletea::{KeyMsg, KeyType, Message};
1188
1189        let columns = vec![Column::new("Name", 10)];
1190        let rows = vec![
1191            vec!["Alice".into()],
1192            vec!["Bob".into()],
1193            vec!["Carol".into()],
1194        ];
1195        let mut table = Table::new().columns(columns).rows(rows).focused(true);
1196
1197        assert_eq!(table.selected_row().unwrap()[0], "Alice");
1198
1199        let down_msg = Message::new(KeyMsg::from_type(KeyType::Down));
1200        let _ = Model::update(&mut table, down_msg);
1201        assert_eq!(table.selected_row().unwrap()[0], "Bob");
1202
1203        let _ = Model::update(&mut table, Message::new(KeyMsg::from_type(KeyType::Down)));
1204        assert_eq!(table.selected_row().unwrap()[0], "Carol");
1205    }
1206
1207    // -------------------------------------------------------------------------
1208    // Mouse support tests (bd-3ps4)
1209    // -------------------------------------------------------------------------
1210
1211    mod mouse_tests {
1212        use super::*;
1213        use bubbletea::Message;
1214
1215        fn wheel_up_msg() -> Message {
1216            Message::new(MouseMsg {
1217                x: 0,
1218                y: 0,
1219                shift: false,
1220                alt: false,
1221                ctrl: false,
1222                action: MouseAction::Press,
1223                button: MouseButton::WheelUp,
1224            })
1225        }
1226
1227        fn wheel_down_msg() -> Message {
1228            Message::new(MouseMsg {
1229                x: 0,
1230                y: 0,
1231                shift: false,
1232                alt: false,
1233                ctrl: false,
1234                action: MouseAction::Press,
1235                button: MouseButton::WheelDown,
1236            })
1237        }
1238
1239        fn click_msg(x: u16, y: u16) -> Message {
1240            Message::new(MouseMsg {
1241                x,
1242                y,
1243                shift: false,
1244                alt: false,
1245                ctrl: false,
1246                action: MouseAction::Press,
1247                button: MouseButton::Left,
1248            })
1249        }
1250
1251        #[test]
1252        fn test_table_mouse_wheel_scroll_down() {
1253            let rows = vec![
1254                vec!["1".into()],
1255                vec!["2".into()],
1256                vec!["3".into()],
1257                vec!["4".into()],
1258                vec!["5".into()],
1259            ];
1260            let mut table = Table::new().rows(rows).focused(true);
1261            assert_eq!(table.cursor(), 0);
1262
1263            table.update(&wheel_down_msg());
1264            // Default delta is 3, so cursor should be at 3
1265            assert_eq!(table.cursor(), 3);
1266        }
1267
1268        #[test]
1269        fn test_table_mouse_wheel_scroll_up() {
1270            let rows = vec![
1271                vec!["1".into()],
1272                vec!["2".into()],
1273                vec!["3".into()],
1274                vec!["4".into()],
1275                vec!["5".into()],
1276            ];
1277            let mut table = Table::new().rows(rows).focused(true);
1278
1279            // Move to bottom first
1280            table.goto_bottom();
1281            assert_eq!(table.cursor(), 4);
1282
1283            table.update(&wheel_up_msg());
1284            // Default delta is 3, so cursor should be at 1
1285            assert_eq!(table.cursor(), 1);
1286        }
1287
1288        #[test]
1289        fn test_table_mouse_click_select_row() {
1290            let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
1291            let mut table = Table::new().rows(rows).focused(true);
1292            assert_eq!(table.cursor(), 0);
1293
1294            // Click on row 2 (y=0 is header, y=1 is row 0, y=2 is row 1)
1295            table.update(&click_msg(5, 2));
1296            assert_eq!(table.cursor(), 1);
1297
1298            // Click on row 3
1299            table.update(&click_msg(5, 3));
1300            assert_eq!(table.cursor(), 2);
1301        }
1302
1303        #[test]
1304        fn test_table_mouse_click_header_does_nothing() {
1305            let rows = vec![vec!["1".into()], vec!["2".into()]];
1306            let mut table = Table::new().rows(rows).focused(true);
1307            assert_eq!(table.cursor(), 0);
1308
1309            // Click on header (y=0)
1310            table.update(&click_msg(5, 0));
1311            // Cursor should not change
1312            assert_eq!(table.cursor(), 0);
1313        }
1314
1315        #[test]
1316        fn test_table_mouse_click_out_of_bounds() {
1317            let rows = vec![vec!["1".into()], vec!["2".into()]];
1318            let mut table = Table::new().rows(rows).focused(true);
1319            assert_eq!(table.cursor(), 0);
1320
1321            // Click way below the table
1322            table.update(&click_msg(5, 100));
1323            // Cursor should not change (out of bounds)
1324            assert_eq!(table.cursor(), 0);
1325        }
1326
1327        #[test]
1328        fn test_table_mouse_disabled() {
1329            let rows = vec![vec!["1".into()], vec!["2".into()], vec!["3".into()]];
1330            let mut table = Table::new()
1331                .rows(rows)
1332                .focused(true)
1333                .mouse_wheel(false)
1334                .mouse_click(false);
1335
1336            // Wheel should be ignored
1337            table.update(&wheel_down_msg());
1338            assert_eq!(table.cursor(), 0);
1339
1340            // Click should be ignored
1341            table.update(&click_msg(5, 2));
1342            assert_eq!(table.cursor(), 0);
1343        }
1344
1345        #[test]
1346        fn test_table_mouse_not_focused() {
1347            let rows = vec![vec!["1".into()], vec!["2".into()]];
1348            let mut table = Table::new().rows(rows).focused(false);
1349
1350            // Mouse should be ignored when not focused
1351            table.update(&wheel_down_msg());
1352            assert_eq!(table.cursor(), 0);
1353
1354            table.update(&click_msg(5, 2));
1355            assert_eq!(table.cursor(), 0);
1356        }
1357
1358        #[test]
1359        fn test_table_mouse_wheel_delta_builder() {
1360            let rows = vec![
1361                vec!["1".into()],
1362                vec!["2".into()],
1363                vec!["3".into()],
1364                vec!["4".into()],
1365                vec!["5".into()],
1366            ];
1367            let mut table = Table::new().rows(rows).focused(true).mouse_wheel_delta(1); // Single step
1368
1369            table.update(&wheel_down_msg());
1370            assert_eq!(table.cursor(), 1); // Only moved by 1
1371        }
1372
1373        #[test]
1374        fn test_table_mouse_release_ignored() {
1375            let rows = vec![vec!["1".into()], vec!["2".into()]];
1376            let mut table = Table::new().rows(rows).focused(true);
1377
1378            // Release event should be ignored
1379            let release_msg = Message::new(MouseMsg {
1380                x: 5,
1381                y: 2,
1382                shift: false,
1383                alt: false,
1384                ctrl: false,
1385                action: MouseAction::Release,
1386                button: MouseButton::Left,
1387            });
1388            table.update(&release_msg);
1389            assert_eq!(table.cursor(), 0);
1390        }
1391    }
1392}