vim: Add support for temporary normal mode (ctrl-o) within insert mode (#19454)

Axel Carlsson and Conrad Irwin created

Support has been added for the ctrl-o command within insert mode. Ctrl-o
is used to partially enter normal mode for 1 motion to then return back
into insert mode.

Release Notes:

- vim: Added support for `ctrl-o` in insert mode to enter temporary
normal mode

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                        |  3 +
crates/vim/src/insert.rs                       |  8 +++
crates/vim/src/mode_indicator.rs               |  9 ++++
crates/vim/src/normal.rs                       |  8 ++++
crates/vim/src/normal/paste.rs                 |  2 
crates/vim/src/normal/repeat.rs                |  6 +++
crates/vim/src/normal/search.rs                |  7 +++
crates/vim/src/normal/yank.rs                  |  2 +
crates/vim/src/state.rs                        |  7 ++-
crates/vim/src/test.rs                         | 33 ++++++++++++++++++
crates/vim/src/vim.rs                          | 36 +++++++++++++++++--
crates/vim/test_data/test_ctrl_o_dot.json      | 11 ++++++
crates/vim/test_data/test_ctrl_o_position.json | 10 +++++
crates/vim/test_data/test_ctrl_o_visual.json   | 14 +++++++
14 files changed, 145 insertions(+), 11 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -304,7 +304,8 @@
       "ctrl-q": ["vim::PushOperator", { "Literal": {} }],
       "ctrl-shift-q": ["vim::PushOperator", { "Literal": {} }],
       "ctrl-r": ["vim::PushOperator", "Register"],
-      "insert": "vim::ToggleReplace"
+      "insert": "vim::ToggleReplace",
+      "ctrl-o": "vim::TemporaryNormal"
     }
   },
   {

crates/vim/src/insert.rs 🔗

@@ -3,10 +3,11 @@ use editor::{scroll::Autoscroll, Bias, Editor};
 use gpui::{actions, Action, ViewContext};
 use language::SelectionGoal;
 
-actions!(vim, [NormalBefore]);
+actions!(vim, [NormalBefore, TemporaryNormal]);
 
 pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
     Vim::action(editor, cx, Vim::normal_before);
+    Vim::action(editor, cx, Vim::temporary_normal);
 }
 
 impl Vim {
@@ -35,6 +36,11 @@ impl Vim {
 
         self.repeat(true, cx)
     }
+
+    fn temporary_normal(&mut self, _: &TemporaryNormal, cx: &mut ViewContext<Self>) {
+        self.switch_mode(Mode::Normal, true, cx);
+        self.temp_mode = true;
+    }
 }
 
 #[cfg(test)]

crates/vim/src/mode_indicator.rs 🔗

@@ -88,12 +88,19 @@ impl Render for ModeIndicator {
             return div().into_any();
         };
 
+        let vim_readable = vim.read(cx);
+        let mode = if vim_readable.temp_mode {
+            format!("(insert) {}", vim_readable.mode)
+        } else {
+            vim_readable.mode.to_string()
+        };
+
         let current_operators_description = self.current_operators_description(vim.clone(), cx);
         let pending = self
             .pending_keys
             .as_ref()
             .unwrap_or(&current_operators_description);
-        Label::new(format!("{} -- {} --", pending, vim.read(cx).mode))
+        Label::new(format!("{} -- {} --", pending, mode))
             .size(LabelSize::Small)
             .line_height_style(LineHeightStyle::UiLabel)
             .into_any_element()

crates/vim/src/normal.rs 🔗

@@ -185,6 +185,8 @@ impl Vim {
                 error!("Unexpected normal mode motion operator: {:?}", operator)
             }
         }
+        // Exit temporary normal mode (if active).
+        self.exit_temporary_normal(cx);
     }
 
     pub fn normal_object(&mut self, object: Object, cx: &mut ViewContext<Self>) {
@@ -483,6 +485,12 @@ impl Vim {
             });
         });
     }
+
+    fn exit_temporary_normal(&mut self, cx: &mut ViewContext<Self>) {
+        if self.temp_mode {
+            self.switch_mode(Mode::Insert, true, cx);
+        }
+    }
 }
 #[cfg(test)]
 mod test {

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

@@ -176,7 +176,7 @@ impl Vim {
                                     .0;
                                 }
                                 cursor = movement::indented_line_beginning(map, cursor, true);
-                            } else if !is_multiline {
+                            } else if !is_multiline && !vim.temp_mode {
                                 cursor = movement::saturating_left(map, cursor)
                             }
                             cursors.push(cursor);

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

@@ -3,6 +3,7 @@ use std::{cell::RefCell, rc::Rc};
 use crate::{
     insert::NormalBefore,
     motion::Motion,
+    normal::InsertBefore,
     state::{Mode, Operator, RecordedSelection, ReplayableAction, VimGlobals},
     Vim,
 };
