2

I have a task scheduler which runs a bash script. The task first opens a GIT Bash terminal, an opening message is shown ("The script is about to start in 60 seconds.") and runs a script at the end of that countdown.

Now, I would like to improve user experience, allowing him/her to stop/resume the countdown or (without any intervention) leaving the script running automatically. So, this is the procedure flow:

  1. After the GIT Bash terminal open, allow the user to pause the script within the time frame shown by pressing ENTER or any other key;
  2. If no action taken by user, the countdown continues and the script will run at the end of the timeframe;
  3. If the user had pressed ENTER (or any other), then by pressing again ENTER (or any other key) he/she resumes the countdown and the will run immediately.

I've tried to use the read -p but it is not good for me: I don't want the user action to fire something but to stop/pause the countdown instead (and then resume it).

TylerH
  • 20,799
  • 66
  • 75
  • 101
R99Photography
  • 71
  • 1
  • 2
  • 10

1 Answers1

4

Update history:

  • A Pausable Countdown is implemented in the first part of the answer (prints lot of lines)
  • A much less verbose Pausable Timeout is implemented in the second part (prints one static line + additional messages on key press)
  • A somewhat more sophisticated Pausable countdown that constantly updates the same line is in the third code snippet.

Pausable Countdown

Combining some hints from similar questions here and some external resources about how to read single character (e.g. here, otherwise everywhere on the internet), and adding an additional loop for resumption, this is what I came up with:

#!/bin/bash

# Starts a pausable/resumable countdown.
# 
# Starts the countdown that runs for the
# specified number of seconds. The 
# countdown can be paused and resumed by pressing the
# spacebar. 
#
# The countdown can be sped up by holding down any button
# that is no the space bar.
#
# Expects the number of seconds as single
# argument.
#
# @param $1 number of seconds for the countdown
function resumableCountdown() {
  local totalSeconds=$1
  while (( $totalSeconds > 0 ))
  do
    IFS= read -n1 -t 1 -p "Countdown $totalSeconds seconds (press <Space> to pause)" userKey
    echo ""
    if [ "$userKey" == " " ]
    then
      userKey=not_space
      while [ "$userKey" != " " ]
      do
        IFS= read -n1 -p "Paused, $totalSeconds seconds left (press <Space> to resume)" userKey
    echo ""
      done
    elif  [ -n "$userKey" ]
    then
      echo "You pressed '$userKey', press <Space> to pause!"
    fi
    totalSeconds=$((totalSeconds - 1))
  done
}

# little test
resumableCountdown 60

This can be saved and run as a stand-alone script. The function can be reused elsewhere. It pauses / resumes with SPACE, because this seemed to be more intuitive to me, because it's how it works e.g. in video-players embedded in browsers.

