alma 0.1.0

A Bevy-native modal text editor with Vim-style navigation.
Documentation
//! Cursor state for Vim-style navigation.

use super::motion::{
    Motion, ParagraphDirection, WordKind, apply_motion, apply_vertical_motion_to_column,
    character_column, clamp_to_cursor_position,
};
use bevy::prelude::Resource;

/// Cursor state for navigating the managed text stream.
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct VimCursor {
    /// Current cursor byte index, always clamped to a visible cursor cell when possible.
    byte_index: usize,
    /// Preferred character column for consecutive vertical motions.
    desired_column: Option<usize>,
}

impl VimCursor {
    /// Creates a cursor at the start of the stream.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            byte_index: 0,
            desired_column: None,
        }
    }

    /// Reads the current cursor byte index.
    #[must_use]
    pub const fn byte_index(&self) -> usize {
        self.byte_index
    }

    /// Reads the preferred vertical-motion character column.
    #[must_use]
    pub const fn desired_column(&self) -> Option<usize> {
        self.desired_column
    }

    /// Restores the preferred vertical-motion character column.
    pub const fn set_desired_column(&mut self, desired_column: Option<usize>) {
        self.desired_column = desired_column;
    }

    /// Replaces the cursor byte index after clamping to a visible cursor cell.
    pub fn set_byte_index(&mut self, text: &str, byte_index: usize) {
        self.byte_index = clamp_to_cursor_position(text, byte_index);
        self.desired_column = None;
    }

    /// Applies a Vim motion to the cursor.
    pub fn apply_motion(&mut self, text: &str, motion: Motion) {
        match motion {
            Motion::Up | Motion::Down => {
                let desired_column = self
                    .desired_column
                    .unwrap_or_else(|| character_column(text, self.byte_index));
                self.byte_index =
                    apply_vertical_motion_to_column(text, self.byte_index, motion, desired_column);
                self.desired_column = Some(desired_column);
            }
            _ => {
                self.byte_index = apply_motion(text, self.byte_index, motion);
                self.desired_column = None;
            }
        }
    }

    /// Moves the cursor one character left.
    pub fn move_left(&mut self, text: &str) {
        self.apply_motion(text, Motion::Left);
    }

    /// Moves the cursor one character right.
    pub fn move_right(&mut self, text: &str) {
        self.apply_motion(text, Motion::Right);
    }

    /// Moves the cursor to the same column on the previous line.
    pub fn move_up(&mut self, text: &str) {
        self.apply_motion(text, Motion::Up);
    }

    /// Moves the cursor to the same column on the next line.
    pub fn move_down(&mut self, text: &str) {
        self.apply_motion(text, Motion::Down);
    }

    /// Moves the cursor to the start of the next word.
    pub fn move_next_word(&mut self, text: &str) {
        self.apply_motion(text, Motion::WordForward(WordKind::Normal));
    }

    /// Moves the cursor to the start of the previous word.
    pub fn move_previous_word(&mut self, text: &str) {
        self.apply_motion(text, Motion::WordBackward(WordKind::Normal));
    }

    /// Moves the cursor to the start of the next paragraph.
    pub fn move_next_paragraph(&mut self, text: &str) {
        self.apply_motion(text, Motion::Paragraph(ParagraphDirection::Forward));
    }

    /// Moves the cursor to the start of the previous paragraph.
    pub fn move_previous_paragraph(&mut self, text: &str) {
        self.apply_motion(text, Motion::Paragraph(ParagraphDirection::Backward));
    }

    /// Clamps the cursor to a valid boundary for the current text.
    pub fn clamp_to_text(&mut self, text: &str) {
        self.byte_index = clamp_to_cursor_position(text, self.byte_index);
    }
}

#[cfg(test)]
mod tests {
    use super::VimCursor;
    use crate::vim::{Motion, ParagraphDirection, WordKind};
    use proptest::prelude::*;

