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}