# Bash script for speedreading training

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

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

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  ## 1 Answer # 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} ))


(( 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

• 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. – Enrico Maria De Angelis Oct 6 '19 at 18:26
• 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! – Enrico Maria De Angelis Oct 6 '19 at 18:40
• I will test all your suggestions and accept the answer :) – Enrico Maria De Angelis Oct 6 '19 at 18:41
• 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. – Oh My Goodness Oct 6 '19 at 18:44
• 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 – Oh My Goodness Oct 10 '19 at 21:51