use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
use crate::core::render::Rendered;
use crate::core::style::Style;
use crate::core::terminal::is_input_tty;
use crate::core::text::{Line, Span};
use crate::core::theme::{Theme, theme};
use crate::error::{Result, SparcliError};
use crate::input::Outcome;
use crate::input::event::{
CrosstermSource, EventSource, InputEvent, KeyCode, KeyPress,
};
use crate::input::guard::TerminalGuard;
use crate::input::line_edit::LineEditor;
use crate::input::prompt::{Flow, run_prompt};
use crate::input::shortcut::{self, Shortcut};
const DEFAULT_VISIBLE: usize = 10;
struct State {
query: LineEditor,
filtered: Vec<usize>,
cursor: usize,
offset: usize,
checked: Vec<bool>,
}
pub struct FuzzySelect {
prompt: String,
options: Vec<String>,
max_visible: usize,
multi: bool,
shortcuts: Vec<Shortcut>,
initial_query: String,
}
impl FuzzySelect {
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
options: Vec::new(),
max_visible: DEFAULT_VISIBLE,
multi: false,
shortcuts: Vec::new(),
initial_query: String::new(),
}
}
#[must_use]
pub fn query(mut self, query: impl Into<String>) -> Self {
self.initial_query = query.into();
self
}
#[must_use]
pub fn shortcuts<I>(mut self, shortcuts: I) -> Self
where
I: IntoIterator<Item = Shortcut>,
{
self.shortcuts = shortcuts.into_iter().collect();
self
}
#[must_use]
pub fn options<I, S>(mut self, options: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.options = options.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn multi(mut self) -> Self {
self.multi = true;
self
}
#[must_use]
pub fn max_visible(mut self, rows: usize) -> Self {
self.max_visible = rows.max(1);
self
}
pub fn run(self) -> Result<Outcome<usize>> {
let outcome = self.run_collect()?;
Ok(match outcome {
Outcome::Submitted(indices) => indices
.first()
.copied()
.map_or(Outcome::Cancelled, Outcome::Submitted),
Outcome::Cancelled => Outcome::Cancelled,
Outcome::Shortcut(id) => Outcome::Shortcut(id),
})
}
pub fn run_multi(self) -> Result<Outcome<Vec<usize>>> {
self.run_collect()
}
fn run_collect(self) -> Result<Outcome<Vec<usize>>> {
if !is_input_tty() {
return Err(SparcliError::NoTerminal);
}
let _guard = TerminalGuard::new()?;
let mut source = CrosstermSource;
self.run_with(&mut source)
}
fn run_with(
&self,
source: &mut impl EventSource,
) -> Result<Outcome<Vec<usize>>> {
let mut state = self.initial_state();
run_prompt(
source,
&mut state,
|state, final_frame| self.render(state, final_frame),
|state, event| self.handle(state, event),
)
}
fn initial_state(&self) -> State {
State {
query: LineEditor::new(&self.initial_query, false),
filtered: self.filter(&self.initial_query),
cursor: 0,
offset: 0,
checked: vec![false; self.options.len()],
}
}
pub fn frame(&self) -> Rendered {
self.render(&self.initial_state(), false)
}
fn render(&self, state: &State, final_frame: bool) -> Rendered {
let theme = theme();
let mut lines =
vec![query_line(&self.prompt, state, &theme, final_frame)];
let end = (state.offset + self.max_visible).min(state.filtered.len());
for row in state.offset..end {
lines.push(self.result_line(state, row, &theme));
}
if !final_frame && !self.shortcuts.is_empty() {
lines.push(shortcut::hint_line(&self.shortcuts));
}
Rendered::new(lines)
}
fn result_line(&self, state: &State, row: usize, theme: &Theme) -> Line {
let option_index = state.filtered[row];
let is_cursor = row == state.cursor;
let marker = if is_cursor {
theme.cursor_marker()
} else {
theme.marker()
};
let mut spans = vec![Span::styled(marker.to_string(), theme.selection)];
if self.multi {
let checkbox = if state.checked[option_index] {
theme.checkbox_on()
} else {
theme.checkbox_off()
};
spans.push(Span::raw(checkbox.to_string()));
}
let style = if is_cursor {
theme.selection
} else {
Style::new()
};
spans.push(Span::styled(self.options[option_index].clone(), style));
Line::new(spans)
}
fn handle(&self, state: &mut State, event: InputEvent) -> Flow<Vec<usize>> {
match event {
InputEvent::Paste(text) => {
state.query.insert_str(&text);
self.refilter(state);
Flow::Continue
}
InputEvent::Key(key) => self.handle_key(state, key),
InputEvent::Resize => Flow::Continue,
}
}
fn handle_key(&self, state: &mut State, key: KeyPress) -> Flow<Vec<usize>> {
if let Some(id) = shortcut::find(key, &self.shortcuts) {
return Flow::Shortcut(id);
}
match key.code {
KeyCode::Esc => return Flow::Cancel,
KeyCode::Enter => return self.submit(state),
KeyCode::Up => self.move_cursor(state, -1),
KeyCode::Down => self.move_cursor(state, 1),
KeyCode::Char(' ') if self.multi => self.toggle(state),
KeyCode::Backspace => {
state.query.backspace();
self.refilter(state);
}
KeyCode::Char(c) => {
state.query.insert_char(c);
self.refilter(state);
}
_ => {}
}
Flow::Continue
}
fn submit(&self, state: &State) -> Flow<Vec<usize>> {
if self.multi {
let indices = (0..self.options.len())
.filter(|&i| state.checked[i])
.collect();
return Flow::Submit(indices);
}
match state.filtered.get(state.cursor) {
Some(&index) => Flow::Submit(vec![index]),
None => Flow::Continue,
}
}
fn toggle(&self, state: &mut State) {
if let Some(&index) = state.filtered.get(state.cursor) {
state.checked[index] = !state.checked[index];
}
}
fn refilter(&self, state: &mut State) {
state.filtered = self.filter(&state.query.value());
state.cursor = 0;
state.offset = 0;
}
fn move_cursor(&self, state: &mut State, delta: isize) {
if state.filtered.is_empty() {
return;
}
let len = state.filtered.len() as isize;
state.cursor = (state.cursor as isize + delta).rem_euclid(len) as usize;
if state.cursor < state.offset {
state.offset = state.cursor;
} else if state.cursor >= state.offset + self.max_visible {
state.offset = state.cursor + 1 - self.max_visible;
}
}
fn filter(&self, query: &str) -> Vec<usize> {
if query.is_empty() {
return (0..self.options.len()).collect();
}
let pattern =
Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut buf = Vec::new();
let mut scored: Vec<(usize, u32)> = Vec::new();
for (index, option) in self.options.iter().enumerate() {
buf.clear();
let haystack = Utf32Str::new(option, &mut buf);
if let Some(score) = pattern.score(haystack, &mut matcher) {
scored.push((index, score));
}
}
scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
scored.into_iter().map(|(index, _)| index).collect()
}
}
fn query_line(
prompt: &str,
state: &State,
theme: &Theme,
final_frame: bool,
) -> Line {
let mut spans = vec![Span::styled(format!("{prompt} "), theme.title)];
spans.push(Span::raw(state.query.value()));
if !final_frame {
spans.push(Span::styled(" ".to_string(), theme.cursor));
}
Line::new(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::event::ScriptedSource;
fn fuzzy() -> FuzzySelect {
FuzzySelect::new("find").options(["apple", "banana", "cherry", "grape"])
}
#[test]
fn typing_filters_and_enter_selects() {
let outcome = fuzzy()
.run_with(&mut ScriptedSource::keys([
KeyCode::Char('c'),
KeyCode::Char('h'),
KeyCode::Enter,
]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(vec![2]));
}
#[test]
fn empty_query_shows_all_in_order() {
let outcome = fuzzy()
.run_with(&mut ScriptedSource::keys([KeyCode::Enter]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(vec![0]));
}
#[test]
fn multi_select_collects_checked() {
let outcome = fuzzy()
.multi()
.run_with(&mut ScriptedSource::keys([
KeyCode::Char(' '),
KeyCode::Down,
KeyCode::Char(' '),
KeyCode::Enter,
]))
.unwrap();
assert_eq!(outcome, Outcome::Submitted(vec![0, 1]));
}
#[test]
fn esc_cancels() {
let outcome = fuzzy()
.run_with(&mut ScriptedSource::keys([KeyCode::Esc]))
.unwrap();
assert_eq!(outcome, Outcome::Cancelled);
}
}