alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Normal-mode command application.

use super::{
    CharSearch, CharSearchDirection, ColumnMotion, Counted, ModeSwitch, Motion, NormalCommand,
    NormalGrammar, NormalGrammarOutput, SearchOutcome, VimCursor, VimMode, VimSearchState,
    VimSelectionState, VimStatusLine, VisualMode, motion,
};

/// Normal-mode controller.
#[derive(Clone, Debug, Default)]
pub struct NormalState {
    /// Grammar.
    grammar: NormalGrammar,
    /// Last character-search motion for `;` and `,` repeats.
    last_char_search: Option<CharSearch>,
}

impl NormalState {
    /// Clears pending grammar.
    pub const fn reset_grammar(&mut self) {
        self.grammar.reset();
    }

    /// Feeds and applies one token.
    pub fn feed(
        &mut self,
        token: super::KeyToken,
        context: NormalCommandContext<'_>,
    ) -> NormalGrammarOutput {
        match self.feed_command(token) {
            NormalGrammarOutput::Command(command) => {
                self.apply_command(command, context);
                NormalGrammarOutput::Command(command)
            }
            output @ (NormalGrammarOutput::Pending | NormalGrammarOutput::Unmatched) => output,
        }
    }

    /// Feeds one token without applying it.
    pub fn feed_command(&mut self, token: super::KeyToken) -> NormalGrammarOutput {
        self.grammar.feed(token)
    }

    /// Applies one command.
    pub fn apply_command(&mut self, command: NormalCommand, context: NormalCommandContext<'_>) {
        let NormalCommandContext {
            text,
            cursor,
            mode,
            selection_state,
            search_state,
            command_state,
            status_line,
        } = context;

        match command {
            NormalCommand::Motion(motion) => {
                self.apply_counted_motion(text, cursor, motion);
            }
            NormalCommand::ModeSwitch(ModeSwitch::VisualCharacterwise) => {
                toggle_visual_mode(
                    text,
                    cursor,
                    mode,
                    selection_state,
                    VisualMode::Characterwise,
                );
            }
            NormalCommand::ModeSwitch(ModeSwitch::VisualLinewise) => {
                toggle_visual_mode(text, cursor, mode, selection_state, VisualMode::Linewise);
            }
            NormalCommand::ExCommandStart => {
                status_line.clear();
                command_state.start();
            }
            NormalCommand::SearchStart(direction) => {
                status_line.clear();
                search_state.start(direction);
            }
            NormalCommand::SearchRepeat(direction) => {
                let outcome = search_state.repeat_relative(text, cursor.byte_index(), direction);
                apply_search_outcome(text, cursor, status_line, outcome);
            }
            NormalCommand::ViewportPosition(_) | NormalCommand::Operator { .. } => {}
        }
    }

    /// Repeats relative motions; resolves addresses once.
    pub fn apply_counted_motion(
        &mut self,
        text: &str,
        cursor: &mut VimCursor,
        counted: Counted<Motion>,
    ) {
        match counted.item {
            Motion::CharSearch(search) => {
                self.last_char_search = Some(search);
                for _step in 0..counted.count.get() {
                    cursor.apply_motion(text, Motion::CharSearch(search));
                }
            }
            Motion::RepeatCharSearch => {
                if let Some(search) = self.last_char_search {
                    for _step in 0..counted.count.get() {
                        cursor.apply_motion(text, Motion::CharSearch(search));
                    }
                }
            }
            Motion::RepeatCharSearchReversed => {
                if let Some(search) = self.last_char_search.map(reverse_char_search) {
                    for _step in 0..counted.count.get() {
                        cursor.apply_motion(text, Motion::CharSearch(search));
                    }
                }
            }
            Motion::LineAddress(_) => cursor.apply_motion(text, counted.item),
            Motion::Column(ColumnMotion::ScreenColumn) => {
                cursor.set_byte_index(
                    text,
                    motion::apply_screen_column_motion(
                        text,
                        cursor.byte_index(),
                        counted.count.get(),
                    ),
                );
            }
            motion => {
                for _step in 0..counted.count.get() {
                    cursor.apply_motion(text, motion);
                }
            }
        }
    }
}

/// Normal-command context.
pub struct NormalCommandContext<'state> {
    /// Text.
    pub text: &'state str,
    /// Cursor state.
    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-line state.
    pub command_state: &'state mut super::VimCommandState,
    /// Status.
    pub status_line: &'state mut VimStatusLine,
}

/// Toggles visual mode and anchor state.
fn toggle_visual_mode(
    text: &str,
    cursor: &VimCursor,
    mode: &mut VimMode,
    selection_state: &mut VimSelectionState,
    visual_mode: VisualMode,
) {
    if *mode == VimMode::Visual(visual_mode) {
        *mode = VimMode::Normal;
        selection_state.clear();
    } else {
        *mode = VimMode::Visual(visual_mode);
        selection_state.start(text, cursor.byte_index());
    }
}

/// Inverts `;` for `,`.
const fn reverse_char_search(search: CharSearch) -> CharSearch {
    let direction = match search.direction {
        CharSearchDirection::Backward => CharSearchDirection::Forward,
        CharSearchDirection::Forward => CharSearchDirection::Backward,
    };

    CharSearch {
        direction,
        ..search
    }
}

/// Applies a search outcome.
pub fn apply_search_outcome(
    text: &str,
    cursor: &mut VimCursor,
    status_line: &mut VimStatusLine,
    outcome: SearchOutcome,
) {
    match outcome {
        SearchOutcome::Match { byte_index } => {
            cursor.set_byte_index(text, byte_index);
            status_line.clear();
        }
        SearchOutcome::Error(error) => status_line.set_error(error),
    }
}