Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Pure BASH interactive CLI/TUI menu (single and multi-select/checkboxes)
#!/bin/bash
##
# Pure BASH interactive CLI/TUI menu (single and multi-select/checkboxes)
#
# Author: Markus Geiger <mg@evolution515.net>
# Last revised 2019-09-11
#
# ATTENTION! TO BE REFACTORED! FIRST DRAFT!
#
# Demo
#
# - ASCIINEMA
# https://asciinema.org/a/Y4hLxnN20JtAlrn3hsC6dCRn8
#
# Inspired by
#
# - https://serverfault.com/questions/144939/multi-select-menu-in-bash-script
# - Copyright (C) 2017 Ingo Hollmann - All Rights Reserved
# https://www.bughunter2k.de/blog/cursor-controlled-selectmenu-in-bash
#
# Notes
#
# - This is a hacky first implementation for my shell tools/dotfiles (ZSH)
# - Intention is to use it for CLI wizards (my aim is NOT a full blown curses TUI window interface)
# - I concerted TPUT to ANSII-sequences to spare command executions (e.g. `tput ed | xxd`)
# reference: http://linuxcommand.org/lc3_adv_tput.php
#
# Permission to copy and modify is granted under the Creative Commons Attribution 4.0 license
#
# Strict bash scripting (not yet)
# set -euo pipefail -o errtrace
# Templates for ui_widget_select
declare -xr UI_WIDGET_SELECT_TPL_SELECTED='\e[33m → %s \e[39m'
declare -xr UI_WIDGET_SELECT_TPL_DEFAULT=" \e[37m%s %s\e[39m"
declare -xr UI_WIDGET_MULTISELECT_TPL_SELECTED="\e[33m → %s %s\e[39m"
declare -xr UI_WIDGET_MULTISELECT_TPL_DEFAULT=" \e[37m%s %s\e[39m"
declare -xr UI_WIDGET_TPL_CHECKED=""
declare -xr UI_WIDGET_TPL_UNCHECKED=""
# We use env variable to pass results since no interactive output from subshells and we don't wanna go hacky!
declare -xg UI_WIDGET_RC=-1
##
# Get type of a BASH variable (BASH ≥v4.0)
#
# Notes
# - if references are encountered it will automatically try
# to resolve them unless '-f' is passed!
# - resolving functions can be seen as bonus since they also
# use `declare` (but with -fF). this behavior should be removed!
# - bad indicates bad referencing which normally shouldn't occur!
# - types are shorthand and associative arrays map to "map" for convenience
#
# argument
# -f (optional) force resolvement of first hit
# <variable-name> Variable name
#
# stdout
# (nil|number|array|map|reference)
#
# stderr
# -
#
# return
# 0 - always
typeof() {
# __ref: avoid local to overwrite global var declaration and therefore emmit wrong results!
local type="" resolve_ref=true __ref="" signature=()
if [[ "$1" == "-f" ]]; then
# do not resolve reference
resolve_ref=false; shift;
fi
__ref="$1"
while [[ -z "${type}" ]] || ( ${resolve_ref} && [[ "${type}" == *n* ]] ); do
IFS=$'\x20\x0a\x3d\x22' && signature=($(declare -p "$__ref" 2>/dev/null || echo "na"))
if [[ ! "${signature}" == "na" ]]; then
type="${signature[1]}" # could be -xn!
fi
if [[ -z "${__ref}" ]] || [[ "${type}" == "na" ]] || [[ "${type}" == "" ]]; then
printf "nil"
return 0
elif [[ "${type}" == *n* ]]; then
__ref="${signature[4]}"
fi
done
case "$type" in
*i*) printf "number";;
*a*) printf "array";;
*A*) printf "map";;
*n*) printf "reference";;
*) printf "string";;
esac
}
##
# Removes a value from an array
#
# alternatives
# array=( "${array[@]/$delete}"
#
# arguments
# arg1 value
# arg* list or stdin
#
# stdout
# list with space seperator
array_without_value() {
local args=() value="${1}" s
shift
for s in "${@}"; do
if [ "${value}" != "${s}" ]; then
args+=("${s}")
fi
done
echo "${args[@]}"
}
##
# check if a value is in an array
#
# alternatives
# array=( "${array[@]/$delete}"
#
# arguments
# arg1 value
# arg* list or stdin
#
# stdout
# list with space seperator
array_contains_value() {
local e match="$1"
shift
for e; do [[ "$e" == "$match" ]] && return 0; done
return 1
}
##
# BASH only string to hex
#
# stdout
# hex squence
str2hex_echo() {
# USAGE: hex_repr=$(str2hex_echo "ABC")
# returns "0x410x420x43"
local str=${1:-$(cat -)}
local fmt=""
local chr
local -i i
printf "0x"
for i in `seq 0 $((${#str}-1))`; do
chr=${str:i:1}
printf "%x" "'${chr}"
done
}
##
# Read key and map to human readable output
#
# notes
# output prefix (concated by `-`)
# c ctrl key
# a alt key
# c-a ctrl+alt key
# use F if you mean shift!
# uppercase `f` for `c+a` combination is not possible!
#
# arguments
# -d for debugging keycodes (hex output via xxd)
# -l lowercase all chars
# -l <timeout> timeout
#
# stdout
# mapped key code like in notes
ui_key_input() {
local key
local ord
local debug=0
local lowercase=0
local prefix=''
local args=()
local opt
while (( "$#" )); do
opt="${1}"
shift
case "${opt}" in
"-d") debug=1;;
"-l") lowercase=1;;
"-t") args+=(-t $1); shift;;
esac
done
IFS= read ${args[@]} -rsn1 key 2>/dev/null >&2
read -sN1 -t 0.0001 k1; read -sN1 -t 0.0001 k2; read -sN1 -t 0.0001 k3
key+="${k1}${k2}${k3}"
if [[ "${debug}" -eq 1 ]]; then echo -n "${key}" | str2hex_echo; echo -n " : " ;fi;
case "${key}" in
'') key=enter;;
' ') key=space;;
$'\x1b') key=esc;;
$'\x1b\x5b\x36\x7e') key=pgdown;;
$'\x1b\x5b\x33\x7e') key=erase;;
$'\x7f') key=backspace;;
$'\e[A'|$'\e0A '|$'\e[D'|$'\e0D') key=up;;
$'\e[B'|$'\e0B'|$'\e[C'|$'\e0C') key=down;;
$'\e[1~'|$'\e0H'|$'\e[H') key=home;;
$'\e[4~'|$'\e0F'|$'\e[F') key=end;;
$'\e') key=enter;;
$'\e'?) prefix="a-"; key="${key:1:1}";;
esac
# only lowercase if we have a single letter
# ctrl key is hidden within char code (no o)
if [[ "${#key}" == 1 ]]; then
ord=$(LC_CTYPE=C printf '%d' "'${key}")
if [[ "${ord}" -lt 32 ]]; then
prefix="c-${prefix}"
# ord=$(([##16] ord + 0x60))
# let "ord = [##16] ${ord} + 0x60"
ord="$(printf "%X" $((ord + 0x60)))"
key="$(printf "\x${ord}")"
fi
if [[ "${lowercase}" -eq 1 ]]; then
key="${key,,}"
fi
fi
echo "${prefix}${key}"
}
##
# UI Widget Select
#
# arguments
# -i <[menu-item(s)] …> menu items
# -m activate multi-select mode (checkboxes)
# -k <[key(s)] …> keys for menu items (if none given indexes are used)
# -s <[selected-keys(s)] …> selected keys (index or key)
# if keys are used selection needs to be keys
# -c clear complete menu on exit
# -l clear menu and leave selections
#
# env
# UI_WIDGET_RC will be selected index or -1 of nothing was selected
#
# stdout
# menu display - don't use subshell since we need interactive shell and use tput!
#
# stderr
# sometimes (trying to clean up)
#
# return
# 0 success
# -1 cancelled
ui_widget_select() {
local menu=() keys=() selection=() selection_index=()
local cur=0 oldcur=0 collect="item" select="one"
local sel="" marg="" drawn=false ref v=""
local opt_clearonexit=false opt_leaveonexit=false
export UI_WIDGET_RC=-1
while (( "$#" )); do
opt="${1}"; shift
case "${opt}" in
-k) collect="key";;
-i) collect="item";;
-s) collect="selection";;
-m) select="multi";;
-l) opt_clearonexit=true; opt_leaveonexit=true;;
-c) opt_clearonexit=true;;
*)
if [[ "${collect}" == "selection" ]]; then
selection+=("${opt}")
elif [[ "${collect}" == "key" ]]; then
keys+=("${opt}")
else
menu+=("$opt")
fi;;
esac
done
# sanity check
if [[ "${#menu[@]}" -eq 0 ]]; then
>&2 echo "no menu items given"
return 1
fi
if [[ "${#keys[@]}" -gt 0 ]]; then
# if keys are used
# sanity check
if [[ "${#keys[@]}" -gt 0 ]] && [[ "${#keys[@]}" != "${#menu[@]}" ]]; then
>&2 echo "number of keys do not match menu options!"
return 1
fi
# map keys to indexes
selection_index=()
for sel in "${selection[@]}"; do
for ((i=0;i<${#keys[@]};i++)); do
if [[ "${keys[i]}" == "${sel}" ]]; then
selection_index+=("$i")
fi
done
done
else
# if no keys are used assign by indexes
selection_index=(${selection[@]})
fi
clear_menu() {
local str=""
for i in "${menu[@]}"; do str+="\e[2K\r\e[1A"; done
echo -en "${str}"
}
##
# draws menu in three different states
# - initial: draw every line as intenden
# - update: only draw updated lines and skip existing
# - exit: only draw selected lines
draw_menu() {
local mode="${initial:-$1}" check=false check_tpl="" str="" msg="" tpl_selected="" tpl_default="" marg=()
if ${drawn} && [[ "$mode" != "exit" ]]; then
# reset position
str+="\r\e[2K"
for i in "${menu[@]}"; do str+="\e[1A"; done
# str+="${TPUT_ED}"
fi
if [[ "$select" == "one" ]]; then
tpl_selected="$UI_WIDGET_SELECT_TPL_SELECTED"
tpl_default="$UI_WIDGET_SELECT_TPL_DEFAULT"
else
tpl_selected="$UI_WIDGET_MULTISELECT_TPL_SELECTED"
tpl_default="$UI_WIDGET_MULTISELECT_TPL_DEFAULT"
fi
for ((i=0;i<${#menu[@]};i++)); do
check=false
if [[ "$select" == "one" ]]; then
# single selection
marg=("${menu[${i}]}")
if [[ ${cur} == ${i} ]]; then
check=true
fi
else
# multi-select
check_tpl="$UI_WIDGET_TPL_UNCHECKED";
if array_contains_value "$i" "${selection_index[@]}"; then
check_tpl="$UI_WIDGET_TPL_CHECKED"; check=true
fi
marg=("${check_tpl}" "${menu[${i}]}")
fi
if [[ "${mode}" != "exit" ]] && [[ ${cur} == ${i} ]]; then
str+="$(printf "\e[2K${tpl_selected}" "${marg[@]}")\n";
elif ([[ "${mode}" != "exit" ]] && ([[ "${oldcur}" == "${i}" ]] || [[ "${mode}" == "initial" ]])) || (${check} && [[ "${mode}" == "exit" ]]); then
str+="$(printf "\e[2K${tpl_default}" "${marg[@]}")\n";
elif [[ "${mode}" -eq "update" ]] && [[ "${mode}" != "exit" ]]; then
str+="\e[1B\r"
fi
done
echo -en "${str}"
export drawn=true
}
# initial draw
draw_menu initial
# action loop
while true; do
oldcur=${cur}
key=$(ui_key_input)
case "${key}" in
up|left|i|j) ((cur > 0)) && ((cur--));;
down|right|k|l) ((cur < ${#menu[@]}-1)) && ((cur++));;
home) cur=0;;
pgup) let cur-=5; if [[ "${cur}" -lt 0 ]]; then cur=0; fi;;
pgdown) let cur+=5; if [[ "${cur}" -gt $((${#menu[@]}-1)) ]]; then cur=$((${#menu[@]}-1)); fi;;
end) ((cur=${#menu[@]}-1));;
space)
if [[ "$select" == "one" ]]; then
continue
fi
if ! array_contains_value "$cur" "${selection_index[@]}"; then
selection_index+=("$cur")
else
selection_index=($(array_without_value "$cur" "${selection_index[@]}"))
fi
;;
enter)
if [[ "${select}" == "multi" ]]; then
export UI_WIDGET_RC=()
for i in ${selection_index[@]}; do
if [[ "${#keys[@]}" -gt 0 ]]; then
export UI_WIDGET_RC+=("${keys[${i}]}")
else
export UI_WIDGET_RC+=("${i}")
fi
done
else
if [[ "${#keys[@]}" -gt 0 ]]; then
export UI_WIDGET_RC="${keys[${cur}]}";
else
export UI_WIDGET_RC=${cur};
fi
fi
if $opt_clearonexit; then clear_menu; fi
if $opt_leaveonexit; then draw_menu exit; fi
return
;;
[1-9])
let "cur = ${key}"
if [[ ${#menu[@]} -gt 9 ]]; then
echo -n "${key}"
sleep 1
key="$(ui_key_input -t 0.5 )"
if [[ "$key" =~ [0-9] ]]; then
let "cur = cur * 10 + ${key}"
elif [[ "$key" != "enter" ]]; then
echo -en "\e[2K\r$key invalid input!"
sleep 1
fi
fi
let "cur = cur - 1"
if [[ ${cur} -gt ${#menu[@]}-1 ]]; then
echo -en "\e[2K\rinvalid index!"
sleep 1
cur="${oldcur}"
fi
echo -en "\e[2K\r"
;;
esc|q|$'\e')
if $opt_clearonexit; then clear_menu; fi
return 1;;
esac
# Redraw menu
draw_menu update
done
}
##
# Main
# Uncomment for key probing
# ui_key_input -d
echo -e "\e[4mMENU: multi-select, using indexed keys, preselection, leave selected options\e[24m"
options=("Option 1" "Option 2" "Option 3" "Option 4" "Option 5" "Option 6" "Option 7" "Option 8" "Option 9" "Option 10" "Option 11" "Option 12")
ui_widget_select -l -m -s 1 3 5 -i "${options[@]}"
echo "Return code: $?"
echo "Selected item(s): ${UI_WIDGET_RC[@]}";
echo --
echo -e "\e[4mMENU: multi-select, using assoc keys, preselection, leave selected options\e[24m"
declare -A options2=( [foo]="Hallo" [bar]="World" [baz]="Record")
ui_widget_select -l -m -k "${!options2[@]}" -s bar -i "${options2[@]}"
echo "Return code: $?"
echo "Selected item(s): ${UI_WIDGET_RC[@]}";
echo ---
echo -e "\e[4mMENU: select one, using assoc keys\e[24m"
ui_widget_select -k yes no -i "ja" "nein"
echo "Return code: $?"
echo "Selected key: ${UI_WIDGET_RC}";
echo --
echo -e "\e[4mMENU: select-one, using assoc keys, preselection, leave selected options\e[24m"
declare -A options2=( [foo]="Hallo" [bar]="World" [baz]="Record")
ui_widget_select -l -k "${!options2[@]}" -s bar -i "${options2[@]}"
echo "Return code: $?"
echo "Selected item ${UI_WIDGET_RC}: ${options2[${UI_WIDGET_RC}]}";
echo --
echo -e "\e[4mMENU: select on, leave selected item on exit\e[24m"
options=("Option 1" "Option 2" "Option 3" "Option 4" "Option 5" "Option 6" "Option 7" "Option 8" "Option 9" "Option 10" "Option 11" "Option 12")
ui_widget_select -l "${options[@]}"
echo "Return code: $?"
echo "Selected item #${UI_WIDGET_RC}: ${options[${UI_WIDGET_RC}]}";
echo --
echo -e "\e[4mMENU: multi-select, using indexed keys, preselection, clear on exit\e[24m"
options=("Option 1" "Option 2" "Option 3" "Option 4" "Option 5" "Option 6" "Option 7" "Option 8" "Option 9" "Option 10" "Option 11" "Option 12")
ui_widget_select -c -m -s 1 3 5 -i "${options[@]}"
echo "Return code: $?"
echo "Selected item(s): ${UI_WIDGET_RC[@]}";
echo --
echo -e "\e[4mMENU: select-one, using assoc keys, preselection, leave selected options\e[24m"
declare -A options2=( [foo]="Hallo" [bar]="World" [baz]="Record")
ui_widget_select -l -k "${!options2[@]}" -s bar -i "${options2[@]}"
echo "Return code: $?"
echo "Selected item ${UI_WIDGET_RC}: ${options2[${UI_WIDGET_RC}]}";
@amsv01

This comment has been minimized.

Copy link

@amsv01 amsv01 commented May 19, 2020

Looks to be what I was looking for but when tried to use arrows got following error message:

line 200: read: -N: invalid option

Any idea how to fix this?

P.S. I ran it on macOS just in case.

@blurayne

This comment has been minimized.

Copy link
Owner Author

@blurayne blurayne commented Aug 10, 2020

@amsv01

Looks to be what I was looking for but when tried to use arrows got following error message:

line 200: read: -N: invalid option

Any idea how to fix this?

P.S. I ran it on macOS just in case.

MacOS is still using bash v3 since some license foo. Bash v5 is current but the script should work with any bash v4.

Use homebrew or ports to install a recent bash and just use it in your terminal only (don't replace your system's shell or make it available in system-wide PATH variable - only your user profile - since some there is still the danger that something in your system misbehaves PLUS you should NEVER touch Mac OSX system files. they are maintained by Apple ONLY).

FYI https://www.quora.com/Why-does-MacOS-come-with-Bash-version-3-instead-of-Bash-version-4

@emmanuelnk

This comment has been minimized.

Copy link

@emmanuelnk emmanuelnk commented Aug 12, 2020

Just what I was looking for! Thanks!

@kakulukia

This comment has been minimized.

Copy link

@kakulukia kakulukia commented Oct 16, 2020

Awesome!
One addition tho:
#!/usr/bin/env bash

This way it gets executed with the correct bash rather then that outdated OSX version that you really should not change. :)

@pedro-hs

This comment has been minimized.

Copy link

@pedro-hs pedro-hs commented Oct 20, 2020

Thanks for your idea.
Look what i did: https://github.com/pedro-hs/checkbox.sh

Features:

  • Select only a option or multiple options
  • Select or unselect multiple options easily
  • Select all or unselect all
  • Pagination
  • Optional Vim keybinds
  • A .sh file with approximately 500 lines
  • Start with options selected
  • Show selected options counter for multiple options
  • Show custom message
  • Show current option index and options amount
  • Copy current option value to clipboard
  • Help tab when press h or wrongly call the script

I want to remove the command clear to refresh the interface without clean everything, do you know a way to do this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.