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

@ -1281,6 +1281,24 @@ impl Editor {
}
}
pub fn replace_selections_with(
&mut self,
cx: &mut ViewContext<Self>,
find_replacement: impl Fn(&DisplaySnapshot) -> DisplayPoint,
) {
let display_map = self.snapshot(cx);
let cursor = find_replacement(&display_map);
let selection = Selection {
id: post_inc(&mut self.next_selection_id),
start: cursor,
end: cursor,
reversed: false,
goal: SelectionGoal::None,
}
.map(|display_point| display_point.to_point(&display_map));
self.update_selections(vec![selection], None, cx);
}
pub fn move_selections(
&mut self,
cx: &mut ViewContext<Self>,
@ -1291,21 +1309,9 @@ impl Editor {
.local_selections::<Point>(cx)
.into_iter()
.map(|selection| {
let mut selection = Selection {
id: selection.id,
start: selection.start.to_display_point(&display_map),
end: selection.end.to_display_point(&display_map),
reversed: selection.reversed,
goal: selection.goal,
};
let mut selection = selection.map(|point| point.to_display_point(&display_map));
move_selection(&display_map, &mut selection);
Selection {
id: selection.id,
start: selection.start.to_point(&display_map),
end: selection.end.to_point(&display_map),
reversed: selection.reversed,
goal: selection.goal,
}
selection.map(|display_point| display_point.to_point(&display_map))
})
.collect();
self.update_selections(selections, Some(Autoscroll::Fit), cx);

View file

@ -276,6 +276,16 @@ pub enum CharKind {
Word,
}
impl CharKind {
pub fn coerce_punctuation(self, treat_punctuation_as_word: bool) -> Self {
if treat_punctuation_as_word && self == CharKind::Punctuation {
CharKind::Word
} else {
self
}
}
}
impl Buffer {
pub fn new<T: Into<Arc<str>>>(
replica_id: ReplicaId,

View file

@ -21,7 +21,7 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont
let mode = if matches!(editor.read(cx).mode(), EditorMode::SingleLine) {
Mode::Insert
} else {
Mode::Normal
Mode::normal()
};
VimState::update_global(cx, |state, cx| {

View file

@ -25,6 +25,23 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
(map.clip_point(cursor, Bias::Left), SelectionGoal::None)
});
});
state.switch_mode(&SwitchMode(Mode::Normal), cx);
state.switch_mode(&SwitchMode(Mode::normal()), cx);
})
}
#[cfg(test)]
mod test {
use crate::{mode::Mode, vim_test_context::VimTestContext};
#[gpui::test]
async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true, "").await;
cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert);
cx.simulate_keystrokes(&["T", "e", "s", "t"]);
cx.assert_editor_state("Test|");
cx.simulate_keystroke("escape");
assert_eq!(cx.mode(), Mode::normal());
cx.assert_editor_state("Tes|t");
}
}

View file

@ -3,14 +3,14 @@ use gpui::keymap::Context;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
Normal,
Normal(NormalState),
Insert,
}
impl Mode {
pub fn cursor_shape(&self) -> CursorShape {
match self {
Mode::Normal => CursorShape::Block,
Mode::Normal(_) => CursorShape::Block,
Mode::Insert => CursorShape::Bar,
}
}
@ -20,17 +20,53 @@ impl Mode {
context.map.insert(
"vim_mode".to_string(),
match self {
Self::Normal => "normal",
Self::Normal(_) => "normal",
Self::Insert => "insert",
}
.to_string(),
);
match self {
Self::Normal(normal_state) => normal_state.set_context(&mut context),
_ => {}
}
context
}
pub fn normal() -> Mode {
Mode::Normal(Default::default())
}
}
impl Default for Mode {
fn default() -> Self {
Self::Normal
Self::Normal(Default::default())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NormalState {
None,
GPrefix,
}
impl NormalState {
pub fn set_context(&self, context: &mut Context) {
let sub_mode = match self {
Self::GPrefix => Some("g"),
_ => None,
};
if let Some(sub_mode) = sub_mode {
context
.map
.insert("vim_sub_mode".to_string(), sub_mode.to_string());
}
}
}
impl Default for NormalState {
fn default() -> Self {
NormalState::None
}
}

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);
}
}
}

View file

