binocular-cli 0.2.3

Not exactly a telescope, but it's useful sometimes. TUI to search/navigate through files and workspaces.
Documentation
use crossterm::event::{KeyCode, KeyEvent};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperatorMotion {
    StartOfLine,
    EndOfLine,
    WordForward,
    WordEndForward,
    WordBackward,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingOperatorIntent {
    Cancel,
    SetModifier(char),
    RepeatOperator,
    Motion(OperatorMotion),
}

pub fn push_count_digit(buffer: &mut String, key: KeyEvent) -> bool {
    let KeyCode::Char(ch) = key.code else {
        return false;
    };
    if !key.modifiers.is_empty() || !ch.is_ascii_digit() {
        return false;
    }
    if ch == '0' && buffer.is_empty() {
        return false;
    }

    buffer.push(ch);
    true
}

pub fn take_count(buffer: &mut String) -> usize {
    if buffer.is_empty() {
        return 1;
    }

    let count = buffer.parse::<usize>().unwrap_or(1);
    buffer.clear();
    count.max(1)
}

pub fn parse_pending_operator_intent(key: KeyEvent, op: char) -> Option<PendingOperatorIntent> {
    match key.code {
        KeyCode::Esc => Some(PendingOperatorIntent::Cancel),
        KeyCode::Char('i') | KeyCode::Char('a') => {
            if let KeyCode::Char(modifier) = key.code {
                Some(PendingOperatorIntent::SetModifier(modifier))
            } else {
                None
            }
        }
        KeyCode::Char(ch) if ch == op => Some(PendingOperatorIntent::RepeatOperator),
        KeyCode::Char('w') => Some(PendingOperatorIntent::Motion(OperatorMotion::WordForward)),
        KeyCode::Char('e') => Some(PendingOperatorIntent::Motion(
            OperatorMotion::WordEndForward,
        )),
        KeyCode::Char('b') => Some(PendingOperatorIntent::Motion(OperatorMotion::WordBackward)),
        KeyCode::Char('$') => Some(PendingOperatorIntent::Motion(OperatorMotion::EndOfLine)),
        KeyCode::Char('0') => Some(PendingOperatorIntent::Motion(OperatorMotion::StartOfLine)),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::{
        parse_pending_operator_intent, push_count_digit, take_count, OperatorMotion,
        PendingOperatorIntent,
    };
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    #[test]
    fn count_parser_accumulates_digits_but_not_leading_zero() {
        let mut buffer = String::new();

        assert!(!push_count_digit(
            &mut buffer,
            KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE)
        ));
        assert!(push_count_digit(
            &mut buffer,
            KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)
        ));
        assert!(push_count_digit(
            &mut buffer,
            KeyEvent::new(KeyCode::Char('5'), KeyModifiers::NONE)
        ));

        assert_eq!(buffer, "25");
        assert_eq!(take_count(&mut buffer), 25);
        assert!(buffer.is_empty());
    }

    #[test]
    fn pending_operator_parser_maps_repeat_and_motion_keys() {
        assert_eq!(
            parse_pending_operator_intent(
                KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
                'd'
            ),
            Some(PendingOperatorIntent::RepeatOperator)
        );
        assert_eq!(
            parse_pending_operator_intent(
                KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
                'd'
            ),
            Some(PendingOperatorIntent::Motion(OperatorMotion::WordForward))
        );
        assert_eq!(
            parse_pending_operator_intent(
                KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
                'c'
            ),
            Some(PendingOperatorIntent::SetModifier('i'))
        );
    }
}