14

I have a console application in Elixir. I need to interpret user’s input on by keypress basis. For instance, I need to treat “q” as a command to end the session, without user to explicitly press a.k.a. “carriage return.”

IO.getn/2 surprisingly waits for the to be pressed, buffering an input (I am nearly sure, that this buffering is done by console itself, but man stty does not provide any help/flag to turn buffering off.)

Mix.Utils use the infinite loop to hide user input (basically sending backspace control sequence to console every 1ms,) IEx code wraps calls to standard erlang’s io, that provides the only ability to set a callback on Tab (for autocompletion.)

My guess would be I have to use Port, attach it to :stdin and spawn a process to listen to the input. Unfortunately, I am stuck with trying to implement the latter, since I need to attach to the currently running console, not create a new port to some other process (as it is perfectly described here.)

Am I missing something obvious on how am I to attach a Port to the current process’ :stdin (which is btw listed in Port.list/0,) or should I’ve built the whole 3-piped architecture to redirect what’s typed to :stdin and whatever my program wants to puts to :stdout?

Aleksei Matiushkin
  • 119,336
  • 10
  • 100
  • 160
  • The eralng IO system is very different from other languages and fully custom. This makes it behave in strange ways. I've seen questions like this appear many times, but, unfortunately, I've never seen a good answer. As far as I know, something like you're asking is not possible, but I may be wrong, so I'm not posting this as an answer. – michalmuskala Oct 01 '16 at 10:17
  • @michalmuskala The last para of my question contains, in fact, the ready-to-use answer of how it might be done. The only question is that it looks overcomplicated and I doubt nobody had the problem solved already. – Aleksei Matiushkin Oct 01 '16 at 10:19

2 Answers2

6

Your program doesn't get the keys because on Linux, the terminal is by default in cooked mode, which buffers all keypresses until Return is pressed.

You need to switch your terminal to raw mode, which sends the keypresses to the application as soon as they happen. There's no cross-platform to do this.

For unix-like systems there's ncurses, which has an elixir binding that you should check out: https://github.com/jfreeze/ex_ncurses. It even has an example to do what you want.

Dirbaio
  • 2,921
  • 16
  • 15
  • Yes, thank you, that sounds like the most appropriate approach. I will wait for José to say something [hopefully] though. – Aleksei Matiushkin Oct 04 '16 at 11:08
  • I have chosen this because using `ncurses` gave me more-or-less robust solution out of the box (with some drawbacks, though.) JIC: I needed it to make surveys in the [`issuer`](https://github.com/am-kantox/issuer) package, the `rake release` surrogate for mix/hex. – Aleksei Matiushkin Oct 11 '16 at 05:16
4

The simplest thing I could cook up is based on this github repo. So you need the following:

reader.c

#include "erl_driver.h"
#include <stdio.h>

typedef struct {
  ErlDrvPort drv_port;
} state;

static ErlDrvData start(ErlDrvPort port, char *command) {
  state *st = (state *)driver_alloc(sizeof(state));
  st->drv_port = port;
  set_port_control_flags(port, PORT_CONTROL_FLAG_BINARY);
  driver_select(st->drv_port, (ErlDrvEvent)(size_t)fileno(stdin), DO_READ, 1);
  return (ErlDrvData)st;
}

static void stop(ErlDrvData drvstate) {
  state *st = (state *)drvstate;
  driver_select(st->drv_port, (ErlDrvEvent)(size_t)fileno(stdin), DO_READ, 0);
  driver_free(drvstate);
}

static void do_getch(ErlDrvData drvstate, ErlDrvEvent event) {
  state *st = (state *)drvstate;
  char* buf = malloc(1);
  buf[0] = getchar();
  driver_output(st->drv_port, buf, 1);
}

ErlDrvEntry driver_entry = {
  NULL,
  start,
  stop,
  NULL,
  do_getch,
  NULL,
  "reader",
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  NULL,
  ERL_DRV_EXTENDED_MARKER,
  ERL_DRV_EXTENDED_MAJOR_VERSION,
  ERL_DRV_EXTENDED_MINOR_VERSION
};

DRIVER_INIT(reader) {
  return &driver_entry;
}

compile it with gcc -o reader.so -fpic -shared reader.c. Then you'll need in reader.erl

-module(reader).
-behaviour(gen_server).
-export([start/0, init/1, terminate/2, read/0, handle_cast/2, code_change/3, handle_call/3, handle_info/2, getch/0]).
-record(state, {port, caller}).

start() ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, no_args, []).

getch() ->
    gen_server:call(?MODULE, getch, infinity).

handle_call(getch, From, #state{caller = undefined} = State) ->
    {noreply, State#state{caller = From}};
handle_call(getch, _From, State) ->
    {reply, -1, State}.

handle_info({_Port, {data, _Binary}}, #state{ caller = undefined } = State) ->
    {noreply, State};
handle_info({_Port, {data, Binary}}, State) ->
    gen_server:reply(State#state.caller, binary_to_list(Binary)),
    {noreply, State#state{ caller = undefined }}.

init(no_args) ->
    case erl_ddll:load(".","reader") of
    ok -> 
        Port = erlang:open_port({spawn, "reader"}, [binary]),
        {ok, #state{port = Port}};
    {error, ErrorCode} -> 
        exit({driver_error, erl_ddll:format_error(ErrorCode)})
    end.


handle_cast(stop, State) ->    
    {stop, normal, State};
handle_cast(_, State) ->    
    {noreply, State}.

code_change(_, State, _) ->
    {noreply, State}.

terminate(_Reason, State) ->
    erlang:port_close(State#state.port),
    erl_ddll:unload("reader").

read() ->
    C = getch(),
    case C of
    "q" ->
        gen_server:cast(?MODULE, stop);
    _ ->
        io:fwrite("Input received~n",[]),
        read()
    end.

Compile it with erlc reader.erl.

Then in iex :reader.start(); :reader.read() it issues a warning that stdin has been hijacked, and for every keypress you get Input received. The only problem is that when you press q the server terminates, but the stdin is not accessible.

Adam
  • 1,342
  • 7
  • 15
  • Thanks, this looks handy. Since I need more `ncurses`-like functionality, I would depend on the whole implementation rather than on dissected parts of it, but this is definitely a step in the right direction. Now I am to decide what implementation of `ncurses` am I to pick: the one you’ve mentioned, or `ex_ncurses` proposed by @Dirbaio – Aleksei Matiushkin Oct 04 '16 at 11:07
  • Well, I finally used the `ncurses` because I couldn’t overcome the warning and in my case it was unacceptable. I believe, there could be a way to fix it and I don’t quit trying, but as out-of-the-box solution, `ncurses` satisfied all my current needs. Thank you anyway, this answer helped me to understand what I am to implement when I have finally decided to fight the console totally. – Aleksei Matiushkin Oct 11 '16 at 05:13