alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Bevy keyboard-device normalization for Vim input.

use crate::{
    ecs::{
        components::{
            buffer::{EditorView, FocusedEditorView, ViewEntity},
            vim::VimModalState,
        },
        events::input::{EditorInputEvent, KeyInputEvent, TextInputEvent},
    },
    vim::KeyToken,
};
use bevy::{
    input::{ButtonState, keyboard::KeyboardInput},
    prelude::{
        ButtonInput, KeyCode, MessageReader, MessageWriter, Query, Res, ResMut, Resource, Time,
        With,
    },
};

/// Held-key repeat delay.
pub(super) const KEY_REPEAT_INITIAL_DELAY_SECONDS: f32 = 0.34;

/// Held-key repeat interval.
pub(super) const KEY_REPEAT_INTERVAL_SECONDS: f32 = 0.045;

/// Keyboard-device state across frames.
#[derive(Clone, Debug, Default, Resource)]
pub struct VimInputState {
    /// Key repeat.
    pub(super) repeat: KeyRepeatState,
    /// Last focused view.
    pub(super) focused_view: Option<ViewEntity>,
}

/// Held-key repeat.
#[derive(Clone, Debug, Default)]
pub(super) struct KeyRepeatState {
    /// Repeating command.
    command: Option<RepeatableNormalCommand>,
    /// Repeat timer.
    seconds_until_repeat: f32,
}

/// Held-key repeat candidates.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum RepeatableNormalCommand {
    /// `h`
    Left,
    /// `j`
    Down,
    /// `k`
    Up,
    /// `l`
    Right,
    /// `w`
    NextWord,
    /// `b`
    PreviousWord,
    /// `n`
    RepeatSearchForward,
    /// `N`
    RepeatSearchBackward,
}

/// Focused editor input routing mode.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) enum InputModeDispatch {
    /// Normal/visual Vim grammar.
    #[default]
    Normal,
    /// Insert-mode text editing.
    Insert,
    /// Command/search prompt text editing.
    Prompt,
}

/// Normalizes Bevy keyboard input into editor-facing input events.
pub fn emit_editor_input_events(
    mut keyboard_events: MessageReader<KeyboardInput>,
    keys: Res<ButtonInput<KeyCode>>,
    time: Res<Time>,
    mut input_events: MessageWriter<EditorInputEvent>,
    mut input_state: ResMut<VimInputState>,
    focused_modes: Query<&VimModalState, (With<EditorView>, With<FocusedEditorView>)>,
) {
    let dispatch = InputModeDispatch::from_focused(focused_modes.iter().next());
    let time = time.into_inner();
    let typed_text: Vec<String> = keyboard_events
        .read()
        .filter(|event| event.state == ButtonState::Pressed)
        .filter_map(|event| dispatch.text_from_event(event))
        .collect();
    let keys = keys.into_inner();

    for token in special_key_tokens(keys).chain(control_key_tokens(keys)) {
        let _sent = input_events.write(EditorInputEvent::Key(KeyInputEvent {
            token,
            repeated: false,
        }));
    }

    for text in &typed_text {
        let _sent = input_events.write(EditorInputEvent::Text(TextInputEvent {
            text: text.clone(),
        }));
    }

    if dispatch.is_normal_repeat_enabled()
        && typed_text.is_empty()
        && let Some(command) = repeatable_normal_command(keys)
        && input_state
            .repeat
            .tick(command, key_just_pressed(keys, command), time.delta_secs())
        && !key_just_pressed(keys, command)
    {
        let _sent = input_events.write(EditorInputEvent::Key(KeyInputEvent {
            token: repeatable_command_token(command),
            repeated: true,
        }));
    } else if typed_text.is_empty() || !dispatch.is_normal_repeat_enabled() {
        input_state.repeat.cancel();
    }
}

impl InputModeDispatch {
    /// Builds dispatch mode from the focused Vim state.
    const fn from_focused(modal_state: Option<&VimModalState>) -> Self {
        let Some(modal_state) = modal_state else {
            return Self::Normal;
        };
        if modal_state.editor.command.is_active() || modal_state.editor.search.is_active() {
            Self::Prompt
        } else if modal_state.editor.mode.is_insert() {
            Self::Insert
        } else {
            Self::Normal
        }
    }

    /// Returns text accepted from a pressed keyboard event for this mode.
    fn text_from_event(self, event: &KeyboardInput) -> Option<String> {
        let text = event.text.as_ref()?;
        let accepted = match self {
            Self::Normal | Self::Prompt => text.chars().all(is_prompt_text_character),
            Self::Insert => text.chars().all(is_insert_text_character),
        };
        accepted.then(|| text.to_string())
    }

    /// Returns whether held-key normal command synthesis is active.
    const fn is_normal_repeat_enabled(self) -> bool {
        matches!(self, Self::Normal)
    }
}

/// Returns the held repeatable command.
fn repeatable_normal_command(keys: &ButtonInput<KeyCode>) -> Option<RepeatableNormalCommand> {
    if keys.pressed(KeyCode::KeyH) {
        Some(RepeatableNormalCommand::Left)
    } else if keys.pressed(KeyCode::KeyJ) {
        Some(RepeatableNormalCommand::Down)
    } else if keys.pressed(KeyCode::KeyK) {
        Some(RepeatableNormalCommand::Up)
    } else if keys.pressed(KeyCode::KeyL) {
        Some(RepeatableNormalCommand::Right)
    } else if keys.pressed(KeyCode::KeyW) {
        Some(RepeatableNormalCommand::NextWord)
    } else if keys.pressed(KeyCode::KeyB) {
        Some(RepeatableNormalCommand::PreviousWord)
    } else if keys.pressed(KeyCode::KeyN) && shift_pressed(keys) {
        Some(RepeatableNormalCommand::RepeatSearchBackward)
    } else if keys.pressed(KeyCode::KeyN) {
        Some(RepeatableNormalCommand::RepeatSearchForward)
    } else {
        None
    }
}

