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    /// Where the visible rows were drawn by the last render (inside the
46    /// border) — what a mouse click is hit-tested against. Empty until
47    /// rendered, and whenever the popup could not draw.
48    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    /// Replaces the suggestion list, snaps highlight to the first row, and
68    /// resets the scroll window. Called every time core returns a new
69    /// query result.
70    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    /// The currently highlighted suggestion, or `None` when the list is
81    /// empty.
82    pub fn selected(&self) -> Option<&Suggestion> {
83        self.items.get(self.highlighted)
84    }
85
86    /// Inclusive-exclusive range of item indices currently visible in the
87    /// popup. Always `≤ max_visible_rows` items.
88    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    /// Count of items hidden above the visible window.
104    pub fn hidden_above(&self) -> usize {
105        self.scroll_offset
106    }
107
108    /// Count of items hidden below the visible window.
109    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    /// Slides the scroll window so the highlighted row sits inside it.
159    /// Keeps the window stable when the highlight is already visible.
160    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}