From 75f1862268b960f01c3abf95ee454c203a0aeb3d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Thu, 31 Oct 2024 23:25:42 -0600 Subject: [PATCH] vim: Add (half of) ctrl-v/ctrl-q (#19585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 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(-) create mode 100644 crates/vim/test_data/test_ctrl_v.json create mode 100644 crates/vim/test_data/test_ctrl_v_control.json create mode 100644 crates/vim/test_data/test_ctrl_v_escape.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 8b2a728df3fccfbb1b124ca042cd4f4e655d6ce5..24ea1defb05fd2bd56d7521bb566a272164fe7e3 100644 --- a/assets/keymaps/vim.json +++ b/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": { diff --git a/crates/vim/src/digraph.rs b/crates/vim/src/digraph.rs index 443b7ff37801eb337db547ae5ae62a72d5cca78b..4c09dd3e3331344581f4e24371de638f8bee4bff 100644 --- a/crates/vim/src/digraph.rs +++ b/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::action(editor, cx, Vim::literal) +} + static DEFAULT_DIGRAPHS_MAP: LazyLock>> = 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) { + 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, + ) { + // 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, + ) { + 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, suffix: &str, cx: &mut ViewContext) { + 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ˇ"); + } } diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 214462bc8df92a108d40f40ef2439cf29dadfeff..e5bb31944bf420c2f7b04f0dd4d680a143269f1c 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/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::>() .join("") diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index f9dfcdd2c3a8f11c7da63a23e6e260bad466be97..eb1abf1553fc5f83841766176842a77ad66668d4 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -77,6 +77,7 @@ pub enum Operator { Uppercase, OppositeCase, Digraph { first_char: Option }, + Literal { prefix: Option }, 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 diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 6ec708d8b810487d3492cead664e01562ce8ab12..e265b0201e095f0cb1f77eeb6641ba021d4fd49e 100644 --- a/crates/vim/src/vim.rs +++ b/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 { diff --git a/crates/vim/test_data/test_ctrl_v.json b/crates/vim/test_data/test_ctrl_v.json new file mode 100644 index 0000000000000000000000000000000000000000..dfc090ab18d525fde22e5307d57713e727621edb --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_ctrl_v_control.json b/crates/vim/test_data/test_ctrl_v_control.json new file mode 100644 index 0000000000000000000000000000000000000000..a5a55cf6d5a226f244e21ec4c24801315d6d84fa --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_ctrl_v_escape.json b/crates/vim/test_data/test_ctrl_v_escape.json new file mode 100644 index 0000000000000000000000000000000000000000..8c0397ef0aedb9dbc9b6a1712f0868ccf0a84ffe --- /dev/null +++ b/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"}}