Entry-Level Programming - Pro Bash Programming: Scripting the GNU/Linux Shell, Second Edition (2015)

Pro Bash Programming: Scripting the GNU/Linux Shell, Second Edition (2015)

CHAPTER 15. Entry-Level Programming

The preference for bash over any other POSIX shell stems to a great extent from its extensions that enhance interactive programming. The extended options to the read built-in command (which were described in Chapter 9), combined with the history and readline libraries, add functionality that no other shell can match.

Despite its richness, there is still no easy way for the shell to deal with keys such as function keys that generate multiple characters. For that, this chapter presents the key-funcs library of functions. The second major section of this chapter describes how to use the mouse in shell scripts and provides a demonstration program.

Between those sections, we’ll deal with checking user input for validity and the history library. Most people use bash’s history library only at the command line. We’ll use it in scripts, and this chapter will show how that is done, by using the history command in a rudimentary script for editing a multifield record.

Single-Key Entry

When writing an interactive script, you might want a single key to be pressed without requiring the user to press Enter. The portable way to do that is to use stty and dd:

stty -echo -icanon min 1
_KEY=$(dd count=1 bs=1 2>/dev/null)
stty echo icanon

Using three external commands every time you need a key press is overkill. When you need to use a portable method, you can usually first make a call to stty at the beginning of the script and the other at the end, often in an EXIT trap:

trap 'stty echo icanon' EXIT

Bash, on the other hand, doesn’t need to call any external commands. It may still be a good idea to use stty to turn off echoing at the beginning and back on before exiting. This will prevent characters from showing up on the screen when the script is not waiting for input.

Function Library, key-funcs

The functions in this section comprise the key-funcs library. It begins with two variable definitions, shown here in Listing 15-1.

Listing 15-1. key-funcs, Read a Single Key Press

ESC=$'\e'
CSI=$'\e['

To get a single keystroke with bash, you can use the function in Listing 15-2.

Listing 15-2. _key, Functions for Reading a Single Key Press

_key()
{
IFS= read -r -s -n1 -d '' "${1:-_KEY}"
}

First, the field separator is set to an empty string so that read doesn’t ignore a leading space (it’s a valid keystroke, so you want it); the -r option disables backslash escaping, -s turns off echoing of keystrokes, and -n1 tells bash to read a single character only.

The -d '' option tells read not to regard a newline (or any other character) as the end of input; this allows a newline to be stored in a variable. The code instructs read to stop after the first key is received (-n1) so it doesn’t read forever.

The last argument uses ${@:-_KEY} to add options or a variable name to the list of arguments. You can see its use in the _keys function in Listing 15-3. (Note that if you use an option without also including a variable name, the input will be stored in $REPLY.)

Image Note For this to work on earlier versions of bash or on the Mac OS X, add the variable name to the read command, such as IFS= read –r –s –n1 –d'' _KEY "${1:-_KEY}". If not, then you have to look to $REPLY for the key press read.

The _key function can be used in a simple menu, as shown in Listing 15-3.

Listing 15-3. simplemenu, Menu that Responds to a Single Key Press

## the _key function should be defined here if it is not already
while :
do
printf "\n\n\t$bar\n"
printf "\t %d. %s\n" 1 "Do something" \
2 "Do something else" \
3 "Quit"
printf "\t%s\n" "$bar"
_key
case $_KEY in
1) printf "\n%s\n\n" Something ;;
2) printf "\n%s\n\n" "Something else" ;;
3) break ;;
*) printf "\a\n%s\n\n" "Invalid choice; try again"
continue
;;
esac
printf ">>> %s " "Press any key to continue"
_key
done

Although _key is a useful function by itself, it has its limitations (Listing 15-4). It can store a space, a newline, a control code, or any other single character, but what it doesn’t do is handle keys that return more than one character: function keys, cursor keys, and a few others.

These special keys return ESC (0 × 1B, which is kept in a variable $ESC) followed by one or more characters. The number of characters varies according to the key (and the terminal emulation), so you cannot ask for a specific number of keys. Instead, you have to loop until one of the terminating characters is read. This is where it helps to use bash’s built-in read command rather than the external dd.

Listing 15-4. _keys, Read a Sequence of Characters from a Function or Cursor Key

