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