This post is part of the Colemak necessities and Keboard management series.

Struggling to emulate klfc for VIM Colemak bindings on Darwin (macOS) systems with Hammerspoon and Karabiner

Background

I have mentioned in the past my customized Colemak dotfiles which I used with a customized keyboard layout. Unfortunately, the .keylayout system of MacOS is far more primitive than the elegant klfc setup 1. For an understanding of what we are trying to get at, the following poorly made video will suffice.

VIM Colemak Extensions

Just as a reminder, my setup (or hzColemak) consists of an augmented VIM workflow, as shown below, and described in my previous post.

Tools

There are essentially a few options:

Manually write .keylayout
These then go in $HOME/Library/Keyboard\ Layouts
Use Ukelele
The incredibly poorly named (for search purposes) versatile tool is able to ease the pain slightly for writing .keylayout files
Use Karabiner Elements
This seems to be closer to AutoHotKey and the like, runs in the background and actively intercepts keys based on json configurations; though there seems to be a more rational method (a hidutil remapping file generator) for newer kernels 2
Script things with Hammerspoon
Uses a lua engine to interact with the system, can be configured for the most part with Fennel using Spacehammer

Now of the four, I had a predilection to move towards manually writing, with the help of Ukelele. However, evidently, there is no real way to remove the stickiness from the Caps Lock key. It can either be remapped using system settings 3 to one of the other modifier keys, but not to Extend. The closest possible solution would be to do a very awkward Esc based layout. Also, rapid prototyping was out of the question, since Ukelele requires a log-out log-in cycle to set things up. Of Ukelele and manually writing things then, nothing more need be said.

Karabiner

This left me considering json re-writer. I have been using the basic Colemak layout with a simplistic Karabiner caps to delete for a while now, which allows for a standards compliant Colemak experience, but extending this like I needed was a little bit of a struggle.

Apparently it is possible to overload the keyboard system with a “Hyper” key 4, which is the closest to Extend.

This is setup through a karabiner.json file, since it appears that the “Complex modifications” referred to in the GUI (Fig. 2) don’t really allow for more than downloading rules off of the internet 5, like the one below.

 1"complex_modifications": {
 2                "rules": [
 3                    {
 4                        "manipulators": [
 5                            {
 6                                "description": "Change caps_lock to command+control+option+shift. Escape if no other key used.",
 7                                "from": {
 8                                    "key_code": "caps_lock",
 9                                    "modifiers": {
10                                        "optional": [
11                                            "any"
12                                        ]
13                                    }
14                                },
15                                "to": [
16                                    {
17                                        "key_code": "left_shift",
18                                        "modifiers": [
19                                            "left_command",
20                                            "left_control",
21                                            "left_option"
22                                        ]
23                                    }
24                                ],
25                                "to_if_alone": [
26                                    {
27                                        "key_code": "escape",
28                                        "modifiers": {
29                                            "optional": [
30                                                "any"
31                                            ]
32                                        }
33                                    }
34                                ],
35                                "type": "basic"
36                            }
37                        ]
38                    }
39                ]
40            },

Extending this is not pleasant, so we won’t go forward with this approach at all.

We will still need a bit of this unfortunately, so this will stick around. In particular we need to have a simple mapping, from caps_lock to one of the un-mapped function keys, f18 or f19 or some such.

Effectively, this can be done with the GUI, but we prefer the configuration snippet makes it far more reproducible:

 1{
 2    "type": "basic",
 3    "from": {
 4        "key_code": "caps_lock"
 5    },
 6    "to":
 7    {
 8        "key_code": "f19"
 9    }
10}

