Multikey bindings

Some time long ago, I think someone asked about multikey keybindings for elvish, similar to what we have in emacs and readline and so forth. (I don’t remember if it was asked in chat or on the issue tracker.) Elvish doesn’t support that out of the box, but I find that one can now do it in elvish code, with a few shortcomings. Below is how.

But first: Why would one want this? I think of two reasons: First, you often want an easy association between command and key, typically using the first letter of a description of what the command does. So Ctrl-e to enter an external editor, for example. Problem is, one quickly runs out of suitable letters. The other reason is to group related keybindings together under a common prefix for ease of recall.

Caveat the zeroth: This is for unix, including macos. I don’t know if windows has something like the stty command. Even if it does, at least you’d have to replace my uses of /dev/tty below by $os:dev-tty. (More caveats at the end.)

Anyway, on to the implementation. The basic idea is simple. Say we want a bunch of key bindings with a common prefix key like Ctrl-x. What you need is to bind Ctrl-x to a function that reads the next key from the terminal, looks that key up in a map, and executes a function that it finds there. Easy-peasy, except for the read the next key part.

To read a key from the terminal, we need to put the tty in raw mode and read from it. First, putting it in raw mode can be done using the following utility function:

fn with-stty {|@args cmd~ &tty=/dev/tty|
  var orig = (stty -g <$tty)
  try { stty $@args <$tty; cmd } ^
  finally { stty $orig <$tty }
}

To read a single byte in raw mode, do with-stty raw { read-bytes 1 </dev/tty }. Afterwards, the tty is back in its original state.

The following could be called a keymap factory. Give it a map, and it will return a function that you can bind to a suitable prefix key.

fn subkeymap {|map|
  put {
    var key = (with-stty raw { read-bytes 1 </dev/tty })
    if (has-key $map $key) {
      $map[$key]
    } else {
      edit:notify (styled (printf 'not bound: %q' $key) white bg-red bold)
    }
  }
}

Here is an example, creating a keymap and binding it to Ctrl-x:

set edit:insert:binding[Ctrl-x] = (subkeymap ^
  [&a={ edit:insert-at-dot AAA} &b={ edit:insert-at-dot BBB}])

After this is done, Ctrl-x a will insert AAA, and Ctrl-x b inserts BBB. Silly, I know.

Caveat the first: This only works with subkeys that output a single byte. That means the ASCII set of 128 characters, including control characters. It is not terribly hard to extend it to accepting keys that produce a single unicode characters; see Addendum below. But function keys, arrow keys, Alt-characters are harder. First, they all output byte sequences that begin with an escape character. There does not seem to be any way from elvish code to distinguish between the user pressing the up arrow key, say, and escape followed by [A. Or between Alt-a and escape followed by a. If you want to support those, you have to bind the subkey “escape” ("\e") to a function that reads more bytes from the terminal. Supporting the Alt key that way is easy; supporting special keys is a bit harder. And you lose the ability to act on a simple press of the escape key.

Caveat the second: You have to specify control keys literally, not with a string like Ctrl-c. Luckily, that is easy: "\x03" or "\^C" will do just as well. (But "\^c" does not work: you need to use capital letters. See double quoted strings). The ASCII NUL character must be written "\x00" or "\000" or "\^@" (in elvish keymaps, it is written like control-backquote (I don’t know how to get that in markdown)). Similarly what are known as Ctrl-6 and Ctrol-/ in an elvish keymap are written "\^^" (or "\x1e") and "\^_" (or "\x1f") respectively, and the DEL character is "\^?" or "\^x3f". (Note: See the elvish source, here.)

Addendum: The following code will read a unicode character from the terminal. But only if the character is in the basic multilingual plane (BMP), that is, up to U+ffff. Increase the number 3 in the code if you wish to support characters with higher codes.

fn read-char{|&tty=/dev/tty| var bytes n = [] 0
  with-stty raw &tty=$tty { while (< $n 3) {
    set bytes = (conj $bytes (str:to-utf8-bytes (read-bytes 1)))
    if ?(str:from-utf8-bytes $@bytes) { return }
    set n = (+ $n 1)
  } } < $tty
  fail (str:join ' ' ['Can''t decode octets' $@bytes])
}

1 Like

I love how Elvish is a viable language for reimplementing the terminal reader XD

On a more serious note, an Elvish binding for the Go-implemented terminal reader is something to think about…

A nitpick on the implementation of with-stty: You can use defer rather than try/finally, which I find slightly more readable:

fn with-stty {|@args cmd~ &tty=/dev/tty|
  var orig = (stty -g <$tty)
  defer { stty $orig <$tty)
  stty $@args <$tty
  cmd
}

Good point about defer. I wrote with-stty before defer came into existence, though.