mockforge_tui/widgets/
table.rs1use crossterm::event::{KeyCode, KeyEvent};
4
5#[derive(Debug)]
7pub struct TableState {
8 pub selected: usize,
10 pub offset: usize,
12 pub visible_height: usize,
14 pub total_rows: usize,
16 pub sort_column: usize,
18 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 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 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 if self.selected >= self.offset + self.visible_height {
58 self.offset = self.selected - self.visible_height + 1;
59 }
60 }
61
62 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 pub fn scroll_top(&mut self) {
72 self.selected = 0;
73 self.offset = 0;
74 }
75
76 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 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 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 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 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 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 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 ts.set_total(5);
193 assert_eq!(ts.total_rows, 5);
194 assert_eq!(ts.selected, 4); }
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); }
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; ts.offset = 0;
233
234 ts.scroll_down();
235 assert_eq!(ts.selected, 3);
236 assert_eq!(ts.offset, 1); }
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); }
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); }
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); }
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); }
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); }
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 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}