vim: Add (half of) ctrl-v/ctrl-q (#19585)

Conrad Irwin created

Release Notes:

- vim: Add `ctrl-v`/`ctrl-q` to type any unicode code point. For example
`ctrl-v escape` inserts an escape character(U+001B), or `ctrl-v u 1 0 E
2` types αƒ’ (U+10E2). As in vim `ctrl-v ctrl-j` inserts U+0000 not
U+000A. Zed does not yet implement insertion of the vim-specific
representation of the typed keystroke for other keystrokes.
- vim: Add `ctrl-shift-v` as an alias for paste on Linux

Change summary

assets/keymaps/vim.json                       |  55 +++++
crates/vim/src/digraph.rs                     | 200 ++++++++++++++++++++
crates/vim/src/mode_indicator.rs              |   6 
crates/vim/src/state.rs                       |  15 +
crates/vim/src/vim.rs                         |  26 ++
crates/vim/test_data/test_ctrl_v.json         |  24 ++
crates/vim/test_data/test_ctrl_v_control.json |  11 +
crates/vim/test_data/test_ctrl_v_escape.json  |  10 +
8 files changed, 337 insertions(+), 10 deletions(-)

Detailed changes

assets/keymaps/vim.json πŸ”—

@@ -339,6 +339,10 @@
       "ctrl-t": "vim::Indent",
       "ctrl-d": "vim::Outdent",
       "ctrl-k": ["vim::PushOperator", { "Digraph": {} }],
+      "ctrl-v": ["vim::PushOperator", { "Literal": {} }],
+      "ctrl-shift-v": "editor::Paste", // note: this is *very* similar to ctrl-v in vim, but ctrl-shift-v on linux is the typical shortcut for paste when ctrl-v is already in use.
+      "ctrl-q": ["vim::PushOperator", { "Literal": {} }],
+      "ctrl-shift-q": ["vim::PushOperator", { "Literal": {} }],
       "ctrl-r": ["vim::PushOperator", "Register"],
       "insert": "vim::ToggleReplace"
     }
@@ -357,6 +361,10 @@
       "ctrl-c": "vim::NormalBefore",
       "ctrl-[": "vim::NormalBefore",
       "ctrl-k": ["vim::PushOperator", { "Digraph": {} }],
+      "ctrl-v": ["vim::PushOperator", { "Literal": {} }],
+      "ctrl-shift-v": "editor::Paste", // note: this is *very* similar to ctrl-v in vim, but ctrl-shift-v on linux is the typical shortcut for paste when ctrl-v is already in use.
+      "ctrl-q": ["vim::PushOperator", { "Literal": {} }],
+      "ctrl-shift-q": ["vim::PushOperator", { "Literal": {} }],
       "backspace": "vim::UndoReplace",
       "tab": "vim::Tab",
       "enter": "vim::Enter",
@@ -371,7 +379,9 @@
       "escape": "vim::ClearOperators",
       "ctrl-c": "vim::ClearOperators",
       "ctrl-[": "vim::ClearOperators",
-      "ctrl-k": ["vim::PushOperator", { "Digraph": {} }]
+      "ctrl-k": ["vim::PushOperator", { "Digraph": {} }],
+      "ctrl-v": ["vim::PushOperator", { "Literal": {} }],
+      "ctrl-q": ["vim::PushOperator", { "Literal": {} }]
     }
   },
   {
@@ -485,6 +495,49 @@
       "c": "vim::CurrentLine"
     }
   },
