3

I know i can read within a script something into a variable, like this: variable = read But i have to press enter in order to submit the value into the variable. What do i have to know in order to submit the value of a keypress into a variable without hitting enter, or if not submitting into a variable just to react when a certain key is pressed ?

2 Answers2

2

With bash, you can use the -n parameter to the built-in read function to limit the number of characters read without requiring a newline:

#!/bin/bash

echo "Ready? [Y/n]: "
read -n 1 y_or_n
echo

case "$y_or_n" in
    [Yy]|"")
        echo "you said yes"
        ;;
    *)
        echo "you said no"
        ;;
esac

This works whether bash is invoked as sh or bash.

See help read or the bash manpage for more details.

Note that other shells may not support the -n parameter to read. dash, for example, does not.

zackse
  • 1,523
  • 9
  • 10
2

The thing about the shell's read is - whether you limit its total byte count per read or not - it's still not going to get you a keypress per read. It's only going to get some fixed number of characters per read. In order to get a keypress per read you need to block out your input - and the best way to do that is probably dd. This is a shell function I wrote a couple of months ago when I was experimenting w/ dd blocking on terminal i/o:

printkeys()(    IFS= n=1
    set -f -- "$(tty <&2)"
    exec    <${1##[!/]*};set \\t
    _dd()(  c=conv=sync,unblock i=ibs=72 b=bs=8
            dd bs=7 ${c%,*} |   dd $i o$b c$b $c
    )       2>/dev/null
    trap "  trap '' TTOU TTIN
            stty '$(stty -g
            stty raw isig inlcr)'   "   INT EXIT
    _dd |while  ${2:+:} read -r c   ||  exit 2
            do  case $#:$c in (1:): ;; 
        (*:?*)  set ":%d$@"         \
                    \'${c%"${c#?}"} ;
                c=${c#?}            ;; 
        (*)     printf "$@"         ;
        [ 0 -eq $((n=n<4?n+1:0)) ]  &&
                set '\n\r'||set \\t ;;  esac
        done
)

You see, in the above case, the terminal input is filtered on two dd processes in a pipeline. The terminal is set with stty to raw mode in a trap which also restores the terminal state on INTerrupt or EXIT to whatever it was when the function was called (which can be done with CTRL+C probably, or whatever your interrupt key is). In raw mode a terminal flushes input to any reader as soon as it arrives - keypress by keypress. It doesn't just push a byte at a time; it pushes the entire buffer as soon as it possibly can. And so when you press the UP arrow, for example, and your keyboard sends an escape sequence like:

^[[A

...which is three bytes, and it pushes that all at once.

dd is spec'd to satisfy any read as soon as it is offered - no matter how high you set its ibs=. This means that, though I set it with ibs=7, when the terminal pushes only three bytes a read is still completed. Now that's difficult to handle with most any other utility, but dd's conv=sync fills out the difference w/ \0NUL bytes. So when you push the UP arrow on your keyboard, dd reads three bytes and writes 7 to the next dd - the three bytes in the escape sequence and 4 more \0NULs.

But in order to pull that data in with the shell's read you've gotta block it again, so the next dd syncs out its input buffer to 72 bytes - but it also conv=unblocks it. With the unblock conversion, dd splits its input on \newline delimiters for its cbs= count - which is here 8. With sync and unblock (or block) conversions, dd doesn't synchronize on \0NULs, but rather on trailing spaces. So for every 7 bytes the first dd writes to the pipe between them, the second dd buffers 72 bytes - the first few are whatever the keypress was, then \0NULs, then 65 spaces at the tail of each read.

The other thing unblock does is elide trailing spaces - it will eat as many spaces as might occur at the tail-end of each of its cbs= conversion blocks. So because dd writes output at obs=8 bytes per write, it writes 9 lines per read in 2 total writes to the output pipe. The first write is the first line and consists of the 7 bytes read from the input pipe and a trailing newline - one more byte. The next write is 8 newlines - all at once and in a row - 8 more bytes - because dd eats all 8 spaces for each of those 8 conversion blocks.

On the other side of those two dds a shell while loop reads line by line. It can ignore blank lines - because the terminal is converting all newlines to carriage returns on output anyway according to the inlcr stty option. But when it detects even a single byte in $c after the shell's input is read into its value, then you have a keypress - and a whole one too, so long as your keyboard is not sending more than 7 bytes per keystroke (though that would just need a different blocking factor).

When the shell has a keypress in $c it iterates over it byte for byte and splits it out into an array divided by ' characters, then printfs the decimal values for every byte in $c all at once. If you run that function, you should get output like this:

a:97    b:98    c:99    d:100   e:101
f:102   ;:59    ^M:13   ^M:13   ^M:13
s:115   a:97    d:100   f:102    :32
':39    ':39    ':39    a:97    s:115
d:100   f:102   ;:59    ':39    ^[[A:27:91:65
^[[D:27:91:68   ^[[B:27:91:66   ^[[C:27:91:67   ^[[D:27:91:68   ^[[C:27:91:67
^[[A:27:91:65   ^[[D:27:91:68   ^[[C:27:91:67   ^[[B:27:91:66   ^[[D:27:91:68
^[[C:27:91:67   ^[[A:27:91:65   ^[[D:27:91:68   ^[[C:27:91:67   ^[[B:27:91:66

Because the \0NULs that dd inserts to block out the keypresses evaporate as soon as the shell fills out a variable's value with its input - you can't put \0NULs in a shell variable (in any shell but zsh - in which case it is still configurable that way). As far as I know this should work in any shell - and it definitely does work in bash, dash, and ksh93. It might even reliably handle multibyte input - but I won't swear to it.

In the demo output above, the actual output of the function is preceded for each write by other information which it does not write. Each displayed character preceding each first occurring : in any of the above sequences is actually the terminal's echo as can be configured w/ stty echo or -echo. The rest is what is printed as the function's output - and it is printed, as you can see, just as soon as it is typed.

mikeserv
  • 58,310