lnchr 0.2.0

A fuzzy terminal app launcher
use crate::fuzzy::{create_matcher, match_items_with_history};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use nucleo_matcher::Matcher;
use ratatui::{
    DefaultTerminal, Frame,
    layout::{Constraint, Layout, Margin, Position},
    style::{Style, Stylize},
    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};

struct App<'a> {
    input: String,
    cursor: usize,
    title: String,
    matcher: Matcher,
    items: Vec<String>,
    matched: Vec<usize>,
    list_state: ListState,
    history: &'a [String],
}

pub fn search(items: Vec<String>, title: &str, history: &[String]) -> Option<String> {
    let mut terminal = ratatui::init();
    let mut app = App {
        input: String::new(),
        cursor: 0,
        title: title.to_string(),
        matcher: create_matcher(),
        items,
        matched: Vec::new(),
        list_state: ListState::default(),
        history,
    };
    app.list_state.select(Some(0));
    update_matches(&mut app);
    let res = event_loop(&mut terminal, &mut app);
    ratatui::restore();
    match res {
        Ok(Some(selected)) => Some(selected),
        _ => None,
    }
}

fn update_matches(app: &mut App) {
    app.matched = match_items_with_history(&mut app.matcher, &app.items, &app.input, app.history);
    let sel = app
        .list_state
        .selected()
        .unwrap_or(0)
        .min(app.matched.len().saturating_sub(1));
    app.list_state.select(Some(sel));
}

fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> std::io::Result<Option<String>> {
    loop {
        terminal.draw(|frame| render(frame, app))?;
        if let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            match key.code {
                KeyCode::Esc => break Ok(None),
                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                    break Ok(None);
                }
                KeyCode::Enter => {
                    let sel = app.list_state.selected().and_then(|s| app.matched.get(s));
                    if let Some(&idx) = sel {
                        break Ok(Some(app.items[idx].clone()));
                    }
                }
                KeyCode::Backspace => {
                    if app.cursor > 0 {
                        app.input.remove(app.cursor - 1);
                        app.cursor -= 1;
                        update_matches(app);
                    }
                }
                KeyCode::Delete => {
                    if app.cursor < app.input.len() {
                        app.input.remove(app.cursor);
                        update_matches(app);
                    }
                }
                KeyCode::Left => {
                    app.cursor = app.cursor.saturating_sub(1);
                }
                KeyCode::Right => {
                    if app.cursor < app.input.len() {
                        app.cursor += 1;
                    }
                }
                KeyCode::Down | KeyCode::Char('j')
                    if key.code == KeyCode::Down
                        || key.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    let next = app
                        .list_state
                        .selected()
                        .unwrap_or(0)
                        .saturating_add(1)
                        .min(app.matched.len().saturating_sub(1));
                    app.list_state.select(Some(next));
                }
                KeyCode::Up | KeyCode::Char('k')
                    if key.code == KeyCode::Up || key.modifiers.contains(KeyModifiers::CONTROL) =>
                {
                    let prev = app.list_state.selected().unwrap_or(0).saturating_sub(1);
                    app.list_state.select(Some(prev));
                }
                KeyCode::Char(c) => {
                    app.input.insert(app.cursor, c);
                    app.cursor += 1;
                    update_matches(app);
                }
                _ => {}
            }
        }
    }
}

fn render(frame: &mut Frame, app: &App) {
    let [top, sep, bottom] = Layout::vertical([
        Constraint::Length(1),
        Constraint::Length(1),
        Constraint::Min(1),
    ])
    .areas(frame.area());

    frame.render_widget(
        Paragraph::new(format!("> {}", app.input)).style(Style::new().bold()),
        top,
    );

    frame.set_cursor_position(Position {
        x: top.x + 2 + app.cursor as u16,
        y: top.y,
    });

    let sep_inner = sep.inner(Margin {
        horizontal: 1,
        vertical: 0,
    });

    frame.render_widget(
        Block::default()
            .borders(Borders::TOP)
            .title(format!(" {} ", app.title))
            .dim(),
        sep_inner,
    );

    let list_items: Vec<ListItem> = app
        .matched
        .iter()
        .map(|&idx| ListItem::new(app.items[idx].as_str()))
        .collect();

    let mut list_state = app.list_state;
    frame.render_stateful_widget(
        List::new(list_items)
            .highlight_symbol("")
            .scroll_padding(2)
            .highlight_style(Style::new().bg(ratatui::style::Color::DarkGray)),
        bottom,
        &mut list_state,
    );
}