alma 0.1.1

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

use super::{
    Counted, ModeSwitch, Motion, NormalCommand, NormalCommandContext, NormalState, OperationEffect,
    OperationOutcome, PageDirection, SearchDirection, ViewportPosition, VimCommandState, VimCursor,
    VimMode, VimOperation, VimSearchState, VimSelectionState, VimStatusLine, config::VimOptions,
    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,
            options,
        } = context;

        let operation = match action {
            VimAction::NormalCommand(command) => VimOperation::from(*command),
            VimAction::RepeatSearch(direction) => VimOperation::RepeatSearchInDirection(*direction),
            VimAction::ViewportPage(direction) => {
                VimOperation::MoveCursor(Counted::once(Motion::Page(*direction)))
            }
            VimAction::ViewportPosition(position) => VimOperation::ViewportPosition(*position),
            VimAction::ExCommand(command) => VimOperation::PrefilledCommandLine(command.clone()),
            VimAction::NoOp => VimOperation::NoOp,
        };

        let Ok(outcome) = operation.resolve_without_target() else {
            return;
        };

        apply_outcome(
            outcome,
            DispatchState {
                text,
                cursor,
                mode,
                selection_state,
                search_state,
                command_state,
                status_line,
                normal_state,
                visible_line_count,
                options,
            },
        );
    }
}

/// 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,
    /// Search options.
    pub options: &'state VimOptions,
}

/// State borrowed while applying operation effects.
struct DispatchState<'state> {
    /// Buffer text.
    text: &'state str,
    /// Cursor.
    cursor: &'state mut VimCursor,
    /// Mode.
    mode: &'state mut VimMode,
    /// Selection.
    selection_state: &'state mut VimSelectionState,
    /// Search.
    search_state: &'state mut VimSearchState,
    /// Command prompt.
    command_state: &'state mut VimCommandState,
    /// Status line.
    status_line: &'state mut VimStatusLine,
    /// Normal state.
    normal_state: &'state mut NormalState,
    /// Viewport height.
    visible_line_count: usize,
    /// Search options.
    options: &'state VimOptions,
}

/// Applies an operation outcome to pure Vim state.
fn apply_outcome(outcome: OperationOutcome, state: DispatchState<'_>) {
    let DispatchState {
        text,
        cursor,
        mode,
        selection_state,
        search_state,
        command_state,
        status_line,
        normal_state,
        visible_line_count,
        options,
    } = state;

    for effect in outcome.into_effects() {
        match effect {
            OperationEffect::MoveCursor(counted) => {
                apply_motion(text, cursor, normal_state, counted, visible_line_count);
            }
            OperationEffect::SwitchMode(mode_switch) => match mode_switch {
                ModeSwitch::VisualCharacterwise
                | ModeSwitch::VisualLinewise
                | ModeSwitch::VisualBlockwise => {
                    normal_state.apply_command(
                        NormalCommand::ModeSwitch(mode_switch),
                        NormalCommandContext {
                            text,
                            cursor,
                            mode,
                            selection_state,
                            search_state,
                            command_state,
                            status_line,
                            options,
                        },
                    );
                }
            },
            OperationEffect::EnterInsert(entry) => {
                normal_state.apply_command(
                    NormalCommand::Insert(entry),
                    NormalCommandContext {
                        text,
                        cursor,
                        mode,
                        selection_state,
                        search_state,
                        command_state,
                        status_line,
                        options,
                    },
                );
            }
            OperationEffect::Paste { .. }
            | OperationEffect::Undo
            | OperationEffect::Redo
            | OperationEffect::ApplyOperator { .. } => {}
            OperationEffect::StartCommandLine => {
                status_line.clear();
                command_state.start();
            }
            OperationEffect::PrefilledCommandLine(command) => {
                status_line.clear();
                command_state.start_with(&command);
            }
            OperationEffect::StartSearch(direction) => {
                status_line.clear();
                search_state.start(direction);
            }
            OperationEffect::RepeatSearch(direction) => {
                let outcome =
                    search_state.repeat_relative(text, cursor.byte_index(), direction, options);
                super::apply_search_outcome(text, cursor, status_line, outcome);
            }
            OperationEffect::RepeatSearchInDirection(direction) => {
                let outcome = search_state.repeat(text, cursor.byte_index(), direction, options);
                super::apply_search_outcome(text, cursor, status_line, outcome);
            }
            OperationEffect::ViewportPosition(_) => {
                status_line.clear();
            }
        }
    }
}

/// 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, VimConfig, 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();
        let config = VimConfig::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,
                options: &config.options,
            },
        );

        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();
        let config = VimConfig::default();

        search_state.start(SearchDirection::Forward);
        search_state.push_text("two");
        assert_eq!(
            search_state.submit(text, 0, &config.options),
            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,
                options: &config.options,
            },
        );

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