smon 0.1.1

Minimalistic TUI serial monitor
use std::time::Duration;

use anyhow::Result;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use nucleo_matcher::{Config, Matcher, Utf32String};
use ratatui::{
    DefaultTerminal,
    layout::{Constraint, Direction, Layout},
    style::{Modifier, Style},
    text::Line,
    widgets::{Block, List, ListItem, ListState, Paragraph},
};

pub struct Item {
    pub value: String,
    pub label: String,
}

pub fn pick(
    terminal: &mut DefaultTerminal,
    title: &str,
    items: &[Item],
    default_index: Option<usize>,
    allow_free_text: bool,
) -> Result<Option<String>> {
    let mut matcher = Matcher::new(Config::DEFAULT);
    let labels: Vec<Utf32String> = items
        .iter()
        .map(|i| Utf32String::from(i.label.as_str()))
        .collect();

    let mut query = String::new();
    let mut state = ListState::default();
    state.select(default_index.or(if items.is_empty() { None } else { Some(0) }));

    loop {
        let filtered: Vec<&Item> = if query.is_empty() {
            items.iter().collect()
        } else {
            let needle = Utf32String::from(query.as_str());
            let mut hits: Vec<(&Item, u16)> = items
                .iter()
                .enumerate()
                .filter_map(|(i, item)| {
                    matcher
                        .fuzzy_match(labels[i].slice(..), needle.slice(..))
                        .map(|score| (item, score))
                })
                .collect();
            hits.sort_by_key(|h| std::cmp::Reverse(h.1));
            hits.into_iter().map(|(item, _)| item).collect()
        };

        if filtered.is_empty() {
            state.select(None);
        } else if state.selected().is_none_or(|s| s >= filtered.len()) {
            state.select(Some(0));
        }

        terminal.draw(|frame| {
            let chunks = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(3),
                    Constraint::Min(0),
                    Constraint::Length(1),
                ])
                .split(frame.area());

            let header = Paragraph::new(format!("> {query}"))
                .block(Block::bordered().title(title));
            frame.render_widget(header, chunks[0]);

            let list_items: Vec<ListItem> = filtered
                .iter()
                .map(|item| ListItem::new(item.label.as_str()))
                .collect();
            let list = List::new(list_items)
                .highlight_style(Style::new().add_modifier(Modifier::REVERSED))
                .highlight_symbol("> ");
            frame.render_stateful_widget(list, chunks[1], &mut state);

            let footer_text = if allow_free_text {
                "type to filter or enter raw path | up/down: move | enter: select | esc: cancel"
            } else {
                "type to filter | up/down: move | enter: select | esc: cancel"
            };
            let footer = Paragraph::new(Line::from(footer_text));
            frame.render_widget(footer, chunks[2]);
        })?;

        if !event::poll(Duration::from_millis(100))? {
            continue;
        }
        let Event::Key(key) = event::read()? else {
            continue;
        };
        if key.kind != KeyEventKind::Press {
            continue;
        }
        match (key.code, key.modifiers) {
            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(None),
            (KeyCode::Enter, _) => {
                if let Some(sel) = state.selected()
                    && let Some(item) = filtered.get(sel)
                {
                    return Ok(Some(item.value.clone()));
                }
                if allow_free_text && !query.is_empty() {
                    return Ok(Some(query));
                }
            }
            (KeyCode::Up, _) => {
                let len = filtered.len();
                if len > 0 {
                    let i = state.selected().unwrap_or(0);
                    state.select(Some(if i == 0 { len - 1 } else { i - 1 }));
                }
            }
            (KeyCode::Down, _) => {
                let len = filtered.len();
                if len > 0 {
                    let i = state.selected().unwrap_or(0);
                    state.select(Some((i + 1) % len));
                }
            }
            (KeyCode::Backspace, _) => {
                query.pop();
            }
            (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
                query.push(c);
            }
            _ => {}
        }
    }
}