How to map Mod+Number keys in Vim/Neovim (using Wezterm)

alanscodelog

Alan North

Posted on July 26, 2023

How to map Mod+Number keys in Vim/Neovim (using Wezterm)

I've been recently giving neovim a serious try (this is my fourth/fifth attempt). And I think this time it's going to work out, but I really miss Modifier+Number shortcuts.

Usually you cannot map to modifies + number keys in in neovim (e.g. Ctrl+1). You can try to map the raw key sequences but this is a pain and does not work for all numbers.

The best way to get around this is to map to an obscure keybinding then make the terminal remap Mod+Number to those odd keybindings. I never got around to actually doing this usually because most terminal's configs are static it's also often a pain to remap keys (e.g. kitty, I like you, but...)

Also the ideal way to do this is to map them to a key chain like {ObscureCombo} {Mods}+{Remap} so as to not take up useful keys. But that's even more painful.

Recently though I tried and switched to wezterm for my terminal emulator, and it's configured through lua which makes this super easy to do for all sequences.

For example, I picked Ctrl+Alt+Shift+F12 for the obscure key combo and letters for the remap part. So Ctrl+1 would map to Ctrl+Alt+Shift+F12 Ctrl+q.

The letter mapping is to avoid breaking anything. If we mapped them to, for example, the F keys again, we might accidentally shoot ourselves in the foot when pressing Alt+4 .

Note that we also can't map the numbers without the modifiers, since wezterm has no way to know if we're in normal mode or not, and we would be left unable to type numbers if we did so.

Here's my wezterm function for remapping with all mods.

-- Allows using Mods+Number in the receiving program if it does not support it.
-- In the receiving program, the keymap needs to be `<C-A-S-F12> <Mods-F{q-p}>`
local remapModNumber = function(keyConfig, modsToRemap)
    -- it's not <Mods-F{Number}> for obvious reasons (i.e. A-F4)
    local numMap = {
        ["1"] = "q",
        ["2"] = "w",
        ["3"] = "e",
        ["4"] = "r",
        ["5"] = "t",
        ["6"] = "y",
        ["7"] = "u",
        ["8"] = "i",
        ["9"] = "o",
        ["0"] = "p",
    }

    local createModNumberRemap = function(key, mods)
        local res = {
            key = key,
            mods = mods,
            action = act.Multiple({
                --obscure key to use as base
                act.SendKey({ key = "F12", mods = "CTRL|SHIFT|ALT" }),
                act.SendKey({ key = numMap[key], mods = mods }),
            }),
        }
        return res
    end
    print(keyConfig)
    for i = 0, 9 do
        for _, mod in ipairs(modsToRemap) do
            -- cant insert normally???
            keyConfig[#keyConfig + 1] = createModNumberRemap(tostring(i), mod)
        end
    end
end
config.keys = {
    -- ...your other keybindings
}

remapModNumber(config.keys, { "CTRL", "SHIFT", "ALT", "CTRL|ALT", "ALT|SHIFT", "CTRL|SHIFT", "ALT|CTRL", "CTRL|SHIFT|ALT" })
Enter fullscreen mode Exit fullscreen mode

Then in vim, if we wanted to use <C-1> we would map to <C-S-A-F12><C-q>.

I've also added a small helper function in my local utils module to help with this. I might extract this later into a plugin.

local M = {}
local config = {
    baseSequence = "<C-S-A-F12>",
}
M.setup = function(opts)
    opts = opts or {}
    vim.tbl_extend("force", config, opts)
end
local replacements = {
    ["1"] = "q",
    ["2"] = "w",
    ["3"] = "e",
    ["4"] = "r",
    ["5"] = "t",
    ["6"] = "y",
    ["7"] = "u",
    ["8"] = "i",
    ["9"] = "o",
    ["0"] = "p",
}
M.mapNum = function(keysString)
    local str = ""
    for i = 1, #keysString do
        local prevPrevChar = i > 2 and keysString:sub(i - 2, i - 2) or nil

        local prevChar = i > 1 and keysString:sub(i - 1, i - 1) or nil
        local char = keysString:sub(i, i)
        local isNumber = tonumber(char) ~= nil
        local prevIsNumber = prevChar ~= nil and tonumber(prevChar) ~= nil
        local prevIsAllowed = prevChar == nil or prevChar ~= "F" or prevIsNumber
        local prevPrevIsAllowed = prevPrevChar == nil or prevPrevChar ~= "F"
        if isNumber and prevIsAllowed and prevPrevIsAllowed then
            str = str .. replacements[char]
        else
            str = str .. char
        end
    end
    return config.baseSequence .. str
end

return M
Enter fullscreen mode Exit fullscreen mode

It can then be used like so:

local mn = require("utils").mapNum

vim.keymap.set("n", mn("<C-1>"), ...)
-- will not remap F1
vim.keymap.set("n", mn("<C-F1>"), ...) 
Enter fullscreen mode Exit fullscreen mode

How to get a local module to actually load using lazy.nvim is out of scope for this post, but I might write one on that later because it was not very straightforward.

💖 💪 🙅 🚩
alanscodelog
Alan North

Posted on July 26, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related