Getch for OTP26
Is your feature request related to a problem? Please describe.
We've got elixir command line scripts that requires getch. The use case is to detect single keypress events, without needing the return key.
In OTP 25 we had a nice solution for getch. Our approach used Port.open({:spawn, "tty_sl -c -e"}, [:binary, :eof]). Here is a gist with a working demo script.
In OTP 26, Lukas Larsson re-implemented the erlang shell, dropping tty_sl and replacing it with prim_tty.
Here is an demo module that implements getch for OTP26 using prim_tty...
-module(my_ttt).
-export([start/0]).
start() ->
TTYState = prim_tty:init(#{}),
echo(TTYState).
echo(TTYState) ->
receive
M ->
ok = prim_tty:write(TTYState, unicode:characters_to_list(io_lib:format("~p~n",[M]))),
echo(TTYState)
end.
To run: put the module code into my_ttt.erl, then $ erlc my_ttt.erl && erl -user my_ttt
This solution is undocumented, and uses an internal API that can change at any time. It would be great to have official support for getch.
Describe the solution you'd like
Documentation and stable API for getch, based on OTP26/prim_tty.
Describe alternatives you've considered
In the near term we'll use a variation of the demo module above.
Additional context
Thanks to Lukas Larsson for giving guidance on OTP26/prim_tty.
Would you say this work-around is suitable for use in a loop? For example, if we wanted to capture all the keypresses of a skilled typist typing at full speed? Thank you.
It is the exact same logic that we use for the normal Erlang shell. So if that is fast enough, then this is fast enough.
Lovely, thank you again. I know lots of people who will be very happy to have a work-around.
Maybe it would also be good to note that if you are a skilled typist and type very fast (for instance holding down the aaaaaaaaaaaaaaaaaaaaaaaaa key), then most likely you will get multiple characters in the same message. The same thing if you copy-paste text into the console.
Easy ways to reliably observe multiple characters in the same message: hit an arrow key, or start typing in a language with multi-byte characters:
Left = {data,<<"\e[D">>}}
Right = {data,<<"\e[C">>}
я = {data,<<209,143>>}
It also sends window-resize events: {signal,winch}
But it also uncooks the terminal, leading to \n not rendering as expected in the demo, and requiring additional workarounds (\r\n newlines on unix):
{#Ref<0.2028318121.1767112710.140973>,{data,<<"a">>}}
{#Ref<0.2028318121.1767112710.140973>,{data,<<"b">>}}
{#Ref<0.2028318121.1767112710.140973>,{data,<<"c">>}}
Is there a workaround using prim_tty that does not require the -user flag as shown in the example above above? erl -user my_ttt
My particular use-case is collecting user input in raw mode during an test run using Elixir's mix test.
If you pass -noinput to erl it should also work. Not sure how to achieve that for mix.
If you pass
-noinputtoerlit should also work. Not sure how to achieve that for mix.
Appreciate the suggestion. My hope, however, is for an option that does not require any flags on start, but that can "take over" the user at an arbitrary point (and perhaps even cede control back to whatever the default was later). It seems that that's not currently possible?
I don't know of a way to do that. You might be able to do it using some creative sys:replace_state/2 calls to the user_drv process, but I would not recommend doing that as it would very likely break in very subtle ways in future patch releases.
Better to spend that time on implementing a PR adding an official API to do what you want to do :)
Better to spend that time on implementing a PR adding an official API to do what you want to do :)
If you all are open to it, I completely agree!
Yes, we would like to have such an API. My idea for adding it would be to add io:setopts(standard_io, [{eager, true}]) that makes io:getchar(standard_io, 1, "") (and file:read(standard_io, 1)) return on keypress instead of newline.
To make this work on Windows, maybe we first have to implement my "lazy read" solution in #8113.
I'd like to make sure I understand the various parts.
Starting from the lowest level and getting more abstract, we have prim_tty_nif and prim_tty which implement tty functionality. This is then used by user_drv which is a gen_statem that maintains tty state and brokers data between prim_tty and the current group leader. group is the default group leader that implements the IO protocol and communicates with user. Finally, modules like io and file turn function calls into IO protocol requests to the group leader. (Some of this is obviously simplified, but please correct if anything is outright wrong.)
The suggestion in #8113 points to the fact that user_drv currently immediately sends input to group as it's received, which group buffers and responds with when it receives IO requests. The "lazy read" idea is to invert that, such that user_drv receives a request to read from the group leader and then (depending on the mode) reads a line or a certain number of characters from the device. Am I understanding that correctly?
Yes, that seems correct. To add to the complexity, ssh plugs itself in as a driver to group, so any signaling changes that are done between group and user_drv needs to be handled in ssh as well.
I've not spent too much time thinking everything through, so I'm not sure if it will work or not. It is quite a bit of work just to see if the API will work, which is why I've shied away from it so far.
groupis the default group leader that implements the IO protocol and communicates withuser.
nitpick, group is an implementation of an I/O device that is backed by user_drv. The module is used by user and the group leader of a shell.
This was very interesting. I found it especially useful that it is possible to get window resize events too. It is nice that there might be plans to make it more official in the future. Whatever the form it will take, I think it would be bit important to be able to receive both window resizes and keystrokes/input.
If someone stumbles on this before official support has landed, it might be useful to know that to be able to receive in a loop, it is also necessary to start a process and register user, so that the user_sup and the kernel can complete their startup. Something like this:
start() ->
proc_lib:spawn(
fun() ->
TTYState = prim_tty:init(#{}),
register(user, self()),
loop(TTYState)
end).
loop(TTYState) ->
receive
... -> do_stuff(),
loop(TTYState)
end.
FYI, with #8887 you can now get window resize events on Unix.
I've also implemented getch support that will be part of Erlang 28. I'll link the PR here once it is opened.
Fixed in #8962
Thank you for your work on this @garazdawi! I'm hoping to test this out soon.