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

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
This commit is contained in:
Conrad Irwin 2024-10-31 23:25:42 -06:00 committed by GitHub
parent f8ab86f930
commit 75f1862268
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 337 additions and 10 deletions

View File

@ -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": {

View File

@ -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("\x00");
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ˇ");
}
}

View File

@ -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("")

View File

@ -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

View File

@ -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 {

View File

@ -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"}}

View File

@ -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"}}

View File

@ -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"}}