Skip to main content

rusticity_term/
table.rs

1use crate::common::PageSize;
2
3/// Which in-memory window an existing table renders. This does not fetch data
4/// or interpret a service's continuation token.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum TableWindow {
7    /// A page aligned to the current selection (most service tables).
8    SelectedPage,
9    /// A sliding window starting at scroll_offset (e.g. CloudWatch log groups).
10    ScrollOffset,
11}
12
13/// Render-ready rows and metadata from one filtered, ordered result set.
14///
15/// The vector owns references, not resources, and reuses the caller's allocation.
16/// Indices refer to this visible window; counts refer to the full result set.
17#[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    /// Preserves the renderer's saturating selection-offset convention. A stale
24    /// selection may be outside items; preparation never changes navigation state.
25    pub selected_index: usize,
26    pub expanded_index: Option<usize>,
27}
28
29/// Generic table state for list-based services
30#[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    /// Prepare an already filtered and sorted result set for rendering.
70    ///
71    /// Keep service-specific filtering/sorting in the caller so rendering and
72    /// actions continue to use the same ordering. Only in-memory windowing lives
73    /// here: no loading, cursor advancement, selection reset, or resource clones.
74    /// Stale positions produce an empty window instead of panicking.
75    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        // Truncate before draining so only visible references need to be shifted.
93        // No second page-sized Vec allocation (unlike slice.to_vec()).
94        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                // Adjust scroll_offset if selection goes below viewport.
133                // Uses effective_page_size = page_size - extra rows from expanded content.
134                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    /// Like `next_item` but accounts for `extra_rows_before_selected` occupied by
143    /// expanded content that appears BEFORE the selected item in the viewport.
144    /// Ensures scroll_offset advances so the selected item stays visible.
145    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                // Standard scroll: ensure selected is within [scroll_offset, scroll_offset+page_size)
154                if self.selected >= self.scroll_offset + page_size {
155                    self.scroll_offset = self.selected - page_size + 1;
156                }
157
158                // Extra scroll: if expanded content above pushes selected below viewport
159                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            // Adjust scroll_offset if selection goes above viewport
174            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            // Snap scroll_offset to page boundary
186            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        // Snap scroll_offset to page boundary
196        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    /// After expanding an item, scroll up if the detail rows would go below the viewport.
215    /// `detail_rows` = number of detail lines shown for the expanded item.
216    /// `viewport_height` = visible rows in the table (area.height - 3 for borders+header).
217    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        // Visual row of selected item (0-indexed within current page)
223        let selected_visual = self.selected.saturating_sub(self.scroll_offset);
224        // After expansion, detail rows appear at selected_visual+1 .. selected_visual+detail_rows+1
225        let last_detail_row = selected_visual + detail_rows;
226        // Use min of page_size and viewport_height as effective display height
227        let effective_height = page_size.min(viewport_height);
228        if last_detail_row >= effective_height {
229            // Need to scroll up so last detail row is visible
230            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    /// Change the page size and return to the first page. Keeps the filter,
266    /// so the visible list starts from the top rather than jumping to a sparse
267    /// last page when the selection was near the end.
268    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    /// Push a character to the filter and reset selection
280    pub fn filter_push(&mut self, c: char) {
281        self.filter.push(c);
282        self.reset();
283    }
284
285    /// Pop a character from the filter and reset selection
286    pub fn filter_pop(&mut self) {
287        self.filter.pop();
288        self.reset();
289    }
290
291    /// Clear the filter and reset selection
292    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        // Regression: when an item is expanded with N detail rows, navigating past
377        // the last visible item should scroll — not leave cursor below viewport.
378        //
379        // Setup: page_size=10, 20 items, expansion adds 4 detail rows before selected.
380        // selected=5 at visual row 5+4=9 (last visible). Navigate to 6: visual=6+4=10 >= 10 → scroll.
381
382        let mut state = TableState::<i32>::new();
383        state.page_size = crate::common::PageSize::Ten; // page_size = 10
384        state.items = (0..20).collect();
385        state.scroll_offset = 0;
386        state.selected = 5;
387
388        // Navigate: extra_rows=4 before new selected=6 (expanded item is before 6)
389        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        // Deliberately not Clone: preparing a view must only move references.
416        #[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}