diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f9327ee251..0be43fee96 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1281,6 +1281,24 @@ impl Editor { } } + pub fn replace_selections_with( + &mut self, + cx: &mut ViewContext, + 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, @@ -1291,21 +1309,9 @@ impl Editor { .local_selections::(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); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 535798083e..8c20036487 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -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>>( replica_id: ReplicaId, diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 7e49b473f9..e4035197fd 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -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| { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index c027ff2c1f..9c1e36a90e 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -25,6 +25,23 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext 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 } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 232a76a030..5bf1a3c417 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -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) { @@ -64,3 +89,348 @@ fn move_right(_: &mut Workspace, _: &MoveRight, cx: &mut ViewContext) }); }); } + +fn move_to_start_of_line( + _: &mut Workspace, + _: &MoveToStartOfLine, + cx: &mut ViewContext, +) { + 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) { + 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) { + 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, +) { + 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, +) { + 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, +) { + 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); + } + } +} diff --git a/crates/vim/src/normal/g_prefix.rs b/crates/vim/src/normal/g_prefix.rs new file mode 100644 index 0000000000..82510c0cf9 --- /dev/null +++ b/crates/vim/src/normal/g_prefix.rs @@ -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) { + 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()); + } +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 26f7e24cf2..609843d969 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -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()); + } +} diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs new file mode 100644 index 0000000000..91acc8de6c --- /dev/null +++ b/crates/vim/src/vim_test_context.rs @@ -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, +} + +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(¶ms, 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::(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 { + self.editor.update(self.cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + editor + .newest_selection::(cx) + .map(|point| point.to_display_point(&snapshot.display_snapshot)) + }) + } + + pub fn mode(&mut self) -> Mode { + self.cx.update(|cx| cx.global::().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 + } +} diff --git a/crates/vim/src/vim_tests.rs b/crates/vim/src/vim_tests.rs deleted file mode 100644 index 051ff21ce7..0000000000 --- a/crates/vim/src/vim_tests.rs +++ /dev/null @@ -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, -} - -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(¶ms, 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::(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 { - self.editor.update(self.cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - editor - .newest_selection::(cx) - .map(|point| point.to_display_point(&snapshot.display_snapshot)) - }) - } - - fn mode(&mut self) -> Mode { - self.cx.update(|cx| cx.global::().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 - } -}