Add c and d operators to vim normal mode

Keith Simmons created

Extracted motions from normal mode
Changed vim_submode to be vim_operator to enable better composition of operators

Change summary

assets/keymaps/vim.json            | 117 ++++--
crates/vim/src/editor_events.rs    |  24 
crates/vim/src/insert.rs           |  12 
crates/vim/src/mode.rs             |  73 ----
crates/vim/src/motion.rs           | 296 +++++++++++++++++++
crates/vim/src/normal.rs           | 495 +++++++++++++++++++------------
crates/vim/src/normal/g_prefix.rs  |  75 ----
crates/vim/src/state.rs            |  82 +++++
crates/vim/src/vim.rs              |  86 +++-
crates/vim/src/vim_test_context.rs |  37 ++
10 files changed, 863 insertions(+), 434 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -1,58 +1,93 @@
 {
-    "Editor && vim_mode == insert": {
-        "escape": "vim::NormalBefore",
-        "ctrl-c": "vim::NormalBefore"
-    },
-    "Editor && vim_mode == normal && vim_submode == g": {
-        "g": "vim::MoveToStart",
-        "escape": [
-            "vim::SwitchMode",
-            {
-                "Normal": "None"
-            }
-        ]
-    },
-    "Editor && vim_mode == normal": {
+    "Editor && VimControl": {
         "i": [
             "vim::SwitchMode",
             "Insert"
         ],
         "g": [
-            "vim::SwitchMode",
+            "vim::PushOperator",
             {
-                "Normal": "GPrefix"
+                "Namespace": "G"
             }
         ],
-        "h": "vim::MoveLeft",
-        "j": "vim::MoveDown",
-        "k": "vim::MoveUp",
-        "l": "vim::MoveRight",
-        "0": "vim::MoveToStartOfLine",
-        "shift-$": "vim::MoveToEndOfLine",
-        "shift-G": "vim::MoveToEnd",
-        "w": [
-            "vim::MoveToNextWordStart",
-            false
-        ],
+        "h": "vim::Left",
+        "j": "vim::Down",
+        "k": "vim::Up",
+        "l": "vim::Right",
+        "0": "vim::StartOfLine",
+        "shift-$": "vim::EndOfLine",
+        "shift-G": "vim::EndOfDocument",
+        "w": "vim::NextWordStart",
         "shift-W": [
-            "vim::MoveToNextWordStart",
-            true
-        ],
-        "e": [
-            "vim::MoveToNextWordEnd",
-            false
+            "vim::NextWordStart",
+            {
+                "ignorePunctuation": true
+            }
         ],
+        "e": "vim::NextWordEnd",
         "shift-E": [
-            "vim::MoveToNextWordEnd",
-            true
-        ],
-        "b": [
-            "vim::MoveToPreviousWordStart",
-            false
+            "vim::NextWordEnd",
+            {
+                "ignorePunctuation": true
+            }
         ],
+        "b": "vim::PreviousWordStart",
         "shift-B": [
-            "vim::MoveToPreviousWordStart",
-            true
+            "vim::PreviousWordStart",
+            {
+                "ignorePunctuation": true
+            }
+        ],
+        "escape": [
+            "vim::SwitchMode",
+            "Normal"
+        ]
+    },
+    "Editor && vim_operator == g": {
+        "g": "vim::StartOfDocument"
+    },
+    "Editor && vim_mode == insert": {
+        "escape": "vim::NormalBefore",
+        "ctrl-c": "vim::NormalBefore"
+    },
+    "Editor && vim_mode == normal": {
+        "c": [
+            "vim::PushOperator",
+            "Change"
+        ],
+        "d": [
+            "vim::PushOperator",
+            "Delete"
+        ]
+    },
+    "Editor && vim_operator == c": {
+        "w": [
+            "vim::NextWordEnd",
+            {
+                "ignorePunctuation": false
+            }
+        ],
+        "shift-W": [
+            "vim::NextWordEnd",
+            {
+                "ignorePunctuation": true
+            }
+        ]
+    },
+    "Editor && vim_operator == d": {
+        "w": [
+            "vim::NextWordStart",
+            {
+                "ignorePunctuation": false,
+                "stopAtNewline": true
+            }
+        ],
+        "shift-W": [
+            "vim::NextWordStart",
+            {
+                "ignorePunctuation": true,
+                "stopAtNewline": true
+            }
         ]
     }
 }

crates/vim/src/editor_events.rs 🔗

@@ -1,7 +1,7 @@
 use editor::{EditorBlurred, EditorCreated, EditorFocused, EditorMode, EditorReleased};
 use gpui::MutableAppContext;
 
-use crate::{mode::Mode, SwitchMode, VimState};
+use crate::{state::Mode, Vim};
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.subscribe_global(editor_created).detach();
@@ -11,9 +11,9 @@ pub fn init(cx: &mut MutableAppContext) {
 }
 
 fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppContext) {
-    cx.update_default_global(|vim_state: &mut VimState, cx| {
-        vim_state.editors.insert(editor.id(), editor.downgrade());
-        vim_state.sync_editor_options(cx);
+    cx.update_default_global(|vim: &mut Vim, cx| {
+        vim.editors.insert(editor.id(), editor.downgrade());
+        vim.sync_editor_options(cx);
     })
 }
 
@@ -21,17 +21,17 @@ 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| {
+    Vim::update(cx, |state, cx| {
         state.active_editor = Some(editor.downgrade());
-        state.switch_mode(&SwitchMode(mode), cx);
+        state.switch_mode(mode, cx);
     });
 }
 
 fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) {
-    VimState::update_global(cx, |state, cx| {
+    Vim::update(cx, |state, cx| {
         if let Some(previous_editor) = state.active_editor.clone() {
             if previous_editor == editor.clone() {
                 state.active_editor = None;
@@ -42,11 +42,11 @@ fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppCont
 }
 
 fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppContext) {
-    cx.update_default_global(|vim_state: &mut VimState, _| {
-        vim_state.editors.remove(&editor.id());
-        if let Some(previous_editor) = vim_state.active_editor.clone() {
+    cx.update_default_global(|vim: &mut Vim, _| {
+        vim.editors.remove(&editor.id());
+        if let Some(previous_editor) = vim.active_editor.clone() {
             if previous_editor == editor.clone() {
-                vim_state.active_editor = None;
+                vim.active_editor = None;
             }
         }
     });

crates/vim/src/insert.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{mode::Mode, SwitchMode, VimState};
+use crate::{state::Mode, Vim};
 use editor::Bias;
 use gpui::{actions, MutableAppContext, ViewContext};
 use language::SelectionGoal;
@@ -11,30 +11,30 @@ pub fn init(cx: &mut MutableAppContext) {
 }
 
 fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
-    VimState::update_global(cx, |state, cx| {
+    Vim::update(cx, |state, cx| {
         state.update_active_editor(cx, |editor, cx| {
             editor.move_cursors(cx, |map, mut cursor, _| {
                 *cursor.column_mut() = cursor.column().saturating_sub(1);
                 (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
             });
         });
-        state.switch_mode(&SwitchMode(Mode::normal()), cx);
+        state.switch_mode(Mode::Normal, cx);
     })
 }
 
 #[cfg(test)]
 mod test {
-    use crate::{mode::Mode, vim_test_context::VimTestContext};
+    use crate::{state::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.simulate_keystrokes(["T", "e", "s", "t"]);
         cx.assert_editor_state("Test|");
         cx.simulate_keystroke("escape");
-        assert_eq!(cx.mode(), Mode::normal());
+        assert_eq!(cx.mode(), Mode::Normal);
         cx.assert_editor_state("Tes|t");
     }
 }

crates/vim/src/mode.rs 🔗

@@ -1,73 +0,0 @@
-use editor::CursorShape;
-use gpui::keymap::Context;
-use serde::Deserialize;
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
-pub enum Mode {
-    Normal(NormalState),
-    Insert,
-}
-
-impl Mode {
-    pub fn cursor_shape(&self) -> CursorShape {
-        match self {
-            Mode::Normal(_) => CursorShape::Block,
-            Mode::Insert => CursorShape::Bar,
-        }
-    }
-
-    pub fn keymap_context_layer(&self) -> Context {
-        let mut context = Context::default();
-        context.map.insert(
-            "vim_mode".to_string(),
-            match self {
-                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(Default::default())
-    }
-}
-
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
-pub enum NormalState {
-    None,
-    GPrefix,
-}
-
-impl NormalState {
-    pub fn set_context(&self, context: &mut Context) {
-        let submode = match self {
-            Self::GPrefix => Some("g"),
-            _ => None,
-        };
-
-        if let Some(submode) = submode {
-            context
-                .map
-                .insert("vim_submode".to_string(), submode.to_string());
-        }
-    }
-}
-
-impl Default for NormalState {
-    fn default() -> Self {
-        NormalState::None
-    }
-}

crates/vim/src/motion.rs 🔗

@@ -0,0 +1,296 @@
+use editor::{
+    char_kind,
+    display_map::{DisplaySnapshot, ToDisplayPoint},
+    movement, Bias, DisplayPoint,
+};
+use gpui::{actions, impl_actions, MutableAppContext};
+use language::{Selection, SelectionGoal};
+use serde::Deserialize;
+use workspace::Workspace;
+
+use crate::{
+    normal::normal_motion,
+    state::{Mode, Operator},
+    Vim,
+};
+
+#[derive(Copy, Clone)]
+pub enum Motion {
+    Left,
+    Down,
+    Up,
+    Right,
+    NextWordStart {
+        ignore_punctuation: bool,
+        stop_at_newline: bool,
+    },
+    NextWordEnd {
+        ignore_punctuation: bool,
+    },
+    PreviousWordStart {
+        ignore_punctuation: bool,
+    },
+    StartOfLine,
+    EndOfLine,
+    StartOfDocument,
+    EndOfDocument,
+}
+
+#[derive(Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct NextWordStart {
+    #[serde(default)]
+    ignore_punctuation: bool,
+    #[serde(default)]
+    stop_at_newline: bool,
+}
+
+#[derive(Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct NextWordEnd {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
+#[derive(Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PreviousWordStart {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
+actions!(
+    vim,
+    [
+        Left,
+        Down,
+        Up,
+        Right,
+        StartOfLine,
+        EndOfLine,
+        StartOfDocument,
+        EndOfDocument
+    ]
+);
+impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
+    cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
+    cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
+    cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
+    cx.add_action(|_: &mut Workspace, _: &StartOfLine, cx: _| motion(Motion::StartOfLine, cx));
+    cx.add_action(|_: &mut Workspace, _: &EndOfLine, cx: _| motion(Motion::EndOfLine, cx));
+    cx.add_action(|_: &mut Workspace, _: &StartOfDocument, cx: _| {
+        motion(Motion::StartOfDocument, cx)
+    });
+    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,
+            )
+        },
+    );
+    cx.add_action(
+        |_: &mut Workspace, &NextWordEnd { ignore_punctuation }: &NextWordEnd, cx: _| {
+            motion(Motion::NextWordEnd { ignore_punctuation }, cx)
+        },
+    );
+    cx.add_action(
+        |_: &mut Workspace,
+         &PreviousWordStart { ignore_punctuation }: &PreviousWordStart,
+         cx: _| { motion(Motion::PreviousWordStart { ignore_punctuation }, cx) },
+    );
+}
+
+fn motion(motion: Motion, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        if let Some(Operator::Namespace(_)) = vim.active_operator() {
+            vim.pop_operator(cx);
+        }
+    });
+    match Vim::read(cx).state.mode {
+        Mode::Normal => normal_motion(motion, cx),
+        Mode::Insert => panic!("motion bindings in insert mode interfere with normal typing"),
+    }
+}
+
+impl Motion {
+    pub fn move_point(
+        self,
+        map: &DisplaySnapshot,
+        point: DisplayPoint,
+        goal: SelectionGoal,
+    ) -> (DisplayPoint, SelectionGoal) {
+        use Motion::*;
+        match self {
+            Left => (left(map, point), SelectionGoal::None),
+            Down => movement::down(map, point, goal),
+            Up => movement::up(map, point, goal),
+            Right => (right(map, point), SelectionGoal::None),
+            NextWordStart {
+                ignore_punctuation,
+                stop_at_newline,
+            } => (
+                next_word_start(map, point, ignore_punctuation, stop_at_newline),
+                SelectionGoal::None,
+            ),
+            NextWordEnd { ignore_punctuation } => (
+                next_word_end(map, point, ignore_punctuation, true),
+                SelectionGoal::None,
+            ),
+            PreviousWordStart { ignore_punctuation } => (
+                previous_word_start(map, point, ignore_punctuation),
+                SelectionGoal::None,
+            ),
+            StartOfLine => (
+                movement::line_beginning(map, point, false),
+                SelectionGoal::None,
+            ),
+            EndOfLine => (
+                map.clip_point(movement::line_end(map, point, false), Bias::Left),
+                SelectionGoal::None,
+            ),
+            StartOfDocument => (start_of_document(map), SelectionGoal::None),
+            EndOfDocument => (end_of_document(map), SelectionGoal::None),
+        }
+    }
+
+    pub fn expand_selection(self, map: &DisplaySnapshot, selection: &mut Selection<DisplayPoint>) {
+        use Motion::*;
+        match self {
+            Up => {
+                let (start, _) = Up.move_point(map, selection.start, SelectionGoal::None);
+                // Cursor at top of file. Return early rather
+                if start == selection.start {
+                    return;
+                }
+                let (start, _) = StartOfLine.move_point(map, start, SelectionGoal::None);
+                let (end, _) = EndOfLine.move_point(map, selection.end, SelectionGoal::None);
+                selection.start = start;
+                selection.end = end;
+                // TODO: Make sure selection goal is correct here
+                selection.goal = SelectionGoal::None;
+            }
+            Down => {
+                let (end, _) = Down.move_point(map, selection.end, SelectionGoal::None);
+                // Cursor at top of file. Return early rather
+                if end == selection.start {
+                    return;
+                }
+                let (start, _) = StartOfLine.move_point(map, selection.start, SelectionGoal::None);
+                let (end, _) = EndOfLine.move_point(map, end, SelectionGoal::None);
+                selection.start = start;
+                selection.end = end;
+                // TODO: Make sure selection goal is correct here
+                selection.goal = SelectionGoal::None;
+            }
+            NextWordEnd { ignore_punctuation } => {
+                selection.set_head(
+                    next_word_end(map, selection.head(), ignore_punctuation, false),
+                    SelectionGoal::None,
+                );
+            }
+            _ => {
+                let (head, goal) = self.move_point(map, selection.head(), selection.goal);
+                selection.set_head(head, goal);
+            }
+        }
+    }
+}
+
+fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+    *point.column_mut() = point.column().saturating_sub(1);
+    map.clip_point(point, Bias::Left)
+}
+
+fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+    *point.column_mut() += 1;
+    map.clip_point(point, Bias::Right)
+}
+
+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| {
+        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+        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
+
+        if at_newline {
+            crossed_newline = true;
+        }
+        found
+    })
+}
+
+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| {
+        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.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 before_end_character
+        && !map
+            .chars_at(point)
+            .skip(1)
+            .next()
+            .map(|c| c == '\n')
+            .unwrap_or(true)
+    {
+        *point.column_mut() = point.column().saturating_sub(1);
+    }
+    map.clip_point(point, Bias::Left)
+}
+
+fn previous_word_start(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    ignore_punctuation: bool,
+) -> DisplayPoint {
+    // This works even though find_preceding_boundary is called for every character in the line containing
+    // cursor because the newline is checked only once.
+    point = movement::find_preceding_boundary(map, point, |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 && !right.is_whitespace()) || left == '\n'
+    });
+    point
+}
+
+fn start_of_document(map: &DisplaySnapshot) -> DisplayPoint {
+    0usize.to_display_point(map)
+}
+
+fn end_of_document(map: &DisplaySnapshot) -> DisplayPoint {
+    map.clip_point(map.max_point(), Bias::Left)
+}

crates/vim/src/normal.rs 🔗

@@ -1,212 +1,77 @@
-mod g_prefix;
-
-use crate::VimState;
-use editor::{char_kind, movement, Bias};
-use gpui::{actions, impl_actions, MutableAppContext, ViewContext};
+use crate::{
+    motion::Motion,
+    state::{Mode, Operator},
+    Vim,
+};
+use editor::Bias;
+use gpui::MutableAppContext;
 use language::SelectionGoal;
-use serde::Deserialize;
-use workspace::Workspace;
-
-#[derive(Clone, Deserialize)]
-struct MoveToNextWordStart(pub bool);
-
-#[derive(Clone, Deserialize)]
-struct MoveToNextWordEnd(pub bool);
-
-#[derive(Clone, Deserialize)]
-struct MoveToPreviousWordStart(pub bool);
-
-impl_actions!(
-    vim,
-    [
-        MoveToNextWordStart,
-        MoveToNextWordEnd,
-        MoveToPreviousWordStart,
-    ]
-);
-
-actions!(
-    vim,
-    [
-        GPrefix,
-        MoveLeft,
-        MoveDown,
-        MoveUp,
-        MoveRight,
-        MoveToStartOfLine,
-        MoveToEndOfLine,
-        MoveToEnd,
-    ]
-);
-
-pub fn init(cx: &mut MutableAppContext) {
-    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>) {
-    VimState::update_global(cx, |state, cx| {
-        state.update_active_editor(cx, |editor, cx| {
-            editor.move_cursors(cx, |map, mut cursor, _| {
-                *cursor.column_mut() = cursor.column().saturating_sub(1);
-                (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
-            });
-        });
-    })
-}
-
-fn move_down(_: &mut Workspace, _: &MoveDown, cx: &mut ViewContext<Workspace>) {
-    VimState::update_global(cx, |state, cx| {
-        state.update_active_editor(cx, |editor, cx| {
-            editor.move_cursors(cx, movement::down);
-        });
-    });
-}
-
-fn move_up(_: &mut Workspace, _: &MoveUp, cx: &mut ViewContext<Workspace>) {
-    VimState::update_global(cx, |state, cx| {
-        state.update_active_editor(cx, |editor, cx| {
-            editor.move_cursors(cx, movement::up);
-        });
-    });
-}
-
-fn move_right(_: &mut Workspace, _: &MoveRight, 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;
-                (map.clip_point(cursor, Bias::Right), SelectionGoal::None)
-            });
-        });
-    });
-}
-
-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,
-                )
-            });
-        });
+pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        match vim.state.operator_stack.pop() {
+            None => move_cursor(vim, motion, cx),
+            Some(Operator::Change) => change_over(vim, motion, cx),
+            Some(Operator::Delete) => delete_over(vim, motion, cx),
+            Some(Operator::Namespace(_)) => panic!(
+                "Normal mode recieved motion with namespaced operator. Likely this means an invalid keymap was used"),
+        }
+        vim.clear_operator(cx);
     });
 }
 
