chant 0.1.1

Shell glamour - beautiful prompts and output for scripts 🪄
Documentation
//! Fuzzy filter prompt.

use crossterm::{
    cursor,
    event::{self, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{self, ClearType},
};
use glyphs::{style, Color};
use std::io::{stdout, Write};

/// Create a filter prompt.
pub fn filter<S: Into<String>>(options: &[S]) -> Filter
where
    S: Clone,
{
    Filter::new(options.iter().map(|s| s.clone().into()).collect())
}

/// Filter prompt builder.
pub struct Filter {
    options: Vec<String>,
    header: Option<String>,
    placeholder: String,
    limit: usize,
}

impl Filter {
    /// Create a new filter.
    pub fn new(options: Vec<String>) -> Self {
        Self {
            options,
            header: None,
            placeholder: "Type to filter...".to_string(),
            limit: 10,
        }
    }

    /// Set header.
    #[must_use]
    pub fn header(mut self, header: impl Into<String>) -> Self {
        self.header = Some(header.into());
        self
    }

    /// Set placeholder.
    #[must_use]
    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
        self.placeholder = placeholder.into();
        self
    }

    /// Limit visible items.
    #[must_use]
    pub fn limit(mut self, limit: usize) -> Self {
        self.limit = limit;
        self
    }

    fn fuzzy_match(query: &str, target: &str) -> bool {
        if query.is_empty() {
            return true;
        }
        let query_lower = query.to_lowercase();
        let target_lower = target.to_lowercase();
        target_lower.contains(&query_lower)
    }

    /// Run the filter.
    pub fn run(self) -> Option<String> {
        if self.options.is_empty() {
            return None;
        }

        let mut query = String::new();
        let mut selected = 0;
        let mut stdout = stdout();

        terminal::enable_raw_mode().expect("Failed to enable raw mode");

        loop {
            // Filter options
            let filtered: Vec<&String> = self
                .options
                .iter()
                .filter(|opt| Self::fuzzy_match(&query, opt))
                .collect();

            let visible = self.limit.min(filtered.len());

            // Clear screen and render
            execute!(stdout, cursor::MoveToColumn(0)).ok();

            // Header
            if let Some(ref header) = self.header {
                execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
                println!("{}", style(header).bold());
            }

            // Input line
            execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
            print!("> ");
            if query.is_empty() {
                print!("{}", style(&self.placeholder).dim());
            } else {
                print!("{}", query);
            }
            println!();

            // Filtered options
            for (i, option) in filtered.iter().take(visible).enumerate() {
                execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
                if i == selected {
                    println!(
                        "  {}",
                        style(*option).fg(Color::Cyan).bold()
                    );
                } else {
                    println!("  {}", option);
                }
            }

            // Show count
            execute!(stdout, terminal::Clear(ClearType::CurrentLine)).ok();
            println!(
                "{}",
                style(format!("{}/{} matches", filtered.len(), self.options.len())).dim()
            );

            stdout.flush().ok();

            // Handle input
            if let Ok(Event::Key(key)) = event::read() {
                match key.code {
                    KeyCode::Enter => {
                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
                        return filtered.get(selected).map(|s| (*s).clone());
                    }
                    KeyCode::Esc => {
                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
                        return None;
                    }
                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        terminal::disable_raw_mode().expect("Failed to disable raw mode");
                        return None;
                    }
                    KeyCode::Up | KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        if selected > 0 {
                            selected -= 1;
                        }
                    }
                    KeyCode::Down | KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        if selected < filtered.len().saturating_sub(1) {
                            selected += 1;
                        }
                    }
                    KeyCode::Backspace => {
                        query.pop();
                        selected = 0;
                    }
                    KeyCode::Char(c) => {
                        query.push(c);
                        selected = 0;
                    }
                    _ => {}
                }
            }

            // Keep selected in bounds
            if selected >= filtered.len() {
                selected = filtered.len().saturating_sub(1);
            }

            // Move cursor back up for re-render
            let lines = visible + 2 + if self.header.is_some() { 1 } else { 0 };
            execute!(stdout, cursor::MoveUp(lines as u16)).ok();
        }
    }
}