5
\$\begingroup\$

I have coded a speedreading training bash script. My targed was to make some experience with bash in a funny way.

It essentially does the following:

  1. Prints to screen a random number consisting of a default number of digits
  2. Erases that number after a default time interval
  3. Waits for the user input
    • if the user enters a string starting with a digit (only in this case, Enter is required as the end-of-input key)
      1. Checks if the answer matches the number that was shown
      2. Updates the number of hits and misses which is always shown on the top right
    • if the user hits j/k the time interval is reduced/increased
    • if the user hits h/l the number of digits is reduced/increased
    • if the user hits q the script exits (*)
    • if the user hits any other key, does nothing
  4. Throws the next number (i.e. it goes to step 1 in outer list)

(*) This is the only "clean" way to exit the code; in this respect, the line

while [[ "$answer${answer+xxx}" != "xxx" ]]; do # keep going if a non-null answer is provided

behaves just like while true (and the comment is therefore wrong), as answer is unset before while, and it becomes set only when the [0-9] case is entered, in which case it becomes and remains non-empty.

The script is also on GitHub.

#!/bin/bash
# terminal based speedreading tester
# links that helped me:
#  - https://superuser.com/q/835824/597763

# activate extended regex
shopt -s extglob

# fall back on defaults
BLINK_DIGITS=${BLINK_DIGITS:-5}
BLINK_INTERVAL=${BLINK_INTERVAL:-0.5}
BLINK_QUIT=${BLINK_QUIT:-q}
BLINK_INCREMENT_DIGITS=${BLINK_INCREMENT_DIGITS:-l}
BLINK_DECREMENT_DIGITS=${BLINK_DECREMENT_DIGITS:-h}
BLINK_DECREMENT_TIME=${BLINK_DECREMENT_TIME:-j}
BLINK_INCREMENT_TIME=${BLINK_INCREMENT_TIME:-k}

# initialize score
right=0
wrong=0

