Skip to content

Instantly share code, notes, and snippets.

@nekofar
Forked from blurayne/ui-widget-select.sh
Created September 20, 2020 21:43
Show Gist options
  • Save nekofar/2c3814ebfa7a80a6ff403ba5094a1670 to your computer and use it in GitHub Desktop.
Save nekofar/2c3814ebfa7a80a6ff403ba5094a1670 to your computer and use it in GitHub Desktop.
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 <[email protected]>
# 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}]}";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment