31

I need to write an infinite loop that stops when any key is pressed.

Unfortunately this one loops only when a key is pressed.

Ideas please?

#!/bin/bash

count=0
while : ; do

    # dummy action
    echo -n "$a "
    let "a+=1"

    # detect any key  press
    read -n 1 keypress
    echo $keypress

done
echo "Thanks for using this script."
exit 0
mkj
  • 2,761
  • 5
  • 24
  • 28
janmartin
  • 361
  • 1
  • 3
  • 5

6 Answers6

37

You need to put the standard input in non-blocking mode. Here is an example that works:

#!/bin/bash

if [ -t 0 ]; then
  SAVED_STTY="`stty --save`"
  stty -echo -icanon -icrnl time 0 min 0
fi

count=0
keypress=''
while [ "x$keypress" = "x" ]; do
  let count+=1
  echo -ne $count'\r'
  keypress="`cat -v`"
done

if [ -t 0 ]; then stty "$SAVED_STTY"; fi

echo "You pressed '$keypress' after $count loop iterations"
echo "Thanks for using this script."
exit 0

Edit 2014/12/09: Add the -icrnl flag to stty to properly catch the Return key, use cat -v instead of read in order to catch Space.

It is possible that cat reads more than one character if it is fed data fast enough; if not the desired behaviour, replace cat -v with dd bs=1 count=1 status=none | cat -v.

Edit 2019/09/05: Use stty --save to restore the TTY settings.

sam hocevar
  • 11,853
  • 5
  • 49
  • 68
  • I know this is a bit out of left field, but why do people do conditionals in bash like `"x$variable" = "x"` rather than the simpler `"$variable" = ""`? Is there any benefit or is it just something people do because that's how they learned it? – Vala Oct 26 '14 at 11:31
  • 2
    @Thor84no it’s a safeguard against old, buggy systems: http://stackoverflow.com/a/6853353/111461 – sam hocevar Oct 26 '14 at 12:24
  • Works with almost any key: Return and Space keys aren't detected apparently (OS X 10.10 here). Ideas? – DavidD Dec 08 '14 at 10:05
  • How would you revert the mode of standard input? – jarno Jun 25 '17 at 08:35
  • Oh, but I think it would be better to use `stty --save` to save the original state and restore that later. – jarno Sep 05 '19 at 09:57
  • Thanks for the suggestion; I updated the answer accordingly. – sam hocevar Sep 05 '19 at 15:56
  • using `-g` instead of `--save` will work on macOS and linux (tested with 11.4 and 18.04 respectively). – mathandy Aug 09 '21 at 07:22
10

read has a number of characters parameter -n and a timeout parameter -t which could be used.

From bash manual:

-n nchars read returns after reading nchars characters rather than waiting for a complete line of input, but honors a delimiter if fewer than nchars characters are read before the delimiter.

-t timeout

Cause read to time out and return failure if a complete line of input (or a specified number of characters) is not read within timeout seconds. timeout may be a decimal number with a fractional portion following the decimal point. This option is only effective if read is reading input from a terminal, pipe, or other special file; it has no effect when reading from regular files. If read times out, read saves any partial input read into the specified variable name. If timeout is 0, read returns immediately, without trying to read any data. The exit status is 0 if input is available on the specified file descriptor, non-zero otherwise. The exit status is greater than 128 if the timeout is exceeded.

However, the read builtin uses the terminal which has its own settings. So as other answers have pointed out we need to set the flags for the terminal using stty.

#!/bin/bash
old_tty=$(stty --save)

# Minimum required changes to terminal.  Add -echo to avoid output to screen.
stty -icanon min 0;

while true ; do
    if read -t 0; then # Input ready
        read -n 1 char
        echo -e "\nRead: ${char}\n"
        break
    else # No input
        echo -n '.'
        sleep 1
    fi       
done

