vim: (BREAKING) clean up keymap contexts (#14233)

Release Notes:

- vim: (BREAKING) Improved vim keymap contexts.

Previously `vim_mode == normal` was true even when operators were
pending, which led to bugs like #13789 and a requirement for custom
keymaps to exclude various conditions like (`!VimObject` and
`!VimWaiting`) to avoid bugs.

Now `vim_mode` will be set to `operator` or `waiting` in these cases as
described in [the docs](https://zed.dev/docs/vim#keybindings). For most
custom keymaps this change will be a no-op or an improvement, but if you
were deliberately relying on the old behaviour (if you were relying on
`VimObject` or `VimWaiting` becoming true) you will need to update your
keymap.

---------

Co-authored-by: Thorsten <thorsten@zed.dev>
This commit is contained in:
Conrad Irwin 2024-07-11 13:16:26 -06:00 committed by GitHub
parent 8e853e2b56
commit b0dbc80575
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 186 additions and 246 deletions

View file

@ -1,12 +1,6 @@
[
{
"context": "ProjectPanel || Editor",
"bindings": {
"ctrl-6": "pane::AlternateFile"
}
},
{
"context": "Editor && VimControl && !VimWaiting && !menu",
"context": "VimControl && !menu",
"bindings": {
"i": ["vim::PushOperator", { "Object": { "around": false } }],
"a": ["vim::PushOperator", { "Object": { "around": true } }],
@ -198,20 +192,21 @@
"ctrl-w g shift-d": "editor::GoToTypeDefinitionSplit",
"ctrl-w space": "editor::OpenExcerptsSplit",
"ctrl-w g space": "editor::OpenExcerptsSplit",
"-": "pane::RevealInProjectPanel"
"-": "pane::RevealInProjectPanel",
"ctrl-6": "pane::AlternateFile"
}
},
{
// escape is in its own section so that it cancels a pending count.
"context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
"context": "VimControl && VimCount",
"bindings": {
"0": ["vim::Number", 0]
}
},
{
"context": "vim_mode == normal",
"bindings": {
"escape": "editor::Cancel",
"ctrl-[": "editor::Cancel"
}
},
{
"context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
"bindings": {
"ctrl-[": "editor::Cancel",
".": "vim::Repeat",
"c": ["vim::PushOperator", "Change"],
"shift-c": "vim::ChangeToEndOfLine",
@ -255,127 +250,12 @@
"] d": "editor::GoToDiagnostic",
"[ d": "editor::GoToPrevDiagnostic",
"] c": "editor::GoToHunk",
"[ c": "editor::GoToPrevHunk"
"[ c": "editor::GoToPrevHunk",
"g c c": "vim::ToggleComments"
}
},
{
"context": "Editor && vim_mode == visual && vim_operator == none && !VimWaiting",
"bindings": {
"\"": ["vim::PushOperator", "Register"],
// tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode"
}
},
{
"context": "Editor && VimCount && vim_mode != insert",
"bindings": {
"0": ["vim::Number", 0]
}
},
{
"context": "Editor && vim_operator == c",
"bindings": {
"c": "vim::CurrentLine",
"d": "editor::Rename" // zed specific
}
},
{
"context": "Editor && vim_mode == normal && vim_operator == c",
"bindings": {
"s": ["vim::PushOperator", { "ChangeSurrounds": {} }]
}
},
{
"context": "Editor && vim_operator == d",
"bindings": {
"d": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_operator == gu",
"bindings": {
"g u": "vim::CurrentLine",
"u": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_operator == gU",
"bindings": {
"g shift-u": "vim::CurrentLine",
"shift-u": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_operator == g~",
"bindings": {
"g ~": "vim::CurrentLine",
"~": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_mode == normal && vim_operator == d",
"bindings": {
"s": ["vim::PushOperator", "DeleteSurrounds"]
}
},
{
"context": "Editor && vim_operator == y",
"bindings": {
"y": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_mode == normal && vim_operator == y",
"bindings": {
"s": ["vim::PushOperator", { "AddSurrounds": {} }]
}
},
{
"context": "Editor && vim_operator == ys",
"bindings": {
"s": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_operator == >",
"bindings": {
">": "vim::CurrentLine"
}
},
{
"context": "Editor && vim_operator == <",
"bindings": {
"<": "vim::CurrentLine"
}
},
{
"context": "Editor && VimObject",
"bindings": {
"w": "vim::Word",
"shift-w": ["vim::Word", { "ignorePunctuation": true }],
"t": "vim::Tag",
"s": "vim::Sentence",
"p": "vim::Paragraph",
"'": "vim::Quotes",
"`": "vim::BackQuotes",
"\"": "vim::DoubleQuotes",
"|": "vim::VerticalBars",
"(": "vim::Parentheses",
")": "vim::Parentheses",
"b": "vim::Parentheses",
"[": "vim::SquareBrackets",
"]": "vim::SquareBrackets",
"{": "vim::CurlyBrackets",
"}": "vim::CurlyBrackets",
"shift-b": "vim::CurlyBrackets",
"<": "vim::AngleBrackets",
">": "vim::AngleBrackets",
"a": "vim::Argument"
}
},
{
"context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
"context": "vim_mode == visual",
"bindings": {
"u": "vim::ConvertToLowerCase",
"U": "vim::ConvertToUpperCase",
@ -410,23 +290,16 @@
">": "vim::Indent",
"<": "vim::Outdent",
"i": ["vim::PushOperator", { "Object": { "around": false } }],
"a": ["vim::PushOperator", { "Object": { "around": true } }]
"a": ["vim::PushOperator", { "Object": { "around": true } }],
"g c": "vim::ToggleComments",
"\"": ["vim::PushOperator", "Register"],
// tree-sitter related commands
"[ x": "editor::SelectLargerSyntaxNode",
"] x": "editor::SelectSmallerSyntaxNode"
}
},
{
"context": "Editor && vim_mode == normal && !VimWaiting",
"bindings": {
"g c c": "vim::ToggleComments"
}
},
{
"context": "Editor && vim_mode == visual",
"bindings": {
"g c": "vim::ToggleComments"
}
},
{
"context": "Editor && vim_mode == insert",
"context": "vim_mode == insert",
"bindings": {
"escape": "vim::NormalBefore",
"ctrl-c": "vim::NormalBefore",
@ -445,30 +318,115 @@
}
},
{
"context": "Editor && vim_mode == replace",
"context": "vim_mode == replace",
"bindings": {
"escape": "vim::NormalBefore",
"ctrl-c": "vim::NormalBefore",
"ctrl-[": "vim::NormalBefore",
"backspace": "vim::UndoReplace",
"tab": "vim::Tab",
"enter": "vim::Enter",
"backspace": "vim::UndoReplace"
"enter": "vim::Enter"
}
},
{
"context": "Editor && vim_mode != replace && VimWaiting",
"context": "vim_mode == waiting",
"bindings": {
"tab": "vim::Tab",
"enter": "vim::Enter",
"escape": ["vim::SwitchMode", "Normal"],
"ctrl-[": ["vim::SwitchMode", "Normal"]
"enter": "vim::Enter"
}
},
{
"context": "Editor && vim_mode == insert && VimWaiting",
"context": "vim_mode == operator",
"bindings": {
"escape": "vim::NormalBefore",
"ctrl-[": "vim::NormalBefore"
"escape": "vim::ClearOperators",
"ctrl-c": "vim::ClearOperators",
"ctrl-[": "vim::ClearOperators"
}
},
{
"context": "vim_operator == a || vim_operator == i || vim_operator == cs",
"bindings": {
"w": "vim::Word",
"shift-w": ["vim::Word", { "ignorePunctuation": true }],
"t": "vim::Tag",
"s": "vim::Sentence",
"p": "vim::Paragraph",
"'": "vim::Quotes",
"`": "vim::BackQuotes",
"\"": "vim::DoubleQuotes",
"|": "vim::VerticalBars",
"(": "vim::Parentheses",
")": "vim::Parentheses",
"b": "vim::Parentheses",
"[": "vim::SquareBrackets",
"]": "vim::SquareBrackets",
"{": "vim::CurlyBrackets",
"}": "vim::CurlyBrackets",
"shift-b": "vim::CurlyBrackets",
"<": "vim::AngleBrackets",
">": "vim::AngleBrackets",
"a": "vim::Argument"
}
},
{
"context": "vim_operator == c",
"bindings": {
"c": "vim::CurrentLine",
"d": "editor::Rename", // zed specific
"s": ["vim::PushOperator", { "ChangeSurrounds": {} }]
}
},
{
"context": "vim_operator == d",
"bindings": {
"d": "vim::CurrentLine",
"s": ["vim::PushOperator", "DeleteSurrounds"]
}
},
{
"context": "vim_operator == gu",
"bindings": {
"g u": "vim::CurrentLine",
"u": "vim::CurrentLine"
}
},
{
"context": "vim_operator == gU",
"bindings": {
"g shift-u": "vim::CurrentLine",
"shift-u": "vim::CurrentLine"
}
},
{
"context": "vim_operator == g~",
"bindings": {
"g ~": "vim::CurrentLine",
"~": "vim::CurrentLine"
}
},
{
"context": "vim_operator == y",
"bindings": {
"y": "vim::CurrentLine",
"s": ["vim::PushOperator", { "AddSurrounds": {} }]
}
},
{
"context": "vim_operator == ys",
"bindings": {
"s": "vim::CurrentLine"
}
},
{
"context": "vim_operator == >",
"bindings": {
">": "vim::CurrentLine"
}
},
{
"context": "vim_operator == <",
"bindings": {
"<": "vim::CurrentLine"
}
},
{
@ -508,7 +466,8 @@
"x": "project_panel::RevealInFileManager",
"shift-g": "menu::SelectLast",
"g g": "menu::SelectFirst",
"-": "project_panel::SelectParent"
"-": "project_panel::SelectParent",
"ctrl-6": "pane::AlternateFile"
}
},
{

View file

@ -228,21 +228,19 @@ impl EditorState {
}
}
pub fn vim_controlled(&self) -> bool {
let is_insert_mode = matches!(self.mode, Mode::Insert);
if !is_insert_mode {
return true;
pub fn editor_input_enabled(&self) -> bool {
match self.mode {
Mode::Insert => {
if let Some(operator) = self.operator_stack.last() {
!operator.is_waiting(self.mode)
} else {
true
}
}
Mode::Normal | Mode::Replace | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
false
}
}
matches!(
self.operator_stack.last(),
Some(Operator::FindForward { .. })
| Some(Operator::FindBackward { .. })
| Some(Operator::Mark)
| Some(Operator::Register)
| Some(Operator::RecordRegister)
| Some(Operator::ReplayRegister)
| Some(Operator::Jump { .. })
)
}
pub fn should_autoindent(&self) -> bool {
@ -264,48 +262,39 @@ impl EditorState {
pub fn keymap_context_layer(&self) -> KeyContext {
let mut context = KeyContext::new_with_defaults();
context.set(
"vim_mode",
match self.mode {
let mut mode = match self.mode {
Mode::Normal => "normal",
Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
Mode::Insert => "insert",
Mode::Replace => "replace",
},
);
if self.vim_controlled() {
context.add("VimControl");
}
.to_string();
if self.active_operator().is_none() && self.pre_count.is_some()
|| self.active_operator().is_some() && self.post_count.is_some()
let mut operator_id = "none";
let active_operator = self.active_operator();
if active_operator.is_none() && self.pre_count.is_some()
|| active_operator.is_some() && self.post_count.is_some()
{
context.add("VimCount");
}
let active_operator = self.active_operator();
if let Some(active_operator) = active_operator.clone() {
for context_flag in active_operator.context_flags().into_iter() {
context.add(*context_flag);
if let Some(active_operator) = active_operator {
if active_operator.is_waiting(self.mode) {
mode = "waiting".to_string();
} else {
mode = "operator".to_string();
operator_id = active_operator.id();
}
}
context.set(
"vim_operator",
active_operator
.clone()
.map(|op| op.id())
.unwrap_or_else(|| "none"),
);
if self.mode == Mode::Replace
|| (matches!(active_operator, Some(Operator::AddSurrounds { .. }))
&& self.mode.is_visual())
{
context.add("VimWaiting");
if mode != "waiting" && mode != "insert" && mode != "replace" {
context.add("VimControl");
}
context.set("vim_mode", mode);
context.set("vim_operator", operator_id);
context
}
}
@ -340,9 +329,9 @@ impl Operator {
}
}
pub fn context_flags(&self) -> &'static [&'static str] {
pub fn is_waiting(&self, mode: Mode) -> bool {
match self {
Operator::Object { .. } | Operator::ChangeSurrounds { target: None } => &["VimObject"],
Operator::AddSurrounds { target } => target.is_some() || mode.is_visual(),
Operator::FindForward { .. }
| Operator::Mark
| Operator::Jump { .. }
@ -351,10 +340,18 @@ impl Operator {
| Operator::RecordRegister
| Operator::ReplayRegister
| Operator::Replace
| Operator::AddSurrounds { target: Some(_) }
| Operator::ChangeSurrounds { .. }
| Operator::DeleteSurrounds => &["VimWaiting"],
_ => &[],
| Operator::ChangeSurrounds { target: Some(_) }
| Operator::DeleteSurrounds => true,
Operator::Change
| Operator::Delete
| Operator::Yank
| Operator::Indent
| Operator::Outdent
| Operator::Lowercase
| Operator::Uppercase
| Operator::Object { .. }
| Operator::ChangeSurrounds { target: None }
| Operator::OppositeCase => false,
}
}
}

View file

@ -76,6 +76,7 @@ struct SelectRegister(String);
actions!(
vim,
[
ClearOperators,
Tab,
Enter,
Object,
@ -129,6 +130,9 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| vim.push_operator(operator.clone(), cx))
},
);
workspace.register_action(|_: &mut Workspace, _: &ClearOperators, cx| {
Vim::update(cx, |vim, cx| vim.clear_operator(cx))
});
workspace.register_action(|_: &mut Workspace, n: &Number, cx: _| {
Vim::update(cx, |vim, cx| vim.push_count_digit(n.0, cx));
});
@ -973,7 +977,7 @@ impl Vim {
editor.set_cursor_shape(state.cursor_shape(), cx);
editor.set_clip_at_line_ends(state.clip_at_line_ends(), cx);
editor.set_collapse_matches(true);
editor.set_input_enabled(!state.vim_controlled());
editor.set_input_enabled(state.editor_input_enabled());
editor.set_autoindent(state.should_autoindent());
editor.selections.line_mode = matches!(state.mode, Mode::VisualLine);
if editor.is_focused(cx) || editor.mouse_menu_is_focused(cx) {

View file

@ -85,36 +85,22 @@ Finally, Vim mode's search and replace functionality is backed by Zed's. This me
## Custom key bindings
You can edit your personal key bindings with `:keymap`.
For vim-specific shortcuts, you may find the following template a good place to start:
For vim-specific shortcuts, you may find the following template a good place to start.
> **Note:** We made some breaking changes in Zed version `0.145.0`. For older versions, see [the previous version of this document](https://github.com/zed-industries/zed/blob/c67aeaa9c58619a58708722ac7d7a78c75c29336/docs/src/vim.md#L90).
```json
[
{
"context": "Editor && (vim_mode == normal || vim_mode == visual) && !VimWaiting && !menu",
"context": "VimControl && !menu",
"bindings": {
// put key-bindings here if you want them to work in normal & visual mode
}
},
{
"context": "Editor && vim_mode == normal && !VimWaiting && !menu",
"context": "vim_mode == insert",
"bindings": {
// put key-bindings here if you want them to work only in normal mode
// "down": ["workspace::SendKeystrokes", "4 j"]
// "up": ["workspace::SendKeystrokes", "4 k"]
}
},
{
"context": "Editor && vim_mode == visual && !VimWaiting && !menu",
"bindings": {
// visual, visual line & visual block modes
}
},
{
"context": "Editor && vim_mode == insert && !menu",
"bindings": {
// put key-bindings here if you want them to work in insert mode
// e.g.
// "j j": "vim::NormalBefore" // remap jj in insert mode to escape.
// "j k": "vim::NormalBefore" // remap jk in insert mode to escape.
}
},
{
@ -122,7 +108,6 @@ For vim-specific shortcuts, you may find the following template a good place to
"bindings": {
// put key-bindings here (in addition to above) if you want them to
// work when no editor exists
// e.g.
// "space f": "file_finder::Toggle"
}
}
@ -133,20 +118,15 @@ If you would like to emulate vim's `map` (`nmap` etc.) commands you can bind to
You can see the bindings that are enabled by default in vim mode [here](https://github.com/zed-industries/zed/blob/main/assets/keymaps/vim.json).
The details of the context are a little out of scope for this doc, but suffice to say that `menu` is true when a menu is open (e.g. the completions menu), `VimWaiting` is true after you type `f` or `t` when were waiting for a new key (and you probably dont want bindings to happen). Please reach out on [GitHub](https://github.com/zed-industries/zed) if you want help making a key bindings work.
#### Contexts
### Examples
Zed's keyboard bindings are evaluated only when the `"context"` matches the location you are in on the screen. Locations are nested, so when you're editing you're in the `"Workspace"` location is at the top, containing a `"Pane"` which contains an `"Editor"`. Contexts are matched only on one level at a time. So it is possible to combine `Editor && vim_mode == normal`, but `Workspace && vim_mode == normal` will never match because we set the vim context at the `Editor` level.
Binding `jk` to exit insert mode and go to normal mode:
Vim mode adds several contexts to the `Editor`:
```
{
"context": "Editor && vim_mode == insert && !menu",
"bindings": {
"j k": ["vim::SwitchMode", "Normal"]
}
}
```
* `vim_mode` is similar to, but not identical to, the current mode. It starts as one of `normal`, `visual`, `insert` or `replace` (depending on your mode). If you are mid-way through typing a sequence, `vim_mode` will be either `waiting` if it's waiting for an arbitrary key (for example after typing `f` or `t`), or `operator` if it's waiting for another binding to trigger (for example after typing `c` or `d`).
* `vim_operator` is set to `none` unless `vim_mode == operator` in which case it is set to the current operator's default keybinding (for example after typing `d`, `vim_operator == d`).
* `"VimControl"` indicates that vim keybindings should work. It is currently an alias for `vim_mode == normal || vim_mode == visual || vim_mode == operator`, but the definition may change over time.
### Restoring some sense of normality
@ -155,7 +135,7 @@ that you can't live without. You can restore them to their defaults by copying t
```
{
"context": "Editor && !VimWaiting && !menu",
"context": "Editor && !menu",
"bindings": {
"ctrl-c": "editor::Copy", // vim default: return to normal mode
"ctrl-x": "editor::Cut", // vim default: increment
@ -304,7 +284,7 @@ Subword motion is not enabled by default. To enable it, add these bindings to yo
```json
{
"context": "Editor && VimControl && !VimWaiting && !menu",
"context": "VimControl && !menu",
"bindings": {
"w": "vim::NextSubwordStart",
"b": "vim::PreviousSubwordStart",
@ -318,7 +298,7 @@ Surrounding the selection in visual mode is also not enabled by default (`shift-
```json
{
"context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
"context": "vim_mode == visual",
"bindings": {
"shift-s": [
"vim::PushOperator",