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
- teminals simply can’t see all the events that GUIs can
- 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.
- 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.