_keys() #@ Store all waiting keypresses in $_KEYS
{
_KEYS=
__KX=

## ESC_END is a list of characters that can end a key sequence
## Some terminal emulations may have others; adjust to taste
ESC_END=[a-zA-NP-Z~^\$@$ESC]

while :
do
IFS= read -rsn1 -d '' -t1 __KX
_KEYS=$_KEYS$__KX
case $__KX in
"" | $ESC_END ) break ;;
esac
done
}

The while : loop calls _key with the argument -t1, which tells read to time out after one second, and the name of the variable in which to store the keystroke. The loop continues until a key in $ESC_END is pressed or read times out, leaving $__KX empty.

The timeout is a partially satisfactory method of detecting the escape key by itself. This is a case where dd works better than read, because it can be set to time out in increments of one-tenth of a second.

To test the functions, use _key to get a single character; if that character is ESC, call _keys to read the rest of the sequence, if any. The following snippet assumes that _key and _keys are already defined and pipes each keystroke through hexdump -C to show its contents:

while :
do
_key
case $_KEY in
$ESC) _keys
_KEY=$ESC$_KEYS
;;
esac
printf "%s" "$_KEY" | hexdump -C | {
read a b
printf " %s\n" "$b"
}
case "$_KEY" in q) break ;; esac
done

Unlike the output sequences, which work everywhere, there is no homogeneity among key sequences produced by various terminal emulators. Here is a sample run, in an rxvt terminal window, of pressing F1, F12, up arrow, Home, and q to quit:

1b 5b 31 31 7e |.[11~|
1b 5b 32 34 7e |.[24~|
1b 5b 41 |.[A|
1b 5b 35 7e |.[5~|
71 |q|

Here are the same keystrokes in an xterm window:

1b 4f 50 |.OP|
1b 5b 32 34 7e |.[24~|
1b 5b 41 |.[A|
1b 5b 48 |.[H|
71 |q|

Finally, here they are as produced by a Linux virtual console:

1b 5b 5b 41 |.[[A|
1b 5b 32 34 7e |.[24~|
1b 5b 41 |.[A|
1b 5b 31 7e |.[1~|
71 |q|

All the terminals tested fit into one of these three groups, at least for unmodified keys.

The codes stored in $_KEY can be either interpreted directly or in a separate function. It is better to keep the interpretation in a function that can be replaced for use with different terminal types. For example, if you are using a Wyse60 terminal, the source wy60-keys function would set the replacement keys.

Listing 15-5 shows a function, _esc2key, that works for the various terminals on a Linux box, as well as in putty in Windows. It converts the character sequence into a string describing the key, for example, UP, DOWN, F1, and so on:

Listing 15-5. _esc2key, Translate a String to a Key Name

_esc2key()
{
case $1 in
## Cursor keys
"$CSI"A | ${CSI}OA ) _ESC2KEY=UP ;;
"$CSI"B | ${CSI}0B ) _ESC2KEY=DOWN ;;
"$CSI"C | ${CSI}OC ) _ESC2KEY=RIGHT ;;
"$CSI"D | ${CSI}OD ) _ESC2KEY=LEFT ;;

## Function keys (unshifted)
"$CSI"11~ | "$CSI["A | ${ESC}OP ) _ESC2KEY=F1 ;;
"$CSI"12~ | "$CSI["B | ${ESC}OQ ) _ESC2KEY=F2 ;;
"$CSI"13~ | "$CSI["C | ${ESC}OR ) _ESC2KEY=F3 ;;
"$CSI"14~ | "$CSI["D | ${ESC}OS ) _ESC2KEY=F4 ;;
"$CSI"15~ | "$CSI["E ) _ESC2KEY=F5 ;;
"$CSI"17~ | "$CSI["F ) _ESC2KEY=F6 ;;
"$CSI"18~ ) _ESC2KEY=F7 ;;
"$CSI"19~ ) _ESC2KEY=F8 ;;
"$CSI"20~ ) _ESC2KEY=F9 ;;
"$CSI"21~ ) _ESC2KEY=F10 ;;
"$CSI"23~ ) _ESC2KEY=F11 ;;
"$CSI"24~ ) _ESC2KEY=F12 ;;