+  {
+    "context": "vim_mode == literal",
+    "bindings": {
+      "ctrl-@": ["vim::Literal", ["ctrl-@", "\u0000"]],
+      "ctrl-a": ["vim::Literal", ["ctrl-a", "\u0001"]],
+      "ctrl-b": ["vim::Literal", ["ctrl-b", "\u0002"]],
+      "ctrl-c": ["vim::Literal", ["ctrl-c", "\u0003"]],
+      "ctrl-d": ["vim::Literal", ["ctrl-d", "\u0004"]],
+      "ctrl-e": ["vim::Literal", ["ctrl-e", "\u0005"]],
+      "ctrl-f": ["vim::Literal", ["ctrl-f", "\u0006"]],
+      "ctrl-g": ["vim::Literal", ["ctrl-g", "\u0007"]],
+      "ctrl-h": ["vim::Literal", ["ctrl-h", "\u0008"]],
+      "ctrl-i": ["vim::Literal", ["ctrl-i", "\u0009"]],
+      "ctrl-j": ["vim::Literal", ["ctrl-j", "\u000A"]],
+      "ctrl-k": ["vim::Literal", ["ctrl-k", "\u000B"]],
+      "ctrl-l": ["vim::Literal", ["ctrl-l", "\u000C"]],
+      "ctrl-m": ["vim::Literal", ["ctrl-m", "\u000D"]],
+      "ctrl-n": ["vim::Literal", ["ctrl-n", "\u000E"]],
+      "ctrl-o": ["vim::Literal", ["ctrl-o", "\u000F"]],
+      "ctrl-p": ["vim::Literal", ["ctrl-p", "\u0010"]],
+      "ctrl-q": ["vim::Literal", ["ctrl-q", "\u0011"]],
+      "ctrl-r": ["vim::Literal", ["ctrl-r", "\u0012"]],
+      "ctrl-s": ["vim::Literal", ["ctrl-s", "\u0013"]],
+      "ctrl-t": ["vim::Literal", ["ctrl-t", "\u0014"]],
+      "ctrl-u": ["vim::Literal", ["ctrl-u", "\u0015"]],
+      "ctrl-v": ["vim::Literal", ["ctrl-v", "\u0016"]],
+      "ctrl-w": ["vim::Literal", ["ctrl-w", "\u0017"]],
+      "ctrl-x": ["vim::Literal", ["ctrl-x", "\u0018"]],
+      "ctrl-y": ["vim::Literal", ["ctrl-y", "\u0019"]],
+      "ctrl-z": ["vim::Literal", ["ctrl-z", "\u001A"]],
+      "ctrl-[": ["vim::Literal", ["ctrl-[", "\u001B"]],
+      "ctrl-\\": ["vim::Literal", ["ctrl-\\", "\u001C"]],
+      "ctrl-]": ["vim::Literal", ["ctrl-]", "\u001D"]],
+      "ctrl-^": ["vim::Literal", ["ctrl-^", "\u001E"]],
+      "ctrl-_": ["vim::Literal", ["ctrl-_", "\u001F"]],
+      "escape": ["vim::Literal", ["escape", "\u001B"]],
+      "enter": ["vim::Literal", ["enter", "\u000D"]],
+      "tab": ["vim::Literal", ["tab", "\u0009"]],
+      // zed extensions:
+      "backspace": ["vim::Literal", ["backspace", "\u0008"]],
+      "delete": ["vim::Literal", ["delete", "\u007F"]]
+    }
+  },
   {
     "context": "BufferSearchBar && !in_replace",
     "bindings": {

crates/vim/src/digraph.rs πŸ”—

@@ -1,15 +1,25 @@
 use std::sync::Arc;
 
 use collections::HashMap;
-use gpui::AppContext;
+use editor::Editor;
+use gpui::{impl_actions, AppContext, Keystroke, KeystrokeEvent};
+use serde::Deserialize;
 use settings::Settings;
 use std::sync::LazyLock;
 use ui::ViewContext;
 
-use crate::{Vim, VimSettings};
+use crate::{state::Operator, Vim, VimSettings};
 
 mod default;
 
+#[derive(PartialEq, Clone, Deserialize)]
+struct Literal(String, char);
+impl_actions!(vim, [Literal]);
+
+pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
+    Vim::action(editor, cx, Vim::literal)
+}
+
 static DEFAULT_DIGRAPHS_MAP: LazyLock<HashMap<String, Arc<str>>> = LazyLock::new(|| {
     let mut map = HashMap::default();
     for &(a, b, c) in default::DEFAULT_DIGRAPHS {
@@ -50,6 +60,153 @@ impl Vim {
             self.input_ignored(text, cx);
         }
     }
+
+    fn literal(&mut self, action: &Literal, cx: &mut ViewContext<Self>) {
+        if let Some(Operator::Literal { prefix }) = self.active_operator() {
+            if let Some(prefix) = prefix {
+                if let Some(keystroke) = Keystroke::parse(&action.0).ok() {
+                    cx.window_context().defer(|cx| {
+                        cx.dispatch_keystroke(keystroke);
+                    });
+                }
+                return self.handle_literal_input(prefix, "", cx);
+            }
+        }
+
+        self.insert_literal(Some(action.1), "", cx);
+    }
+
+    pub fn handle_literal_keystroke(
+        &mut self,
+        keystroke_event: &KeystrokeEvent,
+        prefix: String,
+        cx: &mut ViewContext<Self>,
+    ) {
+        // handled by handle_literal_input
+        if keystroke_event.keystroke.ime_key.is_some() {
+            return;
+        };
+
+        if prefix.len() > 0 {
+            self.handle_literal_input(prefix, "", cx);
+        } else {
+            self.pop_operator(cx);
+        }
+
+        // give another chance to handle the binding outside
+        // of waiting mode.
+        if keystroke_event.action.is_none() {
+            let keystroke = keystroke_event.keystroke.clone();
+            cx.window_context().defer(|cx| {
+                cx.dispatch_keystroke(keystroke);
+            });
+        }
+        return;
+    }
+
+    pub fn handle_literal_input(
+        &mut self,
+        mut prefix: String,
+        text: &str,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let first = prefix.chars().next();
+        let next = text.chars().next().unwrap_or(' ');
+        match first {
+            Some('o' | 'O') => {
+                if next.is_digit(8) {
+                    prefix.push(next);
+                    if prefix.len() == 4 {
+                        let ch: char = u8::from_str_radix(&prefix[1..], 8).unwrap_or(255).into();
+                        return self.insert_literal(Some(ch), "", cx);
+                    }
+                } else {
+                    let ch = if prefix.len() > 1 {
+                        Some(u8::from_str_radix(&prefix[1..], 8).unwrap_or(255).into())
+                    } else {
+                        None
+                    };
+                    return self.insert_literal(ch, text, cx);
+                }
+            }
+            Some('x' | 'X' | 'u' | 'U') => {
+                let max_len = match first.unwrap() {
+                    'x' => 3,
+                    'X' => 3,
+                    'u' => 5,
+                    'U' => 9,
+                    _ => unreachable!(),
+                };
+                if next.is_ascii_hexdigit() {
+                    prefix.push(next);
+                    if prefix.len() == max_len {
+                        let ch: char = u32::from_str_radix(&prefix[1..], 16)
+                            .ok()
+                            .and_then(|n| n.try_into().ok())
+                            .unwrap_or('\u{FFFD}');
+                        return self.insert_literal(Some(ch), "", cx);
+                    }
+                } else {
+                    let ch = if prefix.len() > 1 {
+                        Some(
+                            u32::from_str_radix(&prefix[1..], 16)
+                                .ok()
+                                .and_then(|n| n.try_into().ok())
+                                .unwrap_or('\u{FFFD}'),
+                        )
+                    } else {
+                        None
+                    };
+                    return self.insert_literal(ch, text, cx);
+                }
+            }
+            Some('0'..='9') => {
+                if next.is_ascii_hexdigit() {
+                    prefix.push(next);
+                    if prefix.len() == 3 {
+                        let ch: char = u8::from_str_radix(&prefix, 10).unwrap_or(255).into();
+                        return self.insert_literal(Some(ch), "", cx);
+                    }
+                } else {
+                    let ch: char = u8::from_str_radix(&prefix, 10).unwrap_or(255).into();
+                    return self.insert_literal(Some(ch), "", cx);
+                }
+            }
+            None if matches!(next, 'o' | 'O' | 'x' | 'X' | 'u' | 'U' | '0'..='9') => {
+                prefix.push(next)
+            }
+            _ => {
+                return self.insert_literal(None, text, cx);
+            }
+        };
+
+        self.pop_operator(cx);
+        self.push_operator(
+            Operator::Literal {
+                prefix: Some(prefix),
+            },
+            cx,
+        );
+    }
+
+    fn insert_literal(&mut self, ch: Option<char>, suffix: &str, cx: &mut ViewContext<Self>) {
+        self.pop_operator(cx);
+        let mut text = String::new();
+        if let Some(c) = ch {
+            if c == '\n' {
+                text.push('\x00')
+            } else {
+                text.push(c)
+            }
+        }
+        text.push_str(suffix);
+
+        if self.editor_input_enabled() {
+            self.update_editor(cx, |_, editor, cx| editor.insert(&text, cx));
+        } else {
+            self.input_ignored(text.into(), cx);
+        }
+    }
 }
 
 #[cfg(test)]
@@ -154,4 +311,43 @@ mod test {
         cx.simulate_shared_keystrokes("a ctrl-k s , escape").await;
         cx.shared_state().await.assert_eq("HelloΛ‡ΕŸ");
     }
+
+    #[gpui::test]
+    async fn test_ctrl_v(cx: &mut gpui::TestAppContext) {
+        let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("Λ‡").await;
+        cx.simulate_shared_keystrokes("i ctrl-v 0 0 0").await;
+        cx.shared_state().await.assert_eq("\x00Λ‡");
+
+        cx.simulate_shared_keystrokes("ctrl-v j").await;
+        cx.shared_state().await.assert_eq("\x00jˇ");
+        cx.simulate_shared_keystrokes("ctrl-v x 6 5").await;
+        cx.shared_state().await.assert_eq("\x00jeˇ");
+        cx.simulate_shared_keystrokes("ctrl-v U 1 F 6 4 0 space")
+            .await;
+        cx.shared_state().await.assert_eq("\x00jeπŸ™€ Λ‡");
+    }
+
+    #[gpui::test]
+    async fn test_ctrl_v_escape(cx: &mut gpui::TestAppContext) {
+        let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state("Λ‡").await;
+        cx.simulate_shared_keystrokes("i ctrl-v 9 escape").await;
+        cx.shared_state().await.assert_eq("Λ‡\t");
+        cx.simulate_shared_keystrokes("i ctrl-v escape").await;
+        cx.shared_state().await.assert_eq("\x1bˇ\t");
+    }
+
+    #[gpui::test]
+    async fn test_ctrl_v_control(cx: &mut gpui::TestAppContext) {
+        let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state("Λ‡").await;
+        cx.simulate_shared_keystrokes("i ctrl-v ctrl-d").await;
+        cx.shared_state().await.assert_eq("\x04Λ‡");
+        cx.simulate_shared_keystrokes("ctrl-v ctrl-j").await;
+        cx.shared_state().await.assert_eq("\x04\x00Λ‡");
+        cx.simulate_shared_keystrokes("ctrl-v tab").await;
+        cx.shared_state().await.assert_eq("\x04\x00\x09Λ‡");
+    }
 }

crates/vim/src/mode_indicator.rs πŸ”—

@@ -70,7 +70,11 @@ impl ModeIndicator {
         recording
             .chain(vim.pre_count.map(|count| format!("{}", count)))
             .chain(vim.selected_register.map(|reg| format!("\"{reg}")))
-            .chain(vim.operator_stack.iter().map(|item| item.id().to_string()))
+            .chain(
+                vim.operator_stack
+                    .iter()
+                    .map(|item| item.status().to_string()),
+            )
             .chain(vim.post_count.map(|count| format!("{}", count)))
             .collect::<Vec<_>>()
             .join("")

crates/vim/src/state.rs πŸ”—

@@ -77,6 +77,7 @@ pub enum Operator {
     Uppercase,
     OppositeCase,
     Digraph { first_char: Option<char> },
+    Literal { prefix: Option<String> },
     Register,
     RecordRegister,
     ReplayRegister,
@@ -444,6 +445,7 @@ impl Operator {
             Operator::Yank => "y",
             Operator::Replace => "r",
             Operator::Digraph { .. } => "^K",
+            Operator::Literal { .. } => "^V",
             Operator::FindForward { before: false } => "f",
             Operator::FindForward { before: true } => "t",
             Operator::FindBackward { after: false } => "F",
@@ -467,6 +469,18 @@ impl Operator {
         }
     }
 
+    pub fn status(&self) -> String {
+        match self {
+            Operator::Digraph {
+                first_char: Some(first_char),
+            } => format!("^K{first_char}"),
+            Operator::Literal {
+                prefix: Some(prefix),
+            } => format!("^V{prefix}"),
+            _ => self.id().to_string(),
+        }
+    }
+
     pub fn is_waiting(&self, mode: Mode) -> bool {
         match self {
             Operator::AddSurrounds { target } => target.is_some() || mode.is_visual(),
@@ -479,6 +493,7 @@ impl Operator {
             | Operator::ReplayRegister
             | Operator::Replace
             | Operator::Digraph { .. }
+            | Operator::Literal { .. }
             | Operator::ChangeSurrounds { target: Some(_) }
             | Operator::DeleteSurrounds => true,
             Operator::Change

crates/vim/src/vim.rs πŸ”—

@@ -296,6 +296,7 @@ impl Vim {
             object::register(editor, cx);
             visual::register(editor, cx);
             change_list::register(editor, cx);
+            digraph::register(editor, cx);
 
             cx.defer(|vim, cx| {
                 vim.focused(false, cx);
@@ -359,9 +360,15 @@ impl Vim {
         }
 
         if let Some(operator) = self.active_operator() {
-            if !operator.is_waiting(self.mode) {
-                self.clear_operator(cx);
-                self.stop_recording_immediately(Box::new(ClearOperators), cx)
+            match operator {
+                Operator::Literal { prefix } => {
+                    self.handle_literal_keystroke(keystroke_event, prefix.unwrap_or_default(), cx);
+                }
+                _ if !operator.is_waiting(self.mode) => {
+                    self.clear_operator(cx);
+                    self.stop_recording_immediately(Box::new(ClearOperators), cx)
+                }
+                _ => {}
             }
         }
     }
@@ -602,14 +609,18 @@ impl Vim {
 
         if let Some(active_operator) = active_operator {
             if active_operator.is_waiting(self.mode) {
-                mode = "waiting".to_string();
+                if matches!(active_operator, Operator::Literal { .. }) {
+                    mode = "literal".to_string();
+                } else {
+                    mode = "waiting".to_string();
+                }
             } else {
-                mode = "operator".to_string();
                 operator_id = active_operator.id();
+                mode = "operator".to_string();
             }
         }
 
-        if mode != "waiting" && mode != "insert" && mode != "replace" {
+        if mode == "normal" || mode == "visual" || mode == "operator" {
             context.add("VimControl");
         }
         context.set("vim_mode", mode);
@@ -998,6 +1009,9 @@ impl Vim {
                     self.push_operator(Operator::Digraph { first_char }, cx);
                 }
             }
+            Some(Operator::Literal { prefix }) => {
+                self.handle_literal_input(prefix.unwrap_or_default(), &text, cx)
+            }
             Some(Operator::AddSurrounds { target }) => match self.mode {
                 Mode::Normal => {
                     if let Some(target) = target {

crates/vim/test_data/test_ctrl_v.json πŸ”—

@@ -0,0 +1,24 @@
+{"Put":{"state":"Λ‡"}}
+{"Key":"i"}
+{"Key":"ctrl-v"}
+{"Key":"0"}
+{"Key":"0"}
+{"Key":"0"}
+{"Get":{"state":"\u0000Λ‡","mode":"Insert"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Get":{"state":"\u0000jˇ","mode":"Insert"}}
+{"Key":"ctrl-v"}
+{"Key":"x"}
+{"Key":"6"}
+{"Key":"5"}
+{"Get":{"state":"\u0000jeˇ","mode":"Insert"}}
+{"Key":"ctrl-v"}
+{"Key":"U"}
+{"Key":"1"}
+{"Key":"F"}
+{"Key":"6"}
+{"Key":"4"}
+{"Key":"0"}
+{"Key":"space"}
+{"Get":{"state":"\u0000jeπŸ™€ Λ‡","mode":"Insert"}}

crates/vim/test_data/test_ctrl_v_control.json πŸ”—

@@ -0,0 +1,11 @@
+{"Put":{"state":"Λ‡"}}
+{"Key":"i"}
+{"Key":"ctrl-v"}
+{"Key":"ctrl-d"}
+{"Get":{"state":"\u0004Λ‡","mode":"Insert"}}
+{"Key":"ctrl-v"}
+{"Key":"ctrl-j"}
+{"Get":{"state":"\u0004\u0000Λ‡","mode":"Insert"}}
+{"Key":"ctrl-v"}
+{"Key":"tab"}
+{"Get":{"state":"\u0004\u0000\tˇ","mode":"Insert"}}

crates/vim/test_data/test_ctrl_v_escape.json πŸ”—

@@ -0,0 +1,10 @@
+{"Put":{"state":"Λ‡"}}
+{"Key":"i"}
+{"Key":"ctrl-v"}
+{"Key":"9"}
+{"Key":"escape"}
+{"Get":{"state":"Λ‡\t","mode":"Normal"}}
+{"Key":"i"}
+{"Key":"ctrl-v"}
+{"Key":"escape"}
+{"Get":{"state":"\u001bˇ\t","mode":"Insert"}}