use std::{fmt, iter::FromIterator};
use radix_trie::{Trie, TrieCommon};
#[derive(Clone)]
pub struct PrefixSearch {
candidates: Trie<String, ()>,
query: Option<String>,
selected: Option<usize>,
}
impl Default for PrefixSearch {
fn default() -> Self {
Self {
candidates: Trie::new(),
query: None,
selected: None,
}
}
}
impl<T: fmt::Display> FromIterator<T> for PrefixSearch {
fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
Self {
candidates: Trie::from_iter(iter.into_iter().map(|item| (item.to_string(), ()))),
..Default::default()
}
}
}
impl PrefixSearch {
pub fn search(&mut self, query: impl AsRef<str>) -> bool {
self.query = Some(query.as_ref().to_string());
let has_match = self.candidates().next().is_some();
self.selected = has_match.then_some(0);
has_match
}
pub fn clear(&mut self) {
self.query = None;
self.selected = None;
}
pub fn candidates(&self) -> impl Iterator<Item = &str> {
self.query
.as_deref()
.and_then(|query| self.candidates.get_raw_descendant(query))
.into_iter()
.flat_map(|subtrie| subtrie.iter().map(|(candidate, _)| candidate.as_str()))
}
pub fn candidate_at(&self, index: usize) -> Option<&str> {
self.candidates().nth(index)
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn get(&self) -> Option<&str> {
self.selected
.and_then(|selected| self.candidate_at(selected))
}
pub fn is_empty(&self) -> bool {
self.candidates().next().is_none()
}
pub fn backward(&mut self) -> bool {
let Some(selected) = self.selected.filter(|selected| *selected > 0) else {
return false;
};
self.selected = Some(selected - 1);
true
}
pub fn forward(&mut self) -> bool {
let Some(selected) = self
.selected
.filter(|selected| self.candidate_at(selected.saturating_add(1)).is_some())
else {
return false;
};
self.selected = Some(selected + 1);
true
}
pub fn move_to(&mut self, index: usize) -> bool {
if self.candidate_at(index).is_some() {
self.selected = Some(index);
true
} else {
false
}
}
}
#[cfg(test)]
mod tests {
use super::PrefixSearch;
fn prefix_search() -> PrefixSearch {
["apple", "applet", "application", "banana"]
.into_iter()
.collect()
}
mod search {
use super::*;
#[test]
fn filters_the_trie_and_selects_the_first_match() {
let mut prefix_search = prefix_search();
assert!(prefix_search.search("app"));
assert_eq!(
prefix_search.candidates().collect::<Vec<_>>(),
vec!["apple", "applet", "application"]
);
assert_eq!(prefix_search.selected(), Some(0));
assert_eq!(prefix_search.get(), Some("apple"));
assert!(prefix_search.search("ban"));
assert_eq!(
prefix_search.candidates().collect::<Vec<_>>(),
vec!["banana"]
);
assert_eq!(prefix_search.selected(), Some(0));
assert_eq!(prefix_search.get(), Some("banana"));
}
#[test]
fn missing_match_clears_the_selection() {
let mut prefix_search = prefix_search();
assert!(prefix_search.search("app"));
assert!(!prefix_search.search("orange"));
assert!(prefix_search.is_empty());
assert_eq!(prefix_search.selected(), None);
assert_eq!(prefix_search.get(), None);
}
}
mod backward {
use super::*;
#[test]
fn stops_at_the_first_match() {
let mut prefix_search = prefix_search();
prefix_search.search("app");
assert!(!prefix_search.backward());
assert_eq!(prefix_search.get(), Some("apple"));
}
}
mod forward {
use super::*;
#[test]
fn stops_at_the_last_match() {
let mut prefix_search = prefix_search();
prefix_search.search("app");
assert!(prefix_search.forward());
assert_eq!(prefix_search.get(), Some("applet"));
assert!(prefix_search.forward());
assert_eq!(prefix_search.get(), Some("application"));
assert!(!prefix_search.forward());
}
}
mod move_to {
use super::*;
#[test]
fn rejects_an_out_of_bounds_index() {
let mut prefix_search = prefix_search();
prefix_search.search("app");
prefix_search.move_to(2);
assert!(!prefix_search.move_to(3));
assert_eq!(prefix_search.selected(), Some(2));
}
#[test]
fn selects_the_given_match() {
let mut prefix_search = prefix_search();
prefix_search.search("app");
prefix_search.move_to(2);
assert!(prefix_search.move_to(0));
assert_eq!(prefix_search.get(), Some("apple"));
}
}
mod clear {
use super::*;
#[test]
fn hides_matches_without_removing_candidates() {
let mut prefix_search = prefix_search();
prefix_search.search("app");
prefix_search.clear();
assert!(prefix_search.is_empty());
assert_eq!(prefix_search.selected(), None);
assert!(prefix_search.search("app"));
assert_eq!(prefix_search.get(), Some("apple"));
}
}
}