Skip to content

Instantly share code, notes, and snippets.

@dylnmc
Last active September 24, 2020 05:35
Show Gist options
  • Save dylnmc/1612eceb94179b1f6176da9c0e99dbf6 to your computer and use it in GitHub Desktop.
Save dylnmc/1612eceb94179b1f6176da9c0e99dbf6 to your computer and use it in GitHub Desktop.
Map Meta Keys in Vim!

Meta Sequences in Vim

Terminals are weird. If you type read -r in a shell then hold Alt and tap a, you may be surprised to see ^[a glaring at you.

Why?

Your terminal encodes Alt-a (referred to as <m-a> in vim) as two bytes: Escape (referred to as <esc> in vim) followed by a. This encoding scheme has been used for decades and allows your terminal to send escape sequences like this to remote machines.

Therefore, when you type <m-a> in vim, things do not work as expected. If you try to map <m-a>, it will not work out of the box. This is exactly what Anna, the expert vim user, discovered in her Vim journey.

The Journey of Anna, the Expert Vim User

Anna saw mention of meta sequences on some stackoverflow post, and she has the perfect use-case. She wants to map <m-m> (Meta m) to run :sil make | redraw!. So, she does just that to test:

:nnoremap <m-m> :sil make <bar> redraw!<cr>

Pleased that she remembered | needed to be <bar> in the map and that she needed to add <cr> at the end so vim would execute her mapping, she held Alt and tapped m to issue Meta m, and ...

It didn't work.

Confused, Anna tried mapping another meta sequence.

:inoremap <m-a> <esc>:echo 'Meta a'<cr>gi

she typed furiously and pressed Alta forcibly after entering INSERT mode only to find that, once again, her efforts were futile.

Suddenly, an idea came to Anna. "Meta a and Escape a are basically the same thing!" she exclaimed aloud. "If I map Escape a, when I type Alt a, the mapping will be called!"

:inoremap <esc>a <esc>:echo 'Meta a! Ha!'<cr>gi

she wrote and was extremely happy when she saw Meta a! Ha! printed at the bottom of the screen when she pressed Alta

However, it didn't take her long to notice the elephant in the room: every time she pressed Escape in INSERT mode, vim seemed to stay in INSERT mode for an entire second more!

"Why?" she exclaimed in a frustrated tone.

After some reading, Anna found something about 'timeout' and 'timeoutlen' on stackoverflow and promptly opened :help 'timeout' in vim, as any good vim user would do. There, she discovered the table staring ominously at her:

'timeout' 'ttimeout' action
off off do not time out
on on or off time out on :mappings and key codes
off on time out on key codes

She realized the issue. Because she mapped <esc>a, vim was waiting for 'timeout' milliseconds before timing out her map and issuing <esc> like she typed. Indeed, if she typed Esc then a fast enough in INSERT mode, then she saw the once happy Meta a! Ha! message and realized that this would cause serious harm to her workflow.

After some experimentation, she concluded that this was not a good idea. If she decreased 'timeoutlen' then it was difficult to issue multi-key builtin maps or her own maps that began with <leader>, and Anna had many of these maps that sped up her daily chores.

At a loss, Anna decided not to use any meta maps and told a few of her friends, "Vim sucks at mapping meta keys. It just isn't possible."

No one knows what happened to Anna. Maybe she still has a meta-free vimrc. Maybe she switched to neovim, which has "first class" meta support.

However, you can map meta sequences in Vim without any drawbacks! Keep reading to see how.

Mapping Meta Keys

First, you need to understand that vim can receive multiple bytes but treat this as a single "key". When you type Alta, the terminal encodes this as <esc>a (two bytes, 27 and 97), then Vim receives these two bytes. If you don't tell vim that <esc>a is a normal key, it will treat this as two separate keys. In order to tell vim that <esc> followed rapidly by a is a normal key, just do the following:

:execute "set <m-a>=\<esc>a"

This tells vim "Escape followed by 'a' is a special code that should be interpreted as the normal key, Meta a". See :help :set-termcap for more information.

Note that "\<esc>" (need double quotes) is translated into the byte for <esc> (ASCII 27).

Now, we can map <m-a> without any consequences:

:nnoremap <m-a> :echo 'Meta a works PERFECTLY!'<cr>
:inoremap <m-a> <esc>:echo 'Meta a works PERFECTLY!'<cr>gi

After issuing Alta, you can see that Meta a works perfectly, and there is no longer the issue with pressing <esc> in INESRT mode.

Hooray!

Note that if you only map <esc>{key} instead of <m-{key}> in NORMAL mode, then there should not be an issue, but mapping <m-{key}> is the proper way to achieve this, and it won't break elsewhere.

Any Meta Sequence

In order to map any meta sequence, you can put this anywhere in your vimrc:

if !has('nvim') && !has('gui_running')
  " set up Meta to work properly for most keys in terminal vim
  " NOTE: these do not work: <m-space>,<m->>,<m-[>,<m-{up,down,left,right}>
  " NOTE: <m-@>,<m-O> only work in xterm and gvim - not st, urxvt, etc
  " NOTE: map <m-\|> or <m-bar>
  for ord in range(33,61)+range(63,90)+range(92,126)
    let char = ord is 34 ? '\"' : ord is 124 ? '\|' : nr2char(ord)
    exec printf("set <m-%s>=\<esc>%s", char, char)
    if exists(':tnoremap') " fix terminal control sequences
      exec printf("tnoremap <silent> <m-%s> <esc>%s", char, char)
    endif
  endfor
  " set up <c-left> and <c-right> properly
  " NOTE: if below don't work, compare with ctrl-v + CTRL-{LEFT,RIGHT} in INSERT mode
  " NOTE: <c-up>,<c-down> do not work in any terminal
  exe "set <c-right>=\<esc>[1;5C"
  exe "set <c-left>=\<esc>[1;5D"
endif

There is only one sequence that requires special notation when mapping. In order to map |, you need to use something like :nnoremap <m-bar> :echo 'M-bar!'<cr> or :nnoremap <m-\|> :echo 'M-bar!'<cr>. Every other meta sequence (even <m-">) works as it is.

Note that some keys just do not work at all in a terminal; the broken keys are as follows:

  • <m-@> (not possible to set)
  • <m-O> (capital "Oh"; why doesn't this work? I don't know)
  • <m->> or <m-\>> (not possible to set)
  • <m-[> (causes issues)
  • <m-space> (breaks all other meta sequences)
  • <m-up>
  • <m-down>
  • <m-right>
  • <m-left>

Testing Meta Sequences

You can also test the above code with some vim-script:

sil update
let [s:lhs,s:rhs] = [{'|':'bar'}, {'|':'bar', "'":"''"}]
for ord in range(33,61)+range(63,90)+range(92,126)
  let char = nr2char(ord)
  let [l,r] = [get(s:lhs, char, char), get(s:rhs, char, char)]
  exe printf("nnore <m-%s> :echo 'M-%s PRESSED'<cr>", l, r)
  exe printf("nnore <f20><m-%s> :let g:lastmap = '%s'<cr>", l, r)
endfor
for ord in range(33,61)+range(63,90)+range(92,126)
  let char = nr2char(ord)
  sil call feedkeys("\<f20>\<esc>".char, 'Tx')
  if get(g:, 'lastmap') != get(s:lhs, char, char)
    echom 'Broken' '<m-'.char.'>'
  endif
endfor
if &mod | sil earlier 1f | endif

Paste that into, for example, /tmp/test_meta.vim, run :source %, and then type :messages. It will show you which keys that could be set do not work properly by injecting keys in a low-level way using feedkeys().

You can also issues meta sequences manually to test. For example, if you press Alt#, you should see M-# PRESSED echoed to the command-line.

Pics or it Didn't Happen

If you get lost or don't feel like testing this out but are dubious, here is a gif:

meta testing

tl;dr

When you press <m-a>, the terminal encodes it as <esc>a (two bytes).

Tell vim that <esc>a is the "key sequence" <m-a> by issuing:

:execute "set <m-a>=\<esc>a"

To map any meta sequence (except <m-space>, <m-@>, <m-[, and <m-O), add this to your vimrc:

if !has('nvim') && !has('gui_running')
  " set up Meta to work properly for most keys in terminal vim
  " NOTE: these do not work: <m-space>,<m->>,<m-[>,<m-{up,down,left,right}>
  " NOTE: <m-@>,<m-O> only work in xterm and gvim - not st, urxvt, etc
  " NOTE: map <m-\|> or <m-bar>
  for ord in range(33,61)+range(63,90)+range(92,126)
    let char = ord is 34 ? '\"' : ord is 124 ? '\|' : nr2char(ord)
    exec printf("set <m-%s>=\<esc>%s", char, char)
    if exists(':tnoremap') " fix terminal control sequences
      exec printf("tnoremap <silent> <m-%s> <esc>%s", char, char)
    endif
  endfor
  " set up <c-left> and <c-right> properly
  " NOTE: if below don't work, compare with ctrl-v + CTRL-{LEFT,RIGHT} in INSERT mode
  " NOTE: <c-up>,<c-down> do not work in any terminal
  exe "set <c-right>=\<esc>[1;5C"
  exe "set <c-left>=\<esc>[1;5D"
endif

Example map: nnoremap <m-a> :echo 'M-a works!'<cr>

Note that you need to use <m-bar> (or <m-\|>) for <bar> but everything else is normal (<m-">, <m-#>, etc).

Read :help 'timeout' and below sections yourself to better understand how these options work.

Read :help :set-termcap to read more about translating "special codes" into "normal keys".

" Add this to your vimrc!
if !has('nvim') && !has('gui_running')
" set up Meta to work properly for most keys in terminal vim
" NOTE: these do not work: <m-space>,<m->>,<m-[>,<m-{up,down,left,right}>
" NOTE: <m-@>,<m-O> only work in xterm and gvim - not st, urxvt, etc
" NOTE: map <m-\|> or <m-bar>
for ord in range(33,61)+range(63,90)+range(92,126)
let char = ord is 34 ? '\"' : ord is 124 ? '\|' : nr2char(ord)
exec printf("set <m-%s>=\<esc>%s", char, char)
if exists(':tnoremap') " fix terminal control sequences
exec printf("tnoremap <silent> <m-%s> <esc>%s", char, char)
endif
endfor
" set up <c-left> and <c-right> properly
" NOTE: if below don't work, compare with ctrl-v + CTRL-{LEFT,RIGHT} in INSERT mode
" NOTE: <c-up>,<c-down> do not work in any terminal
exe "set <c-right>=\<esc>[1;5C"
exe "set <c-left>=\<esc>[1;5D"
endif
nnoremap <m-a> :echo 'Meta a works!'<cr>
inoremap <m-a> :echo 'Meta a works!'<cr>
sil update
let [s:lhs,s:rhs] = [{'|':'bar'}, {'|':'bar', "'":"''"}]
for ord in range(33,61)+range(63,90)+range(92,126)
let char = nr2char(ord)
let [l,r] = [get(s:lhs, char, char), get(s:rhs, char, char)]
exe printf("nnore <m-%s> :echo 'M-%s PRESSED'<cr>", l, r)
exe printf("nnore <f20><m-%s> :let g:lastmap = '%s'<cr>", l, r)
endfor
for ord in range(33,61)+range(63,90)+range(92,126)
let char = nr2char(ord)
sil call feedkeys("\<f20>\<esc>".char, 'Tx')
if get(g:, 'lastmap') != get(s:lhs, char, char)
echom 'Broken' '<m-'.char.'>'
endif
endfor
if &mod | sil earlier 1f | endif
" to test:
" 1. paste this file into, for example, /tmp/meta_test.vim and open /tmp/meta_test.vim in vim
" 2. :source % " to source the file and run tests
" 3. :messages " to show the errors from the tests
" 4. press Meta a or any other meta sequence to test
" - it will print "M-a" (or whatever) to the cmdline
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment