alma 0.1.1

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Insert-mode adapter.
//!
//! Insert mode accepts decoded editor input and emits typed buffer/cursor
//! requests. It does not mutate authoritative text directly.

use super::effects::{VimEffect, VimEffectBatch};
use crate::{
    buffer::BufferEdit,
    ecs::{
        components::{
            buffer::{BufferEntity, ViewEntity},
            cursor::ByteIndex,
        },
        events::{
            cursor::CursorMoveRequested,
            edit::BufferEditRequested,
            input::{EditorInputEvent, VimInputRejected, VimInputRejectionReason},
        },
    },
    vim::{
        DEFAULT_INSERT_TEXT_BYTES_PER_FRAME, INSERT_NEWLINE, InsertCommand, InsertEditPlanner,
        InsertEntry, InsertPlanEdit, InsertState, KeyToken, VimMode, resolve_insert_entry_caret,
    },
};
use bevy::prelude::MessageWriter;

/// Maximum insert payload accepted from one input frame.
pub(super) const MAX_INSERT_TEXT_BYTES_PER_FRAME: usize = DEFAULT_INSERT_TEXT_BYTES_PER_FRAME;

/// Insert-mode input context.
pub(super) struct InsertInputContext<'input, 'writer> {
    /// Buffer text snapshot from the start of this input frame.
    pub(super) text: &'input str,
    /// Target view.
    pub(super) target: ViewEntity,
    /// Target buffer.
    pub(super) buffer_target: BufferEntity,
    /// Cursor position interpreted as an insert caret.
    pub(super) cursor_byte_index: usize,
    /// Active insert state.
    pub(super) insert_state: InsertState,
    /// Input events.
    pub(super) events: &'input [EditorInputEvent],
    /// Pending ECS effects.
    pub(super) effects: &'input mut VimEffectBatch,
    /// Rejection output.
    pub(super) rejected: &'input mut MessageWriter<'writer, VimInputRejected>,
}

/// Handles input for an active insert mode.
pub(super) fn handle_insert_input(mode: &mut VimMode, context: InsertInputContext<'_, '_>) -> bool {
    let InsertInputContext {
        text,
        target,
        buffer_target,
        cursor_byte_index,
        insert_state,
        events,
        effects,
        rejected,
    } = context;
    let mut planner =
        InsertEditPlanner::new(text, cursor_byte_index, MAX_INSERT_TEXT_BYTES_PER_FRAME);
    let mut exit_requested = false;

    for event in events {
        let Some(command) = insert_command_from_event(event) else {
            continue;
        };

        match command {
            InsertCommand::InsertText(text) => {
                if planner.push_insert(&text).is_err() {
                    reject_insert_payload(target, text.len(), rejected);
                }
            }
            InsertCommand::InsertNewline => {
                if planner.push_insert(INSERT_NEWLINE).is_err() {
                    reject_insert_payload(target, INSERT_NEWLINE.len(), rejected);
                }
            }
            InsertCommand::DeleteBackward => planner.delete_backward(),
            InsertCommand::Exit => {
                exit_requested = true;
                break;
            }
        }
    }

    let next_caret = if exit_requested {
        *mode = VimMode::Normal;
        planner.exit_caret(insert_state.exit_policy())
    } else {
        planner.caret()
    };
    emit_insert_plan_edits(buffer_target, planner.into_edits(), effects);

    let cursor_move = if exit_requested {
        CursorMoveRequested::cursor_cell(target, ByteIndex(next_caret), None)
    } else {
        CursorMoveRequested::insert_caret(target, ByteIndex(next_caret), None)
    };
    effects.push(VimEffect::CursorMove(cursor_move));

    true
}

