use std::io::{Stdout, stdout, Write};
use std::fmt::Display;
use std::clone::Clone;
use std::time::Duration;
use std::error::Error;
use crossterm::{
QueueableCommand,
cursor::{MoveTo},
style::{Stylize, Print, PrintStyledContent},
terminal::{
self, Clear, ClearType,
EnterAlternateScreen, LeaveAlternateScreen
},
event::{
poll, read, Event, KeyCode, KeyEventKind,
EnableMouseCapture, DisableMouseCapture,
MouseEventKind, MouseButton
}
};
use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
pub struct FuzzyPicker<T: Display + Clone> {
stdout: Stdout,
matcher: SkimMatcherV2,
items: Vec<T>,
display_items: Vec<String>,
num_of_items: usize,
num_of_displayable_items: usize,
prompt: String,
debug: String,
selected: usize,
start_index: usize,
end_index: usize,
height: usize,
}
impl<T: Display + Clone> FuzzyPicker<T> {
pub fn new(items: &[T]) -> Self {
let (_, h) = terminal::size().unwrap();
let list_items = items.to_vec();
let num_of_items = list_items.len();
let num_of_displayable_items = num_of_items.min((h-1) as usize);
Self {
stdout: stdout(),
matcher: SkimMatcherV2::default(),
items: list_items,
display_items: Vec::<String>::new(),
num_of_items,
num_of_displayable_items,
prompt: String::new(),
debug: String::new(),
selected: 0,
start_index: 0,
end_index: num_of_displayable_items - 1,
height: h as usize
}
}
fn prev_item(&mut self) {
if self.num_of_items <= 0 { return; }
if self.selected == 0 {
self.selected = self.num_of_items - 1;
} else {
self.selected -= 1;
}
if self.selected < self.start_index {
self.start_index = self.selected;
self.end_index = self.start_index + self.num_of_displayable_items - 1;
} else if self.selected > self.end_index {
self.end_index = self.selected;
self.start_index = self.end_index - self.num_of_displayable_items + 1;
}
}
fn next_item(&mut self) {
if self.num_of_items <= 0 { return; }
self.selected = (self.selected + 1) % self.num_of_items;
if self.selected == 0 {
self.start_index = 0;
self.end_index = self.num_of_displayable_items - 1;
} else if self.selected > self.end_index {
self.start_index += 1;
self.end_index += 1;
}
}
fn reset_scroll(&mut self) {
self.start_index = 0;
self.selected = self.start_index;
}
pub fn pick(&mut self) -> Result<Option<T>, Box<dyn Error>> {
self.filter_by_prompt();
let mut picked_item: Option<T> = None;
terminal::enable_raw_mode()?;
self.stdout
.queue(EnterAlternateScreen)?
.queue(EnableMouseCapture)?;
loop {
if poll(Duration::from_millis(500))? {
match read()? {
Event::Key(event) => {
if event.kind == KeyEventKind::Press {
match event.code {
KeyCode::Char(ch) => {
self.prompt.push(ch);
self.filter_by_prompt();
self.reset_scroll();
},
KeyCode::Backspace => {
self.prompt.pop();
self.filter_by_prompt();
self.reset_scroll();
}
KeyCode::Esc => {
self.stdout
.queue(LeaveAlternateScreen)?
.queue(DisableMouseCapture)?;
break;
},
KeyCode::Up | KeyCode::Left => {
self.prev_item();
},
KeyCode::Down | KeyCode::Right => {
self.next_item();
},
KeyCode::Enter => {
self.stdout
.queue(LeaveAlternateScreen)?
.queue(DisableMouseCapture)?;
picked_item = self.items.iter().find(
|&item| format!("{item}") == self.display_items[self.selected]
).cloned();
break;
},
_ => {}
}
}
},
Event::Mouse(event) => {
match event.kind {
MouseEventKind::Down(MouseButton::Left) => {
if event.row < self.num_of_items as u16 +1 {
self.selected = (event.row-1) as usize + self.start_index;
}
},
MouseEventKind::ScrollUp => {
if self.start_index > 0
&& self.end_index > 0 {
self.start_index -= 2;
self.end_index -= 2;
self.selected = self.start_index;
}
},
MouseEventKind::ScrollDown => {
if self.start_index < self.num_of_items
&& self.end_index + 2 < self.num_of_items
&& self.num_of_items > self.height-1 {
self.start_index += 2;
self.end_index += 2;
self.selected = self.start_index;
}
},
_ => {}
}
},
Event::Resize(_, rows) => {
self.end_index = self.start_index + (rows-1) as usize;
},
_ => {}
}
}
self.render_frame()?;
}
terminal::disable_raw_mode()?;
Ok(picked_item)
}
fn filter_by_prompt(&mut self) {
self.display_items = self.items.iter()
.filter_map(|item| {
let display_str = format!("{}", item);
if self.prompt.is_empty() || self.matcher.fuzzy_match(
&display_str.to_lowercase(),
&self.prompt.to_lowercase(),
).unwrap_or_default() != 0 {
Some(display_str)
} else {
None
}
})
.collect();
self.display_items.sort_by_key(|item| {
-self.matcher.fuzzy_match(
&item.to_lowercase(),
&self.prompt.to_lowercase(),
).unwrap_or_default()
});
self.num_of_items = self.display_items.len();
self.num_of_displayable_items = self.num_of_items.min(self.height - 1);
if self.num_of_displayable_items == 0 {
self.end_index = 0;
} else {
self.end_index = self.num_of_displayable_items - 1;
}
}
fn render_frame(&mut self) -> Result<(), Box<dyn Error>> {
let prompt_styled = format!("> {}", self.prompt).green().bold();
let debug_info = format!("{}", self.debug).red().bold();
self.stdout
.queue(Clear(ClearType::All))?
.queue(MoveTo(0, 0))?
.queue(PrintStyledContent(prompt_styled))?;
if !self.debug.is_empty() {
self.stdout.queue(MoveTo(20, 0))?
.queue(PrintStyledContent(debug_info))?;
}
let mut row = 1;
for (index, item) in self.display_items.iter().enumerate().skip(self.start_index).take(self.num_of_displayable_items) {
self.stdout
.queue(MoveTo(0, row))?
.queue(PrintStyledContent(" ".on_dark_grey()))?;
if index == self.selected {
self.stdout
.queue(PrintStyledContent(" ".on_dark_grey()))?
.queue(PrintStyledContent(item.as_str().white().on_dark_grey()))?;
} else {
self.stdout.queue(Print(format!(" {}", item)))?;
}
row += 1;
}
self.stdout.queue(MoveTo(self.prompt.len() as u16 + 2, 0))?;
self.stdout.flush()?;
Ok(())
}
}