1use crate::common::PageSize;
2
3#[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 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 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 if self.selected >= self.scroll_offset + page_size {
86 self.scroll_offset = self.selected - page_size + 1;
87 }
88
89 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 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 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 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 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 let selected_visual = self.selected.saturating_sub(self.scroll_offset);
155 let last_detail_row = selected_visual + detail_rows;
157 let effective_height = page_size.min(viewport_height);
159 if last_detail_row >= effective_height {
160 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 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 pub fn filter_push(&mut self, c: char) {
212 self.filter.push(c);
213 self.reset();
214 }
215
216 pub fn filter_pop(&mut self) {
218 self.filter.pop();
219 self.reset();
220 }
221
222 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 let mut state = TableState::<i32>::new();
314 state.page_size = crate::common::PageSize::Ten; state.items = (0..20).collect();
316 state.scroll_offset = 0;
317 state.selected = 5;
318
319 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}