Note that we do not need to define the hyper key to be a series of keystrokes or anything like that here with the complex_modifications in this configuration. The point of using complex_modifications is to not overwrite the Karabiner defaults. The following modifications also allow us to keep CAPS functionality bound to simultaneously pressing left and right shift together6.

 1{
 2  "title": "CAPS to fake hyper (f19) and Shift CAPS",
 3  "rules": [
 4    {
 5      "description": "CAPS_LOCK : (HYPER)",
 6      "manipulators": [
 7        {
 8          "from": {
 9            "key_code": "caps_lock",
10            "modifiers": {
11              "optional": ["any"]
12            }
13          },
14          "to":
15            {
16              "key_code": "f19"
17            },
18          "type": "basic"
19        }
20      ]
21    },
22    {
23      "description": "Toggle CAPS_LOCK with LEFT_SHIFT + RIGHT_SHIFT",
24      "manipulators": [
25        {
26          "from": {
27            "key_code": "left_shift",
28            "modifiers": {
29              "mandatory": ["right_shift"],
30              "optional": ["caps_lock"]
31            }
32          },
33          "to": [
34            {
35              "key_code": "caps_lock"
36            }
37          ],
38          "to_if_alone": [
39            {
40              "key_code": "left_shift"
41            }
42          ],
43          "type": "basic"
44        },
45        {
46          "from": {
47            "key_code": "right_shift",
48            "modifiers": {
49              "mandatory": ["left_shift"],
50              "optional": ["caps_lock"]
51            }
52          },
53          "to": [
54            {
55              "key_code": "caps_lock"
56            }
57          ],
58          "to_if_alone": [
59            {
60              "key_code": "right_shift"
61            }
62          ],
63          "type": "basic"
64        }
65      ]
66    }
67  ]
68}

Hammerspoon

Not that an operating system should need a scripting engine, but apparently Hammerspoon is worth looking into fox better control over the entire OS 7. This made it more attractive than writing a hidden configuration file for Karabiner.

The first thing we will need in our init.lua after installing the Hammerspoon application is one to reload the configuration automatically:

 1-- Reload config when any lua file in config directory changes
 2-- From https://gist.github.com/prenagha/1c28f71cb4d52b3133a4bff1b3849c3e
 3function reloadConfig(files)
 4    doReload = false
 5    for _,file in pairs(files) do
 6        if file:sub(-4) == '.lua' then
 7            doReload = true
 8        end
 9    end
10    if doReload then
11        hs.reload()
12    end
13end
14local myWatcher = hs.pathwatcher.new(os.getenv('HOME') .. '/.hammerspoon/', reloadConfig):start()
15hs.alert.show('Config loaded')

An alternative to using Karabiner to map f19 is to use the foundation remapping plugin which needs to be placed in $HOME/.hammerspoon with the following added to our init.lua:

1-- cd ~/.hammerspoon/ && wget https://raw.githubusercontent.com/hetima/hammerspoon-foundation_remapping/master/foundation_remapping.lua
2-- init.lua
3local FRemap = require('foundation_remapping')
4local remapper = FRemap.new()
5
6remapper:remap('capslock', 'f19')
7remapper:register()

The only problem with this is that it must be unregistered carefully. The Karabiner method is easier overall.

Anyway, once f19 has been bound, one way or another, we need to setup the hyper key itself8:

 1-- init.lua
 2-- A global variable for the Hyper Mode
 3hyper = hs.hotkey.modal.new({}, 'F18')
 4
 5-- Enter Hyper Mode when F19 (Hyper/Capslock) is pressed
 6function enterHyperMode()
 7  hyper.triggered = false
 8  hyper:enter()
 9  hs.alert.show('Hyper on')
10end
11
12-- Leave Hyper Mode when F19 (Hyper/Capslock) is pressed,
13function exitHyperMode()
14  hyper:exit()
15  hs.alert.show('Hyper off')
16end
17
18-- Bind the Hyper key
19f19 = hs.hotkey.bind({}, 'F19', enterHyperMode, exitHyperMode)
20f19cmd = hs.hotkey.bind({'cmd'}, 'F19', enterHyperMode, exitHyperMode)

The alerts can help while setting up muscle memory, but they are optional. We also map cmd+f19 to prevent the cmd+h hide application annoyance.

Remapping Keys

The basic idea is to set a function which executes only when hyper:enter() is true. One thing to remember is that the signature for hs.eventtap.keyStroke has an optional delay which defaults to 200ms and is ridiculous. This means we need to explicitly set 0 for the delay.

Basic Movements

These correspond to the to the top level extend layer. We will disect one example here and refer to a later section for the details.

1-- h - move left {{{3
2function left() hs.eventtap.keyStroke({}, "Left", 0) end
3hyper:bind({}, 'h', left, nil, left)
4-- }}}3

