alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Typed Vim actions.

use super::{
    Counted, Motion, NormalCommand, NormalCommandContext, NormalState, PageDirection,
    SearchDirection, ViewportPosition, VimCommandState, VimCursor, VimMode, VimSearchState,
    VimSelectionState, VimStatusLine, motion,
};

/// Editor action resolved from grammar, maps, or leader bindings.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VimAction {
    /// Normal command.
    NormalCommand(NormalCommand),
    /// Repeat search.
    RepeatSearch(SearchDirection),
    /// Viewport-sized page motion.
    ViewportPage(PageDirection),
    /// Reposition the viewport without moving the cursor.
    ViewportPosition(ViewportPosition),
    /// Start a prefilled `:` command.
    ExCommand(String),
    /// Intentional no-op.
    NoOp,
}

impl From<NormalCommand> for VimAction {
    fn from(command: NormalCommand) -> Self {
        Self::NormalCommand(command)
    }
}

/// Action dispatcher.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct ActionDispatcher;

impl ActionDispatcher {
    /// Applies `action`.
    pub fn dispatch(action: &VimAction, context: ActionContext<'_>) {
        let ActionContext {
            text,
            cursor,
            mode,
            selection_state,
            search_state,
            command_state,
            status_line,
            normal_state,
            visible_line_count,
        } = context;

        match action {
            VimAction::NormalCommand(NormalCommand::Motion(counted)) => {
                apply_motion(text, cursor, normal_state, *counted, visible_line_count);
            }
            VimAction::NormalCommand(NormalCommand::ViewportPosition(_)) => {
                status_line.clear();
            }
            VimAction::NormalCommand(command) => normal_state.apply_command(
                *command,
                NormalCommandContext {
                    text,
                    cursor,
                    mode,
                    selection_state,
                    search_state,
                    command_state,
                    status_line,
                },
            ),
            VimAction::RepeatSearch(direction) => {
                let outcome = search_state.repeat(text, cursor.byte_index(), *direction);
                super::apply_search_outcome(text, cursor, status_line, outcome);
            }
            VimAction::ViewportPage(direction) => {
                let next = motion::apply_page_motion(
                    text,
                    cursor.byte_index(),
                    *direction,
                    visible_line_count,
                );
                cursor.set_byte_index(text, next);
            }
            VimAction::ViewportPosition(_position) => {
                status_line.clear();
            }
            VimAction::ExCommand(command) => {
                status_line.clear();
                command_state.start_with(command);
            }
            VimAction::NoOp => {}
        }
    }
}

/// State borrowed by one action dispatch.
pub struct ActionContext<'state> {
    /// Buffer text.
    pub text: &'state str,
    /// Cursor.
    pub cursor: &'state mut VimCursor,
    /// Mode.
    pub mode: &'state mut VimMode,
    /// Selection.
    pub selection_state: &'state mut VimSelectionState,
    /// Search.
    pub search_state: &'state mut VimSearchState,
    /// Command prompt.
    pub command_state: &'state mut VimCommandState,
    /// Status line.
    pub status_line: &'state mut VimStatusLine,
    /// Normal state.
    pub normal_state: &'state mut NormalState,
    /// Viewport height.
    pub visible_line_count: usize,
}

/// Applies a counted motion with viewport-aware pages.
fn apply_motion(
    text: &str,
    cursor: &mut VimCursor,
    normal_state: &mut NormalState,
    counted: Counted<Motion>,
    visible_line_count: usize,
) {
    match counted.item {
        Motion::Page(direction) => {
            for _step in 0..counted.count.get() {
                let next = motion::apply_page_motion(
                    text,
                    cursor.byte_index(),
                    direction,
                    visible_line_count,
                );
                cursor.set_byte_index(text, next);
            }
        }
        Motion::LineAddress(_)
        | Motion::CharSearch(_)
        | Motion::RepeatCharSearch
        | Motion::RepeatCharSearchReversed
        | Motion::Left
        | Motion::Down
        | Motion::Up
        | Motion::Right
        | Motion::WordForward(_)
        | Motion::WordBackward(_)
        | Motion::WordEnd(_)
        | Motion::Column(_)
        | Motion::Paragraph(_) => normal_state.apply_counted_motion(text, cursor, counted),
    }
}

#[cfg(test)]
mod tests {
    use super::{ActionContext, ActionDispatcher, VimAction};
    use crate::vim::{
        PageDirection, SearchDirection, SearchOutcome, VimCommandState, VimCursor, VimMode,
        VimSearchState, VimSelectionState, VimStatusLine,
    };

    #[test]
    fn viewport_page_action_uses_visible_line_count() {
        let text = "00\n01\n02\n03\n04";
        let mut cursor = VimCursor::new();
        let mut mode = VimMode::Normal;
        let mut selection_state = VimSelectionState::default();
        let mut search_state = VimSearchState::default();
        let mut command_state = VimCommandState::default();
        let mut status_line = VimStatusLine::default();
        let mut normal_state = crate::vim::NormalState::default();

        ActionDispatcher::dispatch(
            &VimAction::ViewportPage(PageDirection::Forward),
            ActionContext {
                text,
                cursor: &mut cursor,
                mode: &mut mode,
                selection_state: &mut selection_state,
                search_state: &mut search_state,
                command_state: &mut command_state,
                status_line: &mut status_line,
                normal_state: &mut normal_state,
                visible_line_count: 3,
            },
        );

        assert_eq!(cursor.byte_index(), "00\n01\n02\n".len());
    }

    #[test]
    fn repeat_search_action_uses_search_state() {
        let text = "one two one two";
        let mut cursor = VimCursor::new();
        let mut mode = VimMode::Normal;
        let mut selection_state = VimSelectionState::default();
        let mut search_state = VimSearchState::default();
        let mut command_state = VimCommandState::default();
        let mut status_line = VimStatusLine::default();
        let mut normal_state = crate::vim::NormalState::default();

        search_state.start(SearchDirection::Forward);
        search_state.push_text("two");
        assert_eq!(
            search_state.submit(text, 0),
            SearchOutcome::Match {
                byte_index: "one ".len()
            }
        );
        cursor.set_byte_index(text, "one ".len());

        ActionDispatcher::dispatch(
            &VimAction::RepeatSearch(SearchDirection::Forward),
            ActionContext {
                text,
                cursor: &mut cursor,
                mode: &mut mode,
                selection_state: &mut selection_state,
                search_state: &mut search_state,
                command_state: &mut command_state,
                status_line: &mut status_line,
                normal_state: &mut normal_state,
                visible_line_count: 20,
            },
        );

        assert_eq!(cursor.byte_index(), "one two one ".len());
    }
}