/// Returns whether `command` was just pressed.
fn key_just_pressed(keys: &ButtonInput<KeyCode>, command: RepeatableNormalCommand) -> bool {
    match command {
        RepeatableNormalCommand::Left => keys.just_pressed(KeyCode::KeyH),
        RepeatableNormalCommand::Down => keys.just_pressed(KeyCode::KeyJ),
        RepeatableNormalCommand::Up => keys.just_pressed(KeyCode::KeyK),
        RepeatableNormalCommand::Right => keys.just_pressed(KeyCode::KeyL),
        RepeatableNormalCommand::NextWord => keys.just_pressed(KeyCode::KeyW),
        RepeatableNormalCommand::PreviousWord => keys.just_pressed(KeyCode::KeyB),
        RepeatableNormalCommand::RepeatSearchForward
        | RepeatableNormalCommand::RepeatSearchBackward => keys.just_pressed(KeyCode::KeyN),
    }
}

impl KeyRepeatState {
    /// Clears repeat state.
    pub(super) const fn cancel(&mut self) {
        self.command = None;
        self.seconds_until_repeat = 0.0;
    }

    /// Advances repeat state.
    pub(super) fn tick(
        &mut self,
        command: RepeatableNormalCommand,
        just_pressed: bool,
        delta_seconds: f32,
    ) -> bool {
        if just_pressed || self.command != Some(command) {
            self.command = Some(command);
            self.seconds_until_repeat = KEY_REPEAT_INITIAL_DELAY_SECONDS;
            return just_pressed;
        }

        self.seconds_until_repeat -= delta_seconds;

        if self.seconds_until_repeat <= 0.0 {
            self.seconds_until_repeat += KEY_REPEAT_INTERVAL_SECONDS;
            true
        } else {
            false
        }
    }
}

/// Control chords pressed this frame.
fn control_key_tokens(keys: &ButtonInput<KeyCode>) -> impl Iterator<Item = KeyToken> {
    let control_pressed = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
    [
        (KeyCode::KeyF, KeyToken::Ctrl('f')),
        (KeyCode::KeyB, KeyToken::Ctrl('b')),
        (KeyCode::KeyR, KeyToken::Ctrl('r')),
    ]
    .into_iter()
    .filter_map(move |(key_code, token)| {
        (control_pressed && keys.just_pressed(key_code)).then_some(token)
    })
}

/// Special keys pressed this frame.
fn special_key_tokens(keys: &ButtonInput<KeyCode>) -> impl Iterator<Item = KeyToken> + '_ {
    [
        (KeyCode::Escape, KeyToken::Escape),
        (KeyCode::Enter, KeyToken::Enter),
        (KeyCode::Backspace, KeyToken::Backspace),
    ]
    .into_iter()
    .filter_map(|(key_code, token)| keys.just_pressed(key_code).then_some(token))
}

/// Test helper for legacy normalization.
#[cfg(test)]
pub(super) fn normalized_key_tokens(
    keys: &ButtonInput<KeyCode>,
    typed_text: &[String],
) -> impl Iterator<Item = KeyToken> {
    control_key_tokens(keys).chain(
        typed_text
            .iter()
            .flat_map(|text| text.chars())
            .map(KeyToken::Char),
    )
}

/// Input events as Vim tokens.
pub(super) fn normalized_editor_tokens(
    input_events: &[EditorInputEvent],
) -> impl Iterator<Item = KeyToken> + '_ {
    input_events.iter().flat_map(|event| match event {
        EditorInputEvent::Key(key) => vec![key.token],
        EditorInputEvent::Text(text) => text.text.chars().map(KeyToken::Char).collect(),
    })
}

/// Held repeat command as Vim token.
const fn repeatable_command_token(command: RepeatableNormalCommand) -> KeyToken {
    match command {
        RepeatableNormalCommand::Left => KeyToken::Char('h'),
        RepeatableNormalCommand::Down => KeyToken::Char('j'),
        RepeatableNormalCommand::Up => KeyToken::Char('k'),
        RepeatableNormalCommand::Right => KeyToken::Char('l'),
        RepeatableNormalCommand::NextWord => KeyToken::Char('w'),
        RepeatableNormalCommand::PreviousWord => KeyToken::Char('b'),
        RepeatableNormalCommand::RepeatSearchForward => KeyToken::Char('n'),
        RepeatableNormalCommand::RepeatSearchBackward => KeyToken::Char('N'),
    }
}

/// Shift key state.
fn shift_pressed(keys: &ButtonInput<KeyCode>) -> bool {
    keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight)
}

/// Single-line prompt filter.
pub(super) fn is_prompt_text_character(character: char) -> bool {
    !character.is_control()
}

/// Insert text filter for keyboard text and future paste/IME payloads.
fn is_insert_text_character(character: char) -> bool {
    !character.is_control() || matches!(character, '\n' | '\t')
}