Using Vim with the Colemak Mod-DH layout April 15, 2023 source/commit

If you are here just for the destination without the journey, I created a plugin, that you can find here.

If you are like me, you like efficiency, and when it comes to text editors Vim is one of the most efficient there is. Why? Vim motions. Vim makes it stupidly easy to move around once you get used to it. You move left, down, right and up via hjkl, you can jump a word forward using w, backward with b and a lot more but that’s not what this post is about.

Despite this supposed greatness of vim, I used VSCode for several years before switching, because one thing was holding me back - Colemak MOD-DH, an alternative English layout, that tries to solve the inefficiencies of QWERTY. Sadly it is not as popular and most software, vim included, does not expect you to be using it.

ANSI variant Colemak MOD-DH
0. ANSI variant Colemak MOD-DH

Just imagine trying to move via hjkl while using the above layout, not ideal. My initial solution was to just remap the most important keys and learn to live with the rest being a bit messed up, remapping things as I go whenever I deemed it necessary.

1
2
3
4
noremap m h
noremap n j
noremap e k
noremap i l

But then I found the langmap option:

This option allows switching your keyboard into a special language
mode. When you are typing text in Insert mode the characters are
inserted directly. When in Normal mode the 'langmap' option takes
care of translating these special characters to the original meaning
of the key.

Which sounds exactly like what I wanted, so I removed my previous mappings, set the langmap option in my config and was happy for maybe like 10 minutes before trying to use a mapping containing CTRL. Turns out langmap does not work with alt or control, on top of that plugins don’t expect you to use this feature, that maybe like three people know about, therefore mappings set by them sometimes stop working and sometimes do completely different things.

I was so close to greatness and I didn’t intend to give up. The only thing left to do was remap all of vim’s default mappings, all of them.

Typing all the mappings by hand was out of question and having a huge for loop run every time vim started also didn’t feel right, thus I settled on generating the mappings with python and then writing them into a .vim file.

We define a dictionary containing all the keys, necessary to translate between Colemak MOD-DH and QWERTY, plus a small helper function, so we can also translate uppercase characters.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
COLEMAK = {
    "p": ";",
    "t": "b",
    "x": "c",
    "c": "d",
    "k": "e",
    "e": "f",
    "m": "h",
    "l": "i",
    "y": "j",
    "n": "k",
    "u": "l",
    "h": "m",
    "j": "n",
    ";": "o",
    "r": "p",
    "s": "r",
    "d": "s",
    "f": "t",
    "i": "u",
    "z": "x",
    "o": "y",
    "b": "z",
}



def to_colemak(ch: str) -> str:
    if ch.isupper():
        return COLEMAK[ch.lower()].upper()
    else:
        return COLEMAK[ch]

Different modes have different mappings and the mappings sometimes get more complex than just “j goes down”. I created another dictionary with keys being modes and elements lists of tuples containing mapping and a list of characters to apply it to. For example ("<C-W>g{}", ["f", "F", "t", "T"]) will format the string "<C-W>g{}" with f, F, t and T, resulting in:

1
2
3
4
noremap <C-W>gT <C-W>gF
noremap <C-W>gB <C-W>gT
noremap <C-W>gt <C-W>gf
noremap <C-W>gb <C-W>gt

As a convenience, if we omit the tuple and just have a string, the string will get formatted for all the characters, e.g. "<C-W>{}" remaps several of the window commands.

For more complex mappings we can use a lambda like this:

1
2
3
4
lambda lhs, rhs: (
    "<C-P><C-" + lhs + ">",
    "<C-R><C-" + rhs + ">",
)

After around 45 lines of mappings we can run the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
outfile = Path("./autoload/colemak_dh.vim")
outfile.parent.mkdir(exist_ok=True)
contents = "function colemak_dh#setup()\n"

for mode in modes:
    for mapping in modes[mode]:
        if type(mapping) == tuple:
            set = mapping[1]
            mapping = mapping[0]
        else:
            set = list(COLEMAK.keys())
        set.sort()
        contents += "".join(gen_mappings(mode, mapping, set))

contents += "endfunction\n"
outfile.write_text(contents)

gen_mappings checks if lhs is a string or a lambda, loops through the set we supplied and then passes the formatted mappings to this function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
REMAPPED = {}


def make_map(mode: str, lhs: str, rhs: str) -> str:
    if not mode in REMAPPED.keys():
        REMAPPED[mode] = []

    s = "    {}noremap {} {}\n".format(mode, lhs, rhs)
    if not rhs in REMAPPED[mode]:
        s += "    {}noremap {} <Nop>\n".format(mode, rhs)

    REMAPPED[mode].append(lhs)

    return s

To avoid clashing mappings, mappings that take too long to resolve because of timeout and similar issues, we check if we already mapped the rhs and if we didn’t, we map it to '<Nop>', disabling it.

In the big dictionary of mappings there are some, that break things, most notably remapping "<C-{}>" for insert mode turns the Enter key into Backspace because of how terminal emulators handle input, the i key, that’s remapped to l will wait for timeout in visual mode and some more shenanigans. To fix this we simply append few more lines to the file before writing it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
contents += """    inoremap <C-i> <C-i>
    cnoremap <C-i> <C-i>
    inoremap <C-m> <C-m>
    cnoremap <C-m> <C-m>
    nnoremap XX ZZ
    vnoremap <nowait> i l
    nnoremap <nowait> z b
    vnoremap <nowait> z b
    noremap <nowait> z b
endfunction
"""

The final result is a .vim file with around 800 lines of noremap commands. To use this in our config we can do call colemak_dh#setup() or vim.fn['colemak_dh#setup']() if using Lua.

I also tried doing this in pure Lua, but I did some quick non-scientific benchmarks and sourcing an empty Lua file takes around 3.5ms on my machine, while sourcing the 800 noremap calls takes 1ms.

Have something to say? Feel free to leave your thoughts in my public inbox or contact me personally.