sabiql 1.11.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use crate::app::model::shared::key_sequence::Prefix;
use crate::app::update::input::keybindings::{Key, KeyCombo};

use super::types::{SearchContinuation, VimCommand, VimModeTransition, VimNavigation, VimOperator};

pub fn classify_command(combo: &KeyCombo) -> Option<VimCommand> {
    if combo.modifiers.alt {
        return None;
    }

    if let Some(navigation) = navigation(combo) {
        return Some(VimCommand::Navigation(navigation));
    }

    if combo.modifiers.ctrl {
        return None;
    }

    match combo.key {
        Key::Esc => Some(VimCommand::ModeTransition(VimModeTransition::Escape)),
        Key::Enter => Some(VimCommand::ModeTransition(
            VimModeTransition::ConfirmOrEnter,
        )),
        Key::Char('i') => Some(VimCommand::ModeTransition(VimModeTransition::Insert)),
        Key::Char('A') => Some(VimCommand::ModeTransition(VimModeTransition::Append)),
        Key::Char('n') => Some(VimCommand::SearchContinuation(SearchContinuation::Next)),
        Key::Char('N') => Some(VimCommand::SearchContinuation(SearchContinuation::Prev)),
        Key::Char('y') => Some(VimCommand::Operator(VimOperator::Yank)),
        Key::Char('d') => Some(VimCommand::Operator(VimOperator::Delete)),
        _ => None,
    }
}

pub fn classify_sequence(prefix: Prefix, combo: &KeyCombo) -> Option<VimCommand> {
    if combo.modifiers.ctrl || combo.modifiers.alt || combo.modifiers.shift {
        return None;
    }

    match prefix {
        Prefix::G => match combo.key {
            Key::Char('g') => Some(VimCommand::Navigation(VimNavigation::MoveToFirst)),
            _ => None,
        },
        Prefix::Z => match combo.key {
            Key::Char('z') => Some(VimCommand::Navigation(VimNavigation::ScrollCursorCenter)),
            Key::Char('t') => Some(VimCommand::Navigation(VimNavigation::ScrollCursorTop)),
            Key::Char('b') => Some(VimCommand::Navigation(VimNavigation::ScrollCursorBottom)),
            _ => None,
        },
    }
}