@ -0,0 +1,82 @@
use gpui::{action, keymap::Binding, MutableAppContext, ViewContext};
use workspace::Workspace;
use crate::{mode::Mode, SwitchMode, VimState};
action!(MoveToStart);
pub fn init(cx: &mut MutableAppContext) {
let context = Some("Editor && vim_mode == normal && vim_sub_mode == g");
cx.add_bindings(vec![
Binding::new("g", MoveToStart, context),
Binding::new("escape", SwitchMode(Mode::normal()), context),
]);
cx.add_action(move_to_start);
}
fn move_to_start(_: &mut Workspace, _: &MoveToStart, cx: &mut ViewContext<Workspace>) {
VimState::update_global(cx, |state, cx| {
state.update_active_editor(cx, |editor, cx| {
editor.move_to_beginning(&editor::MoveToBeginning, cx);
});
state.switch_mode(&SwitchMode(Mode::normal()), cx);
})
}
#[cfg(test)]
mod test {
use indoc::indoc;
use crate::{
mode::{Mode, NormalState},
vim_test_context::VimTestContext,
};
#[gpui::test]
async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true, "").await;
// Can abort with escape to get back to normal mode
cx.simulate_keystroke("g");
assert_eq!(cx.mode(), Mode::Normal(NormalState::GPrefix));
cx.simulate_keystroke("escape");
assert_eq!(cx.mode(), Mode::normal());
}
#[gpui::test]
async fn test_move_to_start(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;
// Jump to the end to
cx.simulate_keystroke("shift-G");
cx.assert_editor_state(indoc! {"
The quick
brown fox jumps
over the lazy do|g"});
// Jump to the start
cx.simulate_keystrokes(&["g", "g"]);
cx.assert_editor_state(indoc! {"
|The quick
brown fox jumps
over the lazy dog"});
assert_eq!(cx.mode(), Mode::normal());
// Repeat action doesn't change
cx.simulate_keystrokes(&["g", "g"]);
cx.assert_editor_state(indoc! {"
|The quick
brown fox jumps
over the lazy dog"});
assert_eq!(cx.mode(), Mode::normal());
}
}

View file

@ -3,7 +3,7 @@ mod insert;
mod mode;
mod normal;
#[cfg(test)]
mod vim_tests;
mod vim_test_context;
use collections::HashMap;
use editor::{CursorShape, Editor};
@ -65,8 +65,9 @@ impl VimState {
fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
if self.enabled != enabled {
self.enabled = enabled;
self.mode = Default::default();
if enabled {
self.mode = Mode::Normal;
self.mode = Mode::normal();
}
self.sync_editor_options(cx);
}
@ -95,3 +96,43 @@ impl VimState {
}
}
}
#[cfg(test)]
mod test {
use crate::{mode::Mode, vim_test_context::VimTestContext};
#[gpui::test]
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, false, "").await;
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
cx.assert_editor_state("hjkl|");
}
#[gpui::test]
async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true, "").await;
cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert);
// Editor acts as though vim is disabled
cx.disable_vim();
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
cx.assert_editor_state("hjkl|");
// Enabling dynamically sets vim mode again and restores normal mode
cx.enable_vim();
assert_eq!(cx.mode(), Mode::normal());
cx.simulate_keystrokes(&["h", "h", "h", "l"]);
assert_eq!(cx.editor_text(), "hjkl".to_owned());
cx.assert_editor_state("hj|kl");
cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
cx.assert_editor_state("hjTest|kl");
// Disabling and enabling resets to normal mode
assert_eq!(cx.mode(), Mode::Insert);
cx.disable_vim();
cx.enable_vim();
assert_eq!(cx.mode(), Mode::normal());
}
}

View file