    #[test]
    fn horizontal_movement_respects_utf8_boundaries() {
        let text = "AλB";
        let mut cursor = VimCursor::new();

        cursor.move_right(text);
        assert_eq!(cursor.byte_index(), 1);

        cursor.move_right(text);
        assert_eq!(cursor.byte_index(), 3);

        cursor.move_left(text);
        assert_eq!(cursor.byte_index(), 1);
    }

    #[test]
    fn vertical_movement_preserves_column_and_clamps_to_shorter_lines() {
        let text = "abc\nδ\nwxyz";
        let mut cursor = VimCursor::new();

        cursor.move_right(text);
        cursor.move_right(text);
        cursor.move_down(text);
        assert_eq!(cursor.byte_index(), "abc\n".len());

        cursor.move_down(text);
        assert_eq!(cursor.byte_index(), "abc\nδ\nwx".len());
    }

    #[test]
    fn vertical_movement_preserves_desired_column_across_short_lines() {
        let text = "abcd\nx\nwxyz";
        let mut cursor = VimCursor::new();

        cursor.set_byte_index(text, "abc".len());
        cursor.move_down(text);
        assert_eq!(cursor.byte_index(), "abcd\n".len());

        cursor.move_down(text);
        assert_eq!(cursor.byte_index(), "abcd\nx\nwxy".len());
    }

    #[test]
    fn horizontal_movement_does_not_enter_newline_cells() {
        let text = "ALMA\nΑλβα";
        let mut cursor = VimCursor::new();

        for _step in 0.."ALMA".chars().count() {
            cursor.move_right(text);
        }

        assert_eq!(cursor.byte_index(), "ALV".len());

        cursor.move_down(text);
        assert_eq!(cursor.byte_index(), "ALMA\nΑλβ".len());

        cursor.move_left(text);
        cursor.move_left(text);
        cursor.move_left(text);
        cursor.move_left(text);
        assert_eq!(cursor.byte_index(), "ALMA\n".len());
    }

    #[test]
    fn word_movement_finds_ascii_and_unicode_word_starts() {
        let text = "alma  λambda_case done";
        let mut cursor = VimCursor::new();

        cursor.move_next_word(text);
        assert_eq!(cursor.byte_index(), "alma  ".len());

        cursor.move_next_word(text);
        assert_eq!(cursor.byte_index(), "alma  λambda_case ".len());

        cursor.move_previous_word(text);
        assert_eq!(cursor.byte_index(), "alma  ".len());
    }

    proptest! {
        #[test]
        fn cursor_stays_on_utf8_boundaries_after_motion_sequences(
            text in any::<String>(),
            motions in prop::collection::vec(
                prop_oneof![
                    Just(Motion::Left),
                    Just(Motion::Down),
                    Just(Motion::Up),
                    Just(Motion::Right),
                    Just(Motion::WordForward(WordKind::Normal)),
                    Just(Motion::WordBackward(WordKind::Normal)),
                    Just(Motion::Paragraph(ParagraphDirection::Forward)),
                    Just(Motion::Paragraph(ParagraphDirection::Backward)),
                ],
                0..128,
            ),
        ) {
            let mut cursor = VimCursor::new();

            for motion in motions {
                cursor.apply_motion(&text, motion);

                prop_assert!(cursor.byte_index() <= text.len());
                prop_assert!(text.is_char_boundary(cursor.byte_index()));
                prop_assert!(crate::vim::motion::is_cursor_position(
                    &text,
                    cursor.byte_index()
                ) || text.is_empty());
            }
        }

        #[test]
        fn clamping_after_text_changes_keeps_cursor_valid(
            original_text in any::<String>(),
            next_text in any::<String>(),
            motions in prop::collection::vec(Just(Motion::Right), 0..128),
        ) {
            let mut cursor = VimCursor::new();

            for motion in motions {
                cursor.apply_motion(&original_text, motion);
            }

            cursor.clamp_to_text(&next_text);

            prop_assert!(cursor.byte_index() <= next_text.len());
            prop_assert!(next_text.is_char_boundary(cursor.byte_index()));
            prop_assert!(crate::vim::motion::is_cursor_position(
                &next_text,
                cursor.byte_index()
            ) || next_text.is_empty());
        }
    }
}