picleo 0.1.11

A fuzzy picker similar to fzf and Skim using the Nucleo library. Can be used via CLI or as a library.
Documentation
use crate::requested_items::RequestedItems;
use crate::{config::Config, selectable::SelectableItem, selected_items::SelectedItems, ui::ui};
use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use nucleo::{Config as NucleoConfig, Injector, Nucleo, Snapshot};
use ratatui::prelude::Backend;
use ratatui::{Terminal, prelude::CrosstermBackend};
use std::time::Instant;
use std::{error, fmt::Display, io, sync::Arc, thread::JoinHandle, time::Duration};

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

// This is the number of milliseconds between frames, target 60 fps, 1000 / 60 = 16ms (positive integer division floors the result)
// Yes, u64 is overkill, but it's what Duration::from_millis() wants
const FRAME_DELAY: u64 = 1000 / 60;

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PickerMode {
    Search,
    Editing,
    Help,
}

pub(crate) enum EventResponse {
    NoAction,
    UpdateUI,
    ExitProgram,
    ReturnSelectedItems,
}

// TODO convert static to a proper lifetime
pub struct Picker<T>
where
    T: Sync + Send + 'static,
{
    pub matcher: Nucleo<SelectableItem<T>>,
    pub first_visible_item_index: u32,
    pub current_index: u32,
    pub height: u16,
    pub query: String,
    pub query_index: usize,
    pub mode: PickerMode,
    pub editing_text: String,
    pub editing_index: usize,
    pub join_handles: Vec<JoinHandle<()>>,
    pub config: Config,
    pub preview_command: Option<String>,
    pub preview_output: String,
    pub keep_colors: bool,
    pub editable: bool,
    pub autocomplete: Option<Box<dyn Fn(&str) -> RequestedItems<String> + Send + Sync>>,
    pub autocomplete_suggestions: RequestedItems<String>,
    pub autocomplete_index: usize,
    pub help_scroll_offset: u16,
}

impl<T: Sync + Send + Display> Default for Picker<T> {
    fn default() -> Self {
        Self::new(true)
    }
}

