use super::providers::{CompletionCx, CompletionItem, CompletionTrigger, Completions};
pub enum CompletionState {
Closed,
Open(PopupList),
DismissedUntilBoundary,
}
pub struct PopupList {
pub items: Vec<CompletionItem>,
pub filtered: Vec<u32>,
pub selected: u32,
pub anchor: u32,
}
impl PopupList {
fn refilter(&mut self, word: &str) {
self.filtered.clear();
self.filtered.extend(
(0..self.items.len() as u32)
.filter(|&i| prefix_matches(&self.items[i as usize].label, word)),
);
self.filtered
.sort_by(|&a, &b| self.items[a as usize].sort_key.cmp(&self.items[b as usize].sort_key));
self.selected = 0;
}
}
fn prefix_matches(label: &str, prefix: &str) -> bool {
let (l, p) = (label.as_bytes(), prefix.as_bytes());
l.len() >= p.len() && l[..p.len()].eq_ignore_ascii_case(p)
}
pub struct CompletionController {
state: CompletionState,
}
impl Default for CompletionController {
fn default() -> Self {
Self::new()
}
}
impl CompletionController {
#[must_use]
pub fn new() -> Self {
Self { state: CompletionState::Closed }
}
#[must_use]
pub fn state(&self) -> &CompletionState {
&self.state
}
#[must_use]
pub fn is_open(&self) -> bool {
matches!(self.state, CompletionState::Open(_))
}
pub fn on_input(&mut self, cx: &CompletionCx, word: &str, provider: &mut dyn Completions) {
match cx.trigger {
CompletionTrigger::Typed(_) => match &mut self.state {
CompletionState::Open(list) => {
list.refilter(word);
if list.filtered.is_empty() {
self.state = CompletionState::Closed;
}
}
CompletionState::DismissedUntilBoundary => {} CompletionState::Closed => {
let items = provider.complete(cx);
self.set_from_items(items, word, cx.word.start);
}
},
CompletionTrigger::TriggerChar(_) | CompletionTrigger::Manual => {
let items = provider.complete(cx);
self.set_from_items(items, word, cx.word.start);
}
}
}
pub fn set_items(&mut self, items: Vec<CompletionItem>, word: &str, anchor: u32) {
self.set_from_items(items, word, anchor);
}
fn set_from_items(&mut self, items: Vec<CompletionItem>, word: &str, anchor: u32) {
if items.is_empty() {
self.state = CompletionState::Closed;
return;
}
let mut list = PopupList { items, filtered: Vec::new(), selected: 0, anchor };
list.refilter(word);
self.state = if list.filtered.is_empty() {
CompletionState::Closed
} else {
CompletionState::Open(list)
};
}
pub fn on_boundary(&mut self) {
if matches!(self.state, CompletionState::DismissedUntilBoundary) {
self.state = CompletionState::Closed;
}
}
pub fn set_selected(&mut self, index: u32) {
if let CompletionState::Open(list) = &mut self.state {
let n = list.filtered.len() as u32;
if n > 0 {
list.selected = index.min(n - 1);
}
}
}
pub fn move_selection(&mut self, down: bool) {
if let CompletionState::Open(list) = &mut self.state {
let n = list.filtered.len() as u32;
if n == 0 {
return;
}
list.selected = if down {
(list.selected + 1) % n
} else {
(list.selected + n - 1) % n
};
}
}
pub fn escape(&mut self) -> bool {
if matches!(self.state, CompletionState::Open(_)) {
self.state = CompletionState::DismissedUntilBoundary;
true
} else {
false
}
}
pub fn accept(&mut self) -> Option<CompletionItem> {
let item = match &self.state {
CompletionState::Open(list) => {
list.filtered.get(list.selected as usize).map(|&i| list.items[i as usize].clone())
}
_ => None,
};
if item.is_some() {
self.state = CompletionState::Closed;
}
item
}
pub fn close(&mut self) {
self.state = CompletionState::Closed;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::intel::providers::CompletionKind;
use crate::Point;
fn kw(label: &str, sort: &str) -> CompletionItem {
CompletionItem::plain(label, CompletionKind::Keyword).with_sort_key(sort)
}
struct Stub {
items: Vec<CompletionItem>,
calls: u32,
}
impl Completions for Stub {
fn complete(&mut self, _cx: &CompletionCx) -> Vec<CompletionItem> {
self.calls += 1;
self.items.clone()
}
}
fn cx(word: &str, trigger: CompletionTrigger) -> CompletionCx {
let doc = crate::Buffer::new("").unwrap().doc_id();
CompletionCx {
doc,
revision: 0,
position: Point::new(0, word.len() as u32),
word: 0..word.len() as u32,
lookback: word.to_string(),
trigger,
}
}
fn labels(c: &CompletionController) -> Vec<String> {
match c.state() {
CompletionState::Open(list) => {
list.filtered.iter().map(|&i| list.items[i as usize].label.clone()).collect()
}
_ => vec![],
}
}
#[test]
fn word_char_opens_from_closed_and_prefix_filters() {
let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set"), kw("let", "3_let")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
assert!(c.is_open());
assert_eq!(labels(&c), ["send", "set"], "prefix 's' in sort_key order");
assert_eq!(stub.calls, 1);
}
#[test]
fn open_word_char_refilters_without_a_provider_call() {
let mut stub = Stub { items: vec![kw("send", "3_send"), kw("set", "3_set")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
assert_eq!(stub.calls, 1);
c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
assert_eq!(stub.calls, 1, "Open × word char must not call the provider");
assert_eq!(labels(&c), ["send", "set"]);
c.on_input(&cx("set", CompletionTrigger::Typed('t')), "set", &mut stub);
assert_eq!(labels(&c), ["set"]);
c.on_input(&cx("setx", CompletionTrigger::Typed('x')), "setx", &mut stub);
assert!(!c.is_open());
}
#[test]
fn empty_provider_result_closes() {
let mut stub = Stub { items: vec![], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
assert!(!c.is_open());
}
#[test]
fn escape_is_sticky_until_a_boundary_but_a_trigger_char_reopens() {
let mut stub = Stub { items: vec![kw("send", "3_send")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
assert!(c.escape(), "Escape while open is captured");
assert!(matches!(c.state(), CompletionState::DismissedUntilBoundary));
c.on_input(&cx("se", CompletionTrigger::Typed('e')), "se", &mut stub);
assert!(!c.is_open());
assert_eq!(stub.calls, 1);
c.on_input(&cx("", CompletionTrigger::TriggerChar('(')), "", &mut stub);
assert!(c.is_open());
assert_eq!(stub.calls, 2);
c.escape();
c.on_boundary();
assert!(matches!(c.state(), CompletionState::Closed));
c.on_input(&cx("s", CompletionTrigger::Typed('s')), "s", &mut stub);
assert!(c.is_open());
}
#[test]
fn selection_wraps_and_accept_returns_it_then_closes() {
let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
assert_eq!(labels(&c), ["a", "b", "c"]);
c.move_selection(true); c.move_selection(true); c.move_selection(true); c.move_selection(false); let accepted = c.accept().expect("open → accepts");
assert_eq!(accepted.label, "c");
assert!(matches!(c.state(), CompletionState::Closed));
assert!(c.accept().is_none(), "closed → nothing to accept");
}
#[test]
fn set_selected_selects_a_row_and_clamps() {
let mut stub = Stub { items: vec![kw("a", "1"), kw("b", "2"), kw("c", "3")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
c.set_selected(1);
assert_eq!(c.accept().unwrap().label, "b", "clicked row 1");
c.on_input(&cx("", CompletionTrigger::Manual), "", &mut stub);
c.set_selected(99);
assert_eq!(c.accept().unwrap().label, "c", "clamped to the last row");
}
#[test]
fn caret_move_closes_and_escape_when_closed_is_not_captured() {
let mut stub = Stub { items: vec![kw("a", "1")], calls: 0 };
let mut c = CompletionController::new();
c.on_input(&cx("a", CompletionTrigger::Typed('a')), "a", &mut stub);
assert!(c.is_open());
c.close();
assert!(matches!(c.state(), CompletionState::Closed));
assert!(!c.escape(), "Escape while closed is not captured");
}
}