Skip to main content

mockforge_tui/widgets/
table.rs

1//! Enhanced table widget with sorting, scrolling, and row selection.
2
3use crossterm::event::{KeyCode, KeyEvent};
4
5/// Table state that tracks selection, scroll position, and sort column.
6#[derive(Debug)]
7pub struct TableState {
8    /// Currently selected row index.
9    pub selected: usize,
10    /// Scroll offset (first visible row).
11    pub offset: usize,
12    /// Visible height (set on each render).
13    pub visible_height: usize,
14    /// Total number of rows.
15    pub total_rows: usize,
16    /// Current sort column index.
17    pub sort_column: usize,
18    /// Sort ascending.
19    pub sort_ascending: bool,
20}
21
22impl Default for TableState {
23    fn default() -> Self {
24        Self {
25            selected: 0,
26            offset: 0,
27            visible_height: 20,
28            total_rows: 0,
29            sort_column: 0,
30            sort_ascending: true,
31        }
32    }
33}
34
35impl TableState {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Update the total row count (call before render).
41    pub fn set_total(&mut self, total: usize) {
42        self.total_rows = total;
43        if self.selected >= total && total > 0 {
44            self.selected = total - 1;
45        }
46    }
47
48    /// Scroll down by one row.
49    pub fn scroll_down(&mut self) {
50        if self.total_rows == 0 {
51            return;
52        }
53        if self.selected < self.total_rows - 1 {
54            self.selected += 1;
55        }
56        // Keep selected visible.
57        if self.selected >= self.offset + self.visible_height {
58            self.offset = self.selected - self.visible_height + 1;
59        }
60    }
61
62    /// Scroll up by one row.
63    pub fn scroll_up(&mut self) {
64        self.selected = self.selected.saturating_sub(1);
65        if self.selected < self.offset {
66            self.offset = self.selected;
67        }
68    }
69
70    /// Jump to first row.
71    pub fn scroll_top(&mut self) {
72        self.selected = 0;
73        self.offset = 0;
74    }
75
76    /// Jump to last row.
77    pub fn scroll_bottom(&mut self) {
78        if self.total_rows > 0 {
79            self.selected = self.total_rows - 1;
80            self.offset = self.total_rows.saturating_sub(self.visible_height);
81        }
82    }
83
84    /// Page down.
85    pub fn page_down(&mut self) {
86        let jump = self.visible_height.saturating_sub(1).max(1);
87        self.selected = (self.selected + jump).min(self.total_rows.saturating_sub(1));
88        self.offset = self.selected.saturating_sub(self.visible_height.saturating_sub(1));
89    }
90
91    /// Page up.
92    pub fn page_up(&mut self) {
93        let jump = self.visible_height.saturating_sub(1).max(1);
94        self.selected = self.selected.saturating_sub(jump);
95        if self.selected < self.offset {
96            self.offset = self.selected;
97        }
98    }
99
100    /// Cycle sort column.
101    pub fn next_sort(&mut self, num_columns: usize) {
102        if num_columns == 0 {
103            return;
104        }
105        if self.sort_column + 1 < num_columns {
106            self.sort_column += 1;
107        } else {
108            self.sort_column = 0;
109            self.sort_ascending = !self.sort_ascending;
110        }
111    }
112
113    /// Handle common navigation keys. Returns `true` if consumed.
114    pub fn handle_key(&mut self, key: KeyEvent) -> bool {
115        match key.code {
116            KeyCode::Char('j') | KeyCode::Down => {
117                self.scroll_down();
118                true
119            }
120            KeyCode::Char('k') | KeyCode::Up => {
121                self.scroll_up();
122                true
123            }
124            KeyCode::Char('g') => {
125                self.scroll_top();
126                true
127            }
128            KeyCode::Char('G') => {
129                self.scroll_bottom();
130                true
131            }
132            KeyCode::PageDown => {
133                self.page_down();
134                true
135            }
136            KeyCode::PageUp => {
137                self.page_up();
138                true
139            }
140            _ => false,
141        }
142    }
143
144    /// Visible row range for slicing data.
145    pub fn visible_range(&self) -> std::ops::Range<usize> {
146        let end = (self.offset + self.visible_height).min(self.total_rows);
147        self.offset..end
148    }
149
150    /// Convert ratatui table state from our state.
151    pub fn to_ratatui_state(&self) -> ratatui::widgets::TableState {
152        let mut state = ratatui::widgets::TableState::default();
153        if self.total_rows > 0 {
154            state.select(Some(self.selected - self.offset));
155        }
156        state
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
164
165    fn key(code: KeyCode) -> KeyEvent {
166        KeyEvent {
167            code,
168            modifiers: KeyModifiers::NONE,
169            kind: KeyEventKind::Press,
170            state: KeyEventState::NONE,
171        }
172    }
173
174    #[test]
175    fn default_state() {
176        let ts = TableState::new();
177        assert_eq!(ts.selected, 0);
178        assert_eq!(ts.offset, 0);
179        assert_eq!(ts.visible_height, 20);
180        assert_eq!(ts.total_rows, 0);
181        assert_eq!(ts.sort_column, 0);
182        assert!(ts.sort_ascending);
183    }
184
185    #[test]
186    fn set_total_clamps_selected() {
187        let mut ts = TableState::new();
188        ts.total_rows = 10;
189        ts.selected = 8;
190
191        // Shrink total below selected
192        ts.set_total(5);
193        assert_eq!(ts.total_rows, 5);
194        assert_eq!(ts.selected, 4); // Clamped to total - 1
195    }
196
197    #[test]
198    fn set_total_does_not_clamp_when_within_range() {
199        let mut ts = TableState::new();
200        ts.selected = 3;
201        ts.set_total(10);
202        assert_eq!(ts.selected, 3);
203    }
204
205    #[test]
206    fn scroll_down_increments_selected() {
207        let mut ts = TableState::new();
208        ts.set_total(10);
209        ts.visible_height = 5;
210
211        ts.scroll_down();
212        assert_eq!(ts.selected, 1);
213        assert_eq!(ts.offset, 0);
214    }
215
216    #[test]
217    fn scroll_down_stops_at_last_row() {
218        let mut ts = TableState::new();
219        ts.set_total(3);
220        ts.selected = 2;
221
222        ts.scroll_down();
223        assert_eq!(ts.selected, 2); // No change — already at end
224    }
225
226    #[test]
227    fn scroll_down_adjusts_offset_when_past_visible() {
228        let mut ts = TableState::new();
229        ts.set_total(10);
230        ts.visible_height = 3;
231        ts.selected = 2; // At bottom of visible area
232        ts.offset = 0;
233
234        ts.scroll_down();
235        assert_eq!(ts.selected, 3);
236        assert_eq!(ts.offset, 1); // offset adjusts to keep selected visible
237    }
238
239    #[test]
240    fn scroll_down_with_zero_rows_does_nothing() {
241        let mut ts = TableState::new();
242        ts.set_total(0);
243        ts.scroll_down();
244        assert_eq!(ts.selected, 0);
245    }
246
247    #[test]
248    fn scroll_up_decrements_selected() {
249        let mut ts = TableState::new();
250        ts.set_total(10);
251        ts.selected = 5;
252
253        ts.scroll_up();
254        assert_eq!(ts.selected, 4);
255    }
256
257    #[test]
258    fn scroll_up_stops_at_zero() {
259        let mut ts = TableState::new();
260        ts.set_total(10);
261        ts.selected = 0;
262
263        ts.scroll_up();
264        assert_eq!(ts.selected, 0);
265    }
266
267    #[test]
268    fn scroll_up_adjusts_offset() {
269        let mut ts = TableState::new();
270        ts.set_total(10);
271        ts.visible_height = 3;
272        ts.selected = 3;
273        ts.offset = 3;
274
275        ts.scroll_up();
276        assert_eq!(ts.selected, 2);
277        assert_eq!(ts.offset, 2); // Adjusts to keep selected visible
278    }
279
280    #[test]
281    fn scroll_top_resets_to_zero() {
282        let mut ts = TableState::new();
283        ts.set_total(10);
284        ts.selected = 7;
285        ts.offset = 5;
286
287        ts.scroll_top();
288        assert_eq!(ts.selected, 0);
289        assert_eq!(ts.offset, 0);
290    }
291
292    #[test]
293    fn scroll_bottom_jumps_to_last_row() {
294        let mut ts = TableState::new();
295        ts.set_total(10);
296        ts.visible_height = 3;
297
298        ts.scroll_bottom();
299        assert_eq!(ts.selected, 9);
300        assert_eq!(ts.offset, 7); // 10 - 3
301    }
302
303    #[test]
304    fn scroll_bottom_with_zero_rows_does_nothing() {
305        let mut ts = TableState::new();
306        ts.set_total(0);
307        ts.scroll_bottom();
308        assert_eq!(ts.selected, 0);
309        assert_eq!(ts.offset, 0);
310    }
311
312    #[test]
313    fn page_down_jumps_visible_height() {
314        let mut ts = TableState::new();
315        ts.set_total(50);
316        ts.visible_height = 10;
317        ts.selected = 0;
318
319        ts.page_down();
320        assert_eq!(ts.selected, 9); // visible_height - 1
321    }
322
323    #[test]
324    fn page_down_clamps_to_last_row() {
325        let mut ts = TableState::new();
326        ts.set_total(5);
327        ts.visible_height = 10;
328        ts.selected = 3;
329
330        ts.page_down();
331        assert_eq!(ts.selected, 4); // Last row
332    }
333
334    #[test]
335    fn page_up_jumps_visible_height() {
336        let mut ts = TableState::new();
337        ts.set_total(50);
338        ts.visible_height = 10;
339        ts.selected = 20;
340        ts.offset = 15;
341
342        ts.page_up();
343        assert_eq!(ts.selected, 11); // 20 - 9
344    }
345
346    #[test]
347    fn page_up_clamps_to_zero() {
348        let mut ts = TableState::new();
349        ts.set_total(50);
350        ts.visible_height = 10;
351        ts.selected = 3;
352        ts.offset = 0;
353
354        ts.page_up();
355        assert_eq!(ts.selected, 0);
356    }
357
358    #[test]
359    fn next_sort_cycles_columns() {
360        let mut ts = TableState::new();
361        assert_eq!(ts.sort_column, 0);
362        assert!(ts.sort_ascending);
363
364        ts.next_sort(3);
365        assert_eq!(ts.sort_column, 1);
366        assert!(ts.sort_ascending);
367
368        ts.next_sort(3);
369        assert_eq!(ts.sort_column, 2);
370        assert!(ts.sort_ascending);
371
372        // Wraps around and flips sort direction
373        ts.next_sort(3);
374        assert_eq!(ts.sort_column, 0);
375        assert!(!ts.sort_ascending);
376    }
377
378    #[test]
379    fn next_sort_zero_columns_does_nothing() {
380        let mut ts = TableState::new();
381        ts.next_sort(0);
382        assert_eq!(ts.sort_column, 0);
383    }
384
385    #[test]
386    fn handle_key_j_scrolls_down() {
387        let mut ts = TableState::new();
388        ts.set_total(10);
389        assert!(ts.handle_key(key(KeyCode::Char('j'))));
390        assert_eq!(ts.selected, 1);
391    }
392
393    #[test]
394    fn handle_key_k_scrolls_up() {
395        let mut ts = TableState::new();
396        ts.set_total(10);
397        ts.selected = 5;
398        assert!(ts.handle_key(key(KeyCode::Char('k'))));
399        assert_eq!(ts.selected, 4);
400    }
401
402    #[test]
403    fn handle_key_g_scrolls_top() {
404        let mut ts = TableState::new();
405        ts.set_total(10);
406        ts.selected = 5;
407        assert!(ts.handle_key(key(KeyCode::Char('g'))));
408        assert_eq!(ts.selected, 0);
409    }
410
411    #[test]
412    fn handle_key_shift_g_scrolls_bottom() {
413        let mut ts = TableState::new();
414        ts.set_total(10);
415        ts.visible_height = 5;
416        assert!(ts.handle_key(key(KeyCode::Char('G'))));
417        assert_eq!(ts.selected, 9);
418    }
419
420    #[test]
421    fn handle_key_arrow_keys() {
422        let mut ts = TableState::new();
423        ts.set_total(10);
424        assert!(ts.handle_key(key(KeyCode::Down)));
425        assert_eq!(ts.selected, 1);
426        assert!(ts.handle_key(key(KeyCode::Up)));
427        assert_eq!(ts.selected, 0);
428    }
429
430    #[test]
431    fn handle_key_page_up_down() {
432        let mut ts = TableState::new();
433        ts.set_total(50);
434        ts.visible_height = 10;
435        assert!(ts.handle_key(key(KeyCode::PageDown)));
436        assert_eq!(ts.selected, 9);
437        assert!(ts.handle_key(key(KeyCode::PageUp)));
438        assert_eq!(ts.selected, 0);
439    }
440
441    #[test]
442    fn handle_key_unrecognized_returns_false() {
443        let mut ts = TableState::new();
444        ts.set_total(10);
445        assert!(!ts.handle_key(key(KeyCode::Char('x'))));
446    }
447
448    #[test]
449    fn visible_range_basic() {
450        let mut ts = TableState::new();
451        ts.set_total(50);
452        ts.visible_height = 10;
453        ts.offset = 5;
454
455        let range = ts.visible_range();
456        assert_eq!(range, 5..15);
457    }
458
459    #[test]
460    fn visible_range_clamps_to_total() {
461        let mut ts = TableState::new();
462        ts.set_total(3);
463        ts.visible_height = 10;
464        ts.offset = 0;
465
466        let range = ts.visible_range();
467        assert_eq!(range, 0..3);
468    }
469
470    #[test]
471    fn visible_range_empty() {
472        let ts = TableState::new();
473        let range = ts.visible_range();
474        assert_eq!(range, 0..0);
475    }
476}