1use crate::common::PageSize;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TableWindow {
7 SelectedPage,
9 ScrollOffset,
11}
12
13#[derive(Debug)]
18pub struct PreparedTableView<'a, T> {
19 pub items: Vec<&'a T>,
20 pub total_items: usize,
21 pub current_page: usize,
22 pub total_pages: usize,
23 pub selected_index: usize,
26 pub expanded_index: Option<usize>,
27}
28
29#[derive(Debug, Clone)]
31pub struct TableState<T> {
32 pub items: Vec<T>,
33 pub selected: usize,
34 pub loading: bool,
35 pub filter: String,
36 pub page_size: PageSize,
37 pub expanded_item: Option<usize>,
38 pub scroll_offset: usize,
39 pub next_token: Option<String>,
40}
41
42impl<T> Default for TableState<T> {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48impl<T> TableState<T> {
49 pub fn new() -> Self {
50 Self {
51 items: Vec::new(),
52 selected: 0,
53 loading: false,
54 filter: String::new(),
55 page_size: PageSize::Fifty,
56 expanded_item: None,
57 scroll_offset: 0,
58 next_token: None,
59 }
60 }
61
62 pub fn filtered<F>(&self, predicate: F) -> Vec<&T>
63 where
64 F: Fn(&T) -> bool,
65 {
66 self.items.iter().filter(|item| predicate(item)).collect()
67 }
68
69 pub fn prepare_view<'a>(
76 &self,
77 mut items: Vec<&'a T>,
78 window: TableWindow,
79 ) -> PreparedTableView<'a, T> {
80 let total_items = items.len();
81 let page_size = self.page_size.value();
82 let start = match window {
83 TableWindow::SelectedPage => self.selected / page_size * page_size,
84 TableWindow::ScrollOffset => self.scroll_offset,
85 };
86 let range = self.window_range(start, total_items);
87 let expanded_index = self
88 .expanded_item
89 .filter(|idx| range.contains(idx))
90 .map(|idx| idx - start);
91
92 items.truncate(range.end);
95 items.drain(..range.start);
96
97 PreparedTableView {
98 items,
99 total_items,
100 current_page: start / page_size,
101 total_pages: self.total_pages(total_items),
102 selected_index: self.selected.saturating_sub(start),
103 expanded_index,
104 }
105 }
106
107 fn window_range(&self, start: usize, total_items: usize) -> std::ops::Range<usize> {
108 start.min(total_items)
109 ..start
110 .saturating_add(self.page_size.value())
111 .min(total_items)
112 }
113
114 pub fn paginate<'a>(&self, filtered: &'a [&'a T]) -> &'a [&'a T] {
115 &filtered[self.window_range(self.scroll_offset, filtered.len())]
116 }
117
118 pub fn current_page(&self, _total_items: usize) -> usize {
119 self.scroll_offset / self.page_size.value()
120 }
121
122 pub fn total_pages(&self, total_items: usize) -> usize {
123 total_items.div_ceil(self.page_size.value())
124 }
125
126 pub fn next_item(&mut self, max: usize) {
127 if max > 0 {
128 let new_selected = (self.selected + 1).min(max - 1);
129 if new_selected != self.selected {
130 self.selected = new_selected;
131
132 let page_size = self.page_size.value();
135 if self.selected >= self.scroll_offset + page_size {
136 self.scroll_offset = self.selected - page_size + 1;
137 }
138 }
139 }
140 }
141
142 pub fn next_item_with_expansion(&mut self, max: usize, extra_rows_before_selected: usize) {
146 if max > 0 {
147 let new_selected = (self.selected + 1).min(max - 1);
148 if new_selected != self.selected {
149 self.selected = new_selected;
150
151 let page_size = self.page_size.value();
152
153 if self.selected >= self.scroll_offset + page_size {
155 self.scroll_offset = self.selected - page_size + 1;
156 }
157
158 let selected_visual_row =
160 self.selected.saturating_sub(self.scroll_offset) + extra_rows_before_selected;
161 if selected_visual_row >= page_size {
162 let overflow = selected_visual_row.saturating_sub(page_size - 1);
163 self.scroll_offset += overflow;
164 }
165 }
166 }
167 }
168
169 pub fn prev_item(&mut self) {
170 if self.selected > 0 {
171 self.selected -= 1;
172
173 if self.selected < self.scroll_offset {
175 self.scroll_offset = self.selected;
176 }
177 }
178 }
179
180 pub fn page_down(&mut self, max: usize) {
181 if max > 0 {
182 let page_size = self.page_size.value();
183 self.selected = (self.selected + 10).min(max - 1);
184
185 let current_page = self.selected / page_size;
187 self.scroll_offset = current_page * page_size;
188 }
189 }
190
191 pub fn page_up(&mut self) {
192 let page_size = self.page_size.value();
193 self.selected = self.selected.saturating_sub(10);
194
195 let current_page = self.selected / page_size;
197 self.scroll_offset = current_page * page_size;
198 }
199
200 pub fn snap_to_page(&mut self) {
201 let page_size = self.page_size.value();
202 let current_page = self.selected / page_size;
203 self.scroll_offset = current_page * page_size;
204 }
205
206 pub fn toggle_expand(&mut self) {
207 self.expanded_item = if self.expanded_item == Some(self.selected) {
208 None
209 } else {
210 Some(self.selected)
211 };
212 }
213
214 pub fn ensure_expansion_visible(&mut self, detail_rows: usize, viewport_height: usize) {
218 if self.expanded_item != Some(self.selected) || detail_rows == 0 || viewport_height == 0 {
219 return;
220 }
221 let page_size = self.page_size.value();
222 let selected_visual = self.selected.saturating_sub(self.scroll_offset);
224 let last_detail_row = selected_visual + detail_rows;
226 let effective_height = page_size.min(viewport_height);
228 if last_detail_row >= effective_height {
229 let overflow = last_detail_row.saturating_sub(effective_height - 1);
231 self.scroll_offset = self.scroll_offset.saturating_add(overflow);
232 }
233 }
234
235 pub fn collapse(&mut self) {
236 self.expanded_item = None;
237 }
238
239 pub fn expand(&mut self) {
240 self.expanded_item = Some(self.selected);
241 }
242
243 pub fn is_expanded(&self) -> bool {
244 self.expanded_item == Some(self.selected)
245 }
246
247 pub fn has_expanded_item(&self) -> bool {
248 self.expanded_item.is_some()
249 }
250
251 pub fn goto_page(&mut self, page: usize, total_items: usize) {
252 let page_size = self.page_size.value();
253 let target = (page - 1) * page_size;
254 let max = total_items.saturating_sub(1);
255 self.selected = target.min(max);
256 self.scroll_offset = target.min(total_items.saturating_sub(page_size));
257 }
258
259 pub fn reset(&mut self) {
260 self.selected = 0;
261 self.scroll_offset = 0;
262 self.expanded_item = None;
263 }
264
265 pub fn set_page_size(&mut self, page_size: PageSize) {
269 self.page_size = page_size;
270 self.selected = 0;
271 self.scroll_offset = 0;
272 self.expanded_item = None;
273 }
274
275 pub fn get_selected<'a>(&self, filtered: &'a [&'a T]) -> Option<&'a T> {
276 filtered.get(self.selected).copied()
277 }
278
279 pub fn filter_push(&mut self, c: char) {
281 self.filter.push(c);
282 self.reset();
283 }
284
285 pub fn filter_pop(&mut self) {
287 self.filter.pop();
288 self.reset();
289 }
290
291 pub fn filter_clear(&mut self) {
293 self.filter.clear();
294 self.reset();
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_table_state_default() {
304 let state: TableState<String> = TableState::new();
305 assert_eq!(state.selected, 0);
306 assert!(!state.loading);
307 assert_eq!(state.filter, "");
308 assert_eq!(state.page_size, PageSize::Fifty);
309 assert_eq!(state.expanded_item, None);
310 }
311
312 #[test]
313 fn test_filtered() {
314 let mut state = TableState::new();
315 state.items = vec![
316 "apple".to_string(),
317 "banana".to_string(),
318 "apricot".to_string(),
319 ];
320
321 let filtered = state.filtered(|item| item.starts_with('a'));
322 assert_eq!(filtered.len(), 2);
323 }
324
325 #[test]
326 fn test_paginate() {
327 let state = TableState::<String> {
328 page_size: PageSize::Ten,
329 selected: 0,
330 ..TableState::new()
331 };
332
333 let items: Vec<String> = (0..25).map(|i| i.to_string()).collect();
334 let refs: Vec<&String> = items.iter().collect();
335
336 let page = state.paginate(&refs);
337 assert_eq!(page.len(), 10);
338 }
339
340 #[test]
341 fn test_navigation() {
342 let mut state = TableState::<String>::new();
343
344 state.next_item(10);
345 assert_eq!(state.selected, 1);
346
347 state.prev_item();
348 assert_eq!(state.selected, 0);
349
350 state.page_down(100);
351 assert_eq!(state.selected, 10);
352
353 state.page_up();
354 assert_eq!(state.selected, 0);
355 }
356
357 #[test]
358 fn test_expand_toggle() {
359 let mut state = TableState::<String>::new();
360
361 assert!(!state.is_expanded());
362
363 state.toggle_expand();
364 assert!(state.is_expanded());
365
366 state.toggle_expand();
367 assert!(!state.is_expanded());
368
369 state.toggle_expand();
370 state.collapse();
371 assert!(!state.is_expanded());
372 }
373
374 #[test]
375 fn test_next_item_with_expansion_scrolls_when_selection_goes_below_viewport() {
376 let mut state = TableState::<i32>::new();
383 state.page_size = crate::common::PageSize::Ten; state.items = (0..20).collect();
385 state.scroll_offset = 0;
386 state.selected = 5;
387
388 state.next_item_with_expansion(20, 4);
390
391 assert_eq!(state.selected, 6, "selected must advance to 6");
392 assert!(
393 state.scroll_offset > 0,
394 "scroll_offset must advance when expansion pushes selected off viewport; got {}",
395 state.scroll_offset
396 );
397 let in_viewport =
398 state.selected >= state.scroll_offset && state.selected < state.scroll_offset + 10;
399 assert!(
400 in_viewport,
401 "selected={} must be within [{}, {})",
402 state.selected,
403 state.scroll_offset,
404 state.scroll_offset + 10
405 );
406 }
407}
408
409#[cfg(test)]
410mod prepared_view_tests {
411 use super::*;
412
413 #[test]
414 fn preparation_reuses_allocation_and_keeps_filtered_sorted_order() {
415 #[derive(Debug)]
417 struct Item(usize);
418 let state = TableState {
419 items: (0..30).map(Item).collect(),
420 selected: 12,
421 expanded_item: Some(11),
422 page_size: PageSize::Ten,
423 ..TableState::new()
424 };
425 let mut filtered = state.filtered(|item| item.0 % 2 == 0);
426 filtered.sort_by_key(|item| std::cmp::Reverse(item.0));
427 let allocation = filtered.as_ptr();
428 let capacity = filtered.capacity();
429 let view = state.prepare_view(filtered, TableWindow::SelectedPage);
430 assert_eq!(view.items.as_ptr(), allocation);
431 assert_eq!(view.items.capacity(), capacity);
432 assert_eq!(
433 view.items.iter().map(|item| item.0).collect::<Vec<_>>(),
434 [8, 6, 4, 2, 0]
435 );
436 assert_eq!(
437 (view.total_items, view.current_page, view.total_pages),
438 (15, 1, 2)
439 );
440 assert_eq!(view.selected_index, 2);
441 assert_eq!(view.expanded_index, Some(1));
442 }
443
444 #[test]
445 fn window_modes_preserve_page_vs_sliding_scroll_behavior() {
446 let state = TableState {
447 items: (0..25).collect::<Vec<_>>(),
448 selected: 14,
449 scroll_offset: 7,
450 expanded_item: Some(9),
451 page_size: PageSize::Ten,
452 ..TableState::new()
453 };
454 let page = state.prepare_view(state.items.iter().collect(), TableWindow::SelectedPage);
455 let scroll = state.prepare_view(state.items.iter().collect(), TableWindow::ScrollOffset);
456 assert_eq!(
457 page.items,
458 (10..20).collect::<Vec<_>>().iter().collect::<Vec<_>>()
459 );
460 assert_eq!(
461 scroll.items,
462 (7..17).collect::<Vec<_>>().iter().collect::<Vec<_>>()
463 );
464 assert_eq!(
465 (page.current_page, page.selected_index, page.expanded_index),
466 (1, 4, None)
467 );
468 assert_eq!(
469 (
470 scroll.current_page,
471 scroll.selected_index,
472 scroll.expanded_index
473 ),
474 (0, 7, Some(2))
475 );
476 assert_eq!(
477 state.paginate(&state.items.iter().collect::<Vec<_>>()),
478 scroll.items
479 );
480 }
481
482 #[test]
483 fn expansion_is_relative_and_only_present_inside_the_window() {
484 let mut state = TableState {
485 items: (0..25).collect::<Vec<_>>(),
486 selected: 12,
487 page_size: PageSize::Ten,
488 ..TableState::new()
489 };
490 for (expanded, expected) in [
491 (None, None),
492 (Some(9), None),
493 (Some(10), Some(0)),
494 (Some(19), Some(9)),
495 (Some(20), None),
496 (Some(usize::MAX), None),
497 ] {
498 state.expanded_item = expanded;
499 let view = state.prepare_view(state.items.iter().collect(), TableWindow::SelectedPage);
500 assert_eq!(view.expanded_index, expected);
501 }
502 }
503
504 #[test]
505 fn empty_and_stale_positions_are_safe_and_do_not_mutate_state() {
506 for window in [TableWindow::SelectedPage, TableWindow::ScrollOffset] {
507 for len in [0, 3, 10] {
508 let state = TableState {
509 items: (0..len).collect::<Vec<_>>(),
510 selected: usize::MAX,
511 scroll_offset: usize::MAX,
512 expanded_item: Some(usize::MAX),
513 filter: "unchanged".into(),
514 next_token: Some("server-cursor".into()),
515 page_size: PageSize::Ten,
516 ..TableState::new()
517 };
518 let view = state.prepare_view(state.items.iter().collect(), window);
519 assert!(view.items.is_empty());
520 assert_eq!(view.expanded_index, None);
521 assert_eq!(view.total_items, len);
522 assert_eq!(view.total_pages, len.div_ceil(10));
523 assert!(state
524 .paginate(&state.items.iter().collect::<Vec<_>>())
525 .is_empty());
526 assert_eq!(state.selected, usize::MAX);
527 assert_eq!(state.scroll_offset, usize::MAX);
528 assert_eq!(state.expanded_item, Some(usize::MAX));
529 assert_eq!(state.filter, "unchanged");
530 assert_eq!(state.next_token.as_deref(), Some("server-cursor"));
531 }
532 }
533 }
534
535 #[test]
536 fn prepared_views_match_existing_arithmetic_at_all_page_sizes() {
537 for page_size in [
538 PageSize::Ten,
539 PageSize::TwentyFive,
540 PageSize::Fifty,
541 PageSize::OneHundred,
542 ] {
543 let size = page_size.value();
544 for len in [0, 1, size - 1, size, size + 1, 2 * size + 3] {
545 let mut state = TableState {
546 items: (0..len).collect::<Vec<_>>(),
547 page_size,
548 ..TableState::new()
549 };
550 for selected in 0..len.max(1) {
551 state.selected = selected;
552 state.scroll_offset = selected.saturating_sub(3);
553 for window in [TableWindow::SelectedPage, TableWindow::ScrollOffset] {
554 let start = match window {
555 TableWindow::SelectedPage => selected / size * size,
556 TableWindow::ScrollOffset => state.scroll_offset,
557 };
558 let end = (start + size).min(len);
559 let view = state.prepare_view(state.items.iter().collect(), window);
560 assert_eq!(
561 view.items,
562 state.items[start..end].iter().collect::<Vec<_>>()
563 );
564 assert_eq!(view.selected_index, selected.saturating_sub(start));
565 assert_eq!(view.current_page, start / size);
566 assert_eq!(view.total_pages, len.div_ceil(size));
567 assert!(view.items.len() <= size);
568 }
569 }
570 }
571 }
572 }
573
574 #[test]
575 fn scrolling_selection_before_window_keeps_saturating_convention() {
576 let state = TableState {
577 items: (0..20).collect::<Vec<_>>(),
578 selected: 3,
579 scroll_offset: 7,
580 page_size: PageSize::Ten,
581 ..TableState::new()
582 };
583 let view = state.prepare_view(state.items.iter().collect(), TableWindow::ScrollOffset);
584 assert_eq!(view.items[0], &7);
585 assert_eq!(view.selected_index, 0);
586 }
587}