/// Resolves an insert-entry command into mode, cursor, and optional edit effects.
pub(super) fn enter_insert_mode(
    entry: InsertEntry,
    text: &str,
    cursor_byte_index: usize,
    mode: &mut VimMode,
    target: ViewEntity,
    buffer_target: BufferEntity,
    effects: &mut VimEffectBatch,
) {
    let caret = resolve_insert_entry_caret(text, cursor_byte_index, entry);
    if entry.opens_line() {
        effects.push(VimEffect::BufferEdit(BufferEditRequested {
            target: buffer_target,
            edit: BufferEdit::insert(caret.edit_byte_index(), INSERT_NEWLINE),
        }));
    }

    *mode = VimMode::Insert(entry.insert_state());
    effects.push(VimEffect::CursorMove(CursorMoveRequested::insert_caret(
        target,
        ByteIndex(caret.caret_byte_index()),
        None,
    )));
}

/// Insert command decoded from an input event.
fn insert_command_from_event(event: &EditorInputEvent) -> Option<InsertCommand> {
    match event {
        EditorInputEvent::Text(text) if !text.text.is_empty() => {
            Some(InsertCommand::insert_text(text.text.clone()))
        }
        EditorInputEvent::Text(_) => None,
        EditorInputEvent::Key(key) => match key.token {
            KeyToken::Escape => Some(InsertCommand::Exit),
            KeyToken::Enter => Some(InsertCommand::InsertNewline),
            KeyToken::Backspace => Some(InsertCommand::DeleteBackward),
            KeyToken::Char(character) if key.repeated && !character.is_control() => {
                Some(InsertCommand::insert_text(character.to_string()))
            }
            KeyToken::Ctrl(_) | KeyToken::Char(_) => None,
        },
    }
}

/// Converts pure insert edit plans into ECS-facing buffer edit requests.
fn emit_insert_plan_edits(
    buffer_target: BufferEntity,
    edits: Vec<InsertPlanEdit>,
    effects: &mut VimEffectBatch,
) {
    effects.extend(edits.into_iter().map(|edit| {
        let edit = match edit {
            InsertPlanEdit::Insert { byte_index, text } => BufferEdit::insert(byte_index, text),
            InsertPlanEdit::Delete { range } => BufferEdit::delete_unchecked(range),
        };
        VimEffect::BufferEdit(BufferEditRequested {
            target: buffer_target,
            edit,
        })
    }));
}

/// Emits a redacted insert rejection.
fn reject_insert_payload(
    target: ViewEntity,
    rejected_byte_len: usize,
    rejected: &mut MessageWriter<VimInputRejected>,
) {
    let _sent = rejected.write(VimInputRejected {
        target,
        reason: VimInputRejectionReason::InsertPayloadTooLarge {
            target,
            rejected_byte_len,
            max_byte_len: MAX_INSERT_TEXT_BYTES_PER_FRAME,
        },
    });
}

#[cfg(test)]
mod tests {
    use super::{VimEffectBatch, emit_insert_plan_edits};
    use crate::{
        buffer::{BufferEdit, BufferEditShape},
        vim::InsertPlanEdit,
    };

    #[test]
    fn adapter_converts_insert_plans_to_buffer_edit_effects() {
        let mut effects = VimEffectBatch::default();
        emit_insert_plan_edits(
            crate::ecs::components::buffer::BufferEntity(
                bevy::prelude::Entity::from_raw_u32(0).expect("valid entity index"),
            ),
            vec![InsertPlanEdit::insert(1, "λ")],
            &mut effects,
        );

        let [super::VimEffect::BufferEdit(request)] = effects.as_slice() else {
            panic!("expected one buffer edit");
        };
        assert_eq!(
            BufferEditShape::from_edit(&request.edit),
            BufferEditShape::from_edit(&BufferEdit::insert(1, "λ"))
        );
    }

    #[test]
    fn adapter_preserves_delete_plan_order() {
        let mut effects = VimEffectBatch::default();
        emit_insert_plan_edits(
            crate::ecs::components::buffer::BufferEntity(
                bevy::prelude::Entity::from_raw_u32(0).expect("valid entity index"),
            ),
            vec![InsertPlanEdit::delete(2..3), InsertPlanEdit::delete(1..2)],
            &mut effects,
        );

        assert_eq!(effects.as_slice().len(), 2);
    }
}