Skip to main content

alimentar/tui/
scroll.rs

1//! Scroll state management for TUI widgets
2//!
3//! Provides bounded scroll handling with page-based navigation.
4
5/// Scroll state for navigating large datasets
6///
7/// Manages scroll position with bounds checking and page navigation.
8#[derive(Debug, Clone, Copy, Default)]
9pub struct ScrollState {
10    /// Current scroll offset (first visible row)
11    offset: usize,
12    /// Total number of rows
13    total_rows: usize,
14    /// Number of visible rows in viewport
15    visible_rows: usize,
16    /// Currently selected row (relative to data, not viewport)
17    selected: Option<usize>,
18}
19
20impl ScrollState {
21    /// Create a new scroll state
22    ///
23    /// # Arguments
24    /// * `total_rows` - Total number of rows in the dataset
25    /// * `visible_rows` - Number of rows visible in the viewport
26    pub fn new(total_rows: usize, visible_rows: usize) -> Self {
27        Self {
28            offset: 0,
29            total_rows,
30            visible_rows,
31            selected: None,
32        }
33    }
34
35    /// Get current scroll offset
36    #[inline]
37    pub fn offset(&self) -> usize {
38        self.offset
39    }
40
41    /// Set scroll offset with bounds clamping
42    pub fn set_offset(&mut self, offset: usize) {
43        self.offset = self.clamp_offset(offset);
44    }
45
46    /// Get total row count
47    #[inline]
48    pub fn total_rows(&self) -> usize {
49        self.total_rows
50    }
51
52    /// Update total row count
53    pub fn set_total_rows(&mut self, total: usize) {
54        self.total_rows = total;
55        // Re-clamp offset if needed
56        self.offset = self.clamp_offset(self.offset);
57        // Re-clamp selection if needed
58        if let Some(sel) = self.selected {
59            if sel >= total {
60                self.selected = if total > 0 { Some(total - 1) } else { None };
61            }
62        }
63    }
64
65    /// Get visible row count
66    #[inline]
67    pub fn visible_rows(&self) -> usize {
68        self.visible_rows
69    }
70
71    /// Update visible row count
72    pub fn set_visible_rows(&mut self, visible: usize) {
73        self.visible_rows = visible;
74        // Re-clamp offset if needed
75        self.offset = self.clamp_offset(self.offset);
76    }
77
78    /// Get currently selected row
79    #[inline]
80    pub fn selected(&self) -> Option<usize> {
81        self.selected
82    }
83
84    /// Set selected row with bounds checking
85    pub fn set_selected(&mut self, row: Option<usize>) {
86        self.selected = match row {
87            Some(r) if r >= self.total_rows => {
88                if self.total_rows > 0 {
89                    Some(self.total_rows - 1)
90                } else {
91                    None
92                }
93            }
94            other => other,
95        };
96
97        // Ensure selected row is visible
98        if let Some(sel) = self.selected {
99            self.ensure_visible(sel);
100        }
101    }
102
103    /// Select the next row
104    pub fn select_next(&mut self) {
105        let new_sel = match self.selected {
106            Some(sel) => {
107                if sel + 1 < self.total_rows {
108                    Some(sel + 1)
109                } else {
110                    Some(sel)
111                }
112            }
113            None if self.total_rows > 0 => Some(0),
114            None => None,
115        };
116        self.set_selected(new_sel);
117    }
118
119    /// Select the previous row
120    pub fn select_prev(&mut self) {
121        let new_sel = match self.selected {
122            Some(sel) if sel > 0 => Some(sel - 1),
123            Some(sel) => Some(sel),
124            None if self.total_rows > 0 => Some(0),
125            None => None,
126        };
127        self.set_selected(new_sel);
128    }
129
130    /// Scroll down by one row
131    pub fn scroll_down(&mut self) {
132        let max_offset = self.max_offset();
133        if self.offset < max_offset {
134            self.offset += 1;
135        }
136    }
137
138    /// Scroll up by one row
139    pub fn scroll_up(&mut self) {
140        self.offset = self.offset.saturating_sub(1);
141    }
142
143    /// Scroll down by one page
144    pub fn page_down(&mut self) {
145        let page_size = self.visible_rows.max(1);
146        let new_offset = self.offset.saturating_add(page_size);
147        self.offset = self.clamp_offset(new_offset);
148    }
149
150    /// Scroll up by one page
151    pub fn page_up(&mut self) {
152        let page_size = self.visible_rows.max(1);
153        self.offset = self.offset.saturating_sub(page_size);
154    }
155
156    /// Jump to the first row
157    pub fn home(&mut self) {
158        self.offset = 0;
159    }
160
161    /// Jump to the last page
162    pub fn end(&mut self) {
163        self.offset = self.max_offset();
164    }
165
166    /// Ensure a specific row is visible
167    pub fn ensure_visible(&mut self, row: usize) {
168        if row < self.offset {
169            // Row is above viewport
170            self.offset = row;
171        } else if row >= self.offset + self.visible_rows {
172            // Row is below viewport
173            self.offset = row.saturating_sub(self.visible_rows.saturating_sub(1));
174        }
175        // Re-clamp to ensure valid
176        self.offset = self.clamp_offset(self.offset);
177    }
178
179    /// Check if scrolling is needed (content exceeds viewport)
180    pub fn needs_scrollbar(&self) -> bool {
181        self.total_rows > self.visible_rows
182    }
183
184    /// Calculate scrollbar position (0.0 to 1.0)
185    #[allow(clippy::cast_precision_loss)]
186    pub fn scrollbar_position(&self) -> f32 {
187        if self.total_rows <= self.visible_rows {
188            return 0.0;
189        }
190        let max = self.max_offset();
191        if max == 0 {
192            return 0.0;
193        }
194        self.offset as f32 / max as f32
195    }
196
197    /// Calculate scrollbar size (0.0 to 1.0)
198    #[allow(clippy::cast_precision_loss)]
199    pub fn scrollbar_size(&self) -> f32 {
200        if self.total_rows == 0 {
201            return 1.0;
202        }
203        (self.visible_rows as f32 / self.total_rows as f32).min(1.0)
204    }
205
206    /// Get the maximum valid offset
207    fn max_offset(&self) -> usize {
208        self.total_rows.saturating_sub(self.visible_rows)
209    }
210
211    /// Clamp an offset to valid bounds
212    fn clamp_offset(&self, offset: usize) -> usize {
213        offset.min(self.max_offset())
214    }
215
216    /// Check if a row index is currently visible
217    pub fn is_visible(&self, row: usize) -> bool {
218        row >= self.offset && row < self.offset + self.visible_rows
219    }
220
221    /// Convert a global row index to viewport-relative row
222    pub fn to_viewport_row(&self, global_row: usize) -> Option<usize> {
223        if self.is_visible(global_row) {
224            Some(global_row - self.offset)
225        } else {
226            None
227        }
228    }
229
230    /// Convert a viewport-relative row to global row index
231    pub fn to_global_row(&self, viewport_row: usize) -> usize {
232        self.offset + viewport_row
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn f_scroll_new() {
242        let scroll = ScrollState::new(100, 20);
243        assert_eq!(scroll.offset(), 0);
244        assert_eq!(scroll.total_rows(), 100);
245        assert_eq!(scroll.visible_rows(), 20);
246    }
247
248    #[test]
249    fn f_scroll_down() {
250        let mut scroll = ScrollState::new(100, 20);
251        scroll.scroll_down();
252        assert_eq!(scroll.offset(), 1);
253    }
254
255    #[test]
256    fn f_scroll_up() {
257        let mut scroll = ScrollState::new(100, 20);
258        scroll.set_offset(10);
259        scroll.scroll_up();
260        assert_eq!(scroll.offset(), 9);
261    }
262
263    #[test]
264    fn f_scroll_up_at_zero() {
265        let mut scroll = ScrollState::new(100, 20);
266        scroll.scroll_up();
267        assert_eq!(scroll.offset(), 0, "FALSIFIED: Should not go negative");
268    }
269
270    #[test]
271    fn f_scroll_down_at_max() {
272        let mut scroll = ScrollState::new(100, 20);
273        scroll.set_offset(80); // Max offset for 100 rows, 20 visible
274        scroll.scroll_down();
275        assert_eq!(scroll.offset(), 80, "FALSIFIED: Should not exceed max");
276    }
277
278    #[test]
279    fn f_scroll_page_down() {
280        let mut scroll = ScrollState::new(100, 20);
281        scroll.page_down();
282        assert_eq!(scroll.offset(), 20);
283    }
284
285    #[test]
286    fn f_scroll_page_up() {
287        let mut scroll = ScrollState::new(100, 20);
288        scroll.set_offset(50);
289        scroll.page_up();
290        assert_eq!(scroll.offset(), 30);
291    }
292
293    #[test]
294    fn f_scroll_home() {
295        let mut scroll = ScrollState::new(100, 20);
296        scroll.set_offset(50);
297        scroll.home();
298        assert_eq!(scroll.offset(), 0);
299    }
300
301    #[test]
302    fn f_scroll_end() {
303        let mut scroll = ScrollState::new(100, 20);
304        scroll.end();
305        assert_eq!(scroll.offset(), 80);
306    }
307
308    #[test]
309    fn f_scroll_ensure_visible_above() {
310        let mut scroll = ScrollState::new(100, 20);
311        scroll.set_offset(50);
312        scroll.ensure_visible(30);
313        assert_eq!(scroll.offset(), 30);
314    }
315
316    #[test]
317    fn f_scroll_ensure_visible_below() {
318        let mut scroll = ScrollState::new(100, 20);
319        scroll.set_offset(0);
320        scroll.ensure_visible(30);
321        assert!(scroll.offset() + scroll.visible_rows() > 30);
322    }
323
324    #[test]
325    fn f_scroll_ensure_visible_already() {
326        let mut scroll = ScrollState::new(100, 20);
327        scroll.set_offset(10);
328        scroll.ensure_visible(15);
329        assert_eq!(
330            scroll.offset(),
331            10,
332            "FALSIFIED: Should not change if visible"
333        );
334    }
335
336    #[test]
337    fn f_scroll_needs_scrollbar_yes() {
338        let scroll = ScrollState::new(100, 20);
339        assert!(scroll.needs_scrollbar());
340    }
341
342    #[test]
343    fn f_scroll_needs_scrollbar_no() {
344        let scroll = ScrollState::new(10, 20);
345        assert!(!scroll.needs_scrollbar());
346    }
347
348    #[test]
349    fn f_scroll_scrollbar_position() {
350        let mut scroll = ScrollState::new(100, 20);
351        scroll.set_offset(40);
352        let pos = scroll.scrollbar_position();
353        assert!(pos > 0.4 && pos < 0.6);
354    }
355
356    #[test]
357    fn f_scroll_scrollbar_size() {
358        let scroll = ScrollState::new(100, 20);
359        let size = scroll.scrollbar_size();
360        assert!((size - 0.2).abs() < 0.01);
361    }
362
363    #[test]
364    fn f_scroll_is_visible() {
365        let mut scroll = ScrollState::new(100, 20);
366        scroll.set_offset(10);
367        assert!(scroll.is_visible(15));
368        assert!(!scroll.is_visible(5));
369        assert!(!scroll.is_visible(35));
370    }
371
372    #[test]
373    fn f_scroll_to_viewport_row() {
374        let mut scroll = ScrollState::new(100, 20);
375        scroll.set_offset(10);
376        assert_eq!(scroll.to_viewport_row(15), Some(5));
377        assert_eq!(scroll.to_viewport_row(5), None);
378    }
379
380    #[test]
381    fn f_scroll_to_global_row() {
382        let mut scroll = ScrollState::new(100, 20);
383        scroll.set_offset(10);
384        assert_eq!(scroll.to_global_row(5), 15);
385    }
386
387    #[test]
388    fn f_scroll_select_next() {
389        let mut scroll = ScrollState::new(100, 20);
390        scroll.set_selected(Some(0));
391        scroll.select_next();
392        assert_eq!(scroll.selected(), Some(1));
393    }
394
395    #[test]
396    fn f_scroll_select_prev() {
397        let mut scroll = ScrollState::new(100, 20);
398        scroll.set_selected(Some(5));
399        scroll.select_prev();
400        assert_eq!(scroll.selected(), Some(4));
401    }
402
403    #[test]
404    fn f_scroll_select_prev_at_zero() {
405        let mut scroll = ScrollState::new(100, 20);
406        scroll.set_selected(Some(0));
407        scroll.select_prev();
408        assert_eq!(scroll.selected(), Some(0));
409    }
410
411    #[test]
412    fn f_scroll_select_next_at_end() {
413        let mut scroll = ScrollState::new(100, 20);
414        scroll.set_selected(Some(99));
415        scroll.select_next();
416        assert_eq!(scroll.selected(), Some(99));
417    }
418
419    #[test]
420    fn f_scroll_select_from_none() {
421        let mut scroll = ScrollState::new(100, 20);
422        scroll.select_next();
423        assert_eq!(scroll.selected(), Some(0));
424    }
425
426    #[test]
427    fn f_scroll_empty_dataset() {
428        let scroll = ScrollState::new(0, 20);
429        assert_eq!(scroll.offset(), 0);
430        assert!(!scroll.needs_scrollbar());
431    }
432
433    #[test]
434    fn f_scroll_set_total_rows_shrink() {
435        let mut scroll = ScrollState::new(100, 20);
436        scroll.set_offset(80);
437        scroll.set_selected(Some(90));
438        scroll.set_total_rows(50);
439        assert!(scroll.offset() <= 30);
440        assert!(scroll.selected().unwrap_or(0) < 50);
441    }
442
443    #[test]
444    fn f_scroll_default() {
445        let scroll = ScrollState::default();
446        assert_eq!(scroll.offset(), 0);
447        assert_eq!(scroll.total_rows(), 0);
448        assert_eq!(scroll.visible_rows(), 0);
449    }
450
451    #[test]
452    fn f_scroll_clone() {
453        let mut scroll = ScrollState::new(100, 20);
454        scroll.set_offset(50);
455        let cloned = scroll;
456        assert_eq!(scroll.offset(), cloned.offset());
457    }
458
459    #[test]
460    fn f_scroll_set_total_rows_shrink_to_zero() {
461        // Test shrinking to zero rows - should clear selection
462        let mut scroll = ScrollState::new(100, 20);
463        scroll.set_selected(Some(50));
464        scroll.set_total_rows(0);
465        assert_eq!(scroll.selected(), None);
466    }
467
468    #[test]
469    fn f_scroll_set_selected_out_of_bounds_empty() {
470        // Test selecting row out of bounds on empty scroll
471        let mut scroll = ScrollState::new(0, 20);
472        scroll.set_selected(Some(100));
473        // Should be None because total_rows is 0
474        assert_eq!(scroll.selected(), None);
475    }
476
477    #[test]
478    fn f_scroll_set_selected_out_of_bounds_clamps() {
479        // Test selecting row out of bounds - should clamp to last row
480        let mut scroll = ScrollState::new(50, 20);
481        scroll.set_selected(Some(100));
482        // Should clamp to last valid row (49)
483        assert_eq!(scroll.selected(), Some(49));
484    }
485
486    #[test]
487    fn f_scroll_set_total_rows_shrink_selection_out_of_bounds() {
488        // Selection at row 90, then shrink to 50 rows
489        let mut scroll = ScrollState::new(100, 20);
490        scroll.set_selected(Some(90));
491        scroll.set_total_rows(50);
492        // Selection should be clamped to 49
493        assert_eq!(scroll.selected(), Some(49));
494    }
495
496    #[test]
497    fn f_scroll_page_up_at_zero() {
498        let mut scroll = ScrollState::new(100, 20);
499        scroll.page_up();
500        assert_eq!(scroll.offset(), 0);
501    }
502
503    #[test]
504    fn f_scroll_select_prev_from_none() {
505        let mut scroll = ScrollState::new(100, 20);
506        // Select prev with no selection should select first row
507        scroll.select_prev();
508        assert_eq!(scroll.selected(), Some(0));
509    }
510
511    #[test]
512    fn f_scroll_select_next_empty() {
513        // Select next on empty dataset
514        let mut scroll = ScrollState::new(0, 20);
515        scroll.select_next();
516        // Should still be None since total_rows is 0
517        assert_eq!(scroll.selected(), None);
518    }
519
520    #[test]
521    fn f_scroll_select_prev_empty() {
522        // Select prev on empty dataset
523        let mut scroll = ScrollState::new(0, 20);
524        scroll.select_prev();
525        // Should still be None since total_rows is 0
526        assert_eq!(scroll.selected(), None);
527    }
528
529    #[test]
530    fn f_scroll_scrollbar_position_small_content() {
531        // When total_rows <= visible_rows
532        let scroll = ScrollState::new(10, 20);
533        let pos = scroll.scrollbar_position();
534        assert!((pos - 0.0).abs() < 0.001);
535    }
536
537    #[test]
538    fn f_scroll_scrollbar_position_max_zero() {
539        // When max_offset is 0 (total_rows == visible_rows)
540        let scroll = ScrollState::new(20, 20);
541        let pos = scroll.scrollbar_position();
542        assert!((pos - 0.0).abs() < 0.001);
543    }
544
545    #[test]
546    fn f_scroll_scrollbar_size_empty() {
547        // When total_rows is 0
548        let scroll = ScrollState::new(0, 20);
549        let size = scroll.scrollbar_size();
550        assert!((size - 1.0).abs() < 0.001);
551    }
552
553    #[test]
554    fn f_scroll_set_total_rows_with_selection_clamps() {
555        // When selection is set and total_rows shrinks
556        let mut scroll = ScrollState::new(100, 20);
557        scroll.set_selected(Some(80));
558        // Shrink to 50 rows - selection should clamp to 49
559        scroll.set_total_rows(50);
560        assert_eq!(scroll.selected(), Some(49));
561    }
562}