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