Essentially, when hyper is active, then tapping h activates the keyStroke. The remaining hnei movements are similar. For the carriage return case we can go for a slightly more interesting option.

 1-- o - open new line below cursor {{{3
 2hyper:bind({}, 'o', nil, function()
 3    local app = hs.application.frontmostApplication()
 4    if app:name() == "Finder" then
 5        hs.eventtap.keyStroke({"cmd"}, "o", 0)
 6    else
 7        hs.eventtap.keyStroke({}, "Return", 0)
 8    end
 9end)
10-- }}}3

We can effectively setup application specific keyStrokes which will help in the long run.

Extend Layers

These are implemented in much the same way, since modifiers can be called in the bind function.

1-- cmd+h - delete character before the cursor {{{3
2local function delete()
3    hs.eventtap.keyStroke({}, "delete", 0)
4end
5hyper:bind({"cmd"}, 'h', delete, nil, delete)
6-- }}}3

VIM Extras

Since we have a whole scripting engine anyway, we might as well use it to get some additional mileage not possible from our older keyboard layout.

1-- w - move to next word {{{3
2function word() hs.eventtap.keyStroke({"alt"}, "Right", 0) end
3hyper:bind({}, 'w', word, nil, word)
4-- }}}3

These can be extended further into whole pseudo-VIM approach.

All together

For this post, we opted for a minimal Hammerspoon setup which ended up with a monolithic setup defined in init.lua files as follows:

  1-- Reload config when any lua file in config directory changes
  2-- From https://gist.github.com/prenagha/1c28f71cb4d52b3133a4bff1b3849c3e
  3function reloadConfig(files)
  4    doReload = false
  5    for _,file in pairs(files) do
  6        if file:sub(-4) == '.lua' then
  7            doReload = true
  8        end
  9    end
 10    if doReload then
 11        hs.reload()
 12    end
 13end
 14local myWatcher = hs.pathwatcher.new(os.getenv('HOME') .. '/.hammerspoon/', reloadConfig):start()
 15hs.alert.show('Config loaded')
 16
 17-- A global variable for the Hyper Mode
 18hyper = hs.hotkey.modal.new({}, 'F18')
 19
 20-- Enter Hyper Mode when F19 (Hyper/Capslock) is pressed
 21function enterHyperMode()
 22  hyper.triggered = false
 23  hyper:enter()
 24  hs.alert.show('Hyper on')
 25end
 26
 27-- Leave Hyper Mode when F19 (Hyper/Capslock) is pressed,
 28-- send ESCAPE if no other keys are pressed.
 29function exitHyperMode()
 30  hyper:exit()
 31  -- if not hyper.triggered then
 32  --   hs.eventtap.keyStroke({}, 'ESCAPE')
 33  -- end
 34  hs.alert.show('Hyper off')
 35end
 36
 37-- Bind the Hyper key
 38f19 = hs.hotkey.bind({}, 'F19', enterHyperMode, exitHyperMode)
 39
 40-- Vim Colemak bindings (hzColemak)
 41-- Basic Movements {{{2
 42
 43-- h - move left {{{3
 44function left() hs.eventtap.keyStroke({}, "Left", 0) end
 45hyper:bind({}, 'h', left, nil, left)
 46-- }}}3
 47
 48-- n - move down {{{3
 49function down() hs.eventtap.keyStroke({}, "Down", 0) end
 50hyper:bind({}, 'n', down, nil, down)
 51-- }}}3
 52
 53-- e - move up {{{3
 54function up() hs.eventtap.keyStroke({}, "Up", 0) end
 55hyper:bind({}, 'e', up, nil, up)
 56-- }}}3
 57
 58-- i - move right {{{3
 59function right() hs.eventtap.keyStroke({}, "Right", 0) end
 60hyper:bind({}, 'i', right, nil, right)
 61-- }}}3
 62
 63-- ) - right programming brace {{{3
 64function rbroundL() hs.eventtap.keyStrokes("(") end
 65hyper:bind({}, 'k', rbroundL, nil, rbroundL)
 66-- }}}3
 67
 68-- ) - left programming brace {{{3
 69function rbroundR() hs.eventtap.keyStrokes(")") end
 70hyper:bind({}, 'v', rbroundR, nil, rbroundR)
 71-- }}}3
 72
 73-- o - open new line below cursor {{{3
 74hyper:bind({}, 'o', nil, function()
 75    local app = hs.application.frontmostApplication()
 76    if app:name() == "Finder" then
 77        hs.eventtap.keyStroke({"cmd"}, "o", 0)
 78    else
 79        hs.eventtap.keyStroke({}, "Return", 0)
 80    end
 81end)
 82-- }}}3
 83
 84-- Extend+AltGr layer
 85-- Delete {{{3
 86
 87-- cmd+h - delete character before the cursor {{{3
 88local function delete()
 89    hs.eventtap.keyStroke({}, "delete", 0)
 90end
 91hyper:bind({"cmd"}, 'h', delete, nil, delete)
 92-- }}}3
 93
 94-- cmd+i - delete character after the cursor {{{3
 95local function fndelete()
 96    hs.eventtap.keyStroke({}, "Right", 0)
 97    hs.eventtap.keyStroke({}, "delete", 0)
 98end
 99hyper:bind({"cmd"}, 'i', fndelete, nil, fndelete)
100-- }}}3
101
102-- ) - right programming brace {{{3
103function rbcurlyL() hs.eventtap.keyStrokes("{") end
104hyper:bind({"cmd"}, 'k', rbcurlyL, nil, rbcurlyL)
105-- }}}3
106
107-- ) - left programming brace {{{3
108function rbcurlyR() hs.eventtap.keyStrokes("}") end
109hyper:bind({"cmd"}, 'v', rbcurlyR, nil, rbcurlyR)
110-- }}}3
111
112-- Extend+Shift
113
114-- ) - right programming brace {{{3
115function rbsqrL() hs.eventtap.keyStrokes("[") end
116hyper:bind({"shift"}, 'k', rbsqrL, nil, rbsqrL)
117-- }}}3
118
119-- ) - left programming brace {{{3
120function rbsqrR() hs.eventtap.keyStrokes("]") end
121hyper:bind({"shift"}, 'v', rbsqrR, nil, rbsqrR)
122-- }}}3
123
124-- Special Movements
125-- w - move to next word {{{3
126function word() hs.eventtap.keyStroke({"alt"}, "Right", 0) end
127hyper:bind({}, 'w', word, nil, word)
128-- }}}3
129
130-- b - move to previous word {{{3
131function back() hs.eventtap.keyStroke({"alt"}, "Left", 0) end
132hyper:bind({}, 'b', back, nil, back)
133-- }}}3