stty $old_tty
Paul
  • 6,572
  • 2
  • 39
  • 51
  • 2
    Like this `while ! read -t0; do echo -n .; done; read; echo Finished`, but it does not finish until Enter (or Ctrl-d) is pressed, and it echoes the input even with possible `-s` option and does not respect possible `-d` option. (GNU bash, version 4.3.11) – jarno Jun 25 '17 at 07:46
  • As @jarno mentioned, this answer is incorrect as `read -t 0` only sees input as available once the Enter key is pressed. – hackerb9 Sep 02 '19 at 23:04
  • @hackerb9 Thanks, I have added the -n parameter to avoid the need for an enter key. – Paul Sep 02 '19 at 23:29
  • 1
    It might need non-zero timeout to work, like this: `echo -n x | read -t0.001 -n1 && echo caught it` – jarno Sep 03 '19 at 09:13
  • Once again, @jarno is correct, although that solution has the side-effect that the infinite loop will be slowed down by 1ms per iteration. Paul, I suggest changing your answer to show actual bash instead of pseudocode so you can verify it works. – hackerb9 Sep 04 '19 at 19:45
  • @hackerb9 I have updated the manual entry as the behaviour of read seems to have changed lately. We can take advantage of this to check whether there is any data to read using `read -t 0`. I've added this code to the answer now. – Paul Sep 05 '19 at 00:31
  • It still needs enter to finish. (Bash 4.4.20) – jarno Sep 05 '19 at 07:32
  • @jarno It works for me on Bash 4.4.12(1)-release MINGW64 but not on Bash 4.4.12(3)-release Cygwin or Bash 4.4.23 Fedora using virtualbox. There must be some different flags on the terminals? – Paul Sep 05 '19 at 07:53
  • I suppose so. You could test it by `stty --save` or `stty --all`. What kind of difference you see? There are already a couple of answers that change terminal settings... – jarno Sep 05 '19 at 09:44
3

Usually I don't mind breaking a bash infinite loop with a simple CTRL-C. This is the traditional way for terminating a tail -f for instance.

mouviciel
  • 66,855
  • 13
  • 106
  • 140
2

Pure : unattended user input over loop

I've done this without having to play with stty:

loop=true loopDelay=.05
while $loop; do
    trapKey=
    if IFS= read -d '' -rsn 1 -t $loopDelay str; then
        while IFS= read -d '' -rsn 1 -t .002 chr; do
            str+="$chr"
        done
        case $str in
            $'\E[A') trapKey="<UP>"    ;;
            $'\E[B') trapKey="<DOWN>"  ;;
            $'\E[C') trapKey="<RIGHT>" ;;
            $'\E[D') trapKey="<LEFT>"  ;;
            q | $'\E') loop=false;echo ;;
            * ) trapKey=${str@Q}   ;;
        esac
    fi
    if [ "$trapKey" ] ;then
        printf "\nDoing something with %s.\n" "$trapKey"
    fi
    echo -n .
done

This will

  • loop with a very small footprint (max 2 millisecond)
  • react to keys cursor left, cursor right, cursor up and cursor down
  • exit loop with key Escape or q.

Explanation:

As keyboard don't retun character but key pressed, some key could send more than one character, like Home wich should send an escape sequence: \e[H. there are 3 characters.

For supporting this I build a loop over a very small timeouted read command:

if IFS= read -d '' -rsn 1 -t $LOOPDELAY str; then
    while IFS= read -d '' -rsn 1 -t .002 chr; do
        str+="$chr"
    done

If no character read after $LOOPDELAY then no keyboard key is read. Else $str variable will be completed by all character read could acces in less than 0.002 seconds.

Nicer version with support of Fx keys:

#!/bin/bash

loopDelay=.042

# printf -v shapes "%b " \\U28{01,08,10,20,80,40,04,02}
printf -v shapes "%b " \\U28{19,38,B0,e0,c4,46,07,0b}
shapes=($shapes)