fn navigation(combo: &KeyCombo) -> Option<VimNavigation> {
    if combo.modifiers.shift || combo.modifiers.alt {
        return None;
    }

    if combo.modifiers.ctrl {
        return match combo.key {
            // NOTE: Ctrl+N/P are intercepted on the main screen (handlers/normal.rs)
            // and mapped to OpenTablePicker / None. These entries are only reached
            // from modal contexts (SQL Modal Plan/Compare, JSONB Detail via vim dispatch).
            Key::Char('n') => Some(VimNavigation::MoveDown),
            Key::Char('p') => Some(VimNavigation::MoveUp),
            Key::Char('d') => Some(VimNavigation::HalfPageDown),
            Key::Char('u') => Some(VimNavigation::HalfPageUp),
            Key::Char('f') => Some(VimNavigation::FullPageDown),
            Key::Char('b') => Some(VimNavigation::FullPageUp),
            _ => None,
        };
    }

    match combo.key {
        Key::Char('j') | Key::Down => Some(VimNavigation::MoveDown),
        Key::Char('k') | Key::Up => Some(VimNavigation::MoveUp),
        Key::Char('g') | Key::Home => Some(VimNavigation::MoveToFirst),
        Key::Char('G') | Key::End => Some(VimNavigation::MoveToLast),
        Key::Char('0') => Some(VimNavigation::MoveLineStart),
        Key::Char('$') => Some(VimNavigation::MoveLineEnd),
        Key::Char('w') => Some(VimNavigation::MoveWordForward),
        Key::Char('b') => Some(VimNavigation::MoveWordBackward),
        Key::Char('H') => Some(VimNavigation::ViewportTop),
        Key::Char('M') => Some(VimNavigation::ViewportMiddle),
        Key::Char('L') => Some(VimNavigation::ViewportBottom),
        Key::Char('h') | Key::Left => Some(VimNavigation::MoveLeft),
        Key::Char('l') | Key::Right => Some(VimNavigation::MoveRight),
        Key::PageDown => Some(VimNavigation::FullPageDown),
        Key::PageUp => Some(VimNavigation::FullPageUp),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::update::input::keybindings::KeyCombo;
    use rstest::rstest;

    fn combo(key: Key) -> KeyCombo {
        KeyCombo::plain(key)
    }

    fn combo_ctrl(key: Key) -> KeyCombo {
        KeyCombo::ctrl(key)
    }

    #[rstest]
    #[case(Key::Char('i'), VimModeTransition::Insert)]
    #[case(Key::Char('A'), VimModeTransition::Append)]
    #[case(Key::Enter, VimModeTransition::ConfirmOrEnter)]
    #[case(Key::Esc, VimModeTransition::Escape)]
    fn mode_transition_keys(#[case] key: Key, #[case] expected: VimModeTransition) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::ModeTransition(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('n'), SearchContinuation::Next)]
    #[case(Key::Char('N'), SearchContinuation::Prev)]
    fn search_keys(#[case] key: Key, #[case] expected: SearchContinuation) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::SearchContinuation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('y'), VimOperator::Yank)]
    #[case(Key::Char('d'), VimOperator::Delete)]
    fn operator_keys(#[case] key: Key, #[case] expected: VimOperator) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::Operator(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('j'), false, VimNavigation::MoveDown)]
    #[case(Key::Down, false, VimNavigation::MoveDown)]
    #[case(Key::Char('n'), true, VimNavigation::MoveDown)]
    #[case(Key::Char('k'), false, VimNavigation::MoveUp)]
    #[case(Key::Up, false, VimNavigation::MoveUp)]
    #[case(Key::Char('p'), true, VimNavigation::MoveUp)]
    fn vertical_navigation_aliases(
        #[case] key: Key,
        #[case] ctrl: bool,
        #[case] expected: VimNavigation,
    ) {
        let combo = if ctrl { combo_ctrl(key) } else { combo(key) };

        assert_eq!(
            classify_command(&combo),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('h'), VimNavigation::MoveLeft)]
    #[case(Key::Left, VimNavigation::MoveLeft)]
    #[case(Key::Char('l'), VimNavigation::MoveRight)]
    #[case(Key::Right, VimNavigation::MoveRight)]
    fn horizontal_navigation_aliases(#[case] key: Key, #[case] expected: VimNavigation) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('g'), VimNavigation::MoveToFirst)]
    #[case(Key::Home, VimNavigation::MoveToFirst)]
    #[case(Key::Char('G'), VimNavigation::MoveToLast)]
    #[case(Key::End, VimNavigation::MoveToLast)]
    #[case(Key::Char('0'), VimNavigation::MoveLineStart)]
    #[case(Key::Char('$'), VimNavigation::MoveLineEnd)]
    #[case(Key::Char('H'), VimNavigation::ViewportTop)]
    #[case(Key::Char('M'), VimNavigation::ViewportMiddle)]
    #[case(Key::Char('L'), VimNavigation::ViewportBottom)]
    fn boundary_navigation_aliases(#[case] key: Key, #[case] expected: VimNavigation) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('w'), VimNavigation::MoveWordForward)]
    #[case(Key::Char('b'), VimNavigation::MoveWordBackward)]
    fn word_navigation_aliases(#[case] key: Key, #[case] expected: VimNavigation) {
        assert_eq!(
            classify_command(&combo(key)),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('d'), true, VimNavigation::HalfPageDown)]
    #[case(Key::Char('u'), true, VimNavigation::HalfPageUp)]
    #[case(Key::Char('f'), true, VimNavigation::FullPageDown)]
    #[case(Key::PageDown, false, VimNavigation::FullPageDown)]
    #[case(Key::Char('b'), true, VimNavigation::FullPageUp)]
    #[case(Key::PageUp, false, VimNavigation::FullPageUp)]
    fn paging_navigation_aliases(
        #[case] key: Key,
        #[case] ctrl: bool,
        #[case] expected: VimNavigation,
    ) {
        let combo = if ctrl { combo_ctrl(key) } else { combo(key) };

        assert_eq!(
            classify_command(&combo),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[rstest]
    #[case(Key::Char('z'), VimNavigation::ScrollCursorCenter)]
    #[case(Key::Char('t'), VimNavigation::ScrollCursorTop)]
    #[case(Key::Char('b'), VimNavigation::ScrollCursorBottom)]
    fn z_sequence_navigation(#[case] key: Key, #[case] expected: VimNavigation) {
        assert_eq!(
            classify_sequence(Prefix::Z, &combo(key)),
            Some(VimCommand::Navigation(expected))
        );
    }

    #[test]
    fn gg_sequence_navigation() {
        assert_eq!(
            classify_sequence(Prefix::G, &combo(Key::Char('g'))),
            Some(VimCommand::Navigation(VimNavigation::MoveToFirst))
        );
    }
}