Conclusions

It has been very restrictive to not be able to use the keyboard layout I spent years crafting. In that regard, this post is a success story, even with the awkwardness of the implementation. On the other hand, it is baffling to see the lack of good FOSS tools on this ecosystem 9, but that is to be expected perhaps. For my purposes right now, this monolithic init.lua is enough, and the entire configuration corresponds to this commit in my Dotfiles. However, Hammerspoon is one of the more promising configuration systems especially considering the more complete VIM bindings which exist in the community and would bear a second look. It would make sense to look into using Spacehammer and more VIM bindings sometime soon.


  1. There are some text snippet expansion methods, but nothing to simply modify keys. ↩︎

  2. As discussed here on the Random Bits blog ↩︎

  3. A nauseating prospect, far too inelegant and difficult to keep track of a million manually changed GUI settings ↩︎

  4. Coined in its current form by Brett Terpstra here or Jonathan Hollin here, first described by Steve Losh many years ago ↩︎

  5. Yes, the original post was 4 years ago and it still isn’t part of the GUI, but it is now documented ↩︎

  6. This is adapted from these regular VIM Karabiner rules ↩︎

  7. One of the FOSS tools, plus its in my favorite language (used in d-SEAMS) for embedding things, lua ↩︎

  8. This is close to the method Rosco Kalis has ↩︎

  9. It might also just be that there is a lot more media spam, like Alfred is basically SpaceLauncher but the latter isn’t well known ↩︎


Series info

Colemak necessities series

  1. Switching to Colemak
  2. Refactoring Dotfiles For Colemak
  3. Remapping Keys with XKB and KLFC
  4. Remapping Keys for ColemakVIM on MacOS <-- You are here!

Series info

Keboard management series

  1. Remapping Keys with XKB and KLFC
  2. Remapping Keys for ColemakVIM on MacOS <-- You are here!