The countdown can also be sped up by pressing keys other than the space bar (that's a feature).


Issuing a warning message and waiting for a pausable timeout

The following variation implements a pausable timeout, which prints nothing but the initial warning message, unless the user pauses or resumes the (internal) countdown by pressing the spacebar:

# Prints a warning and then waits for a
# timeout. The timeout is pausable.
#
# If the user presses the spacebar, the 
# internal countdown for the timeout is 
# paused. It can be resumed by pressing
# spacebar once again.
#
# @param $1 timeout in seconds
# @param $2 warning message
warningWithPausableTimeout() {
  local remainingSeconds="$1"
  local warningMessage="$2"
  echo -n "$warningMessage $remainingSeconds seconds (Press <SPACE> to pause)"
  while (( "$remainingSeconds" > 0 ))
  do
    readStartSeconds="$SECONDS"
    pressedKey=""
    IFS= read -n1 -t "$remainingSeconds" pressedKey
    nowSeconds="$SECONDS"
    readSeconds=$(( nowSeconds - readStartSeconds ))
    remainingSeconds=$(( remainingSeconds - readSeconds ))
    if [ "$pressedKey" == " " ]
    then
      echo ""
      echo -n "Paused ($remainingSeconds seconds remaining, press <SPACE> to resume)"
      pressedKey=""
      while [ "$pressedKey" != " " ]
      do
        IFS= read -n1 pressedKey
      done
      echo ""
      echo "Resumed"
    fi
  done
  echo ""
}

warningWithPausableTimeout 10 "Program will end in"
echo "end."

Pausable countdown that updates the same line

This is a countdown similar to the first one, but it takes only a single line. Relies on echo -e for erasing and overriding previously printed messages.

# A pausable countdown that repeatedly updates the same line.
#
# Repeatedly prints the message, the remaining time, and the state of
# the countdown, overriding the previously printed messages.
#
# @param $1 number of seconds for the countdown
# @param $2 message
singleLinePausableCountdown() {
  local remainingSeconds="$1"
  local message="$2"
  local state="run"
  local stateMessage=""
  local pressedKey=""
  while (( $remainingSeconds > 0 ))
  do
    if [ "$state" == "run" ]
    then
      stateMessage="[$remainingSeconds sec] Running, press <SPACE> to pause"
    else
      stateMessage="[$remainingSeconds sec] Paused, press <SPACE> to continue"
    fi
    echo -n "$message $stateMessage"
    pressedKey=""
    if [ "$state" == "run" ]
    then 
      IFS= read -n1 -t 1 pressedKey
      if [ "$pressedKey" == " " ]
      then
        state="pause"
      else 
        remainingSeconds=$(( remainingSeconds - 1 ))
      fi
    else
      IFS= read -n1 pressedKey
      if [ "$pressedKey" == " " ]
      then
        state="run"
      fi
    fi
    echo -ne "\033[1K\r"
  done
  echo "$message [Done]"
}

This one might behave strangely if the line is longer than the console width (it does not erase the line completely).


Unsorted collection of hints for anyone who tries to make sth. similar:

  • IFS= read -n1 reads single character
  • read -t <seconds> sets time-out for read. Once the timeout expires, read exits with non-zero, and sets variable to empty.
  • Magic bash built-in variable $SECONDS measures the time from the start of the script in seconds.
  • If a line has been printed with echo -n, then it can be erased and reset with echo -ne "\033[1K\r".
Andrey Tyukin
  • 43,673
  • 4
  • 57
  • 93
  • Any reason `[ ! -z "$userKey" ]` can't be `[ -n "$userKey" ]`? or `[ ! "$userKey" == " " ]` be `[ "$userKey" != " " ]`? (nit: there is only one `=` in the string equality) – David C. Rankin Feb 12 '18 at 23:42
  • @DavidC.Rankin Thanks for the hint, replaced `[ ! -z` by `[ -n`. There was no good reason, I simply remembered `-z` better, and didn't remember `-n`. Also merged some nested `if`s, they were unnecessary. Thank you. – Andrey Tyukin Feb 12 '18 at 23:47
  • @DavidC.Rankin Also fixed the cumbersome string comparison. While I'm at it, I probably better go and look up what the proper syntax for documenting bash functions was... Thank you again, very helpful ;) – Andrey Tyukin Feb 12 '18 at 23:52
  • Bash is fairly flexible on function definitions either `name ()` or `function name [()]`, so you can get away with `function name` or `function name ()` in addition to the standard `name ()`. – David C. Rankin Feb 13 '18 at 00:07
  • It does not work as needed. Every second the script echoes "Countdown XX seconds...", I don't want that, I want an initial message appearing at terminal startup which advises the user that something will happen in the next 60 seconds. – R99Photography Feb 13 '18 at 08:15
  • Then it would be rather something like "issuing a 60-seconds warning with a pausable timeout" rather than a "countdown", because it wouldn't "count down". However, I updated the answer with a version that implements the pausable timeout. – Andrey Tyukin Feb 13 '18 at 12:07
  • @R99Photography I've added yet another solution. The third version is a countdown (it prints something every second when it's not paused), but it takes up only one line, because it repeatedly overrides the same line. – Andrey Tyukin Feb 13 '18 at 13:12