Handling keyboard events

Terminals are quite slow, slow enough that tools like ncurses are based on a similar idea to JavaScript frameworks like React that diff a virtual DOM in order to optimize UI updates. So, some of the latency you’ve seen might be inherent to the terminal rather than the code. A minimal getch() wrapper, on Linux, seems responsive enough:

#include <erl_nif.h>
#include <termios.h>
#include <unistd.h>

static ERL_NIF_TERM getch(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
    struct termios oldattr, newattr;
    tcgetattr(0, &oldattr);
    newattr = oldattr;
    newattr.c_lflag &= ~(ICANON | ECHO);
    tcsetattr(0, TCSANOW, &newattr);
    int ch = getchar();
    tcsetattr(0, TCSANOW, &oldattr);
    if (ch == EOF) {
        return enif_make_atom(env, "eof");
    } else {
        return enif_make_tuple2(env, enif_make_atom(env, "ok"), enif_make_int(env, ch));
    }

}

static ErlNifFunc nif_funcs[] = {
    {"getch", 0, getch},
};

ERL_NIF_INIT(getch, nif_funcs, NULL, NULL, NULL, NULL)

With getch.erl:

-module(getch).
-export([getch/0, demo/0]).
-nifs([getch/0]).
-on_load(init/0).

init() ->
    ok = erlang:load_nif("./getch_nif", 0).

getch() ->
    erlang:nif_error(nif_library_not_loaded).

demo() ->
    io:fwrite("getch() demo: press a key to see its value!\n"),
    loop().

loop() ->
    case getch() of
        eof ->
            init:stop();
        {ok, Byte} ->
            case Byte of
                N when (N =:= $\n) or (N =:= $\r) ->
                    io:fwrite("You pressed enter.\n");
                N when N < $\s ->
                    io:fwrite("You pressed a control key.\n");
                $\s ->
                    io:fwrite("You pressed space.\n");
                N when N < 16#7F ->
                    io:fwrite("You pressed ~c\n", [N]);
                _ ->
                    % multi-byte input isn't treated kindly
                    ok
            end,
            loop()
    end.

As used:

$ gcc -I/usr/local/lib/erlang/usr/include/ -o getch_nif.so -fpic -shared getch_nif.c # or similar path
$ erlc getch.erl
$ erl -noinput -s getch demo
getch() demo: press a key to see its value!
You pressed t
You pressed e
You pressed s
You pressed t

That’s usable also in Eshell:

1> c(getch).
{ok,getch}
2> getch:getch().
{ok,116}
3> getch:getch().
{ok,101}
4> getch:getch().

So just getting keypresses is easy and should be reasonably fast. This is only some keypresses, as

  1. teminals simply can’t see all the events that GUIs can
  2. the terminal handles some keys specially, like Ctrl-C which was turned into a signal that BEAM intercepted to terminate both of the interactions above.
  3. the terminal also sends some keys (arrow-keys, alt-keys) as multi-byte escape codes, which are trickier to handle as timing starts to matter if you want to distinguish them from a lone escape key.

But that and some tactical terminal control codes can get you a lot of the way to a nice terminal interface for something simple - and not very robust, or portable. Since OTP’s already doing some of the portability work, for line-editing across platforms, it’d be nice to reuse that work.

A heavier example. This is an mp4, rendered from asciinema. In the bottom frame: an Erlang node, started with erl -noinput -sname keycodes -s tui init, with loads a NIF that starts controlling the terminal in a separate thread, and then polls to see if it should exit. In the top frame: an Eshell session is spawned to connect to that node and control the TUI from the ‘backend’. I suspect think this isn’t a very good way to do things vs. a C node or a port, but I liked it as a proof-of-concept.

5 Likes