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    /// For a `LinkFilter` popup, the operator char that opened it (`<`,
42    /// `>`, or `=`) so the title renders the matching sigil. `None` for
43    /// `Wikilink`/`Hashtag` (fixed sigils) and until set from the trigger.
44    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    /// Replaces the suggestion list, snaps highlight to the first row, and
63    /// resets the scroll window. Called every time core returns a new
64    /// query result.
65    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    /// The currently highlighted suggestion, or `None` when the list is
76    /// empty.
77    pub fn selected(&self) -> Option<&Suggestion> {
78        self.items.get(self.highlighted)
79    }
80
81    /// Inclusive-exclusive range of item indices currently visible in the
82    /// popup. Always `≤ max_visible_rows` items.
83    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    /// Count of items hidden above the visible window.
99    pub fn hidden_above(&self) -> usize {
100        self.scroll_offset
101    }
102
103    /// Count of items hidden below the visible window.
104    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    /// Slides the scroll window so the highlighted row sits inside it.
154    /// Keeps the window stable when the highlight is already visible.
155    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}