# define functions
generate_number() {
  gen=
  while (( ${#gen} < $BLINK_DIGITS )); do
    gen=$gen$RANDOM
  done
  echo ${gen:0:$BLINK_DIGITS}
}

center_vertically() {
  print_header
  lines=$(tput lines)
  for (( i = 0; i < $lines/2; i++ )); do
    printf "\n"
  done
}

center_horizontally() {
  width=$1
  cols=$(tput cols)
  for (( i = 0; i < ($cols - ${#width})/2; i++ )); do
    printf " "
  done
}

print_spaces() {
  eval "printf ' %.0s' {1..$1}"
}

print_header() {
  # prepare left part of first two lines (I can't use here-strings/documents since
  # some computation has to be donw with the length of the strings, provided I want to flush
  # some text to the right)
  selected_time_interval="Time interval: $BLINK_INTERVAL seconds ([$BLINK_DECREMENT_TIME] reduces, [$BLINK_INCREMENT_TIME] increases)"
  string_length="String length: $BLINK_DIGITS digits ([$BLINK_DECREMENT_DIGITS] removes digit, [$BLINK_INCREMENT_DIGITS] adds digit)"

  # compute scores and align them to the right
  trim=$(( ${#right} - ${#wrong} ))
  (( trim < 0 )) &&  score_plus="Score: ✓ $(print_spaces ${trim#-})$right" ||  score_plus="Score: ✓ $right"
  (( trim > 0 )) && score_minus="       ✗ $(print_spaces ${trim#-})$wrong" || score_minus="       ✗ $wrong"
  max_length=$(( ${#score_plus} > ${#score_minus} ? ${#score_plus} : ${#score_minus} ))

  # fill space in between and print the two lines
  cols=$(tput cols)
  printf "$selected_time_interval$(print_spaces $(( $cols - ${#selected_time_interval} - $max_length )))$score_plus\n"
  printf "$string_length$(print_spaces $(( $cols - ${#string_length} - $max_length )))$score_minus"
  echo "[$BLINK_QUIT] quits, all other keys regenerate the number"
}

redraw_screen() {
  clear
  center_vertically
  center_horizontally $number
}

decrement_time() {
  BLINK_INTERVAL="${BLINK_INTERVAL::$((${#BLINK_INTERVAL}-1))}$((${BLINK_INTERVAL:$((${#BLINK_INTERVAL}-1))} - 1))"
  (( ${BLINK_INTERVAL:$((${#BLINK_INTERVAL}-1))} == 0 )) && BLINK_INTERVAL="${BLINK_INTERVAL}9"
}

increment_time() {
  (( ${BLINK_INTERVAL##0.} == 9 )) && return 1
  BLINK_INTERVAL=${BLINK_INTERVAL%%[1-9]*}$((${BLINK_INTERVAL##0.*(0)} + 1))
  (( ${BLINK_INTERVAL:$((${#BLINK_INTERVAL} - 1))} == 0 )) && BLINK_INTERVAL=${BLINK_INTERVAL/0.0/0.} && BLINK_INTERVAL=${BLINK_INTERVAL%%0}
}

quit() {
  # clear screen and exit
  clear
  exit 0
}

# main loop
while [[ "$answer${answer+xxx}" != "xxx" ]]; do # keep going if a non-null answer is provided
  # generate number
  number=$(generate_number $BLINK_DIGITS)

  # show number for short time
  redraw_screen
  printf $number
  sleep $BLINK_INTERVAL

  # erase the number (go back, overwrite with spaces, go back again)
  eval "printf '\b%.0s' {1..${#number}}"
  eval "printf ' %.0s'  {1..${#number}}"
  eval "printf '\b%.0s' {1..${#number}}"

  read -rn 1 first_char
  case $first_char in
    $BLINK_QUIT )
      quit
      ;;
    $BLINK_DECREMENT_DIGITS )
      (( BLINK_DIGITS > 1 )) && (( BLINK_DIGITS-- ))
      ;;
    $BLINK_INCREMENT_DIGITS )
      (( BLINK_DIGITS < $cols - 1 )) && (( BLINK_DIGITS++ ))
      ;;
    $BLINK_DECREMENT_TIME )
      decrement_time
      ;;
    $BLINK_INCREMENT_TIME )
      increment_time
      ;;
    [0-9] )
      redraw_screen
      # erase $first_char
      printf '\b%.0s'
      # use $first_char as a default to have it printed again
      # the leading space is to allow backspacing on the first digit
      # without the all leading space being erased all together
      read -i " $first_char" -e answer
      if (( $answer == $number )); then
        (( right++ ))
      else
        (( wrong++ ))
      fi
      ;;
  esac

done

(( $? == 0 )) && quit || exit
\$\endgroup\$

1 Answer 1

2
\$\begingroup\$
# fall back on defaults
BLINK_DIGITS=${BLINK_DIGITS:-5}
BLINK_INTERVAL=${BLINK_INTERVAL:-0.5}
BLINK_QUIT=${BLINK_QUIT:-q}
BLINK_INCREMENT_DIGITS=${BLINK_INCREMENT_DIGITS:-l}
BLINK_DECREMENT_DIGITS=${BLINK_DECREMENT_DIGITS:-h}
BLINK_DECREMENT_TIME=${BLINK_DECREMENT_TIME:-j}
BLINK_INCREMENT_TIME=${BLINK_INCREMENT_TIME:-k}

This is exactly the right way to do this. Good job.


# activate extended regex
shopt -s extglob

Is this even needed?


generate_number() {
  gen=
  while (( ${#gen} < $BLINK_DIGITS )); do
    gen=$gen$RANDOM
  done
  echo ${gen:0:$BLINK_DIGITS}
}

This is biased because the first digit of RANDOM will rarely be zero, and the maximum value of 32767 means you get more 1-3 than 4-9. Keep only the ones digit: it nearly eliminates the bias and avoids the need to truncate at the end.

for (( i=0; i < BLINK_DIGITS; ++i )); do 
    gen+=$(( RANDOM % 10 )); 
done

center_vertically() {
  print_header
  lines=$(tput lines)
  for (( i = 0; i < $lines/2; i++ )); do
    printf "\n"
  done
}

You can use the LINES variable here with a printf idiom to repeat a character (which only works with constants because {1..n} doesn't allow variables):

printf -vblank "\n%.0s" {1..1000}
echo "${blank:0:LINES/2}"

print_spaces() {
  eval "printf ' %.0s' {1..$1}"
}

printf can repeat spaces natively, by supplying a width to %s. This function can be reused by center_horizontally():

print_spaces() { printf "%${1}s" ""; }

center_horizontally() { print_spaces $(( ( COLUMNS - ${#1} ) / 2 )); }

trim=$(( ${#right} - ${#wrong} ))
…
max_length=$(( ${#score_plus} > ${#score_minus} ? ${#score_plus} : ${#score_minus} ))

You can assign inside expresssions:

(( trim = ${#right} - ${#wrong} ))
(( max_length = ${#score_plus} > ${#score_minus} ? ${#score_plus} : ${#score_minus} ))

decrement_time() {
  BLINK_INTERVAL="${BLINK_INTERVAL::$((${#BLINK_INTERVAL}-1))}$((${BLINK_INTERVAL:$((${#BLINK_INTERVAL}-1))} - 1))"
  (( ${BLINK_INTERVAL:$((${#BLINK_INTERVAL}-1))} == 0 )) && BLINK_INTERVAL="${BLINK_INTERVAL}9"
}

increment_time() {
  (( ${BLINK_INTERVAL##0.} == 9 )) && return 1
  BLINK_INTERVAL=${BLINK_INTERVAL%%[1-9]*}$((${BLINK_INTERVAL##0.*(0)} + 1))
  (( ${BLINK_INTERVAL:$((${#BLINK_INTERVAL} - 1))} == 0 )) && BLINK_INTERVAL=${BLINK_INTERVAL/0.0/0.} && BLINK_INTERVAL=${BLINK_INTERVAL%%0}
}

This is really ugly and it fails if the user provides an integer value for BLINK_INTERVAL.

You could take a value in milliseconds (or tenths of seconds) and manipulate that; it would be pure bash but still kind of ugly. I think the best solution is to use a tool that does floating point calculations. bc is suitable but not installed by default on some distributions. awk is up to the task and universally available.

Put the logic in a single function.

adjust_time() { 
    BLINK_INTERVAL=$( awk -vb=$BLINK_INTERVAL -voffset=$1 'BEGIN { print b + offset/10 }' ) 
}

increment_time() { 
    adjust_time 1
}

decrement_time() { 
    adjust_time -1
}

printf "$selected_time_interval$(print_spaces $(( $cols - ${#selected_time_interval} - $max_length )))$score_plus\n"
printf "$string_length$(print_spaces $(( $cols - ${#string_length} - $max_length )))$score_minus"

Let printf handle the spacing for you. It's a bad habit to put variable data inside the format string; use %s instead:

printf "%s%$(( COLUMNS - ${#selected_time_interval} - max_length ))s%s\n" $selected_time_interval "" $score_plus
printf "%s%$(( COLUMNS - ${#string_length} - max_length ))s%s" $string_length  "" $score_minus

while [[ "$answer${answer+xxx}" != "xxx" ]]

This is hacky. Just test the length of answer:

while (( ${#answer} ))

      (( BLINK_DIGITS < $cols - 1 )) && (( BLINK_DIGITS++ ))

Use COLUMNS, then it remains correct even if the user resizes the terminal during a run.


   if (( $answer == $number )); then
     (( right++ ))
   else
     (( wrong++ ))
   fi

This can be condensed to one expression. $ inside (( … )) are not needed for simple variables that contain a number.

(( answer == number ? right++ : wrong++ ))

(( $? == 0 )) && quit || exit

Expressions test for zero by default. This could be shortened to:

(( $? )) && exit || quit

But all quit does is clear the screen and exit normally. Just attach clear to the loop, and the quit function can go away.

while (( ${#answer} )); do 
    …
done && clear
exit

Finally, tell bash to exit on undefined variables, because you shouldn't have any:

set -u
\$\endgroup\$
13
  • 1
    \$\begingroup\$ Thank you for the thorough review! About shopt -s extglob, I tend to put it otherwise, should I make use of extended regexes, I would find myself scratching my head for hours, questioning my memory and the books, before discovering for the nth time that I've simply forgot this setting. About RANDOM is Benford's law the reason for what you say? Wow for LINES, so easy! It looks like reading good bash books is never enough. You can assign inside expresssions: in principle I know this ((( BLINK_DIGITS-- ))), but thank you for pointing out other places where I didn't make use of it. \$\endgroup\$
    – Enlico
    Oct 6, 2019 at 18:26
  • 1
    \$\begingroup\$ This is really ugly: I completely agree with you, and if awk is really so universally available, then I will follow also this advise of yours. Yes, I definitely have to use printf more properly. This is hacky: another place where I didn't make use of what I know already. Like LINES, like COLUMNS, thank you. Wow, I didn't know ?: was available! Last suggestion on set -u is illuminating! \$\endgroup\$
    – Enlico
    Oct 6, 2019 at 18:40
  • 1
    \$\begingroup\$ I will test all your suggestions and accept the answer :) \$\endgroup\$
    – Enlico
    Oct 6, 2019 at 18:41
  • 2
    \$\begingroup\$ re: RANDOM, it's just that the numbers have an upper limit that is not a power of 10. Like if you generate random numbers 0-19, the number will >9 about half the time, so the digit "1" appears way too often. Same idea with a max of 32767: every five-digit number has a biased digit in it. It's not really Benford's because "2" is over-represented just as much as "1". Normal probability distributions (of which this is one) do not exhibit Benford's law in general, and а random value 0-99999 won't exhibit it at all. \$\endgroup\$ Oct 6, 2019 at 18:44
  • 2
    \$\begingroup\$ that is a quirk of your terminal emulator; mine doesn't do that. The next step for this kind of application is to use stty raw (so keypresses don't echo, for example) and ANSI escape sequences to position the cursor (instead of printing blanks). You can also read the cursor position with escape sequences, and use that to decide how it will affect the output \$\endgroup\$ Oct 10, 2019 at 21:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.