-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_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))
     });
 }
 
-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
+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| motion.expand_selection(map, selection));
+            editor.set_clip_at_line_ends(true, cx);
+            match motion {
+                Motion::Up => editor.insert(&"\n", cx),
+                Motion::Down => editor.insert(&"\n", cx),
+                _ => editor.insert(&"", cx),
+            }
+
+            if let Motion::Up = motion {
+                // Position cursor on previous line after change
+                editor.move_cursors(cx, |map, cursor, goal| {
+                    Motion::Up.move_point(map, cursor, goal)
                 });
-                (cursor, SelectionGoal::None)
-            });
+            }
         });
     });
+    vim.switch_mode(Mode::Insert, cx)
 }
 
-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()
+fn delete_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 delete operation
+            editor.set_clip_at_line_ends(false, cx);
+            editor.move_selections(cx, |map, selection| motion.expand_selection(map, selection));
+            match motion {
+                Motion::Up => editor.insert(&"\n", cx),
+                Motion::Down => editor.insert(&"\n", cx),
+                _ => editor.insert(&"", cx),
+            }
+
+            if let Motion::Up = motion {
+                // Position cursor on previous line after change
+                editor.move_cursors(cx, |map, cursor, goal| {
+                    Motion::Up.move_point(map, cursor, goal)
                 });
-                // 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)
+            }
+            // Fixup cursor position after the deletion
+            editor.set_clip_at_line_ends(true, cx);
+            editor.move_selection_heads(cx, |map, head, _| {
+                (map.clip_point(head, Bias::Left), SelectionGoal::None)
             });
         });
     });