@ -0,0 +1,179 @@
use std::ops::Deref;
use editor::{display_map::ToDisplayPoint, Bias, DisplayPoint};
use gpui::{json::json, keymap::Keystroke, ViewHandle};
use language::{Point, Selection};
use util::test::marked_text;
use workspace::{WorkspaceHandle, WorkspaceParams};
use crate::*;
pub struct VimTestContext<'a> {
cx: &'a mut gpui::TestAppContext,
window_id: usize,
editor: ViewHandle<Editor>,
}
impl<'a> VimTestContext<'a> {
pub async fn new(
cx: &'a mut gpui::TestAppContext,
enabled: bool,
initial_editor_text: &str,
) -> VimTestContext<'a> {
cx.update(|cx| {
editor::init(cx);
crate::init(cx);
});
let params = cx.update(WorkspaceParams::test);
cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = enabled;
});
});
params
.fs
.as_fake()
.insert_tree(
"/root",
json!({ "dir": { "test.txt": initial_editor_text } }),
)
.await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(cx, |project, cx| {
project.find_or_create_local_worktree("/root", true, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
let item = workspace
.update(cx, |workspace, cx| workspace.open_path(file, cx))
.await
.expect("Could not open test file");
let editor = cx.update(|cx| {
item.act_as::<Editor>(cx)
.expect("Opened test file wasn't an editor")
});
editor.update(cx, |_, cx| cx.focus_self());
Self {
cx,
window_id,
editor,
}
}
pub fn enable_vim(&mut self) {
self.cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = true;
});
})
}
pub fn disable_vim(&mut self) {
self.cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = false;
});
})
}
pub fn newest_selection(&mut self) -> Selection<DisplayPoint> {
self.editor.update(self.cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
editor
.newest_selection::<Point>(cx)
.map(|point| point.to_display_point(&snapshot.display_snapshot))
})
}
pub fn mode(&mut self) -> Mode {
self.cx.update(|cx| cx.global::<VimState>().mode)
}
pub fn editor_text(&mut self) -> String {
self.editor
.update(self.cx, |editor, cx| editor.snapshot(cx).text())
}
pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
let keystroke = Keystroke::parse(keystroke_text).unwrap();
let input = if keystroke.modified() {
None
} else {
Some(keystroke.key.clone())
};
self.cx
.dispatch_keystroke(self.window_id, keystroke, input, false);
}
pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
for keystroke_text in keystroke_texts.into_iter() {
self.simulate_keystroke(keystroke_text);
}
}
pub fn assert_newest_selection_head_offset(&mut self, expected_offset: usize) {
let actual_head = self.newest_selection().head();
let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
(
actual_head.to_offset(&snapshot, Bias::Left),
expected_offset.to_display_point(&snapshot),
)
});
let mut actual_position_text = self.editor_text();
let mut expected_position_text = actual_position_text.clone();
actual_position_text.insert(actual_offset, '|');
expected_position_text.insert(expected_offset, '|');
assert_eq!(
actual_head, expected_head,
"\nActual Position: {}\nExpected Position: {}",
actual_position_text, expected_position_text
)
}
pub fn assert_editor_state(&mut self, text: &str) {
let (unmarked_text, markers) = marked_text(&text);
let editor_text = self.editor_text();
assert_eq!(
editor_text, unmarked_text,
"Unmarked text doesn't match editor text"
);
let expected_offset = markers[0];
let actual_head = self.newest_selection().head();
let (actual_offset, expected_head) = self.editor.update(self.cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
(
actual_head.to_offset(&snapshot, Bias::Left),
expected_offset.to_display_point(&snapshot),
)
});
let mut actual_position_text = self.editor_text();
let mut expected_position_text = actual_position_text.clone();
actual_position_text.insert(actual_offset, '|');
expected_position_text.insert(expected_offset, '|');
assert_eq!(
actual_head, expected_head,
"\nActual Position: {}\nExpected Position: {}",
actual_position_text, expected_position_text
)
}
}
impl<'a> Deref for VimTestContext<'a> {
type Target = gpui::TestAppContext;
fn deref(&self) -> &Self::Target {
self.cx
}
}

View file

