use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{self, ClearType},
};
use glyphs::{style, Color};
use std::io::{stdout, Write};
pub fn filter<S: Into<String>>(options: &[S]) -> Filter
where
S: Clone,
{
Filter::new(options.iter().map(|s| s.clone().into()).collect())
}
pub struct Filter {
options: Vec<String>,
header: Option<String>,
placeholder: String,
limit: usize,
}
impl Filter {
pub fn new(options: Vec<String>) -> Self {
Self {
options,
header: None,
placeholder: "Type to filter...".to_string(),
limit: 10,
}
}
#[must_use]
pub fn header(mut self, header: impl Into<String>) -> Self {
self.header = Some(header.into());
self
}
#[must_use]
pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
self.placeholder = placeholder.into();
self
}
#[must_use]
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit;
self
}
fn fuzzy_match(query: &str, target: &str) -> bool {
if query.is_empty() {
return true;
}
let query_lower = query.to_lowercase();
let target_lower = target.to_lowercase();
target_lower.contains(&query_lower)
}
pub fn run(self) -> Option<String> {
if self.options.is_empty() {
return None;
}
let mut query = String::new();
let mut selected = 0;
let mut stdout = stdout();
terminal::enable_raw_mode().expect("Failed to enable raw mode");
loop {
let filtered: Vec<&String> = self
.options
.iter()
.filter(|opt| Self::fuzzy_match(&query, opt))
.collect();
let visible = self.limit.min(filtered.len());
execute!(stdout, cursor::MoveToColumn(0)).ok();
if let Some(ref header) = self.header {
execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
println!("{}", style(header).bold());
}
execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
print!("> ");
if query.is_empty() {
print!("{}", style(&self.placeholder).dim());
} else {
print!("{}", query);
}
println!();
for (i, option) in filtered.iter().take(visible).enumerate() {
execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
if i == selected {
println!(
" {}",
style(*option).fg(Color::Cyan).bold()
);
} else {
println!(" {}", option);
}
}
execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
println!(
"{}",
style(format!("{}/{} matches", filtered.len(), self.options.len())).dim()
);
stdout.flush().ok();
if let Ok(Event::Key(key)) = event::read() {
match key.code {
KeyCode::Enter => {
terminal::disable_raw_mode().expect("Failed to disable raw mode");
return filtered.get(selected).map(|s| (*s).clone());
}
KeyCode::Esc => {
terminal::disable_raw_mode().expect("Failed to disable raw mode");
return None;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
terminal::disable_raw_mode().expect("Failed to disable raw mode");
return None;
}
KeyCode::Up | KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if selected > 0 {
selected -= 1;
}
}
KeyCode::Down | KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if selected < filtered.len().saturating_sub(1) {
selected += 1;
}
}
KeyCode::Backspace => {
query.pop();
selected = 0;
}
KeyCode::Char(c) => {
query.push(c);
selected = 0;
}
_ => {}
}
}
if selected >= filtered.len() {
selected = filtered.len().saturating_sub(1);
}
let lines = visible + 2 + if self.header.is_some() { 1 } else { 0 };
execute!(stdout, cursor::MoveUp(lines as u16)).ok();
}
}
}