From 5ea782de21e28f5a3494c0fab906ee68c93e577e Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Thu, 21 Apr 2022 16:14:58 -0700 Subject: [PATCH] Add inclusive vs exclusive motions to vim mode --- assets/keymaps/vim.json | 28 +- crates/editor/src/display_map.rs | 13 +- crates/editor/src/editor.rs | 14 +- crates/editor/src/movement.rs | 62 +- crates/vim/src/insert.rs | 2 +- crates/vim/src/motion.rs | 144 +++-- crates/vim/src/normal.rs | 928 +++++++++-------------------- crates/vim/src/normal/change.rs | 436 ++++++++++++++ crates/vim/src/normal/delete.rs | 386 ++++++++++++ crates/vim/src/vim.rs | 10 +- crates/vim/src/vim_test_context.rs | 77 ++- 11 files changed, 1350 insertions(+), 750 deletions(-) create mode 100644 crates/vim/src/normal/change.rs create mode 100644 crates/vim/src/normal/delete.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index c0745afd99..0cf51568fe 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -75,37 +75,13 @@ { "context": "Editor && vim_operator == c", "bindings": { - "w": [ - "vim::NextWordEnd", - { - "ignorePunctuation": false - } - ], + "w": "vim::ChangeWord", "shift-W": [ - "vim::NextWordEnd", + "vim::ChangeWord", { "ignorePunctuation": true } ] } - }, - { - "context": "Editor && vim_operator == d", - "bindings": { - "w": [ - "vim::NextWordStart", - { - "ignorePunctuation": false, - "stopAtNewline": true - } - ], - "shift-W": [ - "vim::NextWordStart", - { - "ignorePunctuation": true, - "stopAtNewline": true - } - ] - } } ] \ No newline at end of file diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index baf97a9b28..d6e061ffc6 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -814,14 +814,20 @@ pub mod tests { DisplayPoint::new(0, 7) ); assert_eq!( - movement::up(&snapshot, DisplayPoint::new(1, 10), SelectionGoal::None), + movement::up( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::None, + false + ), (DisplayPoint::new(0, 7), SelectionGoal::Column(10)) ); assert_eq!( movement::down( &snapshot, DisplayPoint::new(0, 7), - SelectionGoal::Column(10) + SelectionGoal::Column(10), + false ), (DisplayPoint::new(1, 10), SelectionGoal::Column(10)) ); @@ -829,7 +835,8 @@ pub mod tests { movement::down( &snapshot, DisplayPoint::new(1, 10), - SelectionGoal::Column(10) + SelectionGoal::Column(10), + false ), (DisplayPoint::new(2, 4), SelectionGoal::Column(10)) ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 97be28ebd4..975c468616 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1134,8 +1134,10 @@ impl Editor { } pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext) { - self.display_map - .update(cx, |map, _| map.clip_at_line_ends = clip); + if self.display_map.read(cx).clip_at_line_ends != clip { + self.display_map + .update(cx, |map, _| map.clip_at_line_ends = clip); + } } pub fn set_keymap_context_layer(&mut self, context: gpui::keymap::Context) { @@ -3579,13 +3581,13 @@ impl Editor { if !selection.is_empty() { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::up(&map, selection.start, selection.goal); + let (cursor, goal) = movement::up(&map, selection.start, selection.goal, false); selection.collapse_to(cursor, goal); }); } pub fn select_up(&mut self, _: &SelectUp, cx: &mut ViewContext) { - self.move_selection_heads(cx, movement::up) + self.move_selection_heads(cx, |map, head, goal| movement::up(map, head, goal, false)) } pub fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext) { @@ -3606,13 +3608,13 @@ impl Editor { if !selection.is_empty() { selection.goal = SelectionGoal::None; } - let (cursor, goal) = movement::down(&map, selection.end, selection.goal); + let (cursor, goal) = movement::down(&map, selection.end, selection.goal, false); selection.collapse_to(cursor, goal); }); } pub fn select_down(&mut self, _: &SelectDown, cx: &mut ViewContext) { - self.move_selection_heads(cx, movement::down) + self.move_selection_heads(cx, |map, head, goal| movement::down(map, head, goal, false)) } pub fn move_to_previous_word_start( diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index d5f0f480fb..1f4486739b 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -28,6 +28,7 @@ pub fn up( map: &DisplaySnapshot, start: DisplayPoint, goal: SelectionGoal, + preserve_column_at_start: bool, ) -> (DisplayPoint, SelectionGoal) { let mut goal_column = if let SelectionGoal::Column(column) = goal { column @@ -42,6 +43,8 @@ pub fn up( ); if point.row() < start.row() { *point.column_mut() = map.column_from_chars(point.row(), goal_column); + } else if preserve_column_at_start { + return (start, goal); } else { point = DisplayPoint::new(0, 0); goal_column = 0; @@ -63,6 +66,7 @@ pub fn down( map: &DisplaySnapshot, start: DisplayPoint, goal: SelectionGoal, + preserve_column_at_end: bool, ) -> (DisplayPoint, SelectionGoal) { let mut goal_column = if let SelectionGoal::Column(column) = goal { column @@ -74,6 +78,8 @@ pub fn down( let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right); if point.row() > start.row() { *point.column_mut() = map.column_from_chars(point.row(), goal_column); + } else if preserve_column_at_end { + return (start, goal); } else { point = map.max_point(); goal_column = map.column_to_chars(point.row(), point.column()) @@ -503,41 +509,81 @@ mod tests { // Can't move up into the first excerpt's header assert_eq!( - up(&snapshot, DisplayPoint::new(2, 2), SelectionGoal::Column(2)), + up( + &snapshot, + DisplayPoint::new(2, 2), + SelectionGoal::Column(2), + false + ), (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), ); assert_eq!( - up(&snapshot, DisplayPoint::new(2, 0), SelectionGoal::None), + up( + &snapshot, + DisplayPoint::new(2, 0), + SelectionGoal::None, + false + ), (DisplayPoint::new(2, 0), SelectionGoal::Column(0)), ); // Move up and down within first excerpt assert_eq!( - up(&snapshot, DisplayPoint::new(3, 4), SelectionGoal::Column(4)), + up( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::Column(4), + false + ), (DisplayPoint::new(2, 3), SelectionGoal::Column(4)), ); assert_eq!( - down(&snapshot, DisplayPoint::new(2, 3), SelectionGoal::Column(4)), + down( + &snapshot, + DisplayPoint::new(2, 3), + SelectionGoal::Column(4), + false + ), (DisplayPoint::new(3, 4), SelectionGoal::Column(4)), ); // Move up and down across second excerpt's header assert_eq!( - up(&snapshot, DisplayPoint::new(6, 5), SelectionGoal::Column(5)), + up( + &snapshot, + DisplayPoint::new(6, 5), + SelectionGoal::Column(5), + false + ), (DisplayPoint::new(3, 4), SelectionGoal::Column(5)), ); assert_eq!( - down(&snapshot, DisplayPoint::new(3, 4), SelectionGoal::Column(5)), + down( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::Column(5), + false + ), (DisplayPoint::new(6, 5), SelectionGoal::Column(5)), ); // Can't move down off the end assert_eq!( - down(&snapshot, DisplayPoint::new(7, 0), SelectionGoal::Column(0)), + down( + &snapshot, + DisplayPoint::new(7, 0), + SelectionGoal::Column(0), + false + ), (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), ); assert_eq!( - down(&snapshot, DisplayPoint::new(7, 2), SelectionGoal::Column(2)), + down( + &snapshot, + DisplayPoint::new(7, 2), + SelectionGoal::Column(2), + false + ), (DisplayPoint::new(7, 2), SelectionGoal::Column(2)), ); } diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index c98c9db841..afaeda17b0 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -28,7 +28,7 @@ mod test { #[gpui::test] async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true, "").await; + 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"]); diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 530ee1d657..95286516ba 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -4,7 +4,7 @@ use editor::{ movement, Bias, DisplayPoint, }; use gpui::{actions, impl_actions, MutableAppContext}; -use language::SelectionGoal; +use language::{Selection, SelectionGoal}; use serde::Deserialize; use workspace::Workspace; @@ -14,22 +14,15 @@ use crate::{ Vim, }; -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug)] pub enum Motion { Left, Down, Up, Right, - NextWordStart { - ignore_punctuation: bool, - stop_at_newline: bool, - }, - NextWordEnd { - ignore_punctuation: bool, - }, - PreviousWordStart { - ignore_punctuation: bool, - }, + NextWordStart { ignore_punctuation: bool }, + NextWordEnd { ignore_punctuation: bool }, + PreviousWordStart { ignore_punctuation: bool }, StartOfLine, EndOfLine, StartOfDocument, @@ -41,8 +34,6 @@ pub enum Motion { struct NextWordStart { #[serde(default)] ignore_punctuation: bool, - #[serde(default)] - stop_at_newline: bool, } #[derive(Clone, Deserialize)] @@ -87,19 +78,8 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(|_: &mut Workspace, _: &EndOfDocument, cx: _| motion(Motion::EndOfDocument, cx)); cx.add_action( - |_: &mut Workspace, - &NextWordStart { - ignore_punctuation, - stop_at_newline, - }: &NextWordStart, - cx: _| { - motion( - Motion::NextWordStart { - ignore_punctuation, - stop_at_newline, - }, - cx, - ) + |_: &mut Workspace, &NextWordStart { ignore_punctuation }: &NextWordStart, cx: _| { + motion(Motion::NextWordStart { ignore_punctuation }, cx) }, ); cx.add_action( @@ -128,29 +108,48 @@ fn motion(motion: Motion, cx: &mut MutableAppContext) { } } +// Motion handling is specified here: +// https://github.com/vim/vim/blob/master/runtime/doc/motion.txt impl Motion { + pub fn linewise(self) -> bool { + use Motion::*; + match self { + Down | Up | StartOfDocument | EndOfDocument => true, + _ => false, + } + } + + pub fn inclusive(self) -> bool { + use Motion::*; + if self.linewise() { + return true; + } + + match self { + EndOfLine | NextWordEnd { .. } => true, + Left | Right | StartOfLine | NextWordStart { .. } | PreviousWordStart { .. } => false, + _ => panic!("Exclusivity not defined for {self:?}"), + } + } + pub fn move_point( self, map: &DisplaySnapshot, point: DisplayPoint, goal: SelectionGoal, - block_cursor_positioning: bool, ) -> (DisplayPoint, SelectionGoal) { use Motion::*; match self { Left => (left(map, point), SelectionGoal::None), - Down => movement::down(map, point, goal), - Up => movement::up(map, point, goal), + Down => movement::down(map, point, goal, true), + Up => movement::up(map, point, goal, true), Right => (right(map, point), SelectionGoal::None), - NextWordStart { - ignore_punctuation, - stop_at_newline, - } => ( - next_word_start(map, point, ignore_punctuation, stop_at_newline), + NextWordStart { ignore_punctuation } => ( + next_word_start(map, point, ignore_punctuation), SelectionGoal::None, ), NextWordEnd { ignore_punctuation } => ( - next_word_end(map, point, ignore_punctuation, block_cursor_positioning), + next_word_end(map, point, ignore_punctuation), SelectionGoal::None, ), PreviousWordStart { ignore_punctuation } => ( @@ -164,11 +163,55 @@ impl Motion { } } - pub fn line_wise(self) -> bool { - use Motion::*; - match self { - Down | Up | StartOfDocument | EndOfDocument => true, - _ => false, + // Expands a selection using self motion for an operator + pub fn expand_selection( + self, + map: &DisplaySnapshot, + selection: &mut Selection, + expand_to_surrounding_newline: bool, + ) { + let (head, goal) = self.move_point(map, selection.head(), selection.goal); + selection.set_head(head, goal); + + if self.linewise() { + selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; + + if expand_to_surrounding_newline { + if selection.end.row() < map.max_point().row() { + *selection.end.row_mut() += 1; + *selection.end.column_mut() = 0; + // Don't reset the end here + return; + } else if selection.start.row() > 0 { + *selection.start.row_mut() -= 1; + *selection.start.column_mut() = map.line_len(selection.start.row()); + } + } + + selection.end = map.next_line_boundary(selection.end.to_point(map)).1; + } else { + // If the motion is exclusive and the end of the motion is in column 1, the + // end of the motion is moved to the end of the previous line and the motion + // becomes inclusive. Example: "}" moves to the first line after a paragraph, + // but "d}" will not include that line. + let mut inclusive = self.inclusive(); + if !inclusive + && selection.end.row() > selection.start.row() + && selection.end.column() == 0 + && selection.end.row() > 0 + { + inclusive = true; + *selection.end.row_mut() -= 1; + *selection.end.column_mut() = 0; + selection.end = map.clip_point( + map.next_line_boundary(selection.end.to_point(map)).1, + Bias::Left, + ); + } + + if inclusive && selection.end.column() < map.line_len(selection.end.row()) { + *selection.end.column_mut() += 1; + } } } } @@ -187,7 +230,6 @@ fn next_word_start( map: &DisplaySnapshot, point: DisplayPoint, ignore_punctuation: bool, - stop_at_newline: bool, ) -> DisplayPoint { let mut crossed_newline = false; movement::find_boundary(map, point, |left, right| { @@ -196,8 +238,8 @@ fn next_word_start( let at_newline = right == '\n'; let found = (left_kind != right_kind && !right.is_whitespace()) - || (at_newline && (crossed_newline || stop_at_newline)) - || (at_newline && left == '\n'); // Prevents skipping repeated empty lines + || at_newline && crossed_newline + || at_newline && left == '\n'; // Prevents skipping repeated empty lines if at_newline { crossed_newline = true; @@ -210,7 +252,6 @@ fn next_word_end( map: &DisplaySnapshot, mut point: DisplayPoint, ignore_punctuation: bool, - before_end_character: bool, ) -> DisplayPoint { *point.column_mut() += 1; point = movement::find_boundary(map, point, |left, right| { @@ -221,13 +262,12 @@ fn next_word_end( }); // 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 before_end_character - && !map - .chars_at(point) - .skip(1) - .next() - .map(|c| c == '\n') - .unwrap_or(true) + if !map + .chars_at(point) + .skip(1) + .next() + .map(|c| c == '\n') + .unwrap_or(true) { *point.column_mut() = point.column().saturating_sub(1); } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index d0ca7ae870..dc919b651c 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1,11 +1,17 @@ -use crate::{ - motion::Motion, - state::{Mode, Operator}, - Vim, -}; -use editor::Bias; -use gpui::MutableAppContext; -use language::SelectionGoal; +mod change; +mod delete; + +use crate::{motion::Motion, state::Operator, Vim}; +use change::init as change_init; +use gpui::{actions, MutableAppContext}; + +use self::{change::change_over, delete::delete_over}; + +actions!(vim, [InsertLineAbove, InsertLineBelow, InsertAfter]); + +pub fn init(cx: &mut MutableAppContext) { + change_init(cx); +} pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| { @@ -23,82 +29,7 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { vim.update_active_editor(cx, |editor, cx| { - editor.move_cursors(cx, |map, cursor, goal| { - motion.move_point(map, cursor, goal, true) - }) - }); -} - -fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { - vim.update_active_editor(cx, |editor, cx| { - editor.transact(cx, |editor, cx| { - // Don't clip at line ends during change operation - editor.set_clip_at_line_ends(false, cx); - editor.move_selections(cx, |map, selection| { - let (head, goal) = motion.move_point(map, selection.head(), selection.goal, false); - selection.set_head(head, goal); - - if motion.line_wise() { - selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; - selection.end = map.next_line_boundary(selection.end.to_point(map)).1; - } - }); - editor.set_clip_at_line_ends(true, cx); - editor.insert(&"", cx); - }); - }); - vim.switch_mode(Mode::Insert, cx) -} - -fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { - vim.update_active_editor(cx, |editor, cx| { - editor.transact(cx, |editor, cx| { - // Use goal column to preserve previous position - editor.set_clip_at_line_ends(false, cx); - editor.move_selections(cx, |map, selection| { - let original_head = selection.head(); - let (head, _) = motion.move_point(map, selection.head(), selection.goal, false); - // Set the goal column to the original position in order to fix it up - // after the deletion - selection.set_head(head, SelectionGoal::Column(original_head.column())); - - if motion.line_wise() { - if selection.end.row() == map.max_point().row() { - // Delete previous line break since we are at the end of the document - if selection.start.row() > 0 { - *selection.start.row_mut() = selection.start.row().saturating_sub(1); - selection.start = map.clip_point(selection.start, Bias::Left); - selection.start = - map.next_line_boundary(selection.start.to_point(map)).1; - } else { - // Selection covers the whole document. Just delete to the start of the - // line. - selection.start = - map.prev_line_boundary(selection.start.to_point(map)).1; - } - selection.end = map.next_line_boundary(selection.end.to_point(map)).1; - } else { - // Delete next line break so that we leave the previous line alone - selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; - *selection.end.column_mut() = 0; - *selection.end.row_mut() += 1; - selection.end = map.clip_point(selection.end, Bias::Left); - } - } - }); - editor.insert(&"", cx); - - // Fixup cursor position after the deletion - editor.set_clip_at_line_ends(true, cx); - editor.move_cursors(cx, |map, mut cursor, goal| { - if motion.line_wise() { - if let SelectionGoal::Column(column) = goal { - *cursor.column_mut() = column - } - } - (map.clip_point(cursor, Bias::Left), SelectionGoal::None) - }); - }); + editor.move_cursors(cx, |map, cursor, goal| motion.move_point(map, cursor, goal)) }); } @@ -116,144 +47,218 @@ mod test { }; #[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"}); + async fn test_h(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["h"]); + cx.assert("The q|uick", "The |quick"); + cx.assert("|The quick", "|The quick"); + cx.assert( + indoc! {" + The quick + |brown"}, + indoc! {" + The quick + |brown"}, + ); + } - // When moving left, cursor does not wrap to the previous line - cx.simulate_keystroke("h"); - cx.assert_editor_state(indoc! {" - Test - |TestTest - Test"}); + #[gpui::test] + async fn test_l(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["l"]); + cx.assert("The q|uick", "The qu|ick"); + cx.assert("The quic|k", "The quic|k"); + cx.assert( + indoc! {" + The quic|k + brown"}, + indoc! {" + The quic|k + brown"}, + ); + } - // 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"}); + #[gpui::test] + async fn test_j(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["j"]); + cx.assert( + indoc! {" + The |quick + brown fox"}, + indoc! {" + The quick + brow|n fox"}, + ); + cx.assert( + indoc! {" + The quick + brow|n fox"}, + indoc! {" + The quick + brow|n fox"}, + ); + cx.assert( + indoc! {" + The quic|k + brown"}, + indoc! {" + The quick + brow|n"}, + ); + cx.assert( + indoc! {" + The quick + |brown"}, + indoc! {" + The quick + |brown"}, + ); + } - // 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_k(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["k"]); + cx.assert( + indoc! {" + The |quick + brown fox"}, + indoc! {" + The |quick + brown fox"}, + ); + cx.assert( + indoc! {" + The quick + brow|n fox"}, + indoc! {" + The |quick + brown fox"}, + ); + cx.assert( + indoc! {" + The + quic|k"}, + indoc! {" + Th|e + quick"}, + ); } #[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; + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["shift-$"]); + cx.assert("T|est test", "Test tes|t"); + cx.assert("Test tes|t", "Test tes|t"); + cx.assert( + indoc! {" + The |quick + brown"}, + indoc! {" + The quic|k + brown"}, + ); + cx.assert( + indoc! {" + The quic|k + brown"}, + indoc! {" + The quic|k + brown"}, + ); - 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"}); + let mut cx = cx.binding(["0"]); + cx.assert("Test |test", "|Test test"); + cx.assert("|Test test", "|Test test"); + cx.assert( + indoc! {" + The |quick + brown"}, + indoc! {" + |The quick + brown"}, + ); + cx.assert( + indoc! {" + |The quick + brown"}, + indoc! {" + |The quick + brown"}, + ); } #[gpui::test] async fn test_jump_to_end(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true, "").await; + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["shift-G"]); - cx.set_state( + cx.assert( indoc! {" - The |quick - - brown fox jumps - over the lazy dog"}, - Mode::Normal, + The |quick + + brown fox jumps + over the lazy dog"}, + indoc! {" + The quick + + brown fox jumps + over| the lazy dog"}, ); - cx.simulate_keystroke("shift-G"); - cx.assert_editor_state(indoc! {" + cx.assert( + indoc! {" + The quick + + brown fox jumps + over| the lazy dog"}, + indoc! {" + The quick + + brown fox jumps + over| the lazy dog"}, + ); + cx.assert( + indoc! {" + The qui|ck + + brown"}, + indoc! {" The quick - brown fox jumps - over| the lazy dog"}); - - // Repeat the action doesn't move - cx.simulate_keystroke("shift-G"); - cx.assert_editor_state(indoc! {" + brow|n"}, + ); + cx.assert( + indoc! {" + The qui|ck + + "}, + indoc! {" The quick - brown fox jumps - over| the lazy dog"}); + |"}, + ); } #[gpui::test] async fn test_next_word_start(cx: &mut gpui::TestAppContext) { - let (initial_content, cursor_offsets) = marked_text(indoc! {" + let mut cx = VimTestContext::new(cx, true).await; + let (_, cursor_offsets) = marked_text(indoc! {" The |quick|-|brown | | |fox_jumps |over |th||e"}); - let mut cx = VimTestContext::new(cx, true, &initial_content).await; + cx.set_state( + indoc! {" + |The quick-brown + + + fox_jumps over + the"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets { cx.simulate_keystroke("w"); @@ -261,13 +266,21 @@ mod test { } // Reset and test ignoring punctuation - cx.simulate_keystrokes(["g", "g", "0"]); let (_, cursor_offsets) = marked_text(indoc! {" The |quick-brown | | |fox_jumps |over |th||e"}); + cx.set_state( + indoc! {" + |The quick-brown + + + fox_jumps over + the"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets { cx.simulate_keystroke("shift-W"); @@ -277,13 +290,22 @@ mod test { #[gpui::test] async fn test_next_word_end(cx: &mut gpui::TestAppContext) { - let (initial_content, cursor_offsets) = marked_text(indoc! {" + let mut cx = VimTestContext::new(cx, true).await; + let (_, 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; + cx.set_state( + indoc! {" + |The quick-brown + + + fox_jumps over + the"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets { cx.simulate_keystroke("e"); @@ -291,13 +313,21 @@ mod test { } // Reset and test ignoring punctuation - cx.simulate_keystrokes(["g", "g", "0"]); let (_, cursor_offsets) = marked_text(indoc! {" Th|e quick-brow|n fox_jump|s ove|r th||e"}); + cx.set_state( + indoc! {" + |The quick-brown + + + fox_jumps over + the"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets { cx.simulate_keystroke("shift-E"); cx.assert_newest_selection_head_offset(cursor_offset); @@ -306,14 +336,22 @@ mod test { #[gpui::test] async fn test_previous_word_start(cx: &mut gpui::TestAppContext) { - let (initial_content, cursor_offsets) = marked_text(indoc! {" + let mut cx = VimTestContext::new(cx, true).await; + let (_, cursor_offsets) = marked_text(indoc! {" ||The |quick|-|brown | | |fox_jumps |over |the"}); - let mut cx = VimTestContext::new(cx, true, &initial_content).await; - cx.simulate_keystrokes(["shift-G", "shift-$"]); + cx.set_state( + indoc! {" + The quick-brown + + + fox_jumps over + th|e"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets.into_iter().rev() { cx.simulate_keystroke("b"); @@ -321,13 +359,21 @@ mod test { } // Reset and test ignoring punctuation - cx.simulate_keystrokes(["shift-G", "shift-$"]); let (_, cursor_offsets) = marked_text(indoc! {" ||The |quick-brown | | |fox_jumps |over |the"}); + cx.set_state( + indoc! {" + The quick-brown + + + fox_jumps over + th|e"}, + Mode::Normal, + ); for cursor_offset in cursor_offsets.into_iter().rev() { cx.simulate_keystroke("shift-B"); cx.assert_newest_selection_head_offset(cursor_offset); @@ -336,7 +382,7 @@ mod test { #[gpui::test] async fn test_g_prefix_and_abort(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true, "").await; + let mut cx = VimTestContext::new(cx, true).await; // Can abort with escape to get back to normal mode cx.simulate_keystroke("g"); @@ -352,455 +398,55 @@ mod test { #[gpui::test] async fn test_move_to_start(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, true, "").await; - - cx.set_state( + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["g", "g"]); + cx.assert( indoc! {" - The q|uick + The quick - brown fox jumps - over the lazy dog"}, - Mode::Normal, - ); - - // Jump to the end to - cx.simulate_keystroke("shift-G"); - cx.assert_editor_state(indoc! {" - The quick + brown fox jumps + over |the lazy dog"}, + indoc! {" + The q|uick - brown fox jumps - over |the lazy dog"}); - - // Jump to the start - cx.simulate_keystrokes(["g", "g"]); - cx.assert_editor_state(indoc! {" - The q|uick + brown fox jumps + over the lazy dog"}, + ); + cx.assert( + indoc! {" + The q|uick - brown fox jumps - over the lazy dog"}); - assert_eq!(cx.mode(), Normal); - assert_eq!(cx.active_operator(), None); - - // Repeat action doesn't change - cx.simulate_keystrokes(["g", "g"]); - cx.assert_editor_state(indoc! {" - The q|uick + brown fox jumps + over the lazy dog"}, + indoc! {" + The q|uick - brown fox jumps - over the lazy dog"}); - assert_eq!(cx.mode(), Normal); - assert_eq!(cx.active_operator(), None); - } - - #[gpui::test] - async fn test_change(cx: &mut gpui::TestAppContext) { - fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { - cx.assert_binding( - ["c", motion], - initial_state, - Mode::Normal, - state_after, - Mode::Insert, - ); - } - let cx = &mut VimTestContext::new(cx, true, "").await; - assert("h", "Te|st", "T|st", cx); - assert("l", "Te|st", "Te|t", cx); - assert("w", "|Test", "|", cx); - assert("w", "Te|st", "Te|", cx); - assert("w", "Te|st Test", "Te| Test", cx); - assert("e", "Te|st Test", "Te| Test", cx); - assert("b", "Te|st", "|st", cx); - assert("b", "Test Te|st", "Test |st", cx); - assert( - "w", - indoc! {" - The quick - brown |fox - jumps over"}, - indoc! {" - The quick - brown | - jumps over"}, - cx, + brown fox jumps + over the lazy dog"}, ); - assert( - "shift-W", + cx.assert( indoc! {" - The quick - brown |fox-fox - jumps over"}, + The quick + + brown fox jumps + over the la|zy dog"}, indoc! {" - The quick - brown | - jumps over"}, - cx, + The quic|k + + brown fox jumps + over the lazy dog"}, ); - assert( - "k", + cx.assert( indoc! {" - The quick - brown |fox"}, + + + brown fox jumps + over the la|zy dog"}, indoc! {" - |"}, - cx, - ); - assert( - "j", - indoc! {" - The q|uick - brown fox"}, - indoc! {" - |"}, - cx, - ); - assert( - "shift-$", - indoc! {" - The q|uick - brown fox"}, - indoc! {" - The q| - brown fox"}, - cx, - ); - assert( - "0", - indoc! {" - The q|uick - brown fox"}, - indoc! {" - |uick - brown fox"}, - cx, - ); - } - - #[gpui::test] - async fn test_delete(cx: &mut gpui::TestAppContext) { - fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { - cx.assert_binding( - ["d", motion], - initial_state, - Mode::Normal, - state_after, - Mode::Normal, - ); - } - let cx = &mut VimTestContext::new(cx, true, "").await; - assert("h", "Te|st", "T|st", cx); - assert("l", "Te|st", "Te|t", cx); - assert("w", "|Test", "|", cx); - assert("w", "Te|st", "T|e", cx); - assert("w", "Te|st Test", "Te|Test", cx); - assert("e", "Te|st Test", "Te| Test", cx); - assert("b", "Te|st", "|st", cx); - assert("b", "Test Te|st", "Test |st", cx); - assert( - "w", - indoc! {" - The quick - brown |fox - jumps over"}, - // Trailing space after cursor - indoc! {" - The quick - brown| - jumps over"}, - cx, - ); - assert( - "shift-W", - indoc! {" - The quick - brown |fox-fox - jumps over"}, - // Trailing space after cursor - indoc! {" - The quick - brown| - jumps over"}, - cx, - ); - assert( - "shift-$", - indoc! {" - The q|uick - brown fox"}, - indoc! {" - The |q - brown fox"}, - cx, - ); - assert( - "0", - indoc! {" - The q|uick - brown fox"}, - indoc! {" - |uick - brown fox"}, - cx, - ); - } - - #[gpui::test] - async fn test_linewise_delete(cx: &mut gpui::TestAppContext) { - fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { - cx.assert_binding( - ["d", motion], - initial_state, - Mode::Normal, - state_after, - Mode::Normal, - ); - } - let cx = &mut VimTestContext::new(cx, true, "").await; - assert( - "k", - indoc! {" - The quick - brown |fox - jumps over"}, - indoc! {" - jumps |over"}, - cx, - ); - assert( - "k", - indoc! {" - The quick - brown fox - jumps |over"}, - indoc! {" - The qu|ick"}, - cx, - ); - assert( - "j", - indoc! {" - The q|uick - brown fox - jumps over"}, - indoc! {" - jumps| over"}, - cx, - ); - assert( - "j", - indoc! {" - The quick - brown| fox - jumps over"}, - indoc! {" - The q|uick"}, - cx, - ); - assert( - "j", - indoc! {" - The quick - brown| fox - jumps over"}, - indoc! {" - The q|uick"}, - cx, - ); - cx.assert_binding( - ["d", "g", "g"], - indoc! {" - The quick - brown| fox - jumps over - the lazy"}, - Mode::Normal, - indoc! {" - jumps| over - the lazy"}, - Mode::Normal, - ); - cx.assert_binding( - ["d", "g", "g"], - indoc! {" - The quick - brown fox - jumps over - the l|azy"}, - Mode::Normal, - "|", - Mode::Normal, - ); - assert( - "shift-G", - indoc! {" - The quick - brown| fox - jumps over - the lazy"}, - indoc! {" - The q|uick"}, - cx, - ); - cx.assert_binding( - ["d", "g", "g"], - indoc! {" - The q|uick - brown fox - jumps over - the lazy"}, - Mode::Normal, - indoc! {" - brown| fox - jumps over - the lazy"}, - Mode::Normal, - ); - } - - #[gpui::test] - async fn test_linewise_change(cx: &mut gpui::TestAppContext) { - fn assert(motion: &str, initial_state: &str, state_after: &str, cx: &mut VimTestContext) { - cx.assert_binding( - ["c", motion], - initial_state, - Mode::Normal, - state_after, - Mode::Insert, - ); - } - let cx = &mut VimTestContext::new(cx, true, "").await; - assert( - "k", - indoc! {" - The quick - brown |fox - jumps over"}, - indoc! {" - | - jumps over"}, - cx, - ); - assert( - "k", - indoc! {" - The quick - brown fox - jumps |over"}, - indoc! {" - The quick - |"}, - cx, - ); - assert( - "j", - indoc! {" - The q|uick - brown fox - jumps over"}, - indoc! {" - | - jumps over"}, - cx, - ); - assert( - "j", - indoc! {" - The quick - brown| fox - jumps over"}, - indoc! {" - The quick - |"}, - cx, - ); - assert( - "j", - indoc! {" - The quick - brown| fox - jumps over"}, - indoc! {" - The quick - |"}, - cx, - ); - assert( - "shift-G", - indoc! {" - The quick - brown| fox - jumps over - the lazy"}, - indoc! {" - The quick - |"}, - cx, - ); - assert( - "shift-G", - indoc! {" - The quick - brown| fox - jumps over - the lazy"}, - indoc! {" - The quick - |"}, - cx, - ); - assert( - "shift-G", - indoc! {" - The quick - brown fox - jumps over - the l|azy"}, - indoc! {" - The quick - brown fox - jumps over - |"}, - cx, - ); - cx.assert_binding( - ["c", "g", "g"], - indoc! {" - The quick - brown| fox - jumps over - the lazy"}, - Mode::Normal, - indoc! {" - | - jumps over - the lazy"}, - Mode::Insert, - ); - cx.assert_binding( - ["c", "g", "g"], - indoc! {" - The quick - brown fox - jumps over - the l|azy"}, - Mode::Normal, - "|", - Mode::Insert, - ); - cx.assert_binding( - ["c", "g", "g"], - indoc! {" - The q|uick - brown fox - jumps over - the lazy"}, - Mode::Normal, - indoc! {" - | - brown fox - jumps over - the lazy"}, - Mode::Insert, + | + + brown fox jumps + over the lazy dog"}, ); } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs new file mode 100644 index 0000000000..daca2174e5 --- /dev/null +++ b/crates/vim/src/normal/change.rs @@ -0,0 +1,436 @@ +use crate::{motion::Motion, state::Mode, Vim}; +use editor::{char_kind, movement}; +use gpui::{impl_actions, MutableAppContext, ViewContext}; +use serde::Deserialize; +use workspace::Workspace; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ChangeWord { + #[serde(default)] + ignore_punctuation: bool, +} + +impl_actions!(vim, [ChangeWord]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(change_word); +} + +pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + // We are swapping to insert mode anyway. Just set the line end clipping behavior now + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| { + motion.expand_selection(map, selection, false); + }); + editor.insert(&"", cx); + }); + }); + vim.switch_mode(Mode::Insert, cx) +} + +// From the docs https://vimhelp.org/change.txt.html#cw +// Special case: When the cursor is in a word, "cw" and "cW" do not include the +// white space after a word, they only change up to the end of the word. This is +// because Vim interprets "cw" as change-word, and a word does not include the +// following white space. +fn change_word( + _: &mut Workspace, + &ChangeWord { ignore_punctuation }: &ChangeWord, + cx: &mut ViewContext, +) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + // We are swapping to insert mode anyway. Just set the line end clipping behavior now + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| { + if selection.end.column() == map.line_len(selection.end.row()) { + return; + } + + selection.end = movement::find_boundary(map, selection.end, |left, right| { + let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation); + let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation); + + left_kind != right_kind || left == '\n' || right == '\n' + }); + }); + editor.insert(&"", cx); + }); + }); + vim.switch_mode(Mode::Insert, cx); + }); +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, vim_test_context::VimTestContext}; + + #[gpui::test] + async fn test_change_h(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "h"]).mode_after(Mode::Insert); + cx.assert("Te|st", "T|st"); + cx.assert("T|est", "|est"); + cx.assert("|Test", "|Test"); + cx.assert( + indoc! {" + Test + |test"}, + indoc! {" + Test + |test"}, + ); + } + + #[gpui::test] + async fn test_change_l(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "l"]).mode_after(Mode::Insert); + cx.assert("Te|st", "Te|t"); + cx.assert("Tes|t", "Tes|"); + } + + #[gpui::test] + async fn test_change_w(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "w"]).mode_after(Mode::Insert); + cx.assert("Te|st", "Te|"); + cx.assert("T|est test", "T| test"); + cx.assert("Test| test", "Test|test"); + cx.assert( + indoc! {" + Test te|st + test"}, + indoc! {" + Test te| + test"}, + ); + cx.assert( + indoc! {" + Test tes|t + test"}, + indoc! {" + Test tes| + test"}, + ); + cx.assert( + indoc! {" + Test test + | + test"}, + indoc! {" + Test test + | + test"}, + ); + + let mut cx = cx.binding(["c", "shift-W"]); + cx.assert("Test te|st-test test", "Test te| test"); + } + + #[gpui::test] + async fn test_change_e(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "e"]).mode_after(Mode::Insert); + cx.assert("Te|st Test", "Te| Test"); + cx.assert("T|est test", "T| test"); + cx.assert( + indoc! {" + Test te|st + test"}, + indoc! {" + Test te| + test"}, + ); + cx.assert( + indoc! {" + Test tes|t + test"}, + "Test tes|", + ); + cx.assert( + indoc! {" + Test test + | + test"}, + indoc! {" + Test test + | + test"}, + ); + + let mut cx = cx.binding(["c", "shift-E"]); + cx.assert("Test te|st-test test", "Test te| test"); + } + + #[gpui::test] + async fn test_change_b(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "b"]).mode_after(Mode::Insert); + cx.assert("Te|st Test", "|st Test"); + cx.assert("Test |test", "|test"); + cx.assert("Test1 test2 |test3", "Test1 |test3"); + cx.assert( + indoc! {" + Test test + |test"}, + indoc! {" + Test | + test"}, + ); + cx.assert( + indoc! {" + Test test + | + test"}, + indoc! {" + Test | + + test"}, + ); + + let mut cx = cx.binding(["c", "shift-B"]); + cx.assert("Test test-test |test", "Test |test"); + } + + #[gpui::test] + async fn test_change_end_of_line(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "shift-$"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The q|uick + brown fox"}, + indoc! {" + The q| + brown fox"}, + ); + cx.assert( + indoc! {" + The quick + | + brown fox"}, + indoc! {" + The quick + | + brown fox"}, + ); + } + + #[gpui::test] + async fn test_change_0(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "0"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The q|uick + brown fox"}, + indoc! {" + |uick + brown fox"}, + ); + cx.assert( + indoc! {" + The quick + | + brown fox"}, + indoc! {" + The quick + | + brown fox"}, + ); + } + + #[gpui::test] + async fn test_change_k(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "k"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The quick + brown |fox + jumps over"}, + indoc! {" + | + jumps over"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps |over"}, + indoc! {" + The quick + |"}, + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over"}, + indoc! {" + | + brown fox + jumps over"}, + ); + cx.assert( + indoc! {" + | + brown fox + jumps over"}, + indoc! {" + | + brown fox + jumps over"}, + ); + } + + #[gpui::test] + async fn test_change_j(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "j"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The quick + brown |fox + jumps over"}, + indoc! {" + The quick + |"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps |over"}, + indoc! {" + The quick + brown fox + |"}, + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over"}, + indoc! {" + | + jumps over"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + |"}, + indoc! {" + The quick + brown fox + |"}, + ); + } + + #[gpui::test] + async fn test_change_end_of_document(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "shift-G"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + indoc! {" + The quick + |"}, + ); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + indoc! {" + The quick + |"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + the l|azy"}, + indoc! {" + The quick + brown fox + jumps over + |"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + |"}, + indoc! {" + The quick + brown fox + jumps over + |"}, + ); + } + + #[gpui::test] + async fn test_change_gg(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["c", "g", "g"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + indoc! {" + | + jumps over + the lazy"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + the l|azy"}, + "|", + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over + the lazy"}, + indoc! {" + | + brown fox + jumps over + the lazy"}, + ); + cx.assert( + indoc! {" + | + brown fox + jumps over + the lazy"}, + indoc! {" + | + brown fox + jumps over + the lazy"}, + ); + } +} diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs new file mode 100644 index 0000000000..8516b5ee94 --- /dev/null +++ b/crates/vim/src/normal/delete.rs @@ -0,0 +1,386 @@ +use crate::{motion::Motion, Vim}; +use editor::Bias; +use gpui::MutableAppContext; +use language::SelectionGoal; + +pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + editor.move_selections(cx, |map, selection| { + let original_head = selection.head(); + motion.expand_selection(map, selection, true); + selection.goal = SelectionGoal::Column(original_head.column()); + }); + editor.insert(&"", cx); + + // Fixup cursor position after the deletion + editor.set_clip_at_line_ends(true, cx); + editor.move_cursors(cx, |map, mut cursor, goal| { + if motion.linewise() { + if let SelectionGoal::Column(column) = goal { + *cursor.column_mut() = column + } + } + + (map.clip_point(cursor, Bias::Left), SelectionGoal::None) + }); + }); + }); +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::vim_test_context::VimTestContext; + + #[gpui::test] + async fn test_delete_h(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "h"]); + cx.assert("Te|st", "T|st"); + cx.assert("T|est", "|est"); + cx.assert("|Test", "|Test"); + cx.assert( + indoc! {" + Test + |test"}, + indoc! {" + Test + |test"}, + ); + } + + #[gpui::test] + async fn test_delete_l(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "l"]); + cx.assert("|Test", "|est"); + cx.assert("Te|st", "Te|t"); + cx.assert("Tes|t", "Te|s"); + cx.assert( + indoc! {" + Tes|t + test"}, + indoc! {" + Te|s + test"}, + ); + } + + #[gpui::test] + async fn test_delete_w(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "w"]); + cx.assert("Te|st", "T|e"); + cx.assert("T|est test", "T|test"); + cx.assert( + indoc! {" + Test te|st + test"}, + indoc! {" + Test t|e + test"}, + ); + cx.assert( + indoc! {" + Test tes|t + test"}, + indoc! {" + Test te|s + test"}, + ); + cx.assert( + indoc! {" + Test test + | + test"}, + indoc! {" + Test test + | + test"}, + ); + + let mut cx = cx.binding(["d", "shift-W"]); + cx.assert("Test te|st-test test", "Test te|test"); + } + + #[gpui::test] + async fn test_delete_e(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "e"]); + cx.assert("Te|st Test", "Te| Test"); + cx.assert("T|est test", "T| test"); + cx.assert( + indoc! {" + Test te|st + test"}, + indoc! {" + Test t|e + test"}, + ); + cx.assert( + indoc! {" + Test tes|t + test"}, + "Test te|s", + ); + cx.assert( + indoc! {" + Test test + | + test"}, + indoc! {" + Test test + | + test"}, + ); + + let mut cx = cx.binding(["d", "shift-E"]); + cx.assert("Test te|st-test test", "Test te| test"); + } + + #[gpui::test] + async fn test_delete_b(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "b"]); + cx.assert("Te|st Test", "|st Test"); + cx.assert("Test |test", "|test"); + cx.assert("Test1 test2 |test3", "Test1 |test3"); + cx.assert( + indoc! {" + Test test + |test"}, + // Trailing whitespace after cursor + indoc! {" + Test| + test"}, + ); + cx.assert( + indoc! {" + Test test + | + test"}, + // Trailing whitespace after cursor + indoc! {" + Test| + + test"}, + ); + + let mut cx = cx.binding(["d", "shift-B"]); + cx.assert("Test test-test |test", "Test |test"); + } + + #[gpui::test] + async fn test_delete_end_of_line(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "shift-$"]); + cx.assert( + indoc! {" + The q|uick + brown fox"}, + indoc! {" + The |q + brown fox"}, + ); + cx.assert( + indoc! {" + The quick + | + brown fox"}, + indoc! {" + The quick + | + brown fox"}, + ); + } + + #[gpui::test] + async fn test_delete_0(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "0"]); + cx.assert( + indoc! {" + The q|uick + brown fox"}, + indoc! {" + |uick + brown fox"}, + ); + cx.assert( + indoc! {" + The quick + | + brown fox"}, + indoc! {" + The quick + | + brown fox"}, + ); + } + + #[gpui::test] + async fn test_delete_k(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "k"]); + cx.assert( + indoc! {" + The quick + brown |fox + jumps over"}, + "jumps |over", + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps |over"}, + "The qu|ick", + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over"}, + indoc! {" + brown| fox + jumps over"}, + ); + cx.assert( + indoc! {" + |brown fox + jumps over"}, + "|jumps over", + ); + } + + #[gpui::test] + async fn test_delete_j(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "j"]); + cx.assert( + indoc! {" + The quick + brown |fox + jumps over"}, + "The qu|ick", + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps |over"}, + indoc! {" + The quick + brown |fox"}, + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over"}, + "jumps| over", + ); + cx.assert( + indoc! {" + The quick + brown fox + |"}, + indoc! {" + The quick + |brown fox"}, + ); + } + + #[gpui::test] + async fn test_delete_end_of_document(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "shift-G"]); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + "The q|uick", + ); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + "The q|uick", + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + the l|azy"}, + indoc! {" + The quick + brown fox + jumps| over"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + |"}, + indoc! {" + The quick + brown fox + |jumps over"}, + ); + } + + #[gpui::test] + async fn test_delete_gg(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["d", "g", "g"]); + cx.assert( + indoc! {" + The quick + brown| fox + jumps over + the lazy"}, + indoc! {" + jumps| over + the lazy"}, + ); + cx.assert( + indoc! {" + The quick + brown fox + jumps over + the l|azy"}, + "|", + ); + cx.assert( + indoc! {" + The q|uick + brown fox + jumps over + the lazy"}, + indoc! {" + brown| fox + jumps over + the lazy"}, + ); + cx.assert( + indoc! {" + | + brown fox + jumps over + the lazy"}, + indoc! {" + |brown fox + jumps over + the lazy"}, + ); + } +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 65acce7a42..527cfa318c 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1,10 +1,11 @@ +#[cfg(test)] +mod vim_test_context; + mod editor_events; mod insert; mod motion; mod normal; mod state; -#[cfg(test)] -mod vim_test_context; use collections::HashMap; use editor::{CursorShape, Editor}; @@ -25,6 +26,7 @@ impl_actions!(vim, [SwitchMode, PushOperator]); pub fn init(cx: &mut MutableAppContext) { editor_events::init(cx); + normal::init(cx); insert::init(cx); motion::init(cx); @@ -142,14 +144,14 @@ mod test { #[gpui::test] async fn test_initially_disabled(cx: &mut gpui::TestAppContext) { - let mut cx = VimTestContext::new(cx, false, "").await; + 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; + let mut cx = VimTestContext::new(cx, true).await; cx.simulate_keystroke("i"); assert_eq!(cx.mode(), Mode::Insert); diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index cceedd7120..400a8e467a 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -15,11 +15,7 @@ pub struct VimTestContext<'a> { } impl<'a> VimTestContext<'a> { - pub async fn new( - cx: &'a mut gpui::TestAppContext, - enabled: bool, - initial_editor_text: &str, - ) -> VimTestContext<'a> { + pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> { cx.update(|cx| { editor::init(cx); crate::init(cx); @@ -38,10 +34,7 @@ impl<'a> VimTestContext<'a> { params .fs .as_fake() - .insert_tree( - "/root", - json!({ "dir": { "test.txt": initial_editor_text } }), - ) + .insert_tree("/root", json!({ "dir": { "test.txt": "" } })) .await; let (window_id, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx)); @@ -202,6 +195,14 @@ impl<'a> VimTestContext<'a> { assert_eq!(self.mode(), mode_after); assert_eq!(self.active_operator(), None); } + + pub fn binding( + mut self, + keystrokes: [&'static str; COUNT], + ) -> VimBindingTestContext<'a, COUNT> { + let mode = self.mode(); + VimBindingTestContext::new(keystrokes, mode, mode, self) + } } impl<'a> Deref for VimTestContext<'a> { @@ -211,3 +212,61 @@ impl<'a> Deref for VimTestContext<'a> { self.cx } } + +pub struct VimBindingTestContext<'a, const COUNT: usize> { + cx: VimTestContext<'a>, + keystrokes_under_test: [&'static str; COUNT], + initial_mode: Mode, + mode_after: Mode, +} + +impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> { + pub fn new( + keystrokes_under_test: [&'static str; COUNT], + initial_mode: Mode, + mode_after: Mode, + cx: VimTestContext<'a>, + ) -> Self { + Self { + cx, + keystrokes_under_test, + initial_mode, + mode_after, + } + } + + pub fn binding( + self, + keystrokes_under_test: [&'static str; NEW_COUNT], + ) -> VimBindingTestContext<'a, NEW_COUNT> { + VimBindingTestContext { + keystrokes_under_test, + cx: self.cx, + initial_mode: self.initial_mode, + mode_after: self.mode_after, + } + } + + pub fn mode_after(mut self, mode_after: Mode) -> Self { + self.mode_after = mode_after; + self + } + + pub fn assert(&mut self, initial_state: &str, state_after: &str) { + self.cx.assert_binding( + self.keystrokes_under_test, + initial_state, + self.initial_mode, + state_after, + self.mode_after, + ) + } +} + +impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { + type Target = VimTestContext<'a>; + + fn deref(&self) -> &Self::Target { + &self.cx + } +}