@@ -217,7 +82,13 @@ mod test {
     use indoc::indoc;
     use util::test::marked_text;
 
-    use crate::vim_test_context::VimTestContext;
+    use crate::{
+        state::{
+            Mode::{self, *},
+            Namespace, Operator,
+        },
+        vim_test_context::VimTestContext,
+    };
 
     #[gpui::test]
     async fn test_hjkl(cx: &mut gpui::TestAppContext) {
@@ -362,7 +233,7 @@ mod test {
         }
 
         // Reset and test ignoring punctuation
-        cx.simulate_keystrokes(&["g", "g"]);
+        cx.simulate_keystrokes(["g", "g"]);
         let (_, cursor_offsets) = marked_text(indoc! {"
             The |quick-brown
             |
@@ -392,7 +263,7 @@ mod test {
         }
 
         // Reset and test ignoring punctuation
-        cx.simulate_keystrokes(&["g", "g"]);
+        cx.simulate_keystrokes(["g", "g"]);
         let (_, cursor_offsets) = marked_text(indoc! {"
             Th|e quick-brow|n
             
@@ -434,4 +305,232 @@ mod test {
             cx.assert_newest_selection_head_offset(cursor_offset);
         }
     }
+
+    #[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(), Normal);
+        assert_eq!(
+            cx.active_operator(),
+            Some(Operator::Namespace(Namespace::G))
+        );
+        cx.simulate_keystroke("escape");
+        assert_eq!(cx.mode(), Normal);
+        assert_eq!(cx.active_operator(), None);
+    }
+
+    #[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(), Normal);
+        assert_eq!(cx.active_operator(), None);
+
+        // 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(), 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,
+        );
+        assert(
+            "shift-W",
+            indoc! {"
+            The quick
+            brown |fox-fox
+            jumps over"},
+            indoc! {"
+            The quick
+            brown |
+            jumps over"},
+            cx,
+        );
+        assert(
+            "k",
+            indoc! {"
+            The quick
+            brown |fox"},
+            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(
+            "k",
+            indoc! {"
+            The quick
+            brown |fox"},
+            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,
+        );
+    }
 }

crates/vim/src/normal/g_prefix.rs 🔗

@@ -1,75 +0,0 @@
-use crate::{mode::Mode, SwitchMode, VimState};
-use gpui::{actions, MutableAppContext, ViewContext};
-use workspace::Workspace;
-
-actions!(vim, [MoveToStart]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    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());
-    }
-}

crates/vim/src/state.rs 🔗

@@ -0,0 +1,82 @@
+use editor::CursorShape;
+use gpui::keymap::Context;
+use serde::Deserialize;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+pub enum Mode {
+    Normal,
+    Insert,
+}
+
+impl Default for Mode {
+    fn default() -> Self {
+        Self::Normal
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+pub enum Namespace {
+    G,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+pub enum Operator {
+    Namespace(Namespace),
+    Change,
+    Delete,
+}
+
+#[derive(Default)]
+pub struct VimState {
+    pub mode: Mode,
+    pub operator_stack: Vec<Operator>,
+}
+
+impl VimState {
+    pub fn cursor_shape(&self) -> CursorShape {
+        match self.mode {
+            Mode::Normal => CursorShape::Block,
+            Mode::Insert => CursorShape::Bar,
+        }
+    }
+
+    pub fn vim_controlled(&self) -> bool {
+        !matches!(self.mode, Mode::Insert)
+    }
+
+    pub fn keymap_context_layer(&self) -> Context {
+        let mut context = Context::default();
+        context.map.insert(
+            "vim_mode".to_string(),
+            match self.mode {
+                Mode::Normal => "normal",
+                Mode::Insert => "insert",
+            }
+            .to_string(),
+        );
+
+        if self.vim_controlled() {
+            context.set.insert("VimControl".to_string());
+        }
+
+        if let Some(operator) = &self.operator_stack.last() {
+            operator.set_context(&mut context);
+        }
+        context
+    }
+}
+
+impl Operator {
+    pub fn set_context(&self, context: &mut Context) {
+        let operator_context = match self {
+            Operator::Namespace(Namespace::G) => "g",
+            Operator::Change => "c",
+            Operator::Delete => "d",
+        }
+        .to_owned();
+
+        context
+            .map
+            .insert("vim_operator".to_string(), operator_context.to_string());
+    }
+}

crates/vim/src/vim.rs 🔗

@@ -1,7 +1,8 @@
 mod editor_events;
 mod insert;
-mod mode;
+mod motion;
 mod normal;
+mod state;
 #[cfg(test)]
 mod vim_test_context;
 
@@ -10,41 +11,53 @@ use editor::{CursorShape, Editor};
 use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle};
 use serde::Deserialize;
 
-use mode::Mode;
 use settings::Settings;
+use state::{Mode, Operator, VimState};
 use workspace::{self, Workspace};
 
 #[derive(Clone, Deserialize)]
 pub struct SwitchMode(pub Mode);
 
-impl_actions!(vim, [SwitchMode]);
+#[derive(Clone, Deserialize)]
+pub struct PushOperator(pub Operator);
+
+impl_actions!(vim, [SwitchMode, PushOperator]);
 
 pub fn init(cx: &mut MutableAppContext) {
     editor_events::init(cx);
     insert::init(cx);
-    normal::init(cx);
+    motion::init(cx);
 
-    cx.add_action(|_: &mut Workspace, action: &SwitchMode, cx| {
-        VimState::update_global(cx, |state, cx| state.switch_mode(action, cx))
+    cx.add_action(|_: &mut Workspace, &SwitchMode(mode): &SwitchMode, cx| {
+        Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx))
     });
+    cx.add_action(
+        |_: &mut Workspace, &PushOperator(operator): &PushOperator, cx| {
+            Vim::update(cx, |vim, cx| vim.push_operator(operator, cx))
+        },
+    );
 
     cx.observe_global::<Settings, _>(|settings, cx| {
-        VimState::update_global(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
+        Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx))
     })
     .detach();
 }
 
 #[derive(Default)]
-pub struct VimState {
+pub struct Vim {
     editors: HashMap<usize, WeakViewHandle<Editor>>,
     active_editor: Option<WeakViewHandle<Editor>>,
 
     enabled: bool,
-    mode: Mode,
+    state: VimState,
 }
 
-impl VimState {
-    fn update_global<F, S>(cx: &mut MutableAppContext, update: F) -> S
+impl Vim {
+    fn read(cx: &mut MutableAppContext) -> &Self {
+        cx.default_global()
+    }
+
+    fn update<F, S>(cx: &mut MutableAppContext, update: F) -> S
     where
         F: FnOnce(&mut Self, &mut MutableAppContext) -> S,
     {
@@ -62,33 +75,54 @@ impl VimState {
             .map(|ae| ae.update(cx, update))
     }
 
-    fn switch_mode(&mut self, SwitchMode(mode): &SwitchMode, cx: &mut MutableAppContext) {
-        self.mode = *mode;
+    fn switch_mode(&mut self, mode: Mode, cx: &mut MutableAppContext) {
+        self.state.mode = mode;
+        self.state.operator_stack.clear();
         self.sync_editor_options(cx);
     }
 
+    fn push_operator(&mut self, operator: Operator, cx: &mut MutableAppContext) {
+        self.state.operator_stack.push(operator);
+        self.sync_editor_options(cx);
+    }
+
+    fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator {
+        let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
+        self.sync_editor_options(cx);
+        popped_operator
+    }
+
+    fn clear_operator(&mut self, cx: &mut MutableAppContext) {
+        self.state.operator_stack.clear();
+        self.sync_editor_options(cx);
+    }
+
+    fn active_operator(&mut self) -> Option<Operator> {
+        self.state.operator_stack.last().copied()
+    }
+
     fn set_enabled(&mut self, enabled: bool, cx: &mut MutableAppContext) {
         if self.enabled != enabled {
             self.enabled = enabled;
-            self.mode = Default::default();
+            self.state = Default::default();
             if enabled {
-                self.mode = Mode::normal();
+                self.state.mode = Mode::Normal;
             }
             self.sync_editor_options(cx);
         }
     }
 
     fn sync_editor_options(&self, cx: &mut MutableAppContext) {
-        let mode = self.mode;
-        let cursor_shape = mode.cursor_shape();
+        let state = &self.state;
+        let cursor_shape = state.cursor_shape();
         for editor in self.editors.values() {
             if let Some(editor) = editor.upgrade(cx) {
                 editor.update(cx, |editor, cx| {
                     if self.enabled {
                         editor.set_cursor_shape(cursor_shape, cx);
                         editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx);
-                        editor.set_input_enabled(mode == Mode::Insert);
-                        let context_layer = mode.keymap_context_layer();
+                        editor.set_input_enabled(!state.vim_controlled());
+                        let context_layer = state.keymap_context_layer();
                         editor.set_keymap_context_layer::<Self>(context_layer);
                     } else {
                         editor.set_cursor_shape(CursorShape::Bar, cx);
@@ -104,12 +138,12 @@ impl VimState {
 
 #[cfg(test)]
 mod test {
-    use crate::{mode::Mode, vim_test_context::VimTestContext};
+    use crate::{state::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.simulate_keystrokes(["h", "j", "k", "l"]);
         cx.assert_editor_state("hjkl|");
     }
 
@@ -122,22 +156,22 @@ mod test {
 
         // Editor acts as though vim is disabled
         cx.disable_vim();
-        cx.simulate_keystrokes(&["h", "j", "k", "l"]);
+        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.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.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());
+        assert_eq!(cx.mode(), Mode::Normal);
     }
 }

crates/vim/src/vim_test_context.rs 🔗

@@ -6,7 +6,7 @@ use language::{Point, Selection};
 use util::test::marked_text;
 use workspace::{WorkspaceHandle, WorkspaceParams};
 
-use crate::*;
+use crate::{state::Operator, *};
 
 pub struct VimTestContext<'a> {
     cx: &'a mut gpui::TestAppContext,
@@ -100,7 +100,12 @@ impl<'a> VimTestContext<'a> {
     }
 
     pub fn mode(&mut self) -> Mode {
-        self.cx.update(|cx| cx.global::<VimState>().mode)
+        self.cx.read(|cx| cx.global::<Vim>().state.mode)
+    }
+
+    pub fn active_operator(&mut self) -> Option<Operator> {
+        self.cx
+            .read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
     }
 
     pub fn editor_text(&mut self) -> String {
@@ -119,12 +124,23 @@ impl<'a> VimTestContext<'a> {
             .dispatch_keystroke(self.window_id, keystroke, input, false);
     }
 
-    pub fn simulate_keystrokes(&mut self, keystroke_texts: &[&str]) {
+    pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
         for keystroke_text in keystroke_texts.into_iter() {
             self.simulate_keystroke(keystroke_text);
         }
     }
 
+    pub fn set_state(&mut self, text: &str, mode: Mode) {
+        self.cx
+            .update(|cx| Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx)));
+        self.editor.update(self.cx, |editor, cx| {
+            let (unmarked_text, markers) = marked_text(&text);
+            editor.set_text(unmarked_text, cx);
+            let cursor_offset = markers[0];
+            editor.replace_selections_with(cx, |map| cursor_offset.to_display_point(map));
+        })
+    }
+
     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| {
@@ -171,6 +187,21 @@ impl<'a> VimTestContext<'a> {
             actual_position_text, expected_position_text
         )
     }
+
+    pub fn assert_binding<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        initial_state: &str,
+        initial_mode: Mode,
+        state_after: &str,
+        mode_after: Mode,
+    ) {
+        self.set_state(initial_state, initial_mode);
+        self.simulate_keystrokes(keystrokes);
+        self.assert_editor_state(state_after);
+        assert_eq!(self.mode(), mode_after);
+        assert_eq!(self.active_operator(), None);
+    }
 }
 
 impl<'a> Deref for VimTestContext<'a> {