@ -1,253 +0,0 @@
use indoc::indoc;
use std::ops::Deref;
use editor::{display_map::ToDisplayPoint, DisplayPoint};
use gpui::{json::json, keymap::Keystroke, ViewHandle};
use language::{Point, Selection};
use util::test::marked_text;
use workspace::{WorkspaceHandle, WorkspaceParams};
use crate::*;
#[gpui::test]
async fn test_insert_mode(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, true, "").await;
cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert);
cx.simulate_keystrokes(&["T", "e", "s", "t"]);
cx.assert_newest_selection_head("Test|");
cx.simulate_keystroke("escape");
assert_eq!(cx.mode(), Mode::Normal);
cx.assert_newest_selection_head("Tes|t");
}
#[gpui::test]
async fn test_normal_hjkl(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, true, "Test\nTestTest\nTest").await;
cx.simulate_keystroke("l");
cx.assert_newest_selection_head(indoc! {"
T|est
TestTest
Test"});
cx.simulate_keystroke("h");
cx.assert_newest_selection_head(indoc! {"
|Test
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_newest_selection_head(indoc! {"
Test
|TestTest
Test"});
cx.simulate_keystroke("k");
cx.assert_newest_selection_head(indoc! {"
|Test
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_newest_selection_head(indoc! {"
Test
|TestTest
Test"});
// When moving left, cursor does not wrap to the previous line
cx.simulate_keystroke("h");
cx.assert_newest_selection_head(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_newest_selection_head(indoc! {"
Test
TestTes|t
Test"});
// Goal column respects the inability to reach the end of the line
cx.simulate_keystroke("k");
cx.assert_newest_selection_head(indoc! {"
Tes|t
TestTest
Test"});
cx.simulate_keystroke("j");
cx.assert_newest_selection_head(indoc! {"
Test
TestTes|t
Test"});
}
#[gpui::test]
async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, true, "").await;
cx.simulate_keystroke("i");
assert_eq!(cx.mode(), Mode::Insert);
// Editor acts as though vim is disabled
cx.disable_vim();
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
cx.assert_newest_selection_head("hjkl|");
// Enabling dynamically sets vim mode again and restores normal mode
cx.enable_vim();
assert_eq!(cx.mode(), Mode::Normal);
cx.simulate_keystrokes(&["h", "h", "h", "l"]);
assert_eq!(cx.editor_text(), "hjkl".to_owned());
cx.assert_newest_selection_head("hj|kl");
cx.simulate_keystrokes(&["i", "T", "e", "s", "t"]);
cx.assert_newest_selection_head("hjTest|kl");
// Disabling and enabling resets to normal mode
assert_eq!(cx.mode(), Mode::Insert);
cx.disable_vim();
assert_eq!(cx.mode(), Mode::Insert);
cx.enable_vim();
assert_eq!(cx.mode(), Mode::Normal);
}
#[gpui::test]
async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestAppContext::new(cx, false, "").await;
cx.simulate_keystrokes(&["h", "j", "k", "l"]);
cx.assert_newest_selection_head("hjkl|");
}
struct VimTestAppContext<'a> {
cx: &'a mut gpui::TestAppContext,
window_id: usize,
editor: ViewHandle<Editor>,
}
impl<'a> VimTestAppContext<'a> {
async fn new(
cx: &'a mut gpui::TestAppContext,
enabled: bool,
initial_editor_text: &str,
) -> VimTestAppContext<'a> {
cx.update(|cx| {
editor::init(cx);
crate::init(cx);
});
let params = cx.update(WorkspaceParams::test);
cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = enabled;
});
});
params
.fs
.as_fake()
.insert_tree(
"/root",
json!({ "dir": { "test.txt": initial_editor_text } }),
)
.await;
let (window_id, workspace) = cx.add_window(|cx| Workspace::new(&params, cx));
params
.project
.update(cx, |project, cx| {
project.find_or_create_local_worktree("/root", true, cx)
})
.await
.unwrap();
cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
.await;
let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
let item = workspace
.update(cx, |workspace, cx| workspace.open_path(file, cx))
.await
.expect("Could not open test file");
let editor = cx.update(|cx| {
item.act_as::<Editor>(cx)
.expect("Opened test file wasn't an editor")
});
editor.update(cx, |_, cx| cx.focus_self());
Self {
cx,
window_id,
editor,
}
}
fn enable_vim(&mut self) {
self.cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = true;
});
})
}
fn disable_vim(&mut self) {
self.cx.update(|cx| {
cx.update_global(|settings: &mut Settings, _| {
settings.vim_mode = false;
});
})
}
fn newest_selection(&mut self) -> Selection<DisplayPoint> {
self.editor.update(self.cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
editor
.newest_selection::<Point>(cx)
.map(|point| point.to_display_point(&snapshot.display_snapshot))
})
}
fn mode(&mut self) -> Mode {
self.cx.update(|cx| cx.global::<VimState>().mode)
}
fn editor_text(&mut self) -> String {
self.editor
.update(self.cx, |editor, cx| editor.snapshot(cx).text())
}
fn simulate_keystroke(&mut self, keystroke_text: &str) {
let keystroke = Keystroke::parse(keystroke_text).unwrap();
let input = if keystroke.modified() {
None
} else {
Some(keystroke.key.clone())
};
self.cx
.dispatch_keystroke(self.window_id, keystroke, input, false);
}
fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
for keystroke_text in keystroke_texts.into_iter() {
self.simulate_keystroke(keystroke_text);
}
}
fn assert_newest_selection_head(&mut self, text: &str) {
let (unmarked_text, markers) = marked_text(&text);
assert_eq!(
self.editor_text(),
unmarked_text,
"Unmarked text doesn't match editor text"
);
let newest_selection = self.newest_selection();
let expected_head = self.editor.update(self.cx, |editor, cx| {
markers[0].to_display_point(&editor.snapshot(cx))
});
assert_eq!(newest_selection.head(), expected_head)
}
}
impl<'a> Deref for VimTestAppContext<'a> {
type Target = gpui::TestAppContext;
fn deref(&self) -> &Self::Target {
self.cx
}
}