use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher};
use super::byte_index;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FuzzyFocus {
#[default]
List,
Input,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct FuzzyList {
pub focus: FuzzyFocus,
pub query: String,
pub cursor: usize,
pub selected: usize,
pub matches: Vec<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FuzzyKey {
Submit,
Cancel,
Edited,
Consumed,
Other,
}
impl FuzzyList {
pub(crate) fn typing() -> Self {
Self {
focus: FuzzyFocus::Input,
..Self::default()
}
}
pub(crate) fn feed(&mut self, key: &KeyEvent) -> FuzzyKey {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => return FuzzyKey::Cancel,
KeyCode::Enter => return FuzzyKey::Submit,
KeyCode::Tab | KeyCode::BackTab => {
self.focus = match self.focus {
FuzzyFocus::List => FuzzyFocus::Input,
FuzzyFocus::Input => FuzzyFocus::List,
};
return FuzzyKey::Consumed;
}
KeyCode::Down => {
self.step(true);
return FuzzyKey::Consumed;
}
KeyCode::Up => {
self.step(false);
return FuzzyKey::Consumed;
}
KeyCode::Char('n') if ctrl => {
self.step(true);
return FuzzyKey::Consumed;
}
KeyCode::Char('p') if ctrl => {
self.step(false);
return FuzzyKey::Consumed;
}
_ => {}
}
match self.focus {
FuzzyFocus::List => self.feed_list(key),
FuzzyFocus::Input => self.feed_input(key, ctrl),
}
}
fn feed_list(&mut self, key: &KeyEvent) -> FuzzyKey {
match key.code {
KeyCode::Char('j') => self.step(true),
KeyCode::Char('k') => self.step(false),
KeyCode::Char('g') => self.selected = 0,
KeyCode::Char('G') => self.selected = self.matches.len().saturating_sub(1),
KeyCode::Char('q') => return FuzzyKey::Cancel,
_ => return FuzzyKey::Other,
}
FuzzyKey::Consumed
}
fn feed_input(&mut self, key: &KeyEvent, ctrl: bool) -> FuzzyKey {
match key.code {
KeyCode::Backspace => {
if self.cursor > 0 {
let start = byte_index(&self.query, self.cursor - 1);
let end = byte_index(&self.query, self.cursor);
self.query.replace_range(start..end, "");
self.cursor -= 1;
self.selected = 0;
}
FuzzyKey::Edited
}
KeyCode::Char('u') if ctrl => {
self.query.clear();
self.cursor = 0;
self.selected = 0;
FuzzyKey::Edited
}
KeyCode::Char('a') if ctrl => {
self.cursor = 0;
FuzzyKey::Consumed
}
KeyCode::Char('e') if ctrl => {
self.cursor = self.query.chars().count();
FuzzyKey::Consumed
}
KeyCode::Left => {
self.cursor = self.cursor.saturating_sub(1);
FuzzyKey::Consumed
}
KeyCode::Right => {
self.cursor = (self.cursor + 1).min(self.query.chars().count());
FuzzyKey::Consumed
}
KeyCode::Char(c) if !ctrl && !key.modifiers.contains(KeyModifiers::ALT) => {
let at = byte_index(&self.query, self.cursor);
self.query.insert(at, c);
self.cursor += 1;
self.selected = 0;
FuzzyKey::Edited
}
_ => FuzzyKey::Other,
}
}
pub(crate) fn step_selection(&mut self, forward: bool) {
self.step(forward);
}
fn step(&mut self, forward: bool) {
let len = self.matches.len();
if len == 0 {
self.selected = 0;
return;
}
self.selected = if forward {
(self.selected + 1) % len
} else {
(self.selected + len - 1) % len
};
}
pub(crate) fn rerank(&mut self, haystacks: &[String]) {
self.matches = rank(&self.query, haystacks);
self.selected = self.selected.min(self.matches.len().saturating_sub(1));
}
pub(crate) fn rerank_words(&mut self, haystacks: &[String]) {
self.matches = rank_words(&self.query, haystacks);
self.selected = self.selected.min(self.matches.len().saturating_sub(1));
}
}
pub(crate) fn rank(query: &str, haystacks: &[String]) -> Vec<usize> {
if query.is_empty() {
return (0..haystacks.len()).collect();
}
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut buf = Vec::new();
let mut scored: Vec<(u32, usize)> = haystacks
.iter()
.enumerate()
.filter_map(|(index, hay)| {
let hay = nucleo_matcher::Utf32Str::new(hay, &mut buf);
pattern.score(hay, &mut matcher).map(|score| (score, index))
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.into_iter().map(|(_, index)| index).collect()
}
pub(crate) fn rank_words(query: &str, haystacks: &[String]) -> Vec<usize> {
rank(query, haystacks)
.into_iter()
.filter(|&index| {
haystacks
.get(index)
.is_some_and(|hay| matches_word_starts(query, hay))
})
.collect()
}
fn matches_word_starts(query: &str, hay: &str) -> bool {
let hay: Vec<char> = hay.to_lowercase().chars().collect();
let mut letters = Vec::new();
let mut after_space = true;
for ch in query.to_lowercase().chars() {
if ch.is_whitespace() {
after_space = true;
} else {
letters.push((ch, after_space));
after_space = false;
}
}
let opens_word =
|at: usize| at == 0 || hay.get(at - 1).is_some_and(|prev| !prev.is_alphanumeric());
let mut failed = std::collections::HashSet::new();
spell(&letters, &hay, 0, None, &opens_word, &mut failed)
}
fn spell(
letters: &[(char, bool)],
hay: &[char],
next: usize,
prev: Option<usize>,
opens_word: &dyn Fn(usize) -> bool,
failed: &mut std::collections::HashSet<(usize, Option<usize>)>,
) -> bool {
let Some(&(letter, must_open)) = letters.get(next) else {
return true;
};
if failed.contains(&(next, prev)) {
return false;
}
let from = prev.map_or(0, |at| at + 1);
for (at, &ch) in hay.iter().enumerate().skip(from) {
let follows = prev.is_some_and(|p| p + 1 == at) && !must_open;
if ch == letter
&& (follows || opens_word(at))
&& spell(letters, hay, next + 1, Some(at), opens_word, failed)
{
return true;
}
}
failed.insert((next, prev));
false
}
pub(crate) fn selected<'a, T>(list: &FuzzyList, items: &'a [T]) -> Option<&'a T> {
list.matches
.get(list.selected)
.and_then(|index| items.get(*index))
}
pub(crate) fn branch_haystack(branches: &[diffler_core::vcs::BranchInfo]) -> Vec<String> {
branches.iter().map(|b| b.name.clone()).collect()
}
pub(crate) fn name_haystack(names: &[String]) -> Vec<String> {
names.to_vec()
}
pub(crate) fn rev_haystack(entries: &[super::RevChoice]) -> Vec<String> {
entries.iter().map(|e| e.label.clone()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn hay(items: &[&str]) -> Vec<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn empty_query_keeps_original_order() {
assert_eq!(rank("", &hay(&["b", "a"])), vec![0, 1]);
}
#[test]
fn subsequence_matches_and_ranks_tighter_hits_first() {
let items = hay(&["pull from upstream", "push to upstream", "unstage all"]);
let ranked = rank("pus", &items);
assert_eq!(ranked.first(), Some(&1));
assert!(!ranked.contains(&2) || ranked[0] == 1);
}
#[test]
fn a_label_matches_only_from_word_starts() {
let items = hay(&[
"amend the last commit and edit its message",
"review all uncommitted changes",
"blame this file: see who last changed each line",
"reword the last commit's message",
]);
assert_eq!(rank_words("amend", &items), vec![0]);
assert_eq!(rank_words("rlc", &items), vec![3], "initials of words");
assert_eq!(rank_words("last com", &items).len(), 2);
assert!(rank_words("xyz", &items).is_empty());
}
#[test]
fn non_matching_items_drop_out() {
let items = hay(&["stage file", "quit"]);
assert_eq!(rank("stg", &items), vec![0]);
}
#[test]
fn list_focus_keeps_classic_keys_and_tab_switches_to_typing() {
let items = hay(&["stage file", "stash", "search"]);
let mut list = FuzzyList::default();
list.rerank(&items);
let press = |code| KeyEvent::new(code, KeyModifiers::NONE);
list.feed(&press(KeyCode::Char('j')));
assert_eq!(list.selected, 1);
list.feed(&press(KeyCode::Char('k')));
assert_eq!(list.selected, 0);
assert_eq!(list.feed(&press(KeyCode::Char('d'))), FuzzyKey::Other);
assert!(list.query.is_empty(), "list focus never types");
assert_eq!(list.feed(&press(KeyCode::Char('q'))), FuzzyKey::Cancel);
list.feed(&press(KeyCode::Tab));
assert_eq!(list.focus, FuzzyFocus::Input);
assert_eq!(list.feed(&press(KeyCode::Char('s'))), FuzzyKey::Edited);
list.rerank(&items);
list.feed(&press(KeyCode::Char('t')));
list.rerank(&items);
assert_eq!(list.query, "st");
assert_eq!(list.matches.len(), 2, "search drops out of 'st'");
list.feed(&press(KeyCode::Down));
assert_eq!(list.selected, 1);
list.feed(&press(KeyCode::Down));
assert_eq!(list.selected, 0, "selection wraps");
list.feed(&press(KeyCode::Tab));
assert_eq!(list.focus, FuzzyFocus::List);
assert_eq!(list.feed(&press(KeyCode::Esc)), FuzzyKey::Cancel);
}
}