## Insert, Delete, Home, End, Page Up, Page Down
"$CSI"2~ ) _ESC2KEY=INS ;;
"$CSI"3~ ) _ESC2KEY=DEL ;;
"$CSI"[17]~ | "$CSI"H ) _ESC2KEY=HOME ;;
"$CSI"[28]~ | "$CSI"F ) _ESC2KEY=END ;;
"$CSI"5~ ) _ESC2KEY=PGUP ;;
"$CSI"6~ ) _ESC2KEY=PGDN ;;

## Everything else; add other keys before this line
*) _ESC2KEY=UNKNOWN ;;
esac
[ -n "$2" ] && eval "$2=\$_ESC2KEY"
}

You can wrap the _key and _esc2key functions into another function, called get_key (Listing 15-6), which returns either the single character pressed or, in the case of multicharacter keys, the name of the key.

Listing 15-6. get_key, Gets a Key and, if Necessary, Translates It to a Key Name

get_key()
{
_key
case $_KEY in
"$ESC") _keys
_esc2key "$ESC$_KEYS" _KEY
;;
esac
}

In bash-4.x, you can use a simpler function to read keystrokes. The get_key function in Listing 15-7 takes advantage of the capability of read’s -t option to accept fractional times. It reads the first character then waits for one-ten-thousandth of a second for another character. If a multicharacter key was pressed, there will be one to read within that time. If not, it will fall through the remaining read statements before another key can be pressed.

Listing 15-7. get_key, Reads a Key and, if It Is More than a Single Character, Translates It to a Key Name

get_key() #@ USAGE: get_key var
{
local _v_ _w_ _x_ _y_ _z_ delay=${delay:-.0001}
IFS= read -d '' -rsn1 _v_
read -sn1 -t "$delay" _w_
read -sn1 -t "$delay" _x_
read -sn1 -t "$delay" _y_
read -sn1 -t "$delay" _z_
case $_v_ in
$'\e') _esc2key "$_v_$_w_$_x_$_y_$_z_"
printf -v ${1:?} $_ESC2KEY
;;
*) printf -v ${1:?} "%s" "$_v_$_w_$_x_$_y_$_z_" ;;
esac
}

Whenever you want to use cursor or function keys in a script, or for any single-key entry, you can source key-funcs and call get_key to capture key presses. Listing 15-8 is a simple demonstration of using the library.

Listing 15-8. keycapture, Read, and Display Keystrokes Until Q Is Pressed

. key-funcs ## source the library
while : ## infinite loop
do
get_key key
sa "$key" ## the sa command is from previous chapters
case $key in q|Q) break;; esac
done

The script in Listing 15-9 prints a block of text on the screen. It can be moved around the screen with the cursor keys, and the colors can be changed with the function keys. The odd-numbered function keys change the foreground color; the even-numbered keys change the background.

Listing 15-9. key-demo, Capture Function and Cursor Keys to Change Colors and Move a Block of Text Around the Screen

trap '' 2
trap 'stty sane; printf "${CSI}?12l${CSI}?25h\e[0m\n\n"' EXIT

stty -echo ## Turn off echoing of user keystrokes
. key-funcs ## Source key functions

clear ## Clear the screen
bar=====================================

