vim: ALlow counts on insert actions

Conrad Irwin created

This re-uses the existing repeat infrastructure.

Change summary

assets/keymaps/default.json                       |   2 
assets/keymaps/vim.json                           |   2 
crates/vim/src/insert.rs                          | 119 ++++++
crates/vim/src/normal.rs                          |   2 
crates/vim/src/normal/repeat.rs                   | 271 ++++++++++------
crates/vim/src/vim.rs                             |  14 
crates/vim/test_data/test_insert_with_counts.json |  36 ++
crates/vim/test_data/test_insert_with_repeat.json |  23 +
8 files changed, 340 insertions(+), 129 deletions(-)

Detailed changes

assets/keymaps/default.json 🔗

@@ -533,7 +533,7 @@
       // TODO: Move this to a dock open action
       "cmd-shift-c": "collab_panel::ToggleFocus",
       "cmd-alt-i": "zed::DebugElements",
-      "ctrl-:": "editor::ToggleInlayHints",
+      "ctrl-:": "editor::ToggleInlayHints"
     }
   },
   {

crates/vim/src/insert.rs 🔗

@@ -1,6 +1,6 @@
-use crate::{state::Mode, Vim};
+use crate::{normal::repeat, state::Mode, Vim};
 use editor::{scroll::autoscroll::Autoscroll, Bias};
-use gpui::{actions, AppContext, ViewContext};
+use gpui::{actions, Action, AppContext, ViewContext};
 use language::SelectionGoal;
 use workspace::Workspace;
 
@@ -10,24 +10,41 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(normal_before);
 }
 
-fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Workspace>) {
-    Vim::update(cx, |vim, cx| {
-        vim.stop_recording();
-        vim.update_active_editor(cx, |editor, cx| {
-            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                s.move_cursors_with(|map, mut cursor, _| {
-                    *cursor.column_mut() = cursor.column().saturating_sub(1);
-                    (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+fn normal_before(_: &mut Workspace, action: &NormalBefore, cx: &mut ViewContext<Workspace>) {
+    let should_repeat = Vim::update(cx, |vim, cx| {
+        let count = vim.take_count().unwrap_or(1);
+        vim.stop_recording_immediately(action.boxed_clone());
+        if count <= 1 || vim.workspace_state.replaying {
+            vim.update_active_editor(cx, |editor, cx| {
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.move_cursors_with(|map, mut cursor, _| {
+                        *cursor.column_mut() = cursor.column().saturating_sub(1);
+                        (map.clip_point(cursor, Bias::Left), SelectionGoal::None)
+                    });
                 });
             });
-        });
-        vim.switch_mode(Mode::Normal, false, cx);
-    })
+            vim.switch_mode(Mode::Normal, false, cx);
+            false
+        } else {
+            true
+        }
+    });
+
+    if should_repeat {
+        repeat::repeat(cx, true)
+    }
 }
 
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, test::VimTestContext};
+    use std::sync::Arc;
+
+    use gpui::executor::Deterministic;
+
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {
@@ -40,4 +57,78 @@ mod test {
         assert_eq!(cx.mode(), Mode::Normal);
         cx.assert_editor_state("Tesˇt");
     }
+
+    #[gpui::test]
+    async fn test_insert_with_counts(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["5", "i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----ˇ-hello\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["5", "a", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("h----ˇ-ello\n").await;
+
+        cx.simulate_shared_keystrokes(["4", "shift-i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("---ˇ-h-----ello\n").await;
+
+        cx.simulate_shared_keystrokes(["3", "shift-a", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----h-----ello--ˇ-\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "o", "o", "i", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\noi\noi\noˇi\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "shift-o", "o", "i", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("oi\noi\noˇi\nhello\n").await;
+    }
+
+    #[gpui::test]
+    async fn test_insert_with_repeat(
+        deterministic: Arc<Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["3", "i", "-", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("--ˇ-hello\n").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("----ˇ--hello\n").await;
+        cx.simulate_shared_keystrokes(["2", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("-----ˇ---hello\n").await;
+
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes(["2", "o", "k", "k", "escape"])
+            .await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkˇk\n").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkk\nkk\nkˇk\n").await;
+        cx.simulate_shared_keystrokes(["1", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state("hello\nkk\nkk\nkk\nkk\nkˇk\n").await;
+    }
 }

crates/vim/src/normal.rs 🔗

@@ -2,7 +2,7 @@ mod case;
 mod change;
 mod delete;
 mod paste;
-mod repeat;
+pub(crate) mod repeat;
 mod scroll;
 mod search;
 pub mod substitute;

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

@@ -1,10 +1,11 @@
 use crate::{
+    insert::NormalBefore,
     motion::Motion,
     state::{Mode, RecordedSelection, ReplayableAction},
     visual::visual_motion,
     Vim,
 };
-use gpui::{actions, Action, AppContext};
+use gpui::{actions, Action, AppContext, WindowContext};
 use workspace::Workspace;
 
 actions!(vim, [Repeat, EndRepeat,]);
@@ -17,6 +18,27 @@ fn should_replay(action: &Box<dyn Action>) -> bool {
     true
 }
 
+fn repeatable_insert(action: &ReplayableAction) -> Option<Box<dyn Action>> {
+    match action {
+        ReplayableAction::Action(action) => {
+            if super::InsertBefore.id() == action.id()
+                || super::InsertAfter.id() == action.id()
+                || super::InsertFirstNonWhitespace.id() == action.id()
+                || super::InsertEndOfLine.id() == action.id()
+            {
+                Some(super::InsertBefore.boxed_clone())
+            } else if super::InsertLineAbove.id() == action.id()
+                || super::InsertLineBelow.id() == action.id()
+            {
+                Some(super::InsertLineBelow.boxed_clone())
+            } else {
+                None
+            }
+        }
+        ReplayableAction::Insertion { .. } => None,
+    }
+}
+
 pub(crate) fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| {
         Vim::update(cx, |vim, cx| {
@@ -28,127 +50,156 @@ pub(crate) fn init(cx: &mut AppContext) {
         });
     });
 
-    cx.add_action(|_: &mut Workspace, _: &Repeat, cx| {
-        let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| {
-            let actions = vim.workspace_state.recorded_actions.clone();
-            let Some(editor) = vim.active_editor.clone() else {
-                return None;
-            };
-            let count = vim.take_count();
-
-            vim.workspace_state.replaying = true;
-
-            let selection = vim.workspace_state.recorded_selection.clone();
-            match selection {
-                RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::Visual, false, cx)
-                }
-                RecordedSelection::VisualLine { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::VisualLine, false, cx)
-                }
-                RecordedSelection::VisualBlock { .. } => {
-                    vim.workspace_state.recorded_count = None;
-                    vim.switch_mode(Mode::VisualBlock, false, cx)
-                }
-                RecordedSelection::None => {
-                    if let Some(count) = count {
-                        vim.workspace_state.recorded_count = Some(count);
-                    }
-                }
-            }
-
-            if let Some(editor) = editor.upgrade(cx) {
-                editor.update(cx, |editor, _| {
-                    editor.show_local_selections = false;
-                })
-            } else {
-                return None;
-            }
+    cx.add_action(|_: &mut Workspace, _: &Repeat, cx| repeat(cx, false));
+}
 
-            Some((actions, editor, selection))
-        }) else {
-            return;
+pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
+    let Some((mut actions, editor, selection)) = Vim::update(cx, |vim, cx| {
+        let actions = vim.workspace_state.recorded_actions.clone();
+        let Some(editor) = vim.active_editor.clone() else {
+            return None;
         };
+        let count = vim.take_count();
 
+        let selection = vim.workspace_state.recorded_selection.clone();
         match selection {
-            RecordedSelection::SingleLine { cols } => {
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
-                }
+            RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::Visual, false, cx)
             }
-            RecordedSelection::Visual { rows, cols } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
-                visual_motion(
-                    Motion::StartOfLine {
-                        display_lines: false,
-                    },
-                    None,
-                    cx,
-                );
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
-                }
+            RecordedSelection::VisualLine { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::VisualLine, false, cx)
             }
-            RecordedSelection::VisualBlock { rows, cols } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
-                if cols > 1 {
-                    visual_motion(Motion::Right, Some(cols as usize - 1), cx);
+            RecordedSelection::VisualBlock { .. } => {
+                vim.workspace_state.recorded_count = None;
+                vim.switch_mode(Mode::VisualBlock, false, cx)
+            }
+            RecordedSelection::None => {
+                if let Some(count) = count {
+                    vim.workspace_state.recorded_count = Some(count);
                 }
             }
-            RecordedSelection::VisualLine { rows } => {
-                visual_motion(
-                    Motion::Down {
-                        display_lines: false,
-                    },
-                    Some(rows as usize),
-                    cx,
-                );
+        }
+
+        if let Some(editor) = editor.upgrade(cx) {
+            editor.update(cx, |editor, _| {
+                editor.show_local_selections = false;
+            })
+        } else {
+            return None;
+        }
+
+        Some((actions, editor, selection))
+    }) else {
+        return;
+    };
+
+    match selection {
+        RecordedSelection::SingleLine { cols } => {
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+            }
+        }
+        RecordedSelection::Visual { rows, cols } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+            visual_motion(
+                Motion::StartOfLine {
+                    display_lines: false,
+                },
+                None,
+                cx,
+            );
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+            }
+        }
+        RecordedSelection::VisualBlock { rows, cols } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+            if cols > 1 {
+                visual_motion(Motion::Right, Some(cols as usize - 1), cx);
+            }
+        }
+        RecordedSelection::VisualLine { rows } => {
+            visual_motion(
+                Motion::Down {
+                    display_lines: false,
+                },
+                Some(rows as usize),
+                cx,
+            );
+        }
+        RecordedSelection::None => {}
+    }
+
+    // insert internally uses repeat to handle counts
+    // vim doesn't treat 3a1 as though you literally repeated a1
+    // 3 times, instead it inserts the content thrice at the insert position.
+    if let Some(to_repeat) = repeatable_insert(&actions[0]) {
+        if let Some(ReplayableAction::Action(action)) = actions.last() {
+            if action.id() == NormalBefore.id() {
+                actions.pop();
             }
-            RecordedSelection::None => {}
         }
 
-        let window = cx.window();
-        cx.app_context()
-            .spawn(move |mut cx| async move {
-                for action in actions {
-                    match action {
-                        ReplayableAction::Action(action) => {
-                            if should_replay(&action) {
-                                window
-                                    .dispatch_action(editor.id(), action.as_ref(), &mut cx)
-                                    .ok_or_else(|| anyhow::anyhow!("window was closed"))
-                            } else {
-                                Ok(())
-                            }
+        let mut new_actions = actions.clone();
+        actions[0] = ReplayableAction::Action(to_repeat.boxed_clone());
+
+        let mut count = Vim::read(cx).workspace_state.recorded_count.unwrap_or(1);
+
+        // if we came from insert mode we're just doing repititions 2 onwards.
+        if from_insert_mode {
+            count -= 1;
+            new_actions[0] = actions[0].clone();
+        }
+
+        for _ in 1..count {
+            new_actions.append(actions.clone().as_mut());
+        }
+        new_actions.push(ReplayableAction::Action(NormalBefore.boxed_clone()));
+        actions = new_actions;
+    }
+
+    Vim::update(cx, |vim, _| vim.workspace_state.replaying = true);
+    let window = cx.window();
+    cx.app_context()
+        .spawn(move |mut cx| async move {
+            for action in actions {
+                match action {
+                    ReplayableAction::Action(action) => {
+                        if should_replay(&action) {
+                            window
+                                .dispatch_action(editor.id(), action.as_ref(), &mut cx)
+                                .ok_or_else(|| anyhow::anyhow!("window was closed"))
+                        } else {
+                            Ok(())
                         }
-                        ReplayableAction::Insertion {
-                            text,
-                            utf16_range_to_replace,
-                        } => editor.update(&mut cx, |editor, cx| {
-                            editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
-                        }),
-                    }?
-                }
-                window
-                    .dispatch_action(editor.id(), &EndRepeat, &mut cx)
-                    .ok_or_else(|| anyhow::anyhow!("window was closed"))
-            })
-            .detach_and_log_err(cx);
-    });
+                    }
+                    ReplayableAction::Insertion {
+                        text,
+                        utf16_range_to_replace,
+                    } => editor.update(&mut cx, |editor, cx| {
+                        editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
+                    }),
+                }?
+            }
+            window
+                .dispatch_action(editor.id(), &EndRepeat, &mut cx)
+                .ok_or_else(|| anyhow::anyhow!("window was closed"))
+        })
+        .detach_and_log_err(cx);
 }
 
 #[cfg(test)]

