Skip to main content

kimun_notes/components/autocomplete/
state.rs

1use std::ops::Range;
2
3use super::TriggerKind;
4
5/// Default number of suggestion rows visible at once. The popup never grows
6/// beyond this regardless of available screen space.
7pub const DEFAULT_MAX_VISIBLE_ROWS: usize = 8;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Suggestion {
11    /// Primary text shown in the row. For wikilinks this is the note name
12    /// (the wikilink target); for hashtags it is the tag label.
13    pub display: String,
14    /// Optional dimmed text shown right-aligned in the row. Used for tag
15    /// usage counts and note paths (when disambiguating same-name notes).
16    pub secondary: Option<String>,
17}
18
19/// State machine for the autocomplete popup.
20///
21/// Owned by the host (editor or search box) via the controller. The
22/// trigger context lives in `kind` + `query` + `anchor`; the visible
23/// window is `scroll_offset..(scroll_offset+max_visible_rows)`. All scroll
24/// math is encapsulated here so the widget and the host stay dumb.
25#[derive(Debug, Clone)]
26pub struct AutocompleteState {
27    pub kind: TriggerKind,
28    pub query: String,
29    /// Byte range in the host buffer that will be overwritten on accept
30    /// (the text between the trigger sigil and the cursor). The controller
31    /// refreshes this every keystroke so the latest accept replaces the
32    /// up-to-date prefix.
33    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    /// Screen anchor where the popup is rendered (column, row in cells).
39    /// Host computes from the trigger byte offset.
40    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    /// Replaces the suggestion list, snaps highlight to the first row, and
58    /// resets the scroll window. Called every time core returns a new
59    /// query result.
60    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    /// The currently highlighted suggestion, or `None` when the list is
71    /// empty.
72    pub fn selected(&self) -> Option<&Suggestion> {
73        self.items.get(self.highlighted)
74    }
75
76    /// Inclusive-exclusive range of item indices currently visible in the
77    /// popup. Always `≤ max_visible_rows` items.
78    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    /// Count of items hidden above the visible window.
94    pub fn hidden_above(&self) -> usize {
95        self.scroll_offset
96    }
97
98    /// Count of items hidden below the visible window.
99    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    /// Slides the scroll window so the highlighted row sits inside it.
149    /// Keeps the window stable when the highlight is already visible.
150    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}