use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
style::Style,
text::{Line, Span},
};
use super::style;
use crate::theme::Palette;
const MAX_SUGGESTIONS: usize = 6;
pub enum AcOutcome {
Accepted(String),
Navigated,
Closed,
Ignored,
}
pub struct Autocomplete {
candidates: Vec<String>,
matches: Vec<usize>,
overflow: usize,
selected: Option<usize>,
dismissed: bool,
empty_query: bool,
}
impl Autocomplete {
pub fn new(candidates: Vec<String>) -> Self {
Self {
candidates,
matches: Vec::new(),
overflow: 0,
selected: None,
dismissed: true,
empty_query: true,
}
}
pub fn refresh(&mut self, query: &str) {
self.dismissed = false;
self.empty_query = query.trim().is_empty();
let needle = query.trim().to_lowercase();
self.matches.clear();
self.overflow = 0;
if !self.empty_query {
for (index, candidate) in self.candidates.iter().enumerate() {
let lower = candidate.to_lowercase();
if lower.contains(&needle) && lower != needle {
if self.matches.len() < MAX_SUGGESTIONS {
self.matches.push(index);
} else {
self.overflow += 1;
}
}
}
}
self.selected = match self.selected {
Some(index) if index < self.matches.len() => Some(index),
_ => None,
};
}
pub fn is_open(&self) -> bool {
!self.dismissed && !self.empty_query && !self.matches.is_empty()
}
pub fn on_key(&mut self, key: KeyEvent) -> AcOutcome {
if !self.is_open() {
return AcOutcome::Ignored;
}
match key.code {
KeyCode::Esc => {
self.dismissed = true;
self.selected = None;
AcOutcome::Closed
}
KeyCode::Down => {
self.move_selection(1);
AcOutcome::Navigated
}
KeyCode::Up => {
self.move_selection(-1);
AcOutcome::Navigated
}
KeyCode::Enter | KeyCode::Tab | KeyCode::Right => {
match self.accepted() {
Some(value) => {
self.dismissed = true;
self.selected = None;
AcOutcome::Accepted(value)
}
None => AcOutcome::Ignored,
}
}
_ => AcOutcome::Ignored,
}
}
pub fn lines(
&self,
palette: &Palette,
indent: usize,
base: Style,
) -> Vec<Line<'static>> {
if !self.is_open() {
return Vec::new();
}
let pad = " ".repeat(indent);
let mut lines: Vec<Line<'static>> = self
.matches
.iter()
.enumerate()
.map(|(row, &candidate)| {
let row_style = if Some(row) == self.selected {
style::bg(palette.selection)
} else {
base
};
Line::from(vec![
Span::raw(pad.clone()),
Span::styled(self.candidates[candidate].clone(), row_style),
])
})
.collect();
if self.overflow > 0 {
lines.push(Line::from(vec![
Span::raw(pad),
Span::styled(
format!("+{} more", self.overflow),
style::secondary(palette),
),
]));
}
lines
}
fn move_selection(&mut self, delta: isize) {
let last = self.matches.len().saturating_sub(1);
self.selected = match (self.selected, delta) {
(None, step) if step > 0 => Some(0),
(Some(index), step) if step < 0 => index.checked_sub(1),
(Some(index), _) => Some((index + 1).min(last)),
(None, _) => None,
};
}
fn accepted(&self) -> Option<String> {
self.selected
.and_then(|row| self.matches.get(row))
.map(|&index| self.candidates[index].clone())
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use super::*;
fn ac() -> Autocomplete {
Autocomplete::new(vec![
"Rewe big shop".to_string(),
"Edeka".to_string(),
"Rewe small".to_string(),
])
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn matched(ac: &Autocomplete) -> Vec<String> {
ac.matches
.iter()
.map(|&i| ac.candidates[i].clone())
.collect()
}
#[test]
fn matches_contain_query_newest_first() {
let mut ac = ac();
ac.refresh("rewe");
assert_eq!(matched(&ac), vec!["Rewe big shop", "Rewe small"]);
assert!(ac.is_open());
}
#[test]
fn exact_match_is_excluded() {
let mut ac = ac();
ac.refresh("Edeka");
assert!(matched(&ac).is_empty());
assert!(!ac.is_open());
}
#[test]
fn cap_limits_match_count() {
let many: Vec<String> = (0..20).map(|n| format!("Store {n}")).collect();
let mut ac = Autocomplete::new(many);
ac.refresh("store");
assert_eq!(ac.matches.len(), MAX_SUGGESTIONS);
}
#[test]
fn overflow_counts_matches_beyond_the_cap() {
let many: Vec<String> = (0..20).map(|n| format!("Store {n}")).collect();
let mut ac = Autocomplete::new(many);
ac.refresh("store");
assert_eq!(ac.overflow, 20 - MAX_SUGGESTIONS);
}
#[test]
fn esc_closes_and_a_later_edit_reopens() {
let mut ac = ac();
ac.refresh("rewe");
assert!(matches!(ac.on_key(key(KeyCode::Esc)), AcOutcome::Closed));
assert!(!ac.is_open());
ac.refresh("rewe s");
assert!(ac.is_open());
}
#[test]
fn accept_returns_the_highlighted_string() {
let mut ac = ac();
ac.refresh("rewe");
ac.on_key(key(KeyCode::Down));
match ac.on_key(key(KeyCode::Enter)) {
AcOutcome::Accepted(value) => assert_eq!(value, "Rewe big shop"),
_ => panic!("expected acceptance"),
}
assert!(!ac.is_open());
}
}