vi Keybindings in macOS with Karabiner & Hammerspoon

I switched to Vim as my primary programming text editor about six months ago. While I'm really not very good with it yet--part of the attraction was the steep learning curve; I could still be training myself to use it more effectively years from now--I grew accustomed to it quickly enough that I wanted to use vi keybindings everywhere else in my OS (currently macOS).

So I decided to figure out a way to make this happen and, at least for now, narrowed the scope to creating a key binding that would switch the OS to another 'mode' where I could then work with text using very basic vi normal mode controls. The 'default' OS mode would then become the equivalent of vi's insert mode.

This idea was surprisingly simple to get working using a couple of tools. And while this post explains how to create vi key bindings, the same tools (Hammerspoon in particular) can be used to customize macOS in a number of ways. Further, if you want to just create your own custom keybindings or modes, the information here should be easy to generalize for whatever use you want.

Small disclaimer: I'll be assuming from here forward that you have a very basic level of comfort with vi and programming--but, frankly, I'm not sure why you'd be interested in vi keybindings to begin with if you didn't.

Karabiner Elements

This is the first tool, which will allow you to set a sensible modal key trigger (this is a variation on what some people who re-bind their keyboards call a 'Hyper Key').

First, by way of explanation: Karabiner Elements is a subset of Karabiner, which is a much more fully-featured keyboard customizer. Unfortunately, changes in macOS 10.12 broke most of Karabiner's functionality, so Elements is a stripped-down stand-in (that still works perfectly fine here). If you're a running an older version of macOS, the original Karabiner will work as well.

Once you've installed Karabiner Elements (I just used the disk image from the repo README linked above, but feel free to build it yourself), you should see something like this when you open the app:

Karabiner Elements, with a custom 'modal' key binding

What you probably won't see yet are any actual key mappings like the three I have here. The relevant one in this screenshot is 'fn' to 'f18' (the other two are just a personal preference--these can be toggled in normal macOS System Preferences, but Karabiner overrides those settings). You should add a binding that looks like that, with caveats:

This 'From key' binding is specific to my keyboard choices--I currently work with MacBooks (paired with a Magic Keyboard at work, but the same layout), which have an 'fn' key in their bottom left corner that I effectively never used before setting up this config. If you have another keyboard layout, set the 'From key' to something else you're fine with using exclusively for this purpose.

Additionally, you'll need to set the 'To key' binding to a key that you absolutely never use (without specific modification, there's not a way to trigger 'f18' on a MacBook keyboard normally, so I keep it set to that).

Next, you'll run some programmatic actions when this key is triggered:

Hammerspoon

This is the second tool, and allows for flexible macOS automation using Lua scripts. This post only covers using it to remap keybindings, but it has some other fantastic APIs that I highly recommend checking out!

I won't cover basic installation of Hammerspoon, as it's relatively self-explanatory. Once the app is installed, you'll need to make a ~/.hammerspoon/init.lua file, which is a script that will tell Hammerspoon how to modify and automate the OS.

(I store my init.lua in a repository that holds most of my system configuration files and symlink it using a simple bash script.)

A note going forward: I am by no means a Lua expert; beyond this code and some very basic scripts, I've written very little in the language. So if you happen to actually know it, apologies for terrible abuse of idioms. I wasn't aiming for elegance when I wrote this script.

init.lua

I'll provide snippets as I go, but if you'd like to look through the complete script, it's here. If you're lost at any point, Hammerspoon also has great documentation.

First, a constant:

hyper = 0x4F

This is the hex keycode for 'f18'--you should replace this with whatever you've mapped your Hyper Key 'To Key' to in Karabiner.

Next, I've created a custom function that allows for faster keystrokes for the bindings you'll create. At time of writing, Hammerspoon's hs.eventtap.keyStroke function seems to have issues with normal key repeat speed--i.e. it repeats much more slowly than the system KeyRepeat default--so this function leverages the slightly more low-level newKeyEvent API to solve this problem:

fastKeyStroke = function(modifiers, character)
  local event = require("hs.eventtap").event
  event.newKeyEvent(modifiers, string.lower(character), true):post()
  event.newKeyEvent(modifiers, string.lower(character), false):post()
end

Now, you can initialize the mode:

--
-- Create Mode
--

mode = hs.hotkey.modal.new('', hyper)

This code creates a new keybinding mode and assigns it to the variable mode. Any hotkeys you bind to mode will be activated when the Hyper Key is triggered and will override any system defaults.

For the first bindings, I've created a couple of ways to exit the mode. Pressing the Hyper Key again is probably the most natural choice for this, and I've also added 'i' to emulate entering vi's insert mode:

--
-- Mode Exit Bindings
--

mode:bind('', hyper, function() mode:exit() end)
mode:bind('', 'i', function() mode:exit() end)

Now, on to vi bindings! For the basics, assign some fastKeyStroke function calls to variables for easy reference:

left = function() fastKeyStroke({''}, 'left') end
down = function() fastKeyStroke({''}, 'down') end
up = function() fastKeyStroke({''}, 'up') end
right = function() fastKeyStroke({''}, 'right') end

Then bind those variables to some modal keystrokes:

mode:bind('', 'h', left, nil, left)
mode:bind('', 'j', down, nil, down)
mode:bind('', 'k', up, nil, up)
mode:bind('', 'l', right, nil, right)

I reference the variables in two parameters in each binding call to set them to both the initial keypress and the repeat (details on that here).

Now, set some more interesting bindings using the same pattern:

--
-- Text Nav
--

wordStart = function() fastKeyStroke({'alt'}, 'left') end
wordEnd = function() fastKeyStroke({'alt'}, 'right') end
lineStart = function() fastKeyStroke({'cmd'}, 'left') end
lineEnd = function() fastKeyStroke({'cmd'}, 'right') end

mode:bind('', 'b', wordStart, nil, wordStart)
mode:bind('', 'e', wordEnd, nil, wordEnd)
mode:bind('shift', '6', lineStart)
mode:bind('shift', '4', lineEnd)

As I mentioned above, this code creates basic vi keybindings--and that's because the bindings are ultimately limited by how macOS can already navigate text. This only creates custom bindings for pre-existing macOS key combinations; it can't create entirely new ones. So while the above code simulates the 'b', 'e', '^', and '$' vi motions, with this method it's not possible to compose those motions with vi operators or fully mimic vi.

Lastly, here's some bindings for normal mode text manipulation:

--
-- Text Manipulation
--

delete = function() fastKeyStroke({''}, 'forwarddelete') end
yank = function() fastKeyStroke({'cmd'}, 'c') end
paste = function() fastKeyStroke({'cmd'}, 'v') end
undo = function() fastKeyStroke({'cmd'}, 'z') end
mode:bind('', 'x', delete, nil, delete)
mode:bind('', 'y', yank)
mode:bind('', 'p', paste)
mode:bind('', 'u', undo, nil, undo)

Again, you can't, for example, mimic vi's 'P', as macOS doesn't have the ability to paste before the cursor, just after. Also notice that I haven't provided the key repeat parameters for yank and paste bindings, as it's unlikely that I'd want to repeatedly yank or paste.

Etc

This code is just a start. While you're ultimately limited, like I've mentioned, by macOS itself, there's plenty more you can do with Hammerspoon's keystroke APIs--I've considered creating additional modes, or at least attempting to create bindings for more vi motions.

(It's up to you to decide whether any of this over-optimizing is actually useful, though.)