use crossterm::{
cursor, event,
event::{Event, KeyCode, KeyEvent, KeyModifiers},
terminal,
terminal::{
ClearType, DisableLineWrap, EnableLineWrap, EnterAlternateScreen, LeaveAlternateScreen,
},
ErrorKind as CrosstermError, ExecutableCommand, QueueableCommand,
};
use fuzzy_matcher::skim::SkimMatcherV2;
use std::{
borrow::Cow,
fmt,
fmt::{Display, Formatter},
io::{Error as IoError, Write},
slice,
time::Duration,
};
macro_rules! impl_error {
($($err:ident),*) => {
#[derive(Debug)]
pub enum Error {
$($err($err)),*
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
$(Self::$err(e) => Some(e)),*
}
}
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
$(Self::$err(e) => write!(f, "{}", e)),*
}
}
}
$(
impl From<$err> for Error {
fn from(e: $err) -> Self { Self::$err(e) }
}
)*
}
}
pub fn select<'a, W: Write>(writer: W, list: &'a [&str]) -> Result<Cow<'a, [&'a str]>> {
Fz::new(writer)?.select(list)
}
pub type Result<T> = std::result::Result<T, Error>;
impl_error!(IoError, CrosstermError);
struct Fz<'a, W: Write> {
pattern: String,
matches: Vec<&'a str>,
offset: usize,
index: usize,
selected: Vec<&'a str>,
writer: W,
width: u16,
height: u16,
}
impl<'a, W: Write> Fz<'a, W> {
fn new(writer: W) -> Result<Self> {
let (width, height) = terminal::size()?;
Ok(Self {
pattern: String::new(),
matches: Vec::new(),
offset: 0,
index: 0,
selected: Vec::new(),
writer,
width,
height,
})
}
#[inline]
fn max_rows(&self) -> u16 {
self.height - 2
}
fn move_cursor(&self) -> cursor::MoveTo {
cursor::MoveTo(self.pattern.chars().count() as u16, self.height - 1)
}
fn select(mut self, list: &'a [&str]) -> Result<Cow<'a, [&'a str]>> {
self.update_matches(list);
terminal::enable_raw_mode()?;
self.writer
.queue(EnterAlternateScreen)?
.queue(DisableLineWrap)?;
self.redraw()?;
self.writer.execute(self.move_cursor())?;
loop {
if let Ok(true) = event::poll(Duration::from_secs(2)) {
match event::read() {
Ok(Event::Resize(w, h)) => {
self.width = w;
self.height = h;
self.redraw()?;
}
Ok(Event::Key(
KeyEvent {
code: KeyCode::Enter,
..
}
| KeyEvent {
code: KeyCode::Char('m'),
modifiers: KeyModifiers::CONTROL,
},
)) => break,
Ok(Event::Key(
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
},
)) => {
if !self.matches.is_empty() {
if self.offset + self.index < self.matches.len() - 1 {
self.position(false)?;
match self.index == self.max_rows() as usize {
false => self.index += 1,
true => {
self.offset += 1;
self.redraw()?;
}
}
self.position(true)?;
}
}
}
Ok(Event::Key(
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
},
)) => {
if !self.matches.is_empty() {
if self.offset + self.index > 0 {
self.position(false)?;
match self.index == 0 {
false => self.index -= 1,
true => {
self.offset -= 1;
self.redraw()?;
}
}
self.position(true)?;
}
}
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Tab, ..
})) => {
if !self.matches.is_empty() {
let current_item = self.matches[self.offset + self.index];
match self.selected.iter().position(|s| *s == current_item) {
Some(index) => {
self.selected.remove(index);
self.selection(false, self.index as u16)?;
}
None => {
self.selected.push(current_item);
self.selection(true, self.index as u16)?;
}
}
}
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Backspace,
..
})) => {
self.pattern.pop();
self.update_matches(list);
self.redraw()?;
}
Ok(Event::Key(KeyEvent {
code: KeyCode::Char(c),
modifiers: km,
})) if !km.intersects(!KeyModifiers::SHIFT) => {
match km {
KeyModifiers::NONE => self.pattern.push(c),
KeyModifiers::SHIFT => self.pattern.push(c.to_ascii_uppercase()),
_ => unreachable!(),
}
self.update_matches(list);
self.redraw()?;
}
_ => (),
}
}
self.writer.execute(self.move_cursor())?;
}
self.writer
.queue(LeaveAlternateScreen)?
.execute(EnableLineWrap)?;
terminal::disable_raw_mode()?;
let selected = match self.selected.is_empty() {
true => match self.matches.is_empty() {
true => Cow::Borrowed(&[] as &[&str]),
false => {
let selected_item = list
.iter()
.find(|&i| i == &self.matches[self.offset + self.index])
.unwrap();
Cow::Borrowed(slice::from_ref(selected_item))
}
},
false => Cow::from(self.selected),
};
Ok(selected)
}
fn redraw(&mut self) -> Result<()> {
self.writer.queue(terminal::Clear(ClearType::All))?;
let max_rows = self.max_rows();
for (i, m) in self
.matches
.iter()
.skip(self.offset)
.take(max_rows as usize)
.enumerate()
{
self.writer
.queue(cursor::MoveTo(2, max_rows - i as u16))?
.write_all(m.as_bytes())?;
if self.selected.contains(m) {
self.writer
.queue(cursor::MoveTo(1, max_rows - i as u16))?
.write_all(b"*")?;
}
if m.chars().count() > self.width as usize - 2
{
self.writer
.queue(cursor::MoveTo(self.width - 2, max_rows - i as u16))?
.write_all(b"..")?;
}
}
if !self.matches.is_empty() {
self.position(true)?;
}
self.writer
.queue(cursor::MoveTo(0, self.height - 1))?
.write_all(self.pattern.as_bytes())?;
Ok(())
}
fn position(&mut self, show: bool) -> Result<()> {
let character = match show {
true => b'>',
false => b' ',
};
self.writer
.queue(cursor::MoveTo(0, self.max_rows() - self.index as u16))?
.write_all(&[character])?;
Ok(())
}
fn selection(&mut self, show: bool, row: u16) -> Result<()> {
let character = match show {
true => b'*',
false => b' ',
};
self.writer
.queue(cursor::MoveTo(1, self.max_rows() - row))?
.write_all(&[character])?;
Ok(())
}
fn update_matches(&mut self, items: &'a [&str]) {
self.matches.clear();
match self.pattern.is_empty() {
true => {
self.matches.extend(items);
self.matches.sort_unstable();
}
false => {
let matcher = SkimMatcherV2::default();
let mut scored = Vec::new();
for item in items {
if let Some((score, _indices)) = matcher.fuzzy(item, &self.pattern, false) {
scored.push((item, score));
}
}
scored.sort_unstable_by(|(a_item, a_score), (b_item, b_score)| {
match a_score == b_score {
false => a_score.cmp(b_score),
true => a_item.cmp(b_item),
}
});
self.matches.extend(scored.into_iter().map(|(i, _s)| i));
self.offset = 0;
match self.matches.is_empty() {
true => self.index = 0,
false => {
if self.index >= self.matches.len() {
self.index = self.matches.len() - 1;
}
}
}
}
}
}
}