@@ -308,6 +309,11 @@ impl Vim {
 
         actions.push(ReplayableAction::Action(EndRepeat.boxed_clone()));
 
+        if self.temp_mode {
+            self.temp_mode = false;
+            actions.push(ReplayableAction::Action(InsertBefore.boxed_clone()));
+        }
+
         let globals = Vim::globals(cx);
         globals.dot_replaying = true;
         let mut replayer = globals.replayer.get_or_insert_with(Replayer::new).clone();

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

@@ -139,6 +139,11 @@ impl Vim {
                         options |= SearchOptions::REGEX;
                     }
                     search_bar.set_search_options(options, cx);
+                    let prior_mode = if self.temp_mode {
+                        Mode::Insert
+                    } else {
+                        self.mode
+                    };
 
                     self.search = SearchState {
                         direction,
@@ -146,7 +151,7 @@ impl Vim {
                         initial_query: query,
                         prior_selections,
                         prior_operator: self.operator_stack.last().cloned(),
-                        prior_mode: self.mode,
+                        prior_mode,
                     }
                 });
             }

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

@@ -42,6 +42,7 @@ impl Vim {
                 });
             });
         });
+        self.exit_temporary_normal(cx);
     }
 
     pub fn yank_object(&mut self, object: Object, around: bool, cx: &mut ViewContext<Self>) {
@@ -65,6 +66,7 @@ impl Vim {
                 });
             });
         });
