Add word and line movement in vim normal mode

Add jump to start and end of the document
Move vim tests to relevant vim files
Rename VimTestAppContext to VimTestContext for brevity
Improve VimTestContext assertions to pretty print locations when selection position assertion panics
This commit is contained in:
Keith Simmons 2022-03-27 17:58:28 -07:00
parent 3ae5fc74c9
commit a7a52ef3f7
10 changed files with 766 additions and 278 deletions

View file

@ -1,30 +1,55 @@
use editor::{movement, Bias};
mod g_prefix;
use editor::{char_kind, movement, Bias};
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
use language::SelectionGoal;
use workspace::Workspace;
use crate::{Mode, SwitchMode, VimState};
use crate::{mode::NormalState, Mode, SwitchMode, VimState};
action!(InsertBefore);
action!(GPrefix);
action!(MoveLeft);
action!(MoveDown);
action!(MoveUp);
action!(MoveRight);
action!(MoveToStartOfLine);
action!(MoveToEndOfLine);
action!(MoveToEnd);
action!(MoveToNextWordStart, bool);
action!(MoveToNextWordEnd, bool);
action!(MoveToPreviousWordStart, bool);
pub fn init(cx: &mut MutableAppContext) {
let context = Some("Editor && vim_mode == normal");
cx.add_bindings(vec![
Binding::new("i", SwitchMode(Mode::Insert), context),
Binding::new("g", SwitchMode(Mode::Normal(NormalState::GPrefix)), context),
Binding::new("h", MoveLeft, context),
Binding::new("j", MoveDown, context),
Binding::new("k", MoveUp, context),
Binding::new("l", MoveRight, context),
Binding::new("0", MoveToStartOfLine, context),
Binding::new("shift-$", MoveToEndOfLine, context),
Binding::new("shift-G", MoveToEnd, context),
Binding::new("w", MoveToNextWordStart(false), context),
Binding::new("shift-W", MoveToNextWordStart(true), context),
Binding::new("e", MoveToNextWordEnd(false), context),
Binding::new("shift-E", MoveToNextWordEnd(true), context),
Binding::new("b", MoveToPreviousWordStart(false), context),
Binding::new("shift-B", MoveToPreviousWordStart(true), context),
]);
g_prefix::init(cx);
cx.add_action(move_left);
cx.add_action(move_down);
cx.add_action(move_up);
cx.add_action(move_right);
cx.add_action(move_to_start_of_line);
cx.add_action(move_to_end_of_line);
cx.add_action(move_to_end);
cx.add_action(move_to_next_word_start);
cx.add_action(move_to_next_word_end);
cx.add_action(move_to_previous_word_start);
}
fn move_left(_: &mut Workspace, _: &MoveLeft, cx: &mut ViewContext<Workspace>) {
@ -64,3 +89,348 @@ fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext<Workspace>)
});
});
}
fn move_to_start_of_line(
_: &mut Workspace,
_: &MoveToStartOfLine,
cx: &mut ViewContext<Workspace>,
) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, cursor, _| {
(
movement::line_beginning(map, cursor, false),
SelectionGoal::None,
)
});
});
});
}
fn move_to_end_of_line(_: &mut Workspace, _: &MoveToEndOfLine, cx: &mut ViewContext<Workspace>) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, cursor, _| {
(
map.clip_point(movement::line_end(map, cursor, false), Bias::Left),
SelectionGoal::None,
)
});
});
});
}
fn move_to_end(_: &mut Workspace, _: &MoveToEnd, cx: &mut ViewContext<Workspace>) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.replace_selections_with(cx, |map| map.clip_point(map.max_point(), Bias::Left));
});
});
}
fn move_to_next_word_start(
_: &mut Workspace,
&MoveToNextWordStart(treat_punctuation_as_word): &MoveToNextWordStart,
cx: &mut ViewContext<Workspace>,
) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, mut cursor, _| {
let mut crossed_newline = false;
cursor = movement::find_boundary(map, cursor, |left, right| {
let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
let at_newline = right == '\n';
let found = (left_kind != right_kind && !right.is_whitespace())
|| (at_newline && crossed_newline)
|| (at_newline && left == '\n'); // Prevents skipping repeated empty lines
if at_newline {
crossed_newline = true;
}
found
});
(cursor, SelectionGoal::None)
});
});
});
}
fn move_to_next_word_end(
_: &mut Workspace,
&MoveToNextWordEnd(treat_punctuation_as_word): &MoveToNextWordEnd,
cx: &mut ViewContext<Workspace>,
) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, mut cursor, _| {
*cursor.column_mut() += 1;
cursor = movement::find_boundary(map, cursor, |left, right| {
let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
left_kind != right_kind && !left.is_whitespace()
});
// find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
// we have backtraced already
if !map
.chars_at(cursor)
.skip(1)
.next()
.map(|c| c == '\n')
.unwrap_or(true)
{
*cursor.column_mut() = cursor.column().saturating_sub(1);
}
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
});
});
});
}
fn move_to_previous_word_start(
_: &mut Workspace,
&MoveToPreviousWordStart(treat_punctuation_as_word): &MoveToPreviousWordStart,
cx: &mut ViewContext<Workspace>,
) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_cursors(cx, |map, mut cursor, _| {
// This works even though find_preceding_boundary is called for every character in the line containing
// cursor because the newline is checked only once.
cursor = movement::find_preceding_boundary(map, cursor, |left, right| {
let left_kind = char_kind(left).coerce_punctuation(treat_punctuation_as_word);
let right_kind = char_kind(right).coerce_punctuation(treat_punctuation_as_word);
(left_kind != right_kind && !right.is_whitespace()) || left == '\n'
});
(cursor, SelectionGoal::None)
});
});
});
}
#[cfg(test)]
mod test {
use indoc::indoc;
use util::test::marked_text;
use crate::vim_test_context::VimTestContext;
#[gpui::test]
async fn test_hjkl(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true, "Test\nTestTest\nTest").await;
cx.simulate_keystroke("l");
cx.assert_editor_state(indoc! {"
T|est
TestTest
Test"});
cx.simulate_keystroke("h");
cx.assert_editor_state(indoc! {"
|Test
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_editor_state(indoc! {"
Test
|TestTest
Test"});
cx.simulate_keystroke("k");
cx.assert_editor_state(indoc! {"
|Test
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_editor_state(indoc! {"
Test
|TestTest
Test"});
// When moving left, cursor does not wrap to the previous line
cx.simulate_keystroke("h");
cx.assert_editor_state(indoc! {"
Test
|TestTest
Test"});
// When moving right, cursor does not reach the line end or wrap to the next line
for _ in 0..9 {
cx.simulate_keystroke("l");
}
cx.assert_editor_state(indoc! {"
Test
TestTes|t
Test"});
// Goal column respects the inability to reach the end of the line
cx.simulate_keystroke("k");
cx.assert_editor_state(indoc! {"
Tes|t
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_editor_state(indoc! {"
Test
TestTes|t
Test"});
}
#[gpui::test]
async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
let initial_content = indoc! {"
Test Test
T"};
let mut cx = VimTestContext::new(cx, true, initial_content).await;
cx.simulate_keystroke("shift-$");
cx.assert_editor_state(indoc! {"
Test Tes|t
T"});
cx.simulate_keystroke("0");
cx.assert_editor_state(indoc! {"
|Test Test
T"});
cx.simulate_keystroke("j");
cx.simulate_keystroke("shift-$");
cx.assert_editor_state(indoc! {"
Test Test
|
T"});
cx.simulate_keystroke("0");
cx.assert_editor_state(indoc! {"
Test Test
|
T"});
cx.simulate_keystroke("j");
cx.simulate_keystroke("shift-$");
cx.assert_editor_state(indoc! {"
Test Test
|T"});
cx.simulate_keystroke("0");
cx.assert_editor_state(indoc! {"
Test Test
|T"});
}
#[gpui::test]
async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
let initial_content = indoc! {"
The quick
brown fox jumps
over the lazy dog"};
let mut cx = VimTestContext::new(cx, true, initial_content).await;
cx.simulate_keystroke("shift-G");
cx.assert_editor_state(indoc! {"
The quick
brown fox jumps
over the lazy do|g"});
// Repeat the action doesn't move
cx.simulate_keystroke("shift-G");
cx.assert_editor_state(indoc! {"
The quick
brown fox jumps
over the lazy do|g"});
}
#[gpui::test]
async fn test_next_word_start(cx: &mut gpui::TestAppContext) {
let (initial_content, cursor_offsets) = marked_text(indoc! {"
The |quick|-|brown
|
|
|fox_jumps |over
|th||e"});
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
for cursor_offset in cursor_offsets {
cx.simulate_keystroke("w");
cx.assert_newest_selection_head_offset(cursor_offset);
}
// Reset and test ignoring punctuation
cx.simulate_keystrokes(&["g", "g"]);
let (_, cursor_offsets) = marked_text(indoc! {"
The |quick-brown
|
|
|fox_jumps |over
|th||e"});
for cursor_offset in cursor_offsets {
cx.simulate_keystroke("shift-W");
cx.assert_newest_selection_head_offset(cursor_offset);
}
}
#[gpui::test]
async fn test_next_word_end(cx: &mut gpui::TestAppContext) {
let (initial_content, cursor_offsets) = marked_text(indoc! {"
Th|e quic|k|-brow|n
fox_jump|s ove|r
th|e"});
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
for cursor_offset in cursor_offsets {
cx.simulate_keystroke("e");
cx.assert_newest_selection_head_offset(cursor_offset);
}
// Reset and test ignoring punctuation
cx.simulate_keystrokes(&["g", "g"]);
let (_, cursor_offsets) = marked_text(indoc! {"
Th|e quick-brow|n
fox_jump|s ove|r
th||e"});
for cursor_offset in cursor_offsets {
cx.simulate_keystroke("shift-E");
cx.assert_newest_selection_head_offset(cursor_offset);
}
}
#[gpui::test]
async fn test_previous_word_start(cx: &mut gpui::TestAppContext) {
let (initial_content, cursor_offsets) = marked_text(indoc! {"
||The |quick|-|brown
|
|
|fox_jumps |over
|the"});
let mut cx = VimTestContext::new(cx, true, &initial_content).await;
cx.simulate_keystroke("shift-G");
for cursor_offset in cursor_offsets.into_iter().rev() {
cx.simulate_keystroke("b");
cx.assert_newest_selection_head_offset(cursor_offset);
}
// Reset and test ignoring punctuation
cx.simulate_keystroke("shift-G");
let (_, cursor_offsets) = marked_text(indoc! {"
||The |quick-brown
|
|
|fox_jumps |over
|the"});
for cursor_offset in cursor_offsets.into_iter().rev() {
cx.simulate_keystroke("shift-B");
cx.assert_newest_selection_head_offset(cursor_offset);
}
}
}