simple-commit 1.0.2

A little CLI written in rust to improve your dirty commits into conventional ones.
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use promptuity::event::*;
use promptuity::pagination::paginate;
use promptuity::prompts::{DefaultSelectFormatter, SelectFormatter, SelectOption};
use promptuity::style::*;
use promptuity::{Error, InputCursor, Prompt, PromptBody, PromptInput, PromptState, RenderPayload};

pub struct Autocomplete {
    formatter: DefaultSelectFormatter,
    message: String,
    page_size: usize,
    options: Vec<SelectOption<String>>,
    filtered_options: Vec<(usize, i64)>,
    index: usize,
    input: InputCursor,
    matcher: SkimMatcherV2,
    priority: AutocompletePriority,
    strict: bool,
    skip: bool,
}

#[derive(Clone, Copy)]
pub enum AutocompletePriority {
    Hint,
    Label,
}

impl From<AutocompletePriority> for (i64, i64) {
    fn from(value: AutocompletePriority) -> (i64, i64) {
        match value {
            AutocompletePriority::Hint => (1, 4),
            AutocompletePriority::Label => (4, 1),
        }
    }
}

impl Autocomplete {
    pub fn new(
        message: impl std::fmt::Display,
        strict: bool,
        priority: AutocompletePriority,
        options: Vec<SelectOption<String>>,
    ) -> Self {
        Self {
            formatter: DefaultSelectFormatter::new(),
            message: message.to_string(),
            page_size: 8,
            options,
            filtered_options: Vec::new(),
            index: 0,
            input: InputCursor::default(),
            matcher: SkimMatcherV2::default(),
            priority,
            strict,
            skip: false,
        }
    }

    fn run_filter(&mut self) {
        let pattern = self.input.value();
        let (priority_label, priority_hint): (i64, i64) = self.priority.into();

        self.filtered_options = self
            .options
            .iter()
            .enumerate()
            .filter_map(|(i, option)| {
                let label = &option.label;
                let hint = option.hint.clone().unwrap_or_default();
                let a = self
                    .matcher
                    .fuzzy_match(label, &pattern)
                    .unwrap_or_default();
                let b = self
                    .matcher
                    .fuzzy_match(&hint, &pattern)
                    .unwrap_or_default();

                let c = (a.saturating_mul(priority_label))
                    .saturating_add(b.saturating_mul(priority_hint))
                    .saturating_sub(i as i64);

                log::trace!("{pattern} -> {label}; {a} & {b} = {c}");
                if c <= 0 && !pattern.is_empty() {
                    return None;
                }

                Some((i, c))
            })
            .collect::<Vec<_>>();

        self.filtered_options.sort_by_key(|(_, s)| *s);
        self.filtered_options.reverse();

        self.index = std::cmp::min(self.filtered_options.len().saturating_sub(1), self.index);
    }

    fn current_option(&self) -> Option<&SelectOption<String>> {
        self.filtered_options
            .get(self.index)
            .and_then(|(idx, _)| self.options.get(*idx))
    }
}

impl AsMut<Autocomplete> for Autocomplete {
    fn as_mut(&mut self) -> &mut Self {
        self
    }
}

impl Prompt for Autocomplete {
    type Output = String;

    fn setup(&mut self) -> Result<(), Error> {
        if self.options.is_empty() {
            return Err(Error::Config("options cannot be empty.".into()));
        }

        self.filtered_options = (0..self.options.len()).map(|i| (i, 0)).collect();

        Ok(())
    }

    fn handle(&mut self, code: KeyCode, modifiers: KeyModifiers) -> promptuity::PromptState {
        match (code, modifiers) {
            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => PromptState::Cancel,
            (KeyCode::Char('s'), KeyModifiers::CONTROL) => {
                if self.strict {
                    PromptState::Active
                } else {
                    self.skip = true;
                    PromptState::Submit
                }
            }
            (KeyCode::Enter, _) => match self.current_option() {
                Some(_) => PromptState::Submit,
                _ => {
                    if self.strict {
                        PromptState::Error("No matches found".into())
                    } else {
                        PromptState::Submit
                    }
                }
            },
            (KeyCode::Up, _)
            | (KeyCode::Char('k'), KeyModifiers::CONTROL)
            | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
                self.index = self.index.saturating_sub(1);
                PromptState::Active
            }
            (KeyCode::Down, _)
            | (KeyCode::Char('j'), KeyModifiers::CONTROL)
            | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
                self.index = std::cmp::min(
                    self.filtered_options.len().saturating_sub(1),
                    self.index.saturating_add(1),
                );
                PromptState::Active
            }
            (KeyCode::Left, _) | (KeyCode::Char('b'), KeyModifiers::CONTROL) => {
                self.input.move_left();
                PromptState::Active
            }
            (KeyCode::Right, _) | (KeyCode::Char('f'), KeyModifiers::CONTROL) => {
                self.input.move_right();
                PromptState::Active
            }
            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
                self.input.move_home();
                PromptState::Active
            }
            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
                self.input.move_end();
                PromptState::Active
            }
            (KeyCode::Backspace, _) | (KeyCode::Char('h'), KeyModifiers::CONTROL) => {
                self.input.delete_left_char();
                self.run_filter();
                PromptState::Active
            }
            (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
                self.input.delete_left_word();
                self.run_filter();
                PromptState::Active
            }
            (KeyCode::Delete, _) | (KeyCode::Char('d'), KeyModifiers::CONTROL) => {
                self.input.delete_right_char();
                self.run_filter();
                PromptState::Active
            }
            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
                self.input.delete_line();
                self.run_filter();
                PromptState::Active
            }
            (KeyCode::Char(c), _) => {
                self.input.insert(c);
                self.run_filter();
                PromptState::Active
            }
            _ => PromptState::Active,
        }
    }

    fn submit(&mut self) -> Self::Output {
        if self.skip {
            return String::new();
        };
        if self.strict {
            self.current_option().unwrap().value.clone()
        } else {
            self.current_option()
                .map_or(self.input.value(), |option| option.value.clone())
        }
    }

    fn render(&mut self, state: &PromptState) -> Result<RenderPayload, String> {
        let hint = (!self.strict).then_some(String::from("Ctrl + S to skip"));
        let payload = RenderPayload::new(self.message.clone(), hint, None);

        match state {
            PromptState::Submit => {
                if self.skip {
                    return Ok(payload.input(PromptInput::Raw(String::from("skipped"))));
                }
                let option = self
                    .current_option()
                    .map_or(self.input.value(), |option| option.value.clone());
                Ok(payload.input(PromptInput::Raw(option)))
            }

            _ => {
                let page = paginate(self.page_size, &self.filtered_options, self.index);
                let options = page
                    .items
                    .iter()
                    .enumerate()
                    .map(|(i, (idx, _))| {
                        let option = self.options.get(*idx).unwrap();
                        let active = i == page.cursor;
                        self.formatter.option(
                            self.formatter.option_icon(active),
                            self.formatter.option_label(option.label.clone(), active),
                            self.formatter.option_hint(option.hint.clone(), active),
                            active,
                        )
                    })
                    .collect::<Vec<_>>()
                    .join("\n");

                let raw = if options.is_empty() {
                    Styled::new("<No matches found>")
                        .fg(Color::DarkGrey)
                        .to_string()
                } else {
                    options.to_string()
                };

                Ok(payload
                    .input(PromptInput::Cursor(self.input.clone()))
                    .body(PromptBody::Raw(raw)))
            }
        }
    }
}