Build Your Own Vim Emulation for VS Code
Vim has probably survived among modern IDEs because of its relatively unique philosophy. It supports modal editing, where the effect of each key press depends on the mode you're in. In insert mode, typing keys works the way it would in any editor: it inserts the keys you press. In normal mode, the sequences of keys you press invoke various commands, with commonly used commands usally requiring just one or two keystrokes.
This might sound daunting to learn, and granted, the learning curve is steep. But after you are fully accustomed to this way of editing, there's no turning back. You can move to, select and change documents so precisely and quickly that going without modal editing will feel painfully slow and cumbersome. The proof of this is that nearly all popular text editors have some kind of add-in that provides Vim emulation. VS Code has several of them.
One key advantage of modal keybindings is realized when you understand its noun-verb structure: many bindings define objects (regions of text you want to do something to, a.k.a. nouns) and others define operators (things you want to actually do to the objects, a.k.a. verbs). Muscle memory makes these combinations fast, and suddenly there is a large generative space of possible commands that you emmit at the speed-of-thought.
In particular the value added for ModalKeys's approach is is that it utilizes VS Code's existing features and just adds the concept of modal editing to the mix. This choice has two major benefits: (1) the commands can integrate seamlessly with the ecosystem of packages already present in VSCode, providng more long-term capabilities than emulating vim alone could provide and (2) the commands can be customized in precisely the way that works best for you.
In ModalKeys, you define a configuration file as a javascript file, and you then import it using the ModalKeys: Import preset keybindings
command.
We don't have to use Vim's standard key bindings, if we prefer not to. You can map any key (sequence) to any command. However, you probably want to keep most of the basic commands the same, because you can then share muscle memory between basic vim usage and your own keybindings. Here, to keep things familiar, we'll follow most of Vim's conventions.
To start, our preset file will export a single object, containing the property keybindings
.
module.exports = {
"keybindings": {
Switching Between Modes
First things first: we need to be able to enter the normal mode somehow. The modalkeys.enterNormal
command by default, so we dont't need to do anything for that. If you like, you can map other keys to this command using VS Code's standard keymappings pressing
Insert Text
There are multiple ways to enter insert mode. If you want to insert text in the current cursor position, you press
"i": "modalkeys.enterInsert",
To insert text at the beginning of line, you press
"I": [
"cursorHome",
"modalkeys.enterInsert"
],
Append Text
Appending text works analogously;
"a": [
{ "if": "__char == ''", "then": "cursorRight" },
"modalkeys.enterInsert"
],
"A": [ "cursorEnd", "modalkeys.enterInsert" ],
Open a New Line
The third way to enter insert mode is to open a line. This means creating an empty line, and putting the cursor on it. There are two variants of this command as well:
"o": [ "editor.action.insertLineAfter", "modalkeys.enterInsert" ],
"O": [ "editor.action.insertLineBefore", "modalkeys.enterInsert" ],
Now we can test the commands we just created.
Cursor Movement
The next task in hand is to add commands for moving the cursor. As all Vim users know, instead of arrow keys, we move the cursor with
Selecting Text
In Vim, there is a separate "visual" mode that you activate when you want to select text. Visual mode can be characterwise or linewise.
Visual mode is on whenver an additional flag is set (by issuing a setMode
command) and whenver you select text in the usual way from VSCode (e.g. via modealkeys.toggleSelection
).
The end result is that selection mode works almost like visual mode in Vim, the main difference being that selections are not automatically turned off when you enter insert mode.
So, let's add a binding to toggle selections on or off. We use the familiar
"v": "modalkeys.toggleSelection",
Now we can add commands for cursor movement. These commands use the generic cursorMove
command which takes arguments. The __mode == "visual"
ensures that the commands only select text if we're in visual
mode.
"h": { "cursorMove": { to: 'left', select: '__mode == "visual"' } },
"j": { "cursorMove": { to: 'down', select: '__mode == "visual"' } },
"k": { "cursorMove": { to: 'up', select: '__mode == "visual"' } },
"l": { "cursorMove": { to: 'right', select: '__mode == "visual"' } },
If we want to be more succinct in how we write these commands, we can also do the following.
"::using::cursorMove": {
"h": { to: 'left', select: '__mode == "visual"' },
"j": { to: 'down', select: '__mode == "visual"' },
"k": { to: 'up', select: '__mode == "visual"' },
"l": { to: 'right', select: '__mode == "visual"' },
},
ModalKeys knows to re-write this, such that thet two forms are equivalent.
We can also simulate linewise visual mode using VS Code's expandLineSelection
command. Note that we don't need to call modalkeys.toggleSelection
this time as selection mode is turned on automatically.
"V": "expandLineSelection",
Moving Inside Screen
To move cursor quickly to the top, middle, or bottom of the screen we use keys cursorMove
command.
"::using::cursorMove": {
"H": { to: 'viewPortTop', select: '__mode == "visual"' },
"M": { to: 'viewPortCenter', select: '__mode == "visual"' },
"L": { to: 'viewPortBottom', select: '__mode == "visual"' },
},
Jumping to Previous/Next Word
Other commonly used navigation commands in Vim include cursorMove
falls short in this use case.
"w": {
"if": "__mode == 'visual'",
"then": "cursorWordStartRightSelect",
"else": "cursorWordStartRight"
},
"b": {
"if": "__mode == 'visual'",
"then": "cursorWordStartLeftSelect",
"else": "cursorWordStartLeft"
},
"e": {
"if": "__mode == 'visual'",
"then": "cursorWordEndRightSelect",
"else": "cursorWordEndRight"
},
Note: We omit variants of these commands
W ,B , andE which skip the punctuation characters. There are no built-in commands in VS Code that work exactly like those in Vim. There are some extensions you can make use of to implement these commands (e.g. Selection Utilities). That's beyond the scope of this tutorial.
Jumping to Start/End of Line
In the similar vein, we'll throw in commands for jumping to the beginning
"::using::curosrMove": {
"0": { to: 'wrappedLineStart', select: '__mode == "visual"' },
"^": { to: 'wrappedLineFirstNonWhitespaceCharacter', select: '__mode == "visual"' },
"$": { to: 'wrappedLineEnd', select: '__mode == "visual"' },
},
A lesser known variant of above commands is
"g_": { "cursorMove":
{ to: 'wrappedLineLastNonWhitespaceCharacter', select: '__mode == "visual"' }
},
Jumping to Start/End of Document
Another motion command is
"g": {
"if": "__mode == 'visual'",
"then": "cursorTopSelect",
"else": "cursorTop"
},
},
The opposite of that is
"G": {
"if": "__mode == 'visual'",
"then": "cursorBottomSelect",
"else": "cursorBottom"
},
Jump to Character
We have the basic movement commands covered, so let's move on to more sophisticated ones. Seasoned Vim users avoid hitting movement commands repeatedly by using
"f": {
"if": "__mode == 'visual'",
"then": {
"modalkeys.search": {
"caseSensitive": true,
"acceptAfter": 1,
"selectTillMatch": true,
}
},
"else": {
"modalkeys.search": {
"caseSensitive": true,
"acceptAfter": 1,
"offset": "exclusive",
}
},
},
The command is a bit involved, so let's explain what each argument does.
caseSensitive
sets the search mode to case sensitive (as in Vim).acceptAfter
ends the incremental search as soon as first entered character is found. Normally the user needs to pressEnter to accept the search orEsc to cancel it.selectTillMatch
argument controls whether selection is extended until the searched character. This depends on whether we have selection mode on or not.offset
argument allows determine where the cursor should land at each match of the search. By default,modalEdit.search
uses an "inclusive" offset, meaning the cursor ends after the match when moving foward and before it when moving backward. When set to exclusive, the opposite is true: the cursor lands before when moving forward and after the match when moving backward (the offset can also be set to "start" or "end" to always end at the start or end of a match, regardless of search direction).
Now we can implement the opposite backwards
parameter switches the search direction.
"F": {
"if": "__mode == 'visual'",
"then": {
"modalkeys.search": {
"caseSensitive": true,
"acceptAfter": 1,
"selectTillMatch": true,
"backwards": true,
}
},
"else": {
"modalkeys.search": {
"caseSensitive": true,
"acceptAfter": 1,
"offset": "exclusive",
"backwards": true
}
},
},
With
";": "modalkeys.nextMatch",
",": "modalkeys.previousMatch",
We omitted a few useful jump commands, like
t ,T ,{ , and} . The t and T commands could be implemented using the "exclusive" offset. The paragraph operators, require an extension, like selection-utilities to implement.
Center Cursor on Screen
The last movement command we add is __line
parameter to get the line where the cursor is.
"zz": { "revealLine": { lineNumber: '__line', at: 'center' } }
Let's test some of the movement commands. We should be able to navigate now without using arrow keys or
We skipped commands that move cursor up and down on page at the time. The reason for this is that these commands are bound to keybindings.json
file. Below is an example that uses the modalkeys.normal
context to make the shortcuts work only in normal mode. Most of the Vim's standard
/* keybindings.json should contain the following:
{
{
"key": "ctrl+b",
"command": "cursorPageUp",
"when": "editorTextFocus && modalkeys.mode == normal"
},
{
"key": "ctrl+f",
"command": "cursorPageDown",
"when": "editorTextFocus && modalkeys.mode == normal"
}
}
*/
Commands with Counts
In Vim, you can repeat commands by typing a number first. For example, __count
variable.
To make use of counts, we need to update some of the commands above. Below are shown the updated cursor movements that use the __count
variable.
"::using::cursorMove": {
"h": { to: 'left', select: '__mode == "visual"', value: '__count' },
"j": { to: 'down', select: '__mode == "visual"', value: '__count' },
"k": { to: 'up', select: '__mode == "visual"', value: '__count' },
"l": { to: 'right', select: '__mode == "visual"', value: '__count' },
},
"w": {
"if": "__mode == 'visual'",
"then": { "cursorWordStartRightSelect": {}, repeat: '__count' },
"else": { "cursorWordStartRight": {}, repeat: '__count' },
},
"b": {
"if": "__mode == 'visual'",
"then": { "cursorWordStartLeftSelect": {}, repeat: '__count' },
"else": { "cursorWordStartLeft": {}, repeat: '__count' },
},
"e": {
"if": "__mode == 'visual'",
"then": { "cursorWordEndRightSelect": {}, repeat: '__count' },
"else": { "cursorWordEndRight": {}, repeat: '__count' },
},
Many command, like those shown above, can internally repeat (e.g. value
for cursorMove
), and this is generally better, as it execute faster. If a command does not take a parameter like this however, you can make use of the repeat
parameter, shown above for the word motions. This will simply call the command multiple times.
Jumping to a Line
Another command that has a number prefix is xworkbench.action.gotoLine
does not take any arguments, so we have to reinvent the wheel.
"G": [
{ "revealLine": { lineNumber: '__count', at: 'top' } },
{ "cursorMove": { "to": "viewPortTop" } }
],
Editing
Now we'll implement Vim's common editing commands. We only add the ones that have counterparts in VS Code.
Joining Lines
"J": "editor.action.joinLines",
Changing Text
Change commands delete some text and then enter insert mode.
"cc": [
"deleteAllLeft",
"deleteAllRight",
"modalkeys.enterInsert"
],
"c$": [
"deleteAllRight",
"modalkeys.enterInsert"
],
"cw": [
"deleteWordEndRight",
"modalkeys.enterInsert"
],
Change Until/Around/Inside
Very useful variants of change commands are those which allow changing text upto a given character or between given characters. For example,
First, we use the executeAfter
option of the search command to implement changing all characters up until the given letter.
"ct": {
"modalkeys.search": {
caseSensitive: true,
acceptAfter: 1,
backwards: false,
selectTillMatch: true,
offset: 'exclusive',
wrapAround: false,
executeAfter: [
"deleteLeft",
"modalkeys.enterInsert"
]
}
},
"cf": {
"modalkeys.search": {
caseSensitive: true,
acceptAfter: 1,
backwards: false,
selectTillMatch: true,
offset: 'inclusive',
wrapAround: false,
executeAfter: [
"deleteLeft",
"modalkeys.enterInsert"
]
}
},
Next, we add commands to change the text inside or around various brackets, using an extension which implements this behavior.
"ci(": [ "modalkeys.cancelMultipleSelections", "extension.selectParenthesis", "deleteLeft", "modalkeys.enterInsert" ],
"ca(": [ "modalkeys.cancelMultipleSelections", "extension.selectParenthesis", "extension.selectParenthesis", "deleteLeft", "modalkeys.enterInsert" ],
"ci[": [ "modalkeys.cancelMultipleSelections", "extension.selectSquareBrackets", "deleteLeft", "modalkeys.enterInsert" ],
"ca[": [ "modalkeys.cancelMultipleSelections", "extension.selectSquareBrackets", "extension.selectSquareBrackets", "deleteLeft", "modalkeys.enterInsert" ],
"ci{": [ "modalkeys.cancelMultipleSelections", "extension.selectAngleBrackets", "deleteLeft", "modalkeys.enterInsert" ],
"ca{": [ "modalkeys.cancelMultipleSelections", "extension.selectAngleBrackets", "extension.selectAngleBrackets", "deleteLeft", "modalkeys.enterInsert" ],
For each of these commands we first clear the selection, while leaving multiple cursors intact, to ensure the subsequent commands behave properly. Then we use the extension to select the appropriate region of text, delete it, and enter insert mode.
It is also useful to be able to change the current word the cursor is on. You can do this by typing
"ciw": [
"modalkeys.cancelMultipleSelections",
"editor.action.smartSelect.expand",
"deleteLeft",
"modalkeys.enterInsert"
],
We could also implement delete commands
d i w ,d t - , etc. in the similar fashion. But for the sake of keeping the tutorial short, we'll leave those as an exercise.
A shorthand for
"C": [
"deleteAllRight",
"modalkeys.enterInsert"
],
Undo & Redo
You can undo the last change with
"u": [
"undo",
"modalkeys.cancelSelection"
],
Since redo is mapped to
Visual (Selection) Commands
Visual commands operate on the selected text.
"<": "editor.action.outdentLines",
">": "editor.action.indentLines",
Clipboard Commands
"y": [
"editor.action.clipboardCopyAction",
"modalkeys.cancelSelection"
],
/* <key>d</key> deletes (cuts) the selected text and puts it to clipboard. Capital
<key>D</key> deletes the rest of the line. <key>x</key> deletes just the
character under the cursor. */
"d": "editor.action.clipboardCutAction",
"D": [
"cursorEndSelect",
"editor.action.clipboardCutAction"
],
"x": [
"cursorRightSelect",
"editor.action.clipboardCutAction"
],
Note: If there is no text selected,
y andd commands perform exactly the same actions asy y andd d in Vim. That is, they yank or delete the current line. Again, one of the subtle differences that is futile to try to unify.
For pasting (or putting in Vim parlance) the text in clipboard you have two commands:
"p": [
"cursorRight",
"editor.action.clipboardPasteAction"
],
"P": "editor.action.clipboardPasteAction",
Switching Case
Switching selected text to upper or lower case is done with a nifty trick. We can examine the selection in a conditional command that calls different VS Code commands based on the expression. The command is bound to the tilde
"~": {
"if": "__selectionstr == __selection.toUpperCase()",
"then": "editor.action.transformToLowercase",
"else": "editor.action.transformToUppercase"
},
Searching
The last category of commands we implement is searching. We use the incremental search command provided by ModalKeys for this. As in Vim, typing
"/": {
"command": "modalkeys.search",
"args": {
"caseSensitive": true
}
},
"?": {
"command": "modalkeys.search",
"args": {
"caseSensitive": true,
"backwards": true
}
},
Jumping to next previous match is done with keys
"n": "modalkeys.nextMatch",
"N": "modalkeys.previousMatch"
}
There are some subtle differences in the search functionality as well. Instead of just highlighting matches ModalKeys selects them. This is preferable anyway, as replacing needs to be done manually with selection commands. Use VS Code's built-in find command, if you need regex support.
Conclusion
We have built far from complete but nevertheless usable Vim emulation which you can tweak in various ways to make it better. The point of this exercise was to show that you can significantly enhance VS Code's editing experience using just a simple extension and built-in commands.
The goal of ModalKeys is not to emulate Vim. My own bindings, for day-to-day use, do not match Vim's. I'd recommend you start with the preset vim bindings, and then adapt them to your own purposes.
You don't need to learn all the magical Vim facilities to make efficient use of ModalKeys. Just keep an eye on what operations you repeat, and think how you could make them more efficient. Then add commands that will speed up those operations. Try to make the new commands as general as possible, and as easy as possible to use. Your text editor should adapt to your way of working, not the other way around.
Happy Editing! 🚀
View on GitHub