bawa 0.1.0

A TUI game save organizer
Documentation
use crate::{
    app::StatefulList,
    input::{handle_key_fuzzy_mode, Input},
    ui,
};
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use item::Matched;
use nucleo_matcher::{
    pattern::{CaseMatching, Normalization, Pattern},
    Config, Matcher, Utf32String,
};
use picker::Picker;

mod item;
pub mod picker;

pub struct FuzzyFinder {
    matcher: Matcher,
    pub input: Input,
    pattern: Pattern,
    pub picker: Option<Box<dyn Picker>>,
    pub matched: StatefulList<Matched>,
    pub total_count: usize,
    pub match_count: usize,
}

impl Default for FuzzyFinder {
    fn default() -> Self {
        Self {
            matcher: Matcher::new(Config::DEFAULT.match_paths()),
            input: Input::new("> "),
            pattern: Pattern::default(),
            picker: None,
            matched: StatefulList::with_items(Vec::new()),
            total_count: 0,
            match_count: 0,
        }
    }
}

impl FuzzyFinder {
    pub fn set_picker(&mut self, picker: impl Picker + 'static) {
        self.total_count = picker.items().len();
        self.picker = Some(Box::new(picker));
    }

    fn items(&self) -> Option<Vec<Utf32String>> {
        self.picker.as_ref().map(|picker| picker.items())
    }

    pub fn selected_idx(&self) -> Option<usize> {
        self.matched.get_selected().map(|item| item.idx)
    }

    pub fn reset(&mut self) {
        self.input.set_text("");
        self.picker.take();
    }

    pub fn update_matches(&mut self) {
        let Some(items) = self.items() else {
            return;
        };

        self.pattern
            .reparse(&self.input.text, CaseMatching::Smart, Normalization::Smart);

        self.matched.items.clear();

        for (idx, path) in items.iter().enumerate() {
            let mut indices = Vec::new();
            let score = self
                .pattern
                .indices(path.slice(..), &mut self.matcher, &mut indices);

            if score.is_some() {
                indices.sort_unstable();
                indices.dedup();

                self.matched
                    .items
                    .push(Matched::new(path.to_string(), idx, score, &indices));
            }
        }

        self.match_count = self.matched.items.len();
        self.matched.items.sort_by(|a, b| b.score.cmp(&a.score));
        self.matched.select_first();
    }

    pub fn non_interactive<'a>(paths: &'a [String], pattern: &str) -> Vec<&'a String> {
        let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
        let pattern = Pattern::parse(pattern, CaseMatching::Ignore, Normalization::Smart);
        let matched_paths = pattern.match_list(paths, &mut matcher);

        matched_paths.into_iter().map(|(path, _)| path).collect()
    }

    pub fn run_inline(&mut self) -> Result<Option<&str>> {
        self.update_matches();

        if self.matched.items.len() > 1 {
            let mut terminal = ui::init_inline(25);

            loop {
                terminal.draw(|f| ui::draw_fuzzy_finder(f, self, f.area()))?;

                if let Event::Key(key) = crossterm::event::read()? {
                    match key.code {
                        KeyCode::Enter => {
                            break;
                        }
                        KeyCode::Esc => {
                            self.matched.state.select(None);
                            break;
                        }
                        _ => handle_key_fuzzy_mode(key, self),
                    }
                }
            }

            terminal.clear()?;
            ui::restore();
        }

        let selected_item = self.matched.get_selected();
        Ok(selected_item.map(|item| item.text.as_str()))
    }

    pub fn is_active(&self) -> bool {
        self.picker.is_some()
    }
}

#[cfg(test)]
mod tests {
    use super::{FuzzyFinder, Matched};

    #[test]
    fn fuzzy_unicode() {
        let item = Matched::new("türkçe".to_owned(), 0, None, &[1, 2]);

        assert_eq!(
            item.highlight_slices(),
            vec![("t", false), ("ür", true), ("kçe", false)]
        );
    }

    #[test]
    fn fuzzy_unicode2() {
        let item = Matched::new("türkçe".to_owned(), 0, None, &[0]);

        assert_eq!(item.highlight_slices(), vec![("t", true), ("ürkçe", false)]);
    }

    #[test]
    fn fuzzy_postfix() {
        let item = Matched::new("some text".to_owned(), 0, None, &[6, 7, 8]);

        assert_eq!(
            item.highlight_slices(),
            vec![("some t", false), ("ext", true)]
        );
    }

    #[test]
    fn oneshot() {
        let strings = &[
            String::from("Altus Plateau/Godskin Apostle.sl2"),
            String::from("Mt. Gelmir/Godskin Noble.sl2"),
            String::from("Altus Plateau/Leyndell/Goldfrey.sl2"),
        ];

        let matches = FuzzyFinder::non_interactive(strings, "godnob");

        assert_eq!(matches.len(), 1);
    }
}