// TODO maybe expose the Nucleo update callback
impl<T> Picker<T>
where
    T: Sync + Send + Display,
{
    pub fn new(editable: bool) -> Self {
        let config = Config::load().unwrap_or_default();
        let matcher = Nucleo::new(NucleoConfig::DEFAULT, Arc::new(|| {}), None, 1);
        let preview_command = config.preview_command().cloned();
        Picker {
            matcher,
            first_visible_item_index: 0,
            current_index: 0,
            height: config.height().unwrap_or(0),
            query: String::new(),
            query_index: 0,
            mode: PickerMode::Search,
            editing_text: String::new(),
            editing_index: 0,
            join_handles: Vec::new(),
            config,
            preview_command,
            preview_output: String::new(),
            keep_colors: false,
            editable,
            autocomplete: None,
            autocomplete_suggestions: RequestedItems::default(),
            autocomplete_index: 0,
            help_scroll_offset: 0,
        }
    }

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

    pub fn inject_items_threaded<F>(&mut self, f: F)
    where
        F: FnOnce(&Injector<SelectableItem<T>>) + Send + 'static,
    {
        let injector = self.matcher.injector();
        let handle = std::thread::spawn(move || {
            f(&injector);
        });
        self.join_handles.push(handle);
    }

    pub fn join_finished_threads(&mut self) -> usize {
        let mut remaining_handles = Vec::new();

        for handle in self.join_handles.drain(..) {
            if handle.is_finished() {
                // Thread is finished, join it (ignore any errors)
                let _ = handle.join();
            } else {
                // Thread is still running, keep it
                remaining_handles.push(handle);
            }
        }

        self.join_handles = remaining_handles;
        self.join_handles.len()
    }

    pub fn running_threads(&self) -> usize {
        self.join_handles.len()
    }

    pub fn item_count(&self) -> u32 {
        self.matcher.snapshot().item_count()
    }

    /// Returns the total number of matched items
    pub fn matched_item_count(&self) -> u32 {
        self.snapshot().matched_item_count()
    }

    pub fn height(&self) -> u16 {
        // truncation should be fine since we are getting the min and we don't want this to panic
        self.height.min(self.item_count() as u16)
    }

    pub(crate) fn update_height(&mut self, height: u16) {
        self.height = height;
    }

    pub fn tick(&mut self, timeout: u64) -> nucleo::Status {
        // TODO ensure that this is the correct place to call the thread join
        let _running_indexers = self.join_finished_threads();
        self.matcher.tick(timeout)
    }

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

    // TODO add a closure that is provided the current item and must return the initial editing text
    pub(crate) fn enter_editing_mode(&mut self, item_text: String) {
        self.mode = PickerMode::Editing;
        self.editing_text = item_text;
        self.editing_index = 0;
    }

    pub(crate) fn exit_editing_mode(&mut self) {
        self.mode = PickerMode::Search;
        self.editing_text.clear();
        self.editing_index = 0;
        self.autocomplete_suggestions.clear();
        self.autocomplete_index = 0;
    }

    pub(crate) fn enter_help_mode(&mut self) {
        self.mode = PickerMode::Help;
        self.help_scroll_offset = 0;
    }

    pub(crate) fn exit_help_mode(&mut self) {
        self.mode = PickerMode::Search;
        self.help_scroll_offset = 0;
    }

    fn max_help_scroll_offset(&self) -> u16 {
        // Help content has approximately 38 lines (counted from render_help_screen)
        const HELP_CONTENT_LINES: u16 = 38;

        // Account for borders (2 lines) in the help screen
        let available_height = self.height.saturating_sub(2);

        // Ensure we can't scroll so far that the screen becomes blank
        // Keep at least one screenful visible
        if HELP_CONTENT_LINES > available_height {
            HELP_CONTENT_LINES.saturating_sub(available_height)
        } else {
            0
        }
    }

    fn handle_resize_event(&mut self) {
        // If we're in help mode and the window is now large enough to show all content,
        // scroll back to the top
        if self.mode == PickerMode::Help {
            let max_offset = self.max_help_scroll_offset();
            if max_offset == 0 && self.help_scroll_offset > 0 {
                self.help_scroll_offset = 0;
            } else if self.help_scroll_offset > max_offset {
                // Constrain scroll offset if it's now beyond the maximum
                self.help_scroll_offset = max_offset;
            }
        }
    }

    pub(crate) fn help_mode_handle_event(&mut self, event: Event) -> EventResponse {
        match event {
            Event::Key(key) => match key.code {
                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
                    if key.modifiers.contains(KeyModifiers::CONTROL)
                        && key.code == KeyCode::Char('h')
                    {
                        self.exit_help_mode();
                        EventResponse::UpdateUI
                    } else if key.code == KeyCode::Esc || key.code == KeyCode::Char('q') {
                        self.exit_help_mode();
                        EventResponse::UpdateUI
                    } else {
                        EventResponse::NoAction
                    }
                }
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    EventResponse::ExitProgram
                }
                KeyCode::Up | KeyCode::Char('k') => {
                    self.help_scroll_offset = self.help_scroll_offset.saturating_sub(1);
                    EventResponse::UpdateUI
                }
                KeyCode::Down | KeyCode::Char('j') => {
                    let max_offset = self.max_help_scroll_offset();
                    self.help_scroll_offset =
                        (self.help_scroll_offset.saturating_add(1)).min(max_offset);
                    EventResponse::UpdateUI
                }
                KeyCode::PageUp => {
                    self.help_scroll_offset = self.help_scroll_offset.saturating_sub(10);
                    EventResponse::UpdateUI
                }
                KeyCode::PageDown => {
                    let max_offset = self.max_help_scroll_offset();
                    self.help_scroll_offset =
                        (self.help_scroll_offset.saturating_add(10)).min(max_offset);
                    EventResponse::UpdateUI
                }
                KeyCode::Home => {
                    self.help_scroll_offset = 0;
                    EventResponse::UpdateUI
                }
                KeyCode::End => {
                    self.help_scroll_offset = self.max_help_scroll_offset();
                    EventResponse::UpdateUI
                }
                _ => EventResponse::NoAction,
            },
            Event::Mouse(mouse) => match mouse.kind {
                crossterm::event::MouseEventKind::ScrollUp => {
                    self.help_scroll_offset = self.help_scroll_offset.saturating_sub(3);
                    EventResponse::UpdateUI
                }
                crossterm::event::MouseEventKind::ScrollDown => {
                    let max_offset = self.max_help_scroll_offset();
                    self.help_scroll_offset =
                        (self.help_scroll_offset.saturating_add(3)).min(max_offset);
                    EventResponse::UpdateUI
                }
                _ => EventResponse::NoAction,
            },
            _ => EventResponse::NoAction,
        }
    }

    pub fn run(&mut self) -> AppResult<SelectedItems<'_, T>> {
        // Setup terminal
        enable_raw_mode()?;
        // TODO should we allow the caller to pass any arbitrary stream?
        let mut stream = io::stderr();
        execute!(stream, EnterAlternateScreen, EnableMouseCapture)?;
        let backend = CrosstermBackend::new(stream);
        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<SelectedItems<'_, T>>
    where
        <B as Backend>::Error: 'static,
    {
        // setting this to true initially to trigger the initial screen paint
        let mut redraw_requested = true;

        // enter the actual event loop
        loop {
            // Time when we started drawing this frame
            let frame_draw_start = Instant::now();

            // draw the UI before any timeouts so it appears to the user immediately
            // redraw the UI if any of the below are true
            //   1. a redraw is requested by an event
            //   2. the matcher's status has changed
            //   3. injectors are still running and adding items
            if redraw_requested {
                terminal.draw(|f| ui(f, self))?;
            }

            // toggling this back to the default, it will be switched back to true below on appropriate conditions
            redraw_requested = false;

            // we must call this to keep Nucleo up to date
            let status = self.tick(FRAME_DELAY);
            // NOTE: do NOT try to move this logic into the event logic, there are non-event changes that need to trigger redraws
            if status.changed || status.running {
                // TODO need to debounce events here

                // Update preview initially if we have a preview command
                // TODO determine if this is the right place to update the preview
                self.update_preview();

                redraw_requested = true;
            }

            // check how long it took us for the tick command to complete and render the preview
            let frame_draw_duration = frame_draw_start.elapsed().as_millis() as u64;
            // clamp the value between 1ms and FRAME_DELAY, setting the poll timeout to the remainder of the total delay
            let event_poll_timeout = Duration::from_millis(
                FRAME_DELAY
                    .saturating_sub(frame_draw_duration)
                    .clamp(1, FRAME_DELAY),
            );

            // ensure that we update the UI, even when we aren't receiving events from the user
            if event::poll(event_poll_timeout)? {
                // read the event that is ready (normally read blocks, but we're polling until it's ready)
                let event = event::read()?;

                // Handle resize events separately to always trigger a redraw
                if let Event::Resize(_, _) = event {
                    self.handle_resize_event();
                    redraw_requested = true;
                } else {
                    match self.handle_event_by_mode(event) {
                        EventResponse::NoAction => {}
                        EventResponse::UpdateUI => redraw_requested = true,
                        EventResponse::ExitProgram => return Ok(SelectedItems::from_refs(vec![])),
                        EventResponse::ReturnSelectedItems => return Ok(self.selected_items()),
                    }
                }
            }
        }
    }

    fn handle_event_by_mode(&mut self, event: Event) -> EventResponse {
        match self.mode {
            PickerMode::Search => self.search_mode_handle_event(event),
            PickerMode::Editing => self.editing_mode_handle_event(event),
            PickerMode::Help => self.help_mode_handle_event(event),
        }
    }

    pub fn set_autocomplete<F>(&mut self, autocomplete: F)
    where
        F: Fn(&str) -> RequestedItems<String> + Send + Sync + 'static,
    {
        self.autocomplete = Some(Box::new(autocomplete));
    }

    pub(crate) fn update_autocomplete_suggestions(&mut self) {
        if let Some(ref autocomplete_fn) = self.autocomplete {
            self.autocomplete_suggestions = autocomplete_fn(&self.editing_text);
            self.autocomplete_index = 0;
        }
    }
}