picleo 0.1.0

A fuzzy picker similar to fzf and Skim using the Nucleo library. Can be used via CLI or as a library.
Documentation
use std::{error, io, sync::Arc};

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use nucleo::{pattern::CaseMatching, Config, Injector, Nucleo, Snapshot};
use ratatui::{prelude::CrosstermBackend, Terminal};

use crate::{selectable::Selectable, ui::ui};

pub type AppResult<T> = std::result::Result<T, Box<dyn error::Error>>;

// TODO convert static to a proper lifetime
pub struct Picker<T: std::marker::Sync + std::marker::Send + 'static> {
    pub matcher: Nucleo<Selectable<T>>,
    pub current_index: u32,
    pub query: String,
}

// TODO maybe expose the Nucleo update callback
impl<T: std::marker::Sync + std::marker::Send + std::fmt::Display> Picker<T> {
    pub fn new() -> Self {
        let matcher = Nucleo::new(Config::DEFAULT, Arc::new(|| {}), None, 1);
        Picker {
            matcher,
            current_index: 0,
            query: String::new(),
        }
    }

    pub fn inject_items<F>(&self, f: F)
    where
        F: FnOnce(&Injector<Selectable<T>>),
    {
        let injector = self.matcher.injector();
        f(&injector);
    }

    pub fn tick(&mut self, timeout: u64) {
        self.matcher.tick(timeout);
    }

    pub fn snapshot(&self) -> &Snapshot<Selectable<T>> {
        self.matcher.snapshot()
    }

    pub fn items(&self) -> Vec<&Selectable<T>> {
        self.snapshot().matched_items(..).map(|i| i.data).collect()
    }

    pub(crate) fn selected_items(&self) -> Vec<&T> {
        // NOTE: matched_items is not factored out due to ownership issues
        let selected_items: Vec<&T> = self
            .snapshot()
            .matched_items(..)
            .filter(|i| i.data.is_selected())
            .map(|i| i.data.value())
            .collect();

        if !selected_items.is_empty() {
            selected_items
        } else {
            self.snapshot()
                .matched_items(..)
                .nth(self.current_index as usize)
                .map(|i| vec![i.data.value()])
                .unwrap_or(vec![])
        }
    }

    pub fn next(&mut self) {
        let indices = self.snapshot().matched_item_count();
        if indices == 0 {
            return;
        }

        self.current_index = (self.current_index + 1) % indices;
    }

    pub fn previous(&mut self) {
        let indices = self.snapshot().matched_item_count();

        if self.snapshot().matched_item_count() == 0 {
            return;
        }

        self.current_index = if self.current_index == 0 {
            indices - 1
        } else {
            self.current_index.saturating_sub(1)
        };
    }

    pub fn toggle_selected(&mut self) {
        let snapshot = self.snapshot();

        if snapshot.matched_item_count() == 0 {
            return;
        }

        // get the currently selected item and toggle it's selected state
        if let Some(i) = snapshot.get_matched_item(self.current_index) {
            i.data.toggle_selected();
        };
    }

    pub(crate) fn append_to_query(&mut self, key: char) {
        // TODO constrain selected item to match range
        self.query.push(key);
        self.matcher
            .pattern
            .reparse(0, &self.query, CaseMatching::Smart, true);
    }

    pub(crate) fn delete_from_query(&mut self) {
        self.query.pop();
        self.matcher
            .pattern
            .reparse(0, &self.query, CaseMatching::Smart, false);
    }

    pub(crate) fn clear_query(&mut self) {
        self.query.clear();
        // TODO seems like there should be a better way to clear the query
        self.matcher
            .pattern
            .reparse(0, &self.query, CaseMatching::Smart, false);
    }

    pub fn run(&mut self) -> AppResult<Vec<&T>> {
        // Setup terminal
        enable_raw_mode()?;
        let mut stdout = io::stdout();
        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;

        let result = self.run_loop(&mut terminal);

        // Restore terminal
        disable_raw_mode()?;
        execute!(
            terminal.backend_mut(),
            LeaveAlternateScreen,
            DisableMouseCapture
        )?;
        terminal.show_cursor()?;

        result
    }

    pub(crate) fn run_loop<B: ratatui::backend::Backend>(
        &mut self,
        terminal: &mut Terminal<B>,
    ) -> AppResult<Vec<&T>> {
        loop {
            self.tick(10);
            terminal.draw(|f| ui(f, self))?;

            if let Ok(Event::Key(key)) = event::read() {
                match (key.code, key.modifiers) {
                    (KeyCode::Char(key), KeyModifiers::NONE) => {
                        self.append_to_query(key);
                    }
                    (KeyCode::Backspace, KeyModifiers::NONE) => {
                        self.delete_from_query();
                    }
                    (KeyCode::Esc, KeyModifiers::NONE) => {
                        return Ok(vec![]);
                    }
                    (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
                        self.clear_query();
                    }
                    (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
                        return Ok(vec![]);
                    }
                    (KeyCode::Enter, KeyModifiers::NONE) => {
                        // Print selected items and exit
                        return Ok(self.selected_items());
                    }
                    (KeyCode::Down, KeyModifiers::NONE) => {
                        self.next();
                    }
                    (KeyCode::Up, KeyModifiers::NONE) => {
                        self.previous();
                    }
                    (KeyCode::Tab, KeyModifiers::NONE) => {
                        self.toggle_selected();
                    }

                    // ignore other key codes
                    _ => {}
                }
            };
        }
    }
}