Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Getch for OTP26 #8037

Closed
andyl opened this issue Jan 22, 2024 · 17 comments
Closed

Getch for OTP26 #8037

andyl opened this issue Jan 22, 2024 · 17 comments
Assignees
Labels
enhancement team:VM Assigned to OTP team VM
Milestone

Comments

@andyl
Copy link

andyl commented Jan 22, 2024

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.

@garazdawi garazdawi self-assigned this Jan 23, 2024
@IngelaAndin IngelaAndin added the team:VM Assigned to OTP team VM label Jan 23, 2024
@lpil
Copy link
Contributor

lpil commented May 6, 2024

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.

@garazdawi
Copy link
Contributor

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.

@lpil
Copy link
Contributor

lpil commented May 6, 2024

Lovely, thank you again. I know lots of people who will be very happy to have a work-around.

@garazdawi
Copy link
Contributor

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.

@jrfondren
Copy link

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">>}}

@zachallaun
Copy link

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.

@garazdawi
Copy link
Contributor

If you pass -noinput to erl it should also work. Not sure how to achieve that for mix.

@zachallaun
Copy link

If you pass -noinput to erl it 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?

@garazdawi
Copy link
Contributor

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 :)

@zachallaun
Copy link

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!

@garazdawi
Copy link
Contributor

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.

@zachallaun
Copy link

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?

@garazdawi
Copy link
Contributor

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.

group is the default group leader that implements the IO protocol and communicates with user.

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.

@tomas-abrahamsson
Copy link
Contributor

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.

@garazdawi
Copy link
Contributor

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.

@garazdawi
Copy link
Contributor

Fixed in #8962

@garazdawi garazdawi added this to the OTP-28.0 milestone Oct 30, 2024
@zachallaun
Copy link

Thank you for your work on this @garazdawi! I'm hoping to test this out soon.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement team:VM Assigned to OTP team VM
Projects
None yet
Development

No branches or pull requests

7 participants