Skip to main content

scrive_core/intel/
completion.rs

1//! The completion controller state machine — core view-state, driven by
2//! the widget and headlessly tested against a stub provider. It owns the popup
3//! list, local prefix filtering, Escape-stickiness, and the provider-call budget
4//! (at most one `complete()` per input event); it never touches the document.
5//! Accepting returns the chosen item for the caller to apply as one sealed
6//! transaction.
7
8use super::providers::{CompletionCx, CompletionItem, CompletionTrigger, Completions};
9
10/// The popup's open/closed/dismissed state.
11pub enum CompletionState {
12    /// No popup.
13    Closed,
14    /// A popup is showing `PopupList`.
15    Open(PopupList),
16    /// Dismissed by Escape and sticky until a word boundary: word chars
17    /// extending the same word do NOT reopen the popup; a word boundary (or any
18    /// non-word input) restores normal rules.
19    DismissedUntilBoundary,
20}
21
22/// The live popup: the provider's items plus the local filter/selection over
23/// them. The widget renders `items[filtered[i]]` for the visible window and
24/// highlights `filtered[selected]`.
25pub struct PopupList {
26    /// The provider's items, unfiltered (the reference set the filter indexes).
27    pub items: Vec<CompletionItem>,
28    /// Indices into `items` passing the local prefix filter, in `sort_key`
29    /// order. Rebuilt on every refilter.
30    pub filtered: Vec<u32>,
31    /// Index into `filtered` (not `items`); resets to 0 on every refilter.
32    pub selected: u32,
33    /// Absolute byte offset of the completion-word start — the popup's x anchor.
34    pub anchor: u32,
35}
36
37impl PopupList {
38    /// Rebuild `filtered` as the items whose label starts with `word` (folding
39    /// ASCII case — completion labels are code identifiers), in `sort_key` order
40    /// (so the provider's tiers survive), and reset the selection to the top.
41    ///
42    /// Runs on **every word char while the popup is open**, so it allocates
43    /// nothing: it reuses `filtered`'s capacity (`clear` + `extend`) and compares
44    /// prefixes in place — no per-item lowercased `String` and no fresh index
45    /// `Vec` per keystroke, keeping the filter O(items) in comparisons only.
46    fn refilter(&mut self, word: &str) {
47        self.filtered.clear();
48        self.filtered.extend(
49            (0..self.items.len() as u32)
50                .filter(|&i| prefix_matches(&self.items[i as usize].label, word)),
51        );
52        self.filtered
53            .sort_by(|&a, &b| self.items[a as usize].sort_key.cmp(&self.items[b as usize].sort_key));
54        self.selected = 0;
55    }
56}
57
58/// Whether `label` begins with `prefix`, folding ASCII case only. Compares the
59/// raw bytes (`as_bytes` never panics on a non-char-boundary split, and
60/// `eq_ignore_ascii_case` leaves multi-byte UTF-8 exact), so no lowercased copy
61/// is allocated — the hot completion-filter primitive.
62fn prefix_matches(label: &str, prefix: &str) -> bool {
63    let (l, p) = (label.as_bytes(), prefix.as_bytes());
64    l.len() >= p.len() && l[..p.len()].eq_ignore_ascii_case(p)
65}
66
67/// The completion controller. Holds only the popup state; the document, provider,
68/// and caret are supplied by the caller per event.
69pub struct CompletionController {
70    state: CompletionState,
71}
72
73impl Default for CompletionController {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl CompletionController {
80    /// A fresh, closed controller.
81    #[must_use]
82    pub fn new() -> Self {
83        Self { state: CompletionState::Closed }
84    }
85
86    /// The current state (for the widget to render).
87    #[must_use]
88    pub fn state(&self) -> &CompletionState {
89        &self.state
90    }
91
92    /// Whether a popup is showing.
93    #[must_use]
94    pub fn is_open(&self) -> bool {
95        matches!(self.state, CompletionState::Open(_))
96    }
97
98    /// Drive the machine on a completion-relevant keystroke, per `cx.trigger`:
99    /// a **trigger char / Ctrl+Space** requests fresh items in any state (even
100    /// while dismissed); a **word char** opens from `Closed`, refilters locally
101    /// while `Open` (no provider call), and is ignored while dismissed. `word` is
102    /// the live completion-word text under the caret. Calls `provider.complete`
103    /// at most once per event.
104    pub fn on_input(&mut self, cx: &CompletionCx, word: &str, provider: &mut dyn Completions) {
105        match cx.trigger {
106            CompletionTrigger::Typed(_) => match &mut self.state {
107                CompletionState::Open(list) => {
108                    list.refilter(word);
109                    if list.filtered.is_empty() {
110                        self.state = CompletionState::Closed;
111                    }
112                }
113                CompletionState::DismissedUntilBoundary => {} // stay dismissed
114                CompletionState::Closed => {
115                    let items = provider.complete(cx);
116                    self.set_from_items(items, word, cx.word.start);
117                }
118            },
119            CompletionTrigger::TriggerChar(_) | CompletionTrigger::Manual => {
120                // A fresh request in any state — a trigger char reopens even when
121                // Escape-dismissed.
122                let items = provider.complete(cx);
123                self.set_from_items(items, word, cx.word.start);
124            }
125        }
126    }
127
128    /// Open from a fresh item list, or close if empty (either the provider
129    /// returned nothing or nothing prefix-matches the live word).
130    fn set_from_items(&mut self, items: Vec<CompletionItem>, word: &str, anchor: u32) {
131        if items.is_empty() {
132            self.state = CompletionState::Closed;
133            return;
134        }
135        let mut list = PopupList { items, filtered: Vec::new(), selected: 0, anchor };
136        list.refilter(word);
137        self.state = if list.filtered.is_empty() {
138            CompletionState::Closed
139        } else {
140            CompletionState::Open(list)
141        };
142    }
143
144    /// A word boundary was crossed (a non-word / non-trigger char, or backspacing
145    /// out of the word) — clears Escape-stickiness so the next word can reopen.
146    pub fn on_boundary(&mut self) {
147        if matches!(self.state, CompletionState::DismissedUntilBoundary) {
148            self.state = CompletionState::Closed;
149        }
150    }
151
152    /// Set the selected row to `index` (a filtered-list index), clamped; no-op
153    /// when closed. Used by a popup row click.
154    pub fn set_selected(&mut self, index: u32) {
155        if let CompletionState::Open(list) = &mut self.state {
156            let n = list.filtered.len() as u32;
157            if n > 0 {
158                list.selected = index.min(n - 1);
159            }
160        }
161    }
162
163    /// Move the selection (Up/Down) with wrap; no-op when closed.
164    pub fn move_selection(&mut self, down: bool) {
165        if let CompletionState::Open(list) = &mut self.state {
166            let n = list.filtered.len() as u32;
167            if n == 0 {
168                return;
169            }
170            list.selected = if down {
171                (list.selected + 1) % n
172            } else {
173                (list.selected + n - 1) % n
174            };
175        }
176    }
177
178    /// Escape while open → sticky-dismiss; returns whether the event was captured
179    /// (so the widget knows not to let Escape fall through to other handlers).
180    pub fn escape(&mut self) -> bool {
181        if matches!(self.state, CompletionState::Open(_)) {
182            self.state = CompletionState::DismissedUntilBoundary;
183            true
184        } else {
185            false
186        }
187    }
188
189    /// Accept the selected item: returns it (cloned) and closes the popup. The
190    /// caller applies it as one sealed transaction — replace range = the item's
191    /// `replace`, else the live completion word — and, if `retrigger`, fires one
192    /// `Manual` `on_input`. Returns `None` when not open / nothing selected.
193    pub fn accept(&mut self) -> Option<CompletionItem> {
194        let item = match &self.state {
195            CompletionState::Open(list) => {
196                list.filtered.get(list.selected as usize).map(|&i| list.items[i as usize].clone())
197            }
198            _ => None,
199        };
200        if item.is_some() {
201            self.state = CompletionState::Closed;
202        }
203        item
204    }
205
206    /// Hard close — a caret move not caused by typing (arrow/click/find-jump),
207    /// focus loss, `set_text`, or undo/redo. Idempotent.
208    pub fn close(&mut self) {
209        self.state = CompletionState::Closed;
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::intel::providers::CompletionKind;
217    use crate::Point;
218
219    fn kw(label: &str, sort: &str) -> CompletionItem {
220        CompletionItem::plain(label, CompletionKind::Keyword).with_sort_key(sort)
221    }
222
223    struct Stub {
224        items: Vec<CompletionItem>,
225        calls: u32,
226    }
227    impl Completions for Stub {
228        fn complete(&mut self, _cx: &CompletionCx) -> Vec<CompletionItem> {
229            self.calls += 1;
230            self.items.clone()
231        }
232    }
233
234    fn cx(word: &str, trigger: CompletionTrigger) -> CompletionCx {
235        let doc = crate::Buffer::new("").unwrap().doc_id();
236        CompletionCx {
237            doc,
238            revision: 0,
239            position: Point::new(0, word.len() as u32),
240            word: 0..word.len() as u32,
241            lookback: word.to_string(),
242            trigger,
243        }
244    }
245
246    fn labels(c: &CompletionController) -> Vec<String> {
247        match c.state() {
248            CompletionState::Open(list) => {
249                list.filtered.iter().map(|&i| list.items[i as usize].label.clone()).collect()
250            }
251            _ => vec![],
252        }
253    }
254
255    #[test]
256    fn word_char_opens_from_closed_and_prefix_filters() {
257        let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set"), kw("let", "3_let")], calls: 0 };
258        let mut c = CompletionController::new();
259        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
260        assert!(c.is_open());
261        assert_eq!(labels(&c), ["send", "set"], "prefix 's' in sort_key order");
262        assert_eq!(stub.calls, 1);
263    }
264
265    #[test]
266    fn open_word_char_refilters_without_a_provider_call() {
267        let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set")], calls: 0 };
268        let mut c = CompletionController::new();
269        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
270        assert_eq!(stub.calls, 1);
271        // Extending the word refilters locally — no second provider call.
272        c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
273        assert_eq!(stub.calls, 1, "Open × word char must not call the provider");
274        assert_eq!(labels(&c), ["send", "set"]);
275        c.on_input(&cx("set", CompletionTrigger::Typed('t')), "set", &mut stub);
276        assert_eq!(labels(&c), ["set"]);
277        // Filtering to nothing closes it.
278        c.on_input(&cx("setx", CompletionTrigger::Typed('x')), "setx", &mut stub);
279        assert!(!c.is_open());
280    }
281
282    #[test]
283    fn empty_provider_result_closes() {
284        let mut stub = Stub { items: vec![], calls: 0 };
285        let mut c = CompletionController::new();
286        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
287        assert!(!c.is_open());
288    }
289
290    #[test]
291    fn escape_is_sticky_until_a_boundary_but_a_trigger_char_reopens() {
292        let mut stub = Stub { items: vec![kw("send", "3_send")], calls: 0 };
293        let mut c = CompletionController::new();
294        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
295        assert!(c.escape(), "Escape while open is captured");
296        assert!(matches!(c.state(), CompletionState::DismissedUntilBoundary));
297        // Extending the word stays dismissed — no reopen, no provider call.
298        c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
299        assert!(!c.is_open());
300        assert_eq!(stub.calls, 1);
301        // A trigger char reopens even while dismissed.
302        c.on_input(&cx("", CompletionTrigger::TriggerChar('(')), "", &mut stub);
303        assert!(c.is_open());
304        assert_eq!(stub.calls, 2);
305        // Re-dismiss, then a word boundary restores normal rules.
306        c.escape();
307        c.on_boundary();
308        assert!(matches!(c.state(), CompletionState::Closed));
309        c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
310        assert!(c.is_open());
311    }
312
313    #[test]
314    fn selection_wraps_and_accept_returns_it_then_closes() {
315        let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
316        let mut c = CompletionController::new();
317        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
318        assert_eq!(labels(&c), ["a", "b", "c"]);
319        c.move_selection(true); // -> b
320        c.move_selection(true); // -> c
321        c.move_selection(true); // wrap -> a
322        c.move_selection(false); // wrap -> c
323        let accepted = c.accept().expect("open → accepts");
324        assert_eq!(accepted.label, "c");
325        assert!(matches!(c.state(), CompletionState::Closed));
326        assert!(c.accept().is_none(), "closed → nothing to accept");
327    }
328
329    #[test]
330    fn set_selected_selects_a_row_and_clamps() {
331        let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
332        let mut c = CompletionController::new();
333        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
334        c.set_selected(1);
335        assert_eq!(c.accept().unwrap().label, "b", "clicked row 1");
336        // Reopen; a past-the-end index clamps to the last row.
337        c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
338        c.set_selected(99);
339        assert_eq!(c.accept().unwrap().label, "c", "clamped to the last row");
340    }
341
342    #[test]
343    fn caret_move_closes_and_escape_when_closed_is_not_captured() {
344        let mut stub = Stub { items: vec![kw("a", "1")], calls: 0 };
345        let mut c = CompletionController::new();
346        c.on_input(&cx("a", CompletionTrigger::Typed('a')), "a", &mut stub);
347        assert!(c.is_open());
348        c.close();
349        assert!(matches!(c.state(), CompletionState::Closed));
350        assert!(!c.escape(), "Escape while closed is not captured");
351    }
352}