kimun_notes/components/autocomplete/
state.rs1use std::ops::Range;
2
3use super::TriggerKind;
4
5pub const DEFAULT_MAX_VISIBLE_ROWS: usize = 8;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Suggestion {
11 pub display: String,
14 pub secondary: Option<String>,
17}
18
19#[derive(Debug, Clone)]
26pub struct AutocompleteState {
27 pub kind: TriggerKind,
28 pub query: String,
29 pub replace_range: Range<usize>,
34 pub items: Vec<Suggestion>,
35 pub highlighted: usize,
36 pub scroll_offset: usize,
37 pub max_visible_rows: usize,
38 pub anchor: (u16, u16),
41 pub opener: Option<char>,
45 pub rows_rect: ratatui::layout::Rect,
49}
50
51impl AutocompleteState {
52 pub fn new(kind: TriggerKind, anchor: (u16, u16)) -> Self {
53 Self {
54 kind,
55 query: String::new(),
56 replace_range: 0..0,
57 items: Vec::new(),
58 highlighted: 0,
59 scroll_offset: 0,
60 max_visible_rows: DEFAULT_MAX_VISIBLE_ROWS,
61 anchor,
62 opener: None,
63 rows_rect: ratatui::layout::Rect::default(),
64 }
65 }
66
67 pub fn set_items(&mut self, items: Vec<Suggestion>) {
71 self.items = items;
72 self.highlighted = 0;
73 self.scroll_offset = 0;
74 }
75
76 pub fn is_empty(&self) -> bool {
77 self.items.is_empty()
78 }
79
80 pub fn selected(&self) -> Option<&Suggestion> {
83 self.items.get(self.highlighted)
84 }
85
86 pub fn visible_window(&self) -> (usize, usize) {
89 let start = self.scroll_offset.min(self.items.len());
90 let end = (start + self.max_visible_rows).min(self.items.len());
91 (start, end)
92 }
93
94 pub fn has_more_above(&self) -> bool {
95 self.scroll_offset > 0
96 }
97
98 pub fn has_more_below(&self) -> bool {
99 let (_, end) = self.visible_window();
100 end < self.items.len()
101 }
102
103 pub fn hidden_above(&self) -> usize {
105 self.scroll_offset
106 }
107
108 pub fn hidden_below(&self) -> usize {
110 let (_, end) = self.visible_window();
111 self.items.len().saturating_sub(end)
112 }
113
114 pub fn move_highlight_down(&mut self) {
115 if self.items.is_empty() {
116 return;
117 }
118 if self.highlighted + 1 < self.items.len() {
119 self.highlighted += 1;
120 self.ensure_visible();
121 }
122 }
123
124 pub fn move_highlight_up(&mut self) {
125 if self.highlighted > 0 {
126 self.highlighted -= 1;
127 self.ensure_visible();
128 }
129 }
130
131 pub fn page_down(&mut self) {
132 if self.items.is_empty() {
133 return;
134 }
135 let step = self.max_visible_rows.max(1);
136 self.highlighted = (self.highlighted + step).min(self.items.len() - 1);
137 self.ensure_visible();
138 }
139
140 pub fn page_up(&mut self) {
141 let step = self.max_visible_rows.max(1);
142 self.highlighted = self.highlighted.saturating_sub(step);
143 self.ensure_visible();
144 }
145
146 pub fn home(&mut self) {
147 self.highlighted = 0;
148 self.ensure_visible();
149 }
150
151 pub fn end(&mut self) {
152 if !self.items.is_empty() {
153 self.highlighted = self.items.len() - 1;
154 self.ensure_visible();
155 }
156 }
157
158 fn ensure_visible(&mut self) {
161 let window = self.max_visible_rows.max(1);
162 if self.highlighted < self.scroll_offset {
163 self.scroll_offset = self.highlighted;
164 } else if self.highlighted >= self.scroll_offset + window {
165 self.scroll_offset = self.highlighted + 1 - window;
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn s(n: &str) -> Suggestion {
175 Suggestion {
176 display: n.to_string(),
177 secondary: None,
178 }
179 }
180
181 fn state_with(n: usize, max_rows: usize) -> AutocompleteState {
182 let mut st = AutocompleteState::new(TriggerKind::Hashtag, (0, 0));
183 st.max_visible_rows = max_rows;
184 st.set_items((0..n).map(|i| s(&format!("item{i}"))).collect());
185 st
186 }
187
188 #[test]
189 fn empty_state_has_no_selection() {
190 let st = state_with(0, 8);
191 assert!(st.selected().is_none());
192 assert!(!st.has_more_above());
193 assert!(!st.has_more_below());
194 }
195
196 #[test]
197 fn fits_in_window_shows_no_overflow_indicators() {
198 let st = state_with(3, 8);
199 assert!(!st.has_more_above());
200 assert!(!st.has_more_below());
201 assert_eq!(st.visible_window(), (0, 3));
202 }
203
204 #[test]
205 fn overflow_indicator_only_below_at_top() {
206 let st = state_with(30, 8);
207 assert!(!st.has_more_above());
208 assert!(st.has_more_below());
209 assert_eq!(st.hidden_below(), 22);
210 assert_eq!(st.visible_window(), (0, 8));
211 }
212
213 #[test]
214 fn arrow_down_inside_window_does_not_scroll() {
215 let mut st = state_with(30, 8);
216 st.move_highlight_down();
217 assert_eq!(st.highlighted, 1);
218 assert_eq!(st.scroll_offset, 0);
219 }
220
221 #[test]
222 fn arrow_down_past_window_scrolls() {
223 let mut st = state_with(30, 8);
224 for _ in 0..8 {
225 st.move_highlight_down();
226 }
227 assert_eq!(st.highlighted, 8);
228 assert_eq!(st.scroll_offset, 1);
229 assert!(st.has_more_above());
230 assert!(st.has_more_below());
231 }
232
233 #[test]
234 fn scrolling_back_to_top_clears_top_indicator() {
235 let mut st = state_with(30, 8);
236 for _ in 0..10 {
237 st.move_highlight_down();
238 }
239 assert!(st.has_more_above());
240 for _ in 0..20 {
241 st.move_highlight_up();
242 }
243 assert_eq!(st.highlighted, 0);
244 assert_eq!(st.scroll_offset, 0);
245 assert!(!st.has_more_above());
246 assert!(st.has_more_below());
247 }
248
249 #[test]
250 fn page_down_jumps_by_window_size() {
251 let mut st = state_with(30, 8);
252 st.page_down();
253 assert_eq!(st.highlighted, 8);
254 st.page_down();
255 assert_eq!(st.highlighted, 16);
256 }
257
258 #[test]
259 fn page_down_clamps_at_last_item() {
260 let mut st = state_with(10, 8);
261 st.page_down();
262 st.page_down();
263 st.page_down();
264 assert_eq!(st.highlighted, 9);
265 }
266
267 #[test]
268 fn page_up_clamps_at_zero() {
269 let mut st = state_with(10, 8);
270 st.page_up();
271 assert_eq!(st.highlighted, 0);
272 assert_eq!(st.scroll_offset, 0);
273 }
274
275 #[test]
276 fn end_jumps_to_last_item_and_scrolls() {
277 let mut st = state_with(30, 8);
278 st.end();
279 assert_eq!(st.highlighted, 29);
280 assert_eq!(st.scroll_offset, 22);
281 assert!(st.has_more_above());
282 assert!(!st.has_more_below());
283 }
284
285 #[test]
286 fn home_jumps_to_first_item_and_unscrolls() {
287 let mut st = state_with(30, 8);
288 st.end();
289 st.home();
290 assert_eq!(st.highlighted, 0);
291 assert_eq!(st.scroll_offset, 0);
292 }
293
294 #[test]
295 fn set_items_resets_selection_and_scroll() {
296 let mut st = state_with(30, 8);
297 st.end();
298 st.set_items((0..3).map(|i| s(&format!("x{i}"))).collect());
299 assert_eq!(st.highlighted, 0);
300 assert_eq!(st.scroll_offset, 0);
301 }
302
303 #[test]
304 fn highlight_stays_visible_after_navigation() {
305 let mut st = state_with(30, 8);
306 for _ in 0..15 {
307 st.move_highlight_down();
308 }
309 let (start, end) = st.visible_window();
310 assert!(st.highlighted >= start && st.highlighted < end);
311 }
312}