declare -A csiKeys='( [15~]=F5 [17~]=F6 [18~]=F7 [19~]=F8 [20~]=F9 [21~]=F10
    [23~]=F11 [24~]=F12 [A]=UP [B]=DOWN [C]=RIGHT [D]=LEFT [H]=HOME [F]=END
    [2~]=INSERT [3~]=DELETE [5~]=PGUP [6~]=PGDOWN )' \
        escKeys='( [OP]=F1 [OQ]=F2 [OR]=F3 [OS]=F4 )'
loop=true
while $loop; do
    trapKey=
    if IFS= read -d '' -rsn 1 -t $loopDelay str; then
        while IFS= read -d '' -rsn 1 -t .002 chr; do str+="$chr" ; done
        if [[ ${str::2} == $'\e[' ]] && [[ -v "csiKeys['${str:2}']" ]] ;then
            trapKey="${csiKeys[${str:2}]}"
        elif [[ ${str::1} == $'\e' ]] && [[ -v "escKeys['${str:1}']" ]] ;then
            trapKey="${escKeys[${str:1}]}"            
        elif [[ ${str/$'\e'/q} == q ]];then
            printf '"%q" pressed, exit.\n' "$str"
            loop=false
        else
            trapKey=${str@Q}
        fi
    fi
    if [ "$trapKey" ] ;then
        printf "Doing something with %s.\n" "$trapKey"
    fi
    printf >&2 '%s\r' ${shapes[shcnt++%8]}
done
F. Hauri - Give Up GitHub
  • 64,122
  • 17
  • 116
  • 137
0

Here is another solution. It works for any key pressed, including space, enter, arrows, etc.

The original solution tested in bash:

IFS=''
if [ -t 0 ]; then stty -echo -icanon raw time 0 min 0; fi
while [ -z "$key" ]; do
    read key
done
if [ -t 0 ]; then stty sane; fi

An improved solution tested in bash and dash:

if [ -t 0 ]; then
   old_tty=$(stty --save)
   stty raw -echo min 0
fi
while
   IFS= read -r REPLY
   [ -z "$REPLY" ]
do :; done
if [ -t 0 ]; then stty "$old_tty"; fi

In bash you could even leave out REPLY variable for the read command, because it is the default variable there.

YSN
  • 2,504
  • 1
  • 14
  • 12
  • If your loop does nothing but wait for key press, it is good to add e.g. `sleep 0.1` in the while loop so that the loop does not take all available resources of a CPU core. – jarno Sep 08 '19 at 08:07
0

I found this forum post and rewrote era's post into this pretty general use format:

# stuff before main function
printf "INIT\n\n"; sleep 2

INIT(){
  starting="MAIN loop starting"; ending="MAIN loop success"
  runMAIN=1; i=1; echo "0"
}; INIT

# exit script when MAIN is done, if ever (in this case counting out 4 seconds)
exitScript(){
    trap - SIGINT SIGTERM SIGTERM # clear the trap
    kill -- -$$ # Send SIGTERM to child/sub processes
    kill $( jobs -p ) # kill any remaining processes
}; trap exitScript SIGINT SIGTERM # set trap

MAIN(){
  echo "$starting"
  sleep 1

  echo "$i"; let "i++"
  if (($i > 4)); then printf "\nexiting\n"; exitScript; fi

  echo "$ending"; echo
}

# main loop running in subshell due to the '&'' after 'done'
{ while ((runMAIN)); do
  if ! MAIN; then runMain=0; fi
done; } &

# --------------------------------------------------
tput smso
# echo "Press any key to return \c"
tput rmso
oldstty=`stty -g`
stty -icanon -echo min 1 time 0
dd bs=1 count=1 >/dev/null 2>&1
stty "$oldstty"
# --------------------------------------------------

# everything after this point will occur after user inputs any key
printf "\nYou pressed a key!\n\nGoodbye!\n"

Run this script

DogeCode
  • 346
  • 2
  • 10