Skip to content

Instantly share code, notes, and snippets.

@sellmerfud
Last active March 26, 2025 23:13
Show Gist options
  • Save sellmerfud/158f0a6444c4520f3023c11667c6b965 to your computer and use it in GitHub Desktop.
Save sellmerfud/158f0a6444c4520f3023c11667c6b965 to your computer and use it in GitHub Desktop.
Bourne shell function to prompt using a menu
#! /usr/bin/env sh
# Trim leading and trailing spaces
# from stdin and write to stdout.
# Useful when getting numbers from wc -c, etc.
trim() {
sed -e 's/^[ ]*//' -e 's/[ ]*$//'
}
# Return 0 if the first argument is all digits
is_numeric()
{
echo "$1" | grep '^[0-9][0-9]*$' >/dev/null 2>&1
}
# Present a menu and prompt user to select one of the given options.
#
# usage:
# prompt_menu "<choices>" "<cols>:<width>" prompt="Choose one: " delim=":"
# $1 - choices
# contains the values to be seleted separated by newlines
# Optionally, each value can be followed by a User friendly label that will
# be displayed for that value. The label if present must be appened to the value
# following a delimiter. The default delimiter is colon ':'
# $2 - columns and width
# By default the menu options are show in a single column.
# For large menus is it sometimes better to display the choices in
# more than one column.
# This argument may consist of one or two integers separated by a colon ':'
# The first (and possible only) integer is the number of columns desired.
# The second integer is the amount of screen width that the entire menu
# will use. The colums widths will be evenly divided among this screen
# width. If the <width> column is not given, then the scren width
# as reported by `tput cols` is used and if that fails then a value
# of 80 is used as a last resort.
# Normally, you will want to supply the width so that the menu does
# not look spread out on wide monitors.
# $3 - prompt
# The prompt string show to the user. If not given a default prompt
# of "Choose one: " is used.
# $4 - delim
# delimiter used to separate values from their associated labels.
# This must be a single character.
# If not given, a default delimiter of ':' is used.
#
# return
# The selected value is written to stdout
# Use command substitution to save the return value
#
# examples:
# choices='
# international:YYYY-MM-DD (ISO)
# us:MM-DD-YYYY (United States)
# europe:DD-MM-YYYY (Europe)
# '
# date_format=`prompt_menu "$choices"
#
# # With two column layout:
# date_format=`prompt_menu "$choices" "2:80"
#
# # With a custom prompt:
# date_format=`prompt_menu "$choices" "1" "Select date format:"`
prompt_menu() {
choices="$1"
prompt=${3:-"Choose one:"}
delim=${4:-":"}
default_width=`tput cols` || default_width=80
# Set up columns
if [ -z "$2" ]; then
num_cols="1"
menu_width="$default_width"
else
# Putting the IFS= before set was not working!
save_IFS="$IFS"
IFS=':'
# Do not quote $2 here!
set $2
IFS="$save_IFS"
num_cols="${1}"
menu_width="${2:-$default_width}"
fi
separator=""
choices_file="$TMPDIR/menu_choices.$$"
choice=-1
# Pass in name of function to call for each value/label pair
# $1 - the number of the chosen value
# Writes the value to stdout
value_for_index() {
while IFS="$delim" read index value label; do
if [ "$index" -eq "$1" ]; then
printf "%s" "$value"
return 0
fi
done <"$choices_file"
return 1
}
iterate_choices() {
lambda="$1"
shift
while IFS="$delim" read index value label; do
[ -z "$label" ] && label="$value"
"$lambda" "$index" "$value" "$label" "$@"
done <"$choices_file"
}
get_max_label_len() {
max_len=0
get_max() {
this_max=`printf "$3" | wc -c | trim`
[ "$this_max" -gt "$max_len" ] && max_len="$this_max"
}
iterate_choices "get_max"
unset get_max
printf "%s" $max_len
}
# Write the choices to stdout
show_choices() {
print_one() {
printf "%${num_width}d) %s\n" "$1" "$3"
}
# Use pr to handle the display of columns
iterate_choices "print_one" | pr -t "-$num_cols" "-w$menu_width"
unset print_one
}
if [ -z "$choices" ]; then
printf "prompt_menu: first argument (choices) is required\n" >&2
return 1
fi
AWK="awk"
# On SunOS we must expliclty use Posix version of awk
[ `uname -s` = SunOS ] && AWK=/usr/xpg4/bin/awk
# Put the choices in a file with each
# entry normalized:
# <number>:value:display
echo "$choices" |
$AWK 'NF > 0 { print $0 }' | # Remove blank lines
$AWK -F"$delim" '
{
OFS=delim
gsub(/^ *| *$/, "", $1) # trim leading and trailer spaces from field 1
sub(/^ */, "", $2) # trim leading spaces from field 2
print NR, $1, $2 # Ouput <number>:value:display
}' delim="$delim" > "$choices_file"
num_choices=`cat "$choices_file" | wc -l | trim`
max_label_len=`get_max_label_len`
num_width=`printf "%d" "$num_choices" | wc -c | trim`
# plus two to account for ") "
idx=0
idx_limit=`expr "$max_label_len" + "$num_width" + 2`
while [ "$idx" -lt "$idx_limit" ]; do
separator="$separator-"
idx=`expr "$idx" + 1`
done
# Redirect all stdout to stderr for the menu display
# We use stdout to return the chosen value.
printf "\n" >&2
show_choices >&2
while [ "$choice" -eq -1 ]; do
printf -- "%s\n%s " "$separator" "$prompt" >&2
IFS=' ' read response rest
if is_numeric "$response" && [ "$response" -ge 1 ] && [ "$response" -le "$num_choices" ]; then
choice="$response"
else
printf "'%s' is not a valid choice\n" "$response" >&2
fi
done
printf "%s" `value_for_index "$choice"`
rm -f "$choices_file" >/dev/null 2>/dev/null
unset value_for_index iterate_choices get_max_label_len show_choices
return 0
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment