use super::motion::{
Motion, ParagraphDirection, WordKind, apply_motion, apply_vertical_motion_to_column,
character_column, clamp_to_cursor_position,
};
use bevy::prelude::Resource;
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct VimCursor {
byte_index: usize,
desired_column: Option<usize>,
}
impl VimCursor {
#[must_use]
pub const fn new() -> Self {
Self {
byte_index: 0,
desired_column: None,
}
}
#[must_use]
pub const fn byte_index(&self) -> usize {
self.byte_index
}
#[must_use]
pub const fn desired_column(&self) -> Option<usize> {
self.desired_column
}
pub const fn set_desired_column(&mut self, desired_column: Option<usize>) {
self.desired_column = desired_column;
}
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;
}
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;
}
}
}
pub fn move_left(&mut self, text: &str) {
self.apply_motion(text, Motion::Left);
}
pub fn move_right(&mut self, text: &str) {
self.apply_motion(text, Motion::Right);
}
pub fn move_up(&mut self, text: &str) {
self.apply_motion(text, Motion::Up);
}
pub fn move_down(&mut self, text: &str) {
self.apply_motion(text, Motion::Down);
}
pub fn move_next_word(&mut self, text: &str) {
self.apply_motion(text, Motion::WordForward(WordKind::Normal));
}
pub fn move_previous_word(&mut self, text: &str) {
self.apply_motion(text, Motion::WordBackward(WordKind::Normal));
}
pub fn move_next_paragraph(&mut self, text: &str) {
self.apply_motion(text, Motion::Paragraph(ParagraphDirection::Forward));
}
pub fn move_previous_paragraph(&mut self, text: &str) {
self.apply_motion(text, Motion::Paragraph(ParagraphDirection::Backward));
}
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());
}
}
}