Skip to main content

supercode_frontend_tui/input/
scroll_state.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/bottom_pane/scroll_state.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7/// Generic scroll/selection state for a vertical list menu.
8///
9/// Encapsulates the common behavior of a selectable list that supports:
10/// - Optional selection (None when list is empty)
11/// - Wrap-around navigation on Up/Down
12/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
13///
14/// Callers own the filtered row count and the visible window size. Every
15/// mutation method takes those values instead of caching them here, so list
16/// views can apply filters, pagination, or density changes without this helper
17/// knowing about their data model. Passing a stale length after filtering would
18/// leave selection pointing at the wrong row, so callers should clamp or move
19/// through this type immediately after changing their visible row set.
20#[derive(Debug, Default, Clone, Copy)]
21pub struct ScrollState {
22    pub selected_idx: Option<usize>,
23    pub scroll_top: usize,
24}
25
26impl ScrollState {
27    pub fn new() -> Self {
28        Self {
29            selected_idx: None,
30            scroll_top: 0,
31        }
32    }
33
34    /// Reset selection and scroll.
35    pub fn reset(&mut self) {
36        self.selected_idx = None;
37        self.scroll_top = 0;
38    }
39
40    /// Clamp selection to be within the [0, len-1] range, or None when empty.
41    pub fn clamp_selection(&mut self, len: usize) {
42        if self.clear_if_empty(len) {
43            return;
44        }
45        self.selected_idx = Some(self.selected_idx.unwrap_or(0).min(len - 1));
46    }
47
48    /// Move selection up by one, wrapping to the bottom when necessary.
49    pub fn move_up_wrap(&mut self, len: usize) {
50        if self.clear_if_empty(len) {
51            return;
52        }
53        self.selected_idx = Some(match self.selected_idx {
54            Some(idx) if idx > 0 => idx - 1,
55            Some(_) => len - 1,
56            None => 0,
57        });
58    }
59
60    /// Move selection down by one, wrapping to the top when necessary.
61    pub fn move_down_wrap(&mut self, len: usize) {
62        if self.clear_if_empty(len) {
63            return;
64        }
65        self.selected_idx = Some(match self.selected_idx {
66            Some(idx) if idx + 1 < len => idx + 1,
67            _ => 0,
68        });
69    }
70
71    /// Move selection up by one visible page, clamping at the first row.
72    ///
73    /// Page movement intentionally does not wrap. It mirrors terminal list
74    /// behavior where repeated page-up/page-down converges at the nearest edge
75    /// while still keeping the selected row visible.
76    pub fn page_up_clamped(&mut self, len: usize, visible_rows: usize) {
77        if self.clear_if_empty(len) {
78            return;
79        }
80        let step = visible_rows.max(1);
81        let current = self.selected_idx.unwrap_or(0).min(len - 1);
82        self.selected_idx = Some(current.saturating_sub(step));
83        self.ensure_visible(len, visible_rows);
84    }
85
86    /// Move selection down by one visible page, clamping at the last row.
87    ///
88    /// Page movement intentionally does not wrap. It mirrors terminal list
89    /// behavior where repeated page-up/page-down converges at the nearest edge
90    /// while still keeping the selected row visible.
91    pub fn page_down_clamped(&mut self, len: usize, visible_rows: usize) {
92        if self.clear_if_empty(len) {
93            return;
94        }
95        let step = visible_rows.max(1);
96        let current = self.selected_idx.unwrap_or(0).min(len - 1);
97        self.selected_idx = Some(current.saturating_add(step).min(len - 1));
98        self.ensure_visible(len, visible_rows);
99    }
100
101    /// Jump selection to the first row.
102    pub fn jump_top(&mut self, len: usize, visible_rows: usize) {
103        if self.clear_if_empty(len) {
104            return;
105        }
106        self.selected_idx = Some(0);
107        self.ensure_visible(len, visible_rows);
108    }
109
110    /// Jump selection to the last row.
111    pub fn jump_bottom(&mut self, len: usize, visible_rows: usize) {
112        if self.clear_if_empty(len) {
113            return;
114        }
115        self.selected_idx = Some(len - 1);
116        self.ensure_visible(len, visible_rows);
117    }
118
119    fn clear_if_empty(&mut self, len: usize) -> bool {
120        if len != 0 {
121            return false;
122        }
123        self.selected_idx = None;
124        self.scroll_top = 0;
125        true
126    }
127
128    /// Adjust `scroll_top` so that the current `selected_idx` is visible within
129    /// the window of `visible_rows`.
130    pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
131        if len == 0 || visible_rows == 0 {
132            self.scroll_top = 0;
133            return;
134        }
135        if let Some(sel) = self.selected_idx {
136            if sel < self.scroll_top {
137                self.scroll_top = sel;
138            } else {
139                let bottom = self.scroll_top + visible_rows - 1;
140                if sel > bottom {
141                    self.scroll_top = sel + 1 - visible_rows;
142                }
143            }
144        } else {
145            self.scroll_top = 0;
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::ScrollState;
153
154    #[test]
155    fn wrap_navigation_and_visibility() {
156        let mut s = ScrollState::new();
157        let len = 10;
158        let vis = 5;
159
160        s.clamp_selection(len);
161        assert_eq!(s.selected_idx, Some(0));
162        s.ensure_visible(len, vis);
163        assert_eq!(s.scroll_top, 0);
164
165        s.move_up_wrap(len);
166        s.ensure_visible(len, vis);
167        assert_eq!(s.selected_idx, Some(len - 1));
168        match s.selected_idx {
169            Some(sel) => assert!(s.scroll_top <= sel),
170            None => panic!("expected Some(selected_idx) after wrap"),
171        }
172
173        s.move_down_wrap(len);
174        s.ensure_visible(len, vis);
175        assert_eq!(s.selected_idx, Some(0));
176        assert_eq!(s.scroll_top, 0);
177    }
178
179    #[test]
180    fn page_and_jump_navigation_clamps() {
181        let mut s = ScrollState::new();
182        let len = 10;
183        let vis = 4;
184
185        s.clamp_selection(len);
186        s.page_down_clamped(len, vis);
187        assert_eq!(s.selected_idx, Some(4));
188        assert_eq!(s.scroll_top, 1);
189
190        s.page_down_clamped(len, vis);
191        assert_eq!(s.selected_idx, Some(8));
192        assert_eq!(s.scroll_top, 5);
193
194        s.page_down_clamped(len, vis);
195        assert_eq!(s.selected_idx, Some(9));
196        assert_eq!(s.scroll_top, 6);
197
198        s.page_up_clamped(len, vis);
199        assert_eq!(s.selected_idx, Some(5));
200        assert_eq!(s.scroll_top, 5);
201
202        s.jump_top(len, vis);
203        assert_eq!(s.selected_idx, Some(0));
204        assert_eq!(s.scroll_top, 0);
205
206        s.jump_bottom(len, vis);
207        assert_eq!(s.selected_idx, Some(9));
208        assert_eq!(s.scroll_top, 6);
209    }
210}