## Initial position for text block
row=$(( (${LINES:-24} - 10) / 2 ))
col=$(( (${COLUMNS:-80} - ${#bar}) / 2 ))

## Initial colours
fg="${CSI}33m"
bg="${CSI}44m"

## Turn off cursor
printf "%s" "${CSI}?25l"

## Loop until user presses "q"
while :
do
printf "\e[1m\e[%d;%dH" "$row" "$col"
printf "\e7 %-${#bar}.${#bar}s ${CSI}0m \e8\e[1B" "${CSI}0m"
printf "\e7 $fg$bg%-${#bar}.${#bar}s${CSI}0m \e8\e[1B" "$bar" \
"" " Move text with cursor keys" \
"" " Change colors with function keys" \
"" " Press 'q' to quit" \
"" "$bar"
printf "\e7%-${#bar}.${#bar}s " "${CSI}0m"
get_key k
case $k in
UP) row=$(( $row - 1 )) ;;
DOWN) row=$(( $row + 1 )) ;;
LEFT) col=$(( $col - 1 )) ;;
RIGHT) col=$(( $col + 1 )) ;;
F1) fg="${CSI}30m" ;;
F2) bg="${CSI}47m" ;;
F3) fg="${CSI}31m" ;;
F4) bg="${CSI}46m" ;;
F5) fg="${CSI}32m" ;;
F6) bg="${CSI}45m" ;;
F7) fg="${CSI}33m" ;;
F8) bg="${CSI}44m" ;;
F9) fg="${CSI}35m" ;;
F10) bg="${CSI}43m" ;;
F11) fg="${CSI}34m" ;;
F12) bg="${CSI}42m" ;;
q|Q) break ;;
esac
colmax=$(( ${COLUMNS:-80} - ${#bar} - 4 ))
rowmax=$(( ${LINES:-24} - 10 ))
[ $col -lt 1 ] && col=1
[ $col -gt $colmax ] && col=$colmax
[ $row -lt 1 ] && row=1
[ $row -gt $rowmax ] && row=$rowmax
done

History in Scripts

In the readline functions in Chapters 6 and 12, history -s was used to place a default value into the history list. In those examples, only one value was stored, but it is possible to store more than one value in history or even to use an entire file. Before adding to the history, you should (in most cases) clear it:

history -c

By using more than one history -s command, you can store multiple values:

history -s Genesis
history -s Exodus

With the -r option, you can read an entire file into history. This snippet puts the names of the first five books of the Bible into a file and reads that into the history:

cut -d: -f1 "$kjv" | uniq | head -5 > pentateuch
history -r pentateuch

The readline functions in Chapters 6 and 12 use history if the bash version is less than 4, but read’s -i option with version 4 (or greater). There are times when it might be more appropriate to use history rather than -i even when the latter is available. A case in point is when the new input is likely to be very different from the default but there is a chance that it might not be.

For history to be available, you must use the -e option with read. This also gives you access to other key bindings defined in your .inputrc file.

Sanity Checking

Sanity checking is testing input for the correct type and a reasonable value. If a user inputs Jane for her age, it’s obviously wrong: the data is of the wrong type. If she enters 666, it’s the correct type but almost certainly an incorrect value. The incorrect type can easily be detected with thevalint script (see Chapter 3) or function (see Chapter 6). You can use the rangecheck function from Chapter 6 to check for a reasonable value.

Sometimes the error is more problematic, or even malicious. Suppose a script asks for a variable name and then uses eval to assign a value to it:

read -ep "Enter variable name: " var
read -ep "Enter value: " val
eval "$var=\$val"

Now, suppose the entry goes like this:

Enter variable name: rm -rf *;name
Enter value: whatever

The command that eval will execute is as follows:

rm -rf *;name=whatever

Poof! All your files and subdirectories are gone from the current directory. It could have been prevented by checking the value of var with the validname function from Chapter 7:

validname "$var" && eval "$var=\$val" || echo Bad variable name >&2

When editing a database, checking that there are no invalid characters is an important step. For example, in editing /etc/passwd (or a table from which it is created), you must make sure that there are no colons in any of the fields. Figure 15-1 adds some humor to this discussion.

Figure 15-1. Cartoon courtesy of Randall Munroe at http://xkcd.com

Form Entry

The script in Listing 15-10 is a demonstration of handling user input with a menu and history. It uses the key-funcs library to get the user’s selection and to edit password fields. It has a hard-coded record and doesn’t read the /etc/passwd file. It checks for a colon in an entry and prints an error message if one is found.

The record is read into an array from a here document. A single printf statement prints the menu, using a format string with seven blanks and the entire array as its arguments.

Listing 15-10. password, Simple Record-Editing Script

record=root:x:0:0:root:/root:/bin/bash ## record to edit
fieldnames=( User Password UID
GID Name Home Shell )

. key-funcs ## load the key functions

IFS=: read -a user <<EOF ## read record into array
$record
EOF

z=0
clear
while : ## loop until user presses 0 or q
do
printf "\e[H\n
0. Quit
1. User: %s\e[K
2. Password: %s\e[K
3. UID: %s\e[K
4. GID: %s\e[K
5. Name: %s\e[K
6. Home: %s\e[K
7. Shell: %s\e[K

Select field (1-7): \e[0J" "${user[@]}" ## print menu and prompt

get_key field ## get user input

printf "\n\n" ## print a blank line
case $field in
0|q|Q) break ;; ## quit
[1-7]) ;; ## menu item selected; fall through
*) continue;;
esac
history -c ## clear history
history -s "${user[field-1]}" ## insert current value in history
printf ' Press UP to edit "%s"\n' "${user[field-1]}" ## tell user what's there
read -ep " ${fieldnames[field-1]}: " val ## get user entry
case $val in
*:*) echo " Field may not contain a colon (press ENTER)" >&2 ## ERROR
get_key; continue
;;
"") continue ;;
*) user[field-1]=$val ;;
esac
done

Reading the Mouse

On the Linux console_codes1 man page, there is a section labeled “mouse tracking.” Interesting! It reads: “The mouse tracking facility is intended to return xterm-compatible mouse status reports.” Does that mean the mouse can be used in shell scripts?

According to that man page, mouse tracking is available in two modes: X10 compatibility mode, which sends an escape sequence on button press, and normal tracking mode, which sends an escape sequence on both button press and release. Both modes also send modifier-key information.

To test this, printf "\e[?9h" was first entered at a terminal window. This is the escape sequence that sets the "X10 Mouse Reporting (default off): Set reporting mode to 1 (or reset to 0)". If you press the mouse button, the computer will beep and print “FB” on the screen. Repeating the mouse click at various points on the screen will net more beeps and “&% -( 5. =2 H7 T= ]C fG rJ }M.”

A mouse click sends six characters: ESC, [, M, b, x, y. The first three characters are common to all mouse events, the second three contain the button pressed, and the finals ones are the x and y locations of the mouse. To confirm this, save the input in a variable and pipe it to hexdump:

$ printf "\e[?9h"
$ read x
^[[M!MO ## press mouse button and enter
$ printf "$x" | hexdump -C
00000000 1b 5b 4d 21 4d 4f |.[M!MO|
00000006

The first three appear as expected, but what are the final three? According to the man page, the lower two bits of the button character tell which button has been pressed; the upper bits identify the active modifiers. The x and y coordinates are the ASCII values to which 32 has been added to take them out of the range of control characters. The ! is 1, " is 2, and so on.

That gives us a 1 for the mouse button, which means button 2, since 0 to 2 are buttons 1, 2, and 3, respectively, and 4 is release. The x and y coordinates are 45 (O × 4d = 77; 77 – 32 = 45) and 47.

Surprisingly, since running across this information about mouse tracking in a Linux console_codes man page, it was found that these escape codes do not work in all Linux consoles. They work in xterm, rxvt, and gnome-terminal on Linux and FreeBSD. They can also be used on FreeBSD and NetBSD, via ssh from a Linux rxvt terminal window. They do not work in a KDE konsole window.

You now know that mouse reporting works (in most xterm windows), and you can get information from a mouse click on the standard input. That leaves two questions: How do you read the information into a variable (without having to press Return), and how can the button and x, yinformation be decoded in a shell script?

With bash, use the read command’s -n option with an argument to specify the number of characters. To read the mouse, six characters are needed:

read -n6 x

Neither of these is adequate for a real script (not all input will be mouse clicks, and you will want to get single keystrokes), but they suffice to demonstrate the concept.

The next step is to decode the input. For the purposes of this demonstration, you can assume that the six characters do indeed represent a mouse click and that the first three characters are ESC, [, and M. Here we are only interested in the last three, so we extract them into three separate variables using POSIX parameter expansion:

m1=${x#???} ## Remove the first 3 characters
m2=${x#????} ## Remove the first 4 characters
m3=${x#?????} ## Remove the first 5 characters

Then convert the first character of each variable to its ASCII value. This uses a POSIX printf extension: “If the leading character is a single-quote or double-quote, the value shall be the numeric value in the underlying codeset of the character following the single-quote or double-quote.”2

printf -v mb "%d" "'$m1"
printf -v mx "%d" "'$m2"
printf -v my "%d" "'$m3"

Finally, interpret the ASCII values. For the mouse button, do a bitwise AND 3. For the x and y coordinates, subtract 32:

## Values > 127 are signed, so fix if less than 0
[ $mx -lt 0 ] && mx=$(( 255 + $mx ))
[ $my -lt 0 ] && my=$(( 255 + $my ))

BUTTON=$(( ($mb & 3) + 1 ))
MOUSEX=$(( $mx - 32 ))
MOUSEY=$(( $my - 32 ))

Putting it all together, the script in Listing 15-11 prints the mouse’s coordinates whenever you press a mouse button.

There are two sensitive areas on the top row. Clicking the left one toggles the mouse reporting mode between reporting only a button press and reporting the release as well. Clicking the right one exits the script.

Listing 15-11. mouse-demo, Example of Reading Mouse Clicks

ESC=$'\e'
but_row=1

mv=9 ## mv=1000 for press and release reporting; mv=9 for press only

_STTY=$(stty -g) ## Save current terminal setup
stty -echo -icanon ## Turn off line buffering
printf "${ESC}[?${mv}h " ## Turn on mouse reporting
printf "${ESC}[?25l" ## Turn off cursor

printat() #@ USAGE: printat ROW COLUMN
{
printf "${ESC}[${1};${2}H"
}

print_buttons()
{
num_but=$#
gutter=2
gutters=$(( $num_but + 1 ))
but_width=$(( ($COLUMNS - $gutters) / $num_but ))
n=0
for but_str
do
col=$(( $gutter + $n * ($but_width + $gutter) ))
printat $but_row $col
printf "${ESC}[7m%${but_width}s" " "
printat $but_row $(( $col + ($but_width - ${#but_str}) / 2 ))
printf "%.${but_width}s${ESC}[0m" "$but_str"
n=$(( $n + 1 ))
done
}

clear
while :
do
[ $mv -eq 9 ] && mv_str="Click to Show Press & Release" ||
mv_str="Click to Show Press Only"
print_buttons "$mv_str" "Exit"

read -n6 x

m1=${x#???} ## Remove the first 3 characters
m2=${x#????} ## Remove the first 4 characters
m3=${x#?????} ## Remove the first 5 characters

## Convert to characters to decimal values
printf -v mb "%d" "'$m1"
printf -v mx "%d" "'$m2"
printf -v my "%d" "'$m3"
## Values > 127 are signed
[ $mx -lt 0 ] && MOUSEX=$(( 223 + $mx )) || MOUSEX=$(( $mx - 32 ))
[ $my -lt 0 ] && MOUSEY=$(( 223 + $my )) || MOUSEY=$(( $my - 32 ))

## Button pressed is in first 2 bytes; use bitwise AND
BUTTON=$(( ($mb & 3) + 1 ))

case $MOUSEY in
$but_row) ## Calculate which on-screen button has been pressed
button=$(( ($MOUSEX - $gutter) / $but_width + 1 ))
case $button in
1) printf "${ESC}[?${mv}l"
[ $mv -eq 9 ] && mv=1000 || mv=9
printf "${ESC}[?${mv}h"
[ $mv -eq 1000 ] && x=$(dd bs=1 count=6 2>/dev/null)
;;
2) break ;;
esac
;;
*) printat $MOUSEY $MOUSEX
printf "X=%d Y=%d [%d] " $MOUSEX $MOUSEY $BUTTON
;;
esac

done

printf "${ESC}[?${mv}l" ## Turn off mouse reporting
stty "$_STTY" ## Restore terminal settings
printf "${ESC}[?12l${ESC}[?25h" ## Turn cursor back on
printf "\n${ESC}[0J\n" ## Clear from cursor to bottom of screen,

Summary

Bash has a rich set of options for interactive programming. In this chapter, you learned how to leverage that to read any keystroke, including function keys and others that return more than a single character.

Exercises

1. Using the key-funcs library, write a menu script that uses the function keys for selection.

2. Rewrite the key-funcs library to include mouse handling, and incorporate the function into the mouse-demo script.

3. The password script does minimal checking for invalid entries. What checking would you add? How would you code it?

1http://man7.org/linux/man-pages/man4/console_codes.4.html

2http://www.opengroup.org/onlinepubs/9699919799/utilities/printf.html