crates/vim/src/vim.rs 🔗

@@ -15,8 +15,8 @@ use anyhow::Result;
 use collections::{CommandPaletteFilter, HashMap};
 use editor::{movement, Editor, EditorMode, Event};
 use gpui::{
-    actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
-    Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+    actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, Action,
+    AppContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use language::{CursorShape, Point, Selection, SelectionGoal};
 pub use mode_indicator::ModeIndicator;
@@ -284,6 +284,16 @@ impl Vim {
         }
     }
 
+    pub fn stop_recording_immediately(&mut self, action: Box<dyn Action>) {
+        if self.workspace_state.recording {
+            self.workspace_state
+                .recorded_actions
+                .push(ReplayableAction::Action(action.boxed_clone()));
+            self.workspace_state.recording = false;
+            self.workspace_state.stop_recording_after_next_action = false;
+        }
+    }
+
     pub fn record_current_action(&mut self, cx: &mut WindowContext) {
         self.start_recording(cx);
         self.stop_recording();

crates/vim/test_data/test_insert_with_counts.json 🔗

@@ -0,0 +1,36 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"5"}
+{"Key":"i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"----ˇ-hello\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"5"}
+{"Key":"a"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"h----ˇ-ello\n","mode":"Normal"}}
+{"Key":"4"}
+{"Key":"shift-i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"---ˇ-h-----ello\n","mode":"Normal"}}
+{"Key":"3"}
+{"Key":"shift-a"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"----h-----ello--ˇ-\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"o"}
+{"Key":"o"}
+{"Key":"i"}
+{"Key":"escape"}
+{"Get":{"state":"hello\noi\noi\noˇi\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"shift-o"}
+{"Key":"o"}
+{"Key":"i"}
+{"Key":"escape"}
+{"Get":{"state":"oi\noi\noˇi\nhello\n","mode":"Normal"}}

crates/vim/test_data/test_insert_with_repeat.json 🔗

@@ -0,0 +1,23 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"3"}
+{"Key":"i"}
+{"Key":"-"}
+{"Key":"escape"}
+{"Get":{"state":"--ˇ-hello\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"----ˇ--hello\n","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"."}
+{"Get":{"state":"-----ˇ---hello\n","mode":"Normal"}}
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"2"}
+{"Key":"o"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"escape"}
+{"Get":{"state":"hello\nkk\nkˇk\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"hello\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}}
+{"Key":"1"}
+{"Key":"."}
+{"Get":{"state":"hello\nkk\nkk\nkk\nkk\nkˇk\n","mode":"Normal"}}