Skip to main content

rusticity_term/
table.rs

1use crate::common::PageSize;
2
3/// Generic table state for list-based services
4#[derive(Debug, Clone)]
5pub struct TableState<T> {
6    pub items: Vec<T>,
7    pub selected: usize,
8    pub loading: bool,
9    pub filter: String,
10    pub page_size: PageSize,
11    pub expanded_item: Option<usize>,
12    pub scroll_offset: usize,
13    pub next_token: Option<String>,
14}
15
16impl<T> Default for TableState<T> {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22impl<T> TableState<T> {
23    pub fn new() -> Self {
24        Self {
25            items: Vec::new(),
26            selected: 0,
27            loading: false,
28            filter: String::new(),
29            page_size: PageSize::Fifty,
30            expanded_item: None,
31            scroll_offset: 0,
32            next_token: None,
33        }
34    }
35
36    pub fn filtered<F>(&self, predicate: F) -> Vec<&T>
37    where
38        F: Fn(&T) -> bool,
39    {
40        self.items.iter().filter(|item| predicate(item)).collect()
41    }
42
43    pub fn paginate<'a>(&self, filtered: &'a [&'a T]) -> &'a [&'a T] {
44        let page_size = self.page_size.value();
45        let end_idx = (self.scroll_offset + page_size).min(filtered.len());
46        &filtered[self.scroll_offset..end_idx]
47    }
48
49    pub fn current_page(&self, _total_items: usize) -> usize {
50        self.scroll_offset / self.page_size.value()
51    }
52
53    pub fn total_pages(&self, total_items: usize) -> usize {
54        total_items.div_ceil(self.page_size.value())
55    }
56
57    pub fn next_item(&mut self, max: usize) {
58        if max > 0 {
59            let new_selected = (self.selected + 1).min(max - 1);
60            if new_selected != self.selected {
61                self.selected = new_selected;
62
63                // Adjust scroll_offset if selection goes below viewport.
64                // Uses effective_page_size = page_size - extra rows from expanded content.
65                let page_size = self.page_size.value();
66                if self.selected >= self.scroll_offset + page_size {
67                    self.scroll_offset = self.selected - page_size + 1;
68                }
69            }
70        }
71    }
72
73    /// Like `next_item` but accounts for `extra_rows_before_selected` occupied by
74    /// expanded content that appears BEFORE the selected item in the viewport.
75    /// Ensures scroll_offset advances so the selected item stays visible.
76    pub fn next_item_with_expansion(&mut self, max: usize, extra_rows_before_selected: usize) {
77        if max > 0 {
78            let new_selected = (self.selected + 1).min(max - 1);
79            if new_selected != self.selected {
80                self.selected = new_selected;
81
82                let page_size = self.page_size.value();
83
84                // Standard scroll: ensure selected is within [scroll_offset, scroll_offset+page_size)
85                if self.selected >= self.scroll_offset + page_size {
86                    self.scroll_offset = self.selected - page_size + 1;
87                }
88
89                // Extra scroll: if expanded content above pushes selected below viewport
90                let selected_visual_row =
91                    self.selected.saturating_sub(self.scroll_offset) + extra_rows_before_selected;
92                if selected_visual_row >= page_size {
93                    let overflow = selected_visual_row.saturating_sub(page_size - 1);
94                    self.scroll_offset += overflow;
95                }
96            }
97        }
98    }
99
100    pub fn prev_item(&mut self) {
101        if self.selected > 0 {
102            self.selected -= 1;
103
104            // Adjust scroll_offset if selection goes above viewport
105            if self.selected < self.scroll_offset {
106                self.scroll_offset = self.selected;
107            }
108        }
109    }
110
111    pub fn page_down(&mut self, max: usize) {
112        if max > 0 {
113            let page_size = self.page_size.value();
114            self.selected = (self.selected + 10).min(max - 1);
115
116            // Snap scroll_offset to page boundary
117            let current_page = self.selected / page_size;
118            self.scroll_offset = current_page * page_size;
119        }
120    }
121
122    pub fn page_up(&mut self) {
123        let page_size = self.page_size.value();
124        self.selected = self.selected.saturating_sub(10);
125
126        // Snap scroll_offset to page boundary
127        let current_page = self.selected / page_size;
128        self.scroll_offset = current_page * page_size;
129    }
130
131    pub fn snap_to_page(&mut self) {
132        let page_size = self.page_size.value();
133        let current_page = self.selected / page_size;
134        self.scroll_offset = current_page * page_size;
135    }
136
137    pub fn toggle_expand(&mut self) {
138        self.expanded_item = if self.expanded_item == Some(self.selected) {
139            None
140        } else {
141            Some(self.selected)
142        };
143    }
144
145    /// After expanding an item, scroll up if the detail rows would go below the viewport.
146    /// `detail_rows` = number of detail lines shown for the expanded item.
147    /// `viewport_height` = visible rows in the table (area.height - 3 for borders+header).
148    pub fn ensure_expansion_visible(&mut self, detail_rows: usize, viewport_height: usize) {
149        if self.expanded_item != Some(self.selected) || detail_rows == 0 || viewport_height == 0 {
150            return;
151        }
152        let page_size = self.page_size.value();
153        // Visual row of selected item (0-indexed within current page)
154        let selected_visual = self.selected.saturating_sub(self.scroll_offset);
155        // After expansion, detail rows appear at selected_visual+1 .. selected_visual+detail_rows+1
156        let last_detail_row = selected_visual + detail_rows;
157        // Use min of page_size and viewport_height as effective display height
158        let effective_height = page_size.min(viewport_height);
159        if last_detail_row >= effective_height {
160            // Need to scroll up so last detail row is visible
161            let overflow = last_detail_row.saturating_sub(effective_height - 1);
162            self.scroll_offset = self.scroll_offset.saturating_add(overflow);
163        }
164    }
165
166    pub fn collapse(&mut self) {
167        self.expanded_item = None;
168    }
169
170    pub fn expand(&mut self) {
171        self.expanded_item = Some(self.selected);
172    }
173
174    pub fn is_expanded(&self) -> bool {
175        self.expanded_item == Some(self.selected)
176    }
177
178    pub fn has_expanded_item(&self) -> bool {
179        self.expanded_item.is_some()
180    }
181
182    pub fn goto_page(&mut self, page: usize, total_items: usize) {
183        let page_size = self.page_size.value();
184        let target = (page - 1) * page_size;
185        let max = total_items.saturating_sub(1);
186        self.selected = target.min(max);
187        self.scroll_offset = target.min(total_items.saturating_sub(page_size));
188    }
189
190    pub fn reset(&mut self) {
191        self.selected = 0;
192        self.scroll_offset = 0;
193        self.expanded_item = None;
194    }
195
196    /// Change the page size and return to the first page. Keeps the filter,
197    /// so the visible list starts from the top rather than jumping to a sparse
198    /// last page when the selection was near the end.
199    pub fn set_page_size(&mut self, page_size: PageSize) {
200        self.page_size = page_size;
201        self.selected = 0;
202        self.scroll_offset = 0;
203        self.expanded_item = None;
204    }
205
206    pub fn get_selected<'a>(&self, filtered: &'a [&'a T]) -> Option<&'a T> {
207        filtered.get(self.selected).copied()
208    }
209
210    /// Push a character to the filter and reset selection
211    pub fn filter_push(&mut self, c: char) {
212        self.filter.push(c);
213        self.reset();
214    }
215
216    /// Pop a character from the filter and reset selection
217    pub fn filter_pop(&mut self) {
218        self.filter.pop();
219        self.reset();
220    }
221
222    /// Clear the filter and reset selection
223    pub fn filter_clear(&mut self) {
224        self.filter.clear();
225        self.reset();
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_table_state_default() {
235        let state: TableState<String> = TableState::new();
236        assert_eq!(state.selected, 0);
237        assert!(!state.loading);
238        assert_eq!(state.filter, "");
239        assert_eq!(state.page_size, PageSize::Fifty);
240        assert_eq!(state.expanded_item, None);
241    }
242
243    #[test]
244    fn test_filtered() {
245        let mut state = TableState::new();
246        state.items = vec![
247            "apple".to_string(),
248            "banana".to_string(),
249            "apricot".to_string(),
250        ];
251
252        let filtered = state.filtered(|item| item.starts_with('a'));
253        assert_eq!(filtered.len(), 2);
254    }
255
256    #[test]
257    fn test_paginate() {
258        let state = TableState::<String> {
259            page_size: PageSize::Ten,
260            selected: 0,
261            ..TableState::new()
262        };
263
264        let items: Vec<String> = (0..25).map(|i| i.to_string()).collect();
265        let refs: Vec<&String> = items.iter().collect();
266
267        let page = state.paginate(&refs);
268        assert_eq!(page.len(), 10);
269    }
270
271    #[test]
272    fn test_navigation() {
273        let mut state = TableState::<String>::new();
274
275        state.next_item(10);
276        assert_eq!(state.selected, 1);
277
278        state.prev_item();
279        assert_eq!(state.selected, 0);
280
281        state.page_down(100);
282        assert_eq!(state.selected, 10);
283
284        state.page_up();
285        assert_eq!(state.selected, 0);
286    }
287
288    #[test]
289    fn test_expand_toggle() {
290        let mut state = TableState::<String>::new();
291
292        assert!(!state.is_expanded());
293
294        state.toggle_expand();
295        assert!(state.is_expanded());
296
297        state.toggle_expand();
298        assert!(!state.is_expanded());
299
300        state.toggle_expand();
301        state.collapse();
302        assert!(!state.is_expanded());
303    }
304
305    #[test]
306    fn test_next_item_with_expansion_scrolls_when_selection_goes_below_viewport() {
307        // Regression: when an item is expanded with N detail rows, navigating past
308        // the last visible item should scroll — not leave cursor below viewport.
309        //
310        // Setup: page_size=10, 20 items, expansion adds 4 detail rows before selected.
311        // selected=5 at visual row 5+4=9 (last visible). Navigate to 6: visual=6+4=10 >= 10 → scroll.
312
313        let mut state = TableState::<i32>::new();
314        state.page_size = crate::common::PageSize::Ten; // page_size = 10
315        state.items = (0..20).collect();
316        state.scroll_offset = 0;
317        state.selected = 5;
318
319        // Navigate: extra_rows=4 before new selected=6 (expanded item is before 6)
320        state.next_item_with_expansion(20, 4);
321
322        assert_eq!(state.selected, 6, "selected must advance to 6");
323        assert!(
324            state.scroll_offset > 0,
325            "scroll_offset must advance when expansion pushes selected off viewport; got {}",
326            state.scroll_offset
327        );
328        let in_viewport =
329            state.selected >= state.scroll_offset && state.selected < state.scroll_offset + 10;
330        assert!(
331            in_viewport,
332            "selected={} must be within [{}, {})",
333            state.selected,
334            state.scroll_offset,
335            state.scroll_offset + 10
336        );
337    }
338}