+        self.exit_temporary_normal(cx);
     }
 
     pub fn yank_selections_content(

crates/vim/src/state.rs 🔗

@@ -153,6 +153,7 @@ pub struct VimGlobals {
     pub stop_recording_after_next_action: bool,
     pub ignore_current_insertion: bool,
     pub recorded_count: Option<usize>,
+    pub recording_actions: Vec<ReplayableAction>,
     pub recorded_actions: Vec<ReplayableAction>,
     pub recorded_selection: RecordedSelection,
 
@@ -339,11 +340,12 @@ impl VimGlobals {
 
     pub fn observe_action(&mut self, action: Box<dyn Action>) {
         if self.dot_recording {
-            self.recorded_actions
+            self.recording_actions
                 .push(ReplayableAction::Action(action.boxed_clone()));
 
             if self.stop_recording_after_next_action {
                 self.dot_recording = false;
+                self.recorded_actions = std::mem::take(&mut self.recording_actions);
                 self.stop_recording_after_next_action = false;
             }
         }
@@ -363,12 +365,13 @@ impl VimGlobals {
             return;
         }
         if self.dot_recording {
-            self.recorded_actions.push(ReplayableAction::Insertion {
+            self.recording_actions.push(ReplayableAction::Insertion {
                 text: text.clone(),
                 utf16_range_to_replace: range_to_replace.clone(),
             });
             if self.stop_recording_after_next_action {
                 self.dot_recording = false;
+                self.recorded_actions = std::mem::take(&mut self.recording_actions);
                 self.stop_recording_after_next_action = false;
             }
         }

crates/vim/src/test.rs 🔗

@@ -1570,3 +1570,36 @@ async fn test_sentence_forwards(cx: &mut gpui::TestAppContext) {
 
     cx.set_shared_state("helˇlo.\n\n\nworld.").await;
 }
+
+#[gpui::test]
+async fn test_ctrl_o_visual(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("helloˇ world.").await;
+    cx.simulate_shared_keystrokes("i ctrl-o v b r l").await;
+    cx.shared_state().await.assert_eq("ˇllllllworld.");
+    cx.simulate_shared_keystrokes("ctrl-o v f w d").await;
+    cx.shared_state().await.assert_eq("ˇorld.");
+}
+
+#[gpui::test]
+async fn test_ctrl_o_position(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("helˇlo world.").await;
+    cx.simulate_shared_keystrokes("i ctrl-o d i w").await;
+    cx.shared_state().await.assert_eq("ˇ world.");
+    cx.simulate_shared_keystrokes("ctrl-o p").await;
+    cx.shared_state().await.assert_eq(" helloˇworld.");
+}
+
+#[gpui::test]
+async fn test_ctrl_o_dot(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state("heˇllo world.").await;
+    cx.simulate_shared_keystrokes("x i ctrl-o .").await;
+    cx.shared_state().await.assert_eq("heˇo world.");
+    cx.simulate_shared_keystrokes("l l escape .").await;
+    cx.shared_state().await.assert_eq("hellˇllo world.");
+}

crates/vim/src/vim.rs 🔗

@@ -28,7 +28,7 @@ use gpui::{
     actions, impl_actions, Action, AppContext, Entity, EventEmitter, KeyContext, KeystrokeEvent,
     Render, Subscription, View, ViewContext, WeakView,
 };
-use insert::NormalBefore;
+use insert::{NormalBefore, TemporaryNormal};
 use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId};
 pub use mode_indicator::ModeIndicator;
 use motion::Motion;
@@ -38,7 +38,7 @@ use serde::Deserialize;
 use serde_derive::Serialize;
 use settings::{update_settings_file, Settings, SettingsSources, SettingsStore};
 use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals};
-use std::{ops::Range, sync::Arc};
+use std::{mem, ops::Range, sync::Arc};
 use surrounds::SurroundsType;
 use ui::{IntoElement, VisualContext};
 use workspace::{self, Pane, Workspace};
@@ -147,6 +147,8 @@ impl editor::Addon for VimAddon {
 pub(crate) struct Vim {
     pub(crate) mode: Mode,
     pub last_mode: Mode,
+    pub temp_mode: bool,
+    pub exit_temporary_mode: bool,
 
     /// pre_count is the number before an operator is specified (3 in 3d2d)
     pre_count: Option<usize>,
@@ -197,6 +199,8 @@ impl Vim {
         cx.new_view(|cx| Vim {
             mode: Mode::Normal,
             last_mode: Mode::Normal,
+            temp_mode: false,
+            exit_temporary_mode: false,
             pre_count: None,
             post_count: None,
             operator_stack: Vec::new(),
@@ -353,6 +357,16 @@ impl Vim {
     /// Called whenever an keystroke is typed so vim can observe all actions
     /// and keystrokes accordingly.
     fn observe_keystrokes(&mut self, keystroke_event: &KeystrokeEvent, cx: &mut ViewContext<Self>) {
+        if self.exit_temporary_mode {
+            self.exit_temporary_mode = false;
+            // Don't switch to insert mode if the action is temporary_normal.
+            if let Some(action) = keystroke_event.action.as_ref() {
+                if action.as_any().downcast_ref::<TemporaryNormal>().is_some() {
+                    return;
+                }
+            }
+            self.switch_mode(Mode::Insert, false, cx)
+        }
         if let Some(action) = keystroke_event.action.as_ref() {
             // Keystroke is handled by the vim system, so continue forward
             if action.name().starts_with("vim::") {
@@ -438,6 +452,17 @@ impl Vim {
     }
 
     pub fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut ViewContext<Self>) {
+        if self.temp_mode && mode == Mode::Normal {
+            self.temp_mode = false;
+            self.switch_mode(Mode::Normal, leave_selections, cx);
+            self.switch_mode(Mode::Insert, false, cx);
+            return;
+        } else if self.temp_mode
+            && !matches!(mode, Mode::Visual | Mode::VisualLine | Mode::VisualBlock)
+        {
+            self.temp_mode = false;
+        }
+
         let last_mode = self.mode;
         let prior_mode = self.last_mode;
         let prior_tx = self.current_tx;
@@ -729,7 +754,7 @@ impl Vim {
         Vim::update_globals(cx, |globals, cx| {
             if !globals.dot_replaying {
                 globals.dot_recording = true;
-                globals.recorded_actions = Default::default();
+                globals.recording_actions = Default::default();
                 globals.recorded_count = None;
 
                 let selections = self.editor().map(|editor| {
@@ -784,6 +809,7 @@ impl Vim {
         if globals.dot_recording {
             globals.stop_recording_after_next_action = true;
         }
+        self.exit_temporary_mode = self.temp_mode;
     }
 
     /// Stops recording actions immediately rather than waiting until after the
@@ -798,11 +824,13 @@ impl Vim {
         let globals = Vim::globals(cx);
         if globals.dot_recording {
             globals
-                .recorded_actions
+                .recording_actions
                 .push(ReplayableAction::Action(action.boxed_clone()));
+            globals.recorded_actions = mem::take(&mut globals.recording_actions);
             globals.dot_recording = false;
             globals.stop_recording_after_next_action = false;
         }
+        self.exit_temporary_mode = self.temp_mode;
     }
 
     /// Explicitly record one action (equivalents to start_recording and stop_recording)

crates/vim/test_data/test_ctrl_o_dot.json 🔗

@@ -0,0 +1,11 @@
+{"Put":{"state":"heˇllo world."}}
+{"Key":"x"}
+{"Key":"i"}
+{"Key":"ctrl-o"}
+{"Key":"."}
+{"Get":{"state":"heˇo world.","mode":"Insert"}}
+{"Key":"l"}
+{"Key":"l"}
+{"Key":"escape"}
+{"Key":"."}
+{"Get":{"state":"hellˇllo world.","mode":"Normal"}}

crates/vim/test_data/test_ctrl_o_position.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"helˇlo world."}}
+{"Key":"i"}
+{"Key":"ctrl-o"}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"ˇ world.","mode":"Insert"}}
+{"Key":"ctrl-o"}
+{"Key":"p"}
+{"Get":{"state":" helloˇworld.","mode":"Insert"}}

crates/vim/test_data/test_ctrl_o_visual.json 🔗

@@ -0,0 +1,14 @@
+{"Put":{"state":"helloˇ world."}}
+{"Key":"i"}
+{"Key":"ctrl-o"}
+{"Key":"v"}
+{"Key":"b"}
+{"Key":"r"}
+{"Key":"l"}
+{"Get":{"state":"ˇllllllworld.","mode":"Insert"}}
+{"Key":"ctrl-o"}
+{"Key":"v"}
+{"Key":"f"}
+{"Key":"w"}
+{"Key":"d"}
+{"Get":{"state":"ˇorld.","mode":"Insert"}}