picleo 0.1.4

A fuzzy picker similar to fzf and Skim using the Nucleo library. Can be used via CLI or as a library.
Documentation
use crate::picker::Picker;
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, Paragraph},
    Frame,
};
use std::fmt::Display;

pub fn ui<T>(f: &mut Frame, app: &mut Picker<T>)
where
    T: Sync + Send + Display,
{
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(2)
        .constraints(
            [
                Constraint::Length(1),
                Constraint::Length(3),
                Constraint::Min(1),
            ]
            .as_ref(),
        )
        .split(f.area());

    // update the height before rendering so this doesn't get out of sync
    // TODO ensure that 3 is always correct or pull the correct value that takes terminal resizing into account
    app.update_height(chunks[2].height - 3);

    // render the sections of the display now that everything is setup and updated
    render_help(f, chunks[0], app);
    render_search_input(f, app, chunks[1]);
    render_items(f, app, chunks[2]);
}

fn render_help<T>(f: &mut Frame, area: Rect, app: &Picker<T>)
where
    T: Sync + Send + Display,
{
    let snapshot = app.snapshot();
    let text = vec![Line::from(vec![
        Span::raw("Press "),
        Span::styled("↑/↓", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(" to navigate, "),
        Span::styled("Tab", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(" to select, "),
        Span::styled("Enter", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(" to confirm, "),
        Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
        Span::raw(" to quit,"),
        Span::raw(" matching against "),
        Span::styled(
            format!(
                "{}/{}",
                snapshot.matched_item_count(),
                snapshot.item_count()
            ),
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::raw(" items,"),
        Span::raw(" ("),
        Span::styled(
            app.running_threads().to_string(),
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::raw(" threads still indexing)"),
        Span::raw(format!(
            " ({} {} {} by {})",
            app.first_visible_item_index(),
            app.current_index,
            app.last_visible_item_index(),
            app.height()
        )),
    ])];

    let paragraph = Paragraph::new(text);
    f.render_widget(paragraph, area);
}

fn render_search_input<T>(f: &mut Frame, app: &Picker<T>, area: Rect)
where
    T: Sync + Send,
{
    // Split the query at the cursor position
    let before_cursor = app.query.chars().take(app.query_index).collect::<String>();
    let cursor_char = app.query.chars().nth(app.query_index).unwrap_or(' ');
    let after_cursor = app
        .query
        .chars()
        .skip(app.query_index + 1)
        .collect::<String>();

    // Create a line with styled spans for before, cursor, and after
    let line = Line::from(vec![
        Span::raw(before_cursor),
        Span::styled(cursor_char.to_string(), Style::default().bg(Color::Blue)),
        Span::raw(after_cursor),
    ]);

    let input = Paragraph::new(line)
        .style(Style::default())
        .block(Block::default().borders(Borders::ALL).title("Search"));
    f.render_widget(input, area);
}

fn render_items<T>(f: &mut Frame, app: &mut Picker<T>, area: Rect)
where
    T: Sync + Send + Display,
{
    if app.matched_item_count() > 0 {
        let items: Vec<ListItem> = app
            .matched_items()
            .iter()
            .map(|item| {
                let is_selected = item.is_selected();
                let style = if is_selected {
                    Style::default()
                        .fg(Color::Yellow)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default()
                };

                let prefix = if is_selected { "" } else { "  " };
                let content = format!("{}{}", prefix, item);

                ListItem::new(content).style(style)
            })
            .collect();

        let items = List::new(items)
            .block(Block::default().borders(Borders::ALL).title("Items"))
            .highlight_style(
                Style::default()
                    .bg(Color::Blue)
                    .add_modifier(Modifier::BOLD),
            )
            .highlight_symbol("> ");

        f.render_stateful_widget(
            items,
            area,
            &mut ratatui::widgets::ListState::default().with_selected(Some(
                app.current_index
                    // we need to correct the index here so that it's adjusted for the slice we're currently rendering
                    .saturating_sub(app.first_visible_item_index()) as usize,
            )),
        );
    } else {
        let chunks = Layout::default()
            .direction(Direction::Vertical)
            // .margin(2)
            .constraints(
                [
                    Constraint::Min(1),
                    Constraint::Length(1),
                    Constraint::Min(1),
                ]
                .as_ref(),
            )
            .split(f.area());

        let no_items_paragraph = Paragraph::new("No items found").alignment(Alignment::Center);
        f.render_widget(no_items_paragraph, chunks[1]);
    }
}