34

How to create a menu in a shell script that will display 3 options that a user will use the arrows keys to move the highlight cursor and press enter to select one?

Rui F Ribeiro
  • 56,709
  • 26
  • 150
  • 232
Mrplow911
  • 343
  • I think you are out of luck WRT to arrow key functionality and highlighting in a pure shell script (you might be able to do the latter with tput, but I think the former is not possible), but you can create simple menus in bash with select: http://www.tldp.org/LDP/Bash-Beginners-Guide/html/sect_09_06.html – goldilocks Jul 25 '14 at 15:03
  • Do you mean a GUI menu (using something like [zenity]( Ben Browder) or a text-based one using something like ncurses? – terdon Jul 25 '14 at 15:37
  • I am trying to create a menu that is like the one you get to if you had to select the boot option for windows ("safe mode" "normal" etc) – Mrplow911 Jul 25 '14 at 17:49
  • 1
    There is the dialog package which creates basic faux-GUI terminal interfaces in scripts. – HalosGhost Jul 25 '14 at 18:31
  • @HalosGhost Do you know of any examples of this? – Mrplow911 Jul 25 '14 at 18:41
  • Actually, @JohnWHSmith's answer is an example of using dialog. – HalosGhost Jul 25 '14 at 19:25

5 Answers5

52

Here is a pure bash script solution in form of the select_option function, relying solely on ANSI escape sequences and the built-in read.

Works on Bash 4.2.45 on OSX. The funky parts that might not work equally well in all environments from all I know are the get_cursor_row(), key_input() (to detect up/down keys) and the cursor_to() functions.

#!/usr/bin/env bash

# Renders a text based list of options that can be selected by the
# user using up, down and enter keys and returns the chosen option.
#
#   Arguments   : list of options, maximum of 256
#                 "opt1" "opt2" ...
#   Return value: selected index (0 for opt1, 1 for opt2 ...)
function select_option {

    # little helpers for terminal print control and key input
    ESC=$( printf "\033")
    cursor_blink_on()  { printf "$ESC[?25h"; }
    cursor_blink_off() { printf "$ESC[?25l"; }
    cursor_to()        { printf "$ESC[$1;${2:-1}H"; }
    print_option()     { printf "   $1 "; }
    print_selected()   { printf "  $ESC[7m $1 $ESC[27m"; }
    get_cursor_row()   { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#*[}; }
    key_input()        { read -s -n3 key 2>/dev/null >&2
                         if [[ $key = $ESC[A ]]; then echo up;    fi
                         if [[ $key = $ESC[B ]]; then echo down;  fi
                         if [[ $key = ""     ]]; then echo enter; fi; }

    # initially print empty new lines (scroll down if at bottom of screen)
    for opt; do printf "\n"; done

    # determine current screen position for overwriting the options
    local lastrow=`get_cursor_row`
    local startrow=$(($lastrow - $#))

    # ensure cursor and input echoing back on upon a ctrl+c during read -s
    trap "cursor_blink_on; stty echo; printf '\n'; exit" 2
    cursor_blink_off

    local selected=0
    while true; do
        # print options by overwriting the last lines
        local idx=0
        for opt; do
            cursor_to $(($startrow + $idx))
            if [ $idx -eq $selected ]; then
                print_selected "$opt"
            else
                print_option "$opt"
            fi
            ((idx++))
        done

        # user key control
        case `key_input` in
            enter) break;;
            up)    ((selected--));
                   if [ $selected -lt 0 ]; then selected=$(($# - 1)); fi;;
            down)  ((selected++));
                   if [ $selected -ge $# ]; then selected=0; fi;;
        esac
    done

    # cursor position back to normal
    cursor_to $lastrow
    printf "\n"
    cursor_blink_on

    return $selected
}

Here is an example usage:

echo "Select one option using up/down keys and enter to confirm:"
echo

options=("one" "two" "three")

select_option "${options[@]}"
choice=$?

echo "Choosen index = $choice"
echo "        value = ${options[$choice]}"

Output looks like below, with the currently selected option highlighted using inverse ansi coloring (hard to convey here in markdown). This can be adapted in the print_selected() function if desired.

Select one option using up/down keys and enter to confirm:

  [one] 
   two 
   three 

Update: Here is a little extension select_opt wrapping the above select_option function to make it easy to use in a case statement:

function select_opt {
    select_option "$@" 1>&2
    local result=$?
    echo $result
    return $result
}

Example usage with 3 literal options:

case `select_opt "Yes" "No" "Cancel"` in
    0) echo "selected Yes";;
    1) echo "selected No";;
    2) echo "selected Cancel";;
esac

You can also mix if there are some known entries (Yes and No in this case), and leverage the exit code $? for the wildcard case:

options=("Yes" "No" "${array[@]}") # join arrays to add some variable array
case `select_opt "${options[@]}"` in
    0) echo "selected Yes";;
    1) echo "selected No";;
    *) echo "selected ${options[$?]}";;
esac
  • 4
    This is beautiful and amazing; thank you very much for sharing! Is this your own originally? Is there a repo online to clone/fork? The only thing I could find that seemed to be in version control was on GitHub in stephenmm's Gist (with line editing added) which points back to here, lol. Working on my own modifications (in a Gist, but planning to make a repo) here though I need to update with the latest changes still. – l3l_aze Sep 14 '18 at 02:14
  • 2
    I used it in some non public code. Pulled it together from various bits and pieces found on the web :-) – Alexander Klimetschek Sep 14 '18 at 15:51
  • 2
    Wow; nice work. I started a repo with my modifications at https://github.com/l3laze/sind. So far the biggest differences are upgraded input handling and the addition of a title bar. I'm hoping to add single and multi-line editing, but haven't done anything towards those yet beyond looking at some code – l3l_aze Sep 15 '18 at 02:19
  • How to update code so it accepts two arguments - an array of options, index of the preselected option? – srigi May 14 '21 at 18:17
  • @srigi You could have the preselected option as first argument of select_opt and then at the start of the function do local startPos=$1; shift; and later set local selected=startPos (instead of 0 right now). Haven’t tested it though. – Alexander Klimetschek May 16 '21 at 16:29
  • 1
    @AlexanderKlimetschek I was able to figure it out. I wanted the first argument as a number and the list of options as an array (for clearer distinguishing of arguments). Here is the solution: https://pastebin.com/Qe1zHGtN – srigi May 17 '21 at 15:40
  • 1
    This is exactly what I was looking for. Thanks for sharing. I modified this to add a few new features. Each option is labeled with an index and you can select that option by pressing the number. Also, the menu erase itself once a selection is made. This is nice if you want to have a multi-page menu. You can add sub-menus that replace the previous menu. Code in gist: https://gist.github.com/RobertMcReed/05b2dad13e20bb5648e4d8ba356aa60e – RobertMcReed Aug 23 '21 at 03:30
  • @RobertMcReed 404 :( – Foo Apr 14 '23 at 11:46
  • 2
    @Foo relocated: https://gist.github.com/gagregrog/05b2dad13e20bb5648e4d8ba356aa60e – RobertMcReed Apr 15 '23 at 14:42
  • @RobertMcReed thank you – Foo Jun 29 '23 at 19:34
  • doesn't work on macosx – Ricky Levi Sep 05 '23 at 09:53
15

dialog is a great tool for what you are trying to achieve. Here's the example of a simple 3-choices menu:

dialog --menu "Choose one:" 10 30 3 \
    1 Red \
    2 Green \
    3 Blue

The syntax is the following:

dialog --menu <text> <height> <width> <menu-height> [<tag><item>]

The selection will be sent to stderr. Here's a sample script using 3 colors.

#!/bin/bash
TMPFILE=$(mktemp)

dialog --menu "Choose one:" 10 30 3 \
    1 Red \
    2 Green \
    3 Blue 2>$TMPFILE

RESULT=$(cat $TMPFILE)

case $RESULT in
    1) echo "Red";;
    2) echo "Green";;
    3) echo "Blue";;
    *) echo "Unknown color";;
esac

rm $TMPFILE

On Debian, you can install dialog through the package of the same name.

John WH Smith
  • 15,880
14

The question is about only one selection.
If you're looking for a multiple select menu here's a pure bash implementation of it:

Preview of multiselect bash function

Use
j/k or the / arrow keys to navigate up or down
(Space) to toggle the selection and
(Enter) to confirm the selections.

It can be called like this:

my_options=(   "Option 1"  "Option 2"  "Option 3" )
preselection=( "true"      "true"      "false"    )

multiselect result my_options preselection

The last argument of the multiselect function is optional and can be used to preselect certain options.

The result will be stored as an array in a variable that is passed to multiselect as first argument. Here's an example to combine the options with the result:

idx=0
for option in "${my_options[@]}"; do
    echo -e "$option\t=> ${result[idx]}"
    ((idx++))
done

Result handler of multiselect bash function

function multiselect {
    # little helpers for terminal print control and key input
    ESC=$( printf "\033")
    cursor_blink_on()   { printf "$ESC[?25h"; }
    cursor_blink_off()  { printf "$ESC[?25l"; }
    cursor_to()         { printf "$ESC[$1;${2:-1}H"; }
    print_inactive()    { printf "$2   $1 "; }
    print_active()      { printf "$2  $ESC[7m $1 $ESC[27m"; }
    get_cursor_row()    { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#*[}; }
local return_value=$1
local -n options=$2
local -n defaults=$3

local selected=()
for ((i=0; i&lt;${#options[@]}; i++)); do
    if [[ ${defaults[i]} = &quot;true&quot; ]]; then
        selected+=(&quot;true&quot;)
    else
        selected+=(&quot;false&quot;)
    fi
    printf &quot;\n&quot;
done

# determine current screen position for overwriting the options
local lastrow=`get_cursor_row`
local startrow=$(($lastrow - ${#options[@]}))

# ensure cursor and input echoing back on upon a ctrl+c during read -s
trap &quot;cursor_blink_on; stty echo; printf '\n'; exit&quot; 2
cursor_blink_off

key_input() {
    local key
    IFS= read -rsn1 key 2&gt;/dev/null &gt;&amp;2
    if [[ $key = &quot;&quot;      ]]; then echo enter; fi;
    if [[ $key = $'\x20' ]]; then echo space; fi;
    if [[ $key = &quot;k&quot; ]]; then echo up; fi;
    if [[ $key = &quot;j&quot; ]]; then echo down; fi;
    if [[ $key = $'\x1b' ]]; then
        read -rsn2 key
        if [[ $key = [A || $key = k ]]; then echo up;    fi;
        if [[ $key = [B || $key = j ]]; then echo down;  fi;
    fi 
}

toggle_option() {
    local option=$1
    if [[ ${selected[option]} == true ]]; then
        selected[option]=false
    else
        selected[option]=true
    fi
}

print_options() {
    # print options by overwriting the last lines
    local idx=0
    for option in &quot;${options[@]}&quot;; do
        local prefix=&quot;[ ]&quot;
        if [[ ${selected[idx]} == true ]]; then
          prefix=&quot;[\e[38;5;46m✔\e[0m]&quot;
        fi

        cursor_to $(($startrow + $idx))
        if [ $idx -eq $1 ]; then
            print_active &quot;$option&quot; &quot;$prefix&quot;
        else
            print_inactive &quot;$option&quot; &quot;$prefix&quot;
        fi
        ((idx++))
    done
}

local active=0
while true; do
    print_options $active

    # user key control
    case `key_input` in
        space)  toggle_option $active;;
        enter)  print_options -1; break;;
        up)     ((active--));
                if [ $active -lt 0 ]; then active=$((${#options[@]} - 1)); fi;;
        down)   ((active++));
                if [ $active -ge ${#options[@]} ]; then active=0; fi;;
    esac
done

# cursor position back to normal
cursor_to $lastrow
printf &quot;\n&quot;
cursor_blink_on

eval $return_value='(&quot;${selected[@]}&quot;)'

}

Credit: This bash function is a customized version of Denis Semenenko's implementation.

miu
  • 265
  • 2
    this is a great example of a menu! thanks for sharing. – BANJOSA Oct 28 '21 at 14:14
  • @miu Is there a possibilty to use this script inside another script? When I try to source this in order to share the function between scripts the menu does not work anymore. – Steven Thiel Jul 09 '22 at 15:03
  • @StevenThiel Yes you can. You could use this line to source the script or source it from your own file system: source <(curl -sL multiselect.miu.io)

    If you want a full example and a bit of documentation have a look here: https://github.com/mamiu/dotfiles/blob/main/install/utils/multiselect.sh

    – miu Jul 10 '22 at 16:52
  • @miu Thank you very much for getting back to me! If I try out the example I get the same error that I get when trying to source the function locally. It directly exits showing only the first option as checked. Same in bash and zsh. – Steven Thiel Jul 11 '22 at 07:57
  • Ahh I figured it out: I had a set -e at the top of the script. For some reason multiselect does not like this and crashes. – Steven Thiel Jul 11 '22 at 08:02
  • Glad you figured it out and thanks for sharing. Maybe it helps someone with the same issue. For me it works fine with bash on macOS and Ubuntu. – miu Jul 12 '22 at 19:35
  • Thank you again for building such a great component :) One other question: Do you think it would be possible to make the menu scrollable if you have a small terminal window? I thought about some stuff but did not come to a good idea so far. – Steven Thiel Jul 14 '22 at 14:39
  • Yes that's definitely doable (similar to how fzf does it). But it would be a big overhead to this comparably small function. I'd rather use fzf for such use cases. – miu Aug 03 '22 at 07:25
  • On macOS: local: -n: invalid option. bash does not support the -n option for the local utility on macOS. Seems to be realted to version of bash. – D.A.H Jul 19 '23 at 10:41
  • @D.A.H I highly recommend you to use the GNU Bash. You can install it with brew install bash. Make sure that it's your default bash, if it's not automatically. – miu Jul 21 '23 at 12:24
3

I was searching this king of information. It was great to find it here.

So, I take the opportunity to re-use what I have seen here and to improve it (I hope).

One limitation in the menu was due to the limitation of the number of items linked to the terminal for example.

So, I've modified the original script but adding some functionalities:

  • Multi column menu (to increase the number of items to select)
  • Multi selection menu (to improve some features such as "all/none selection"

I share it here in the script files : one with the modified menu and one corresponding with an example of use. Because I had some trouble link to bash version (version < 4.3 and version >= 4.3), you will also find the two version of the scripts that runs of both bash version level.

menu.sh:

#!/bin/bash 
#####################################################################################################################
#
#           R5: MAJ 22/11/2021 : EML 
#               - Pb d'affichage du menu sur on dépasse la taille de l'écran
#               - On restreint le choix au 40 derniers fichiers
#           R6: MAJ 23/11/2021 : EML 
#               - On détermine automatiquement la taille de l'écran pour vérifier que l'affichage est Ok
#               - On affichera le menu compatible du coup
#               - Ajout des flèche gauche/droite pour une évolution sur un menu à plusieurs colonnes parametrables
#           R7: MAJ 24/11/2021 : EML 
#               - Correction pour support toute version de bash
#               - version < 4.3 : option "local -n" inconnue ==> fonction xxx_43m
#               - version > 4.3 : option "local -n" reconnue ==> fonction xxx_43p
#               - Possibilité de délectionner tout ou rien
#           R8: MAJ 24/11/2021 : EML 
#               - Correction checkwinsize
#               - Correction positionnement sur la fenetre
#
#
# SOURCES :
#   https://www.it-swarm-fr.com/fr/bash/menu-de-selection-multiple-dans-le-script-bash/958779139/ 
#   https://unix.stackexchange.com/questions/146570/arrow-key-enter-menu/415155#415155
#
#####################################################################################################################
export noir='\e[0;30m'
export gris='\e[1;30m'
export rougefonce='\e[1;31m'
export rouge='\e[0;31m'
export rose='\e[1;31m'
export vertfonce='\e[0;32m'
export vertclair='\e[1;32m'
export orange='\e[0;33m'
export jaune='\e[1;33m'
export bleufonce='\e[0;34m'
export bleuclair='\e[1;34m'
export violetfonce='\e[0;35m'
export violetclair='\e[1;35m'
export cyanfonce='\e[0;36m'
export cyanclair='\e[1;36m'
export grisclair='\e[0;37m'
export blanc='\e[1;37m'
export neutre='\e[0;m'

function checkwinsize { local __items=$1 local __lines=$2 #local __err=$3

if [ $__items -ge $__lines ]; then

echo "La taille de votre fenêtre ne permet d'afficher le menu correctement..."

    return 1
else

echo "La taille de votre fenêtre est de $__lines lignes, compatible avec le menu de $__items items..."

    return 0
fi

} function multiselect_43p { # little helpers for terminal print control and key input ESC=$( printf "\033") cursor_blink_on() { printf "$ESC[?25h"; } cursor_blink_off() { printf "$ESC[?25l"; } cursor_to() { printf "$ESC[$1;${2:-1}H"; } print_inactive() { printf "$2 $1 "; } print_active() { printf "$2 $ESC[7m $1 $ESC[27m"; } get_cursor_row() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#[}; } get_cursor_col() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${COL#[}; }

local return_value=$1
local colmax=$2
local offset=$3
local -n options=$4
local -n defaults=$5
local title=$6
local LINES=$( tput lines )
local COLS=$( tput cols )

clear

checkwinsize $(( ${#options[@]}/$colmax )) $LINES

err=`checkwinsize $(( ${#options[@]}/$colmax )) $(( $LINES - 2)); echo $?`

if [[ ! $err == 0 ]]; then
    echo &quot;La taille de votre fenêtre est de $LINES lignes, incompatible avec le menu de ${#_liste[@]} items...&quot;
        cursor_to $lastrow
    exit
fi 

local selected=()
for ((i=0; i&lt;${#options[@]}; i++)); do
    if [[ ${defaults[i]} = &quot;true&quot; ]]; then
        selected+=(&quot;true&quot;)
    else
        selected+=(&quot;false&quot;)
    fi
    printf &quot;\n&quot;
done

cursor_to $(( $LINES - 2 ))
printf &quot;_%.s&quot; $(seq $COLS)
echo -e &quot;$bleuclair / $title / | $vertfonce select : key [space] | (un)select all : key ([n])[a] | move : arrow up/down/left/right or keys k/j/l/h | validation : [enter] $neutre\n&quot; | column  -t -s '|'

# determine current screen position for overwriting the options
local lastrow=`get_cursor_row`
local lastcol=`get_cursor_col`
local startrow=1
local startcol=1

# ensure cursor and input echoing back on upon a ctrl+c during read -s
trap &quot;cursor_blink_on; stty echo; printf '\n'; exit&quot; 2
cursor_blink_off

key_input() {
    local key
    IFS= read -rsn1 key 2&gt;/dev/null &gt;&amp;2
    if [[ $key = &quot;&quot;      ]]; then echo enter; fi;
    if [[ $key = $'\x20' ]]; then echo space; fi;
    if [[ $key = &quot;k&quot; ]]; then echo up; fi;
    if [[ $key = &quot;j&quot; ]]; then echo down; fi;
    if [[ $key = &quot;h&quot; ]]; then echo left; fi;
    if [[ $key = &quot;l&quot; ]]; then echo right; fi;
    if [[ $key = &quot;a&quot; ]]; then echo all; fi;
    if [[ $key = &quot;n&quot; ]]; then echo none; fi;
    if [[ $key = $'\x1b' ]]; then
        read -rsn2 key
        if [[ $key = [A || $key = k ]]; then echo up;    fi;
        if [[ $key = [B || $key = j ]]; then echo down;  fi;
        if [[ $key = [C || $key = l ]]; then echo right;  fi;
        if [[ $key = [D || $key = h ]]; then echo left;  fi;
    fi 
}

toggle_option() {
    local option=$1
    if [[ ${selected[option]} == true ]]; then
        selected[option]=false
    else
        selected[option]=true
    fi
}

toggle_option_multicol() {
    local option_row=$1
    local option_col=$2

if [[ $option_row -eq -10 ]] &amp;&amp; [[ $option_row -eq -10 ]]; then
    for ((option=0;option&lt;${#selected[@]};option++)); do
                selected[option]=true
    done
else
    if [[ $option_row -eq -100 ]] &amp;&amp; [[ $option_row -eq -100 ]]; then
        for ((option=0;option&lt;${#selected[@]};option++)); do
                    selected[option]=false
        done
    else
        option=$(( $option_col + $option_row * $colmax )) 

            if [[ ${selected[option]} == true ]]; then
                    selected[option]=false
            else
                selected[option]=true
            fi
        fi
fi

}

print_options_multicol() {
    # print options by overwriting the last lines
    local curr_col=$1
    local curr_row=$2
    local curr_idx=0

    local idx=0
    local row=0
    local col=0

curr_idx=$(( $curr_col + $curr_row * $colmax ))

    for option in &quot;${options[@]}&quot;; do
        local prefix=&quot;[ ]&quot;
        if [[ ${selected[idx]} == true ]]; then
          prefix=&quot;[\e[38;5;46m✔\e[0m]&quot;
        fi

        row=$(( $idx/$colmax ))
    col=$(( $idx - $row * $colmax ))

        cursor_to $(( $startrow + $row + 1)) $(( $offset * $col + 1))
        if [ $idx -eq $curr_idx ]; then
            print_active &quot;$option&quot; &quot;$prefix&quot;
        else
            print_inactive &quot;$option&quot; &quot;$prefix&quot;
        fi
        ((idx++))
    done
}


local active_row=0
local active_col=0


while true; do
    print_options_multicol $active_col $active_row 

    # user key control
    case `key_input` in
        space)  toggle_option_multicol $active_row $active_col;;
        enter)  print_options_multicol -1 -1; break;;
        up)     ((active_row--));
                if [ $active_row -lt 0 ]; then active_row=0; fi;;
        down)   ((active_row++));
                if [ $active_row -ge $(( ${#options[@]} / $colmax ))  ]; then active_row=$(( ${#options[@]} / $colmax )); fi;;
        left)     ((active_col=$active_col - 1));
                if [ $active_col -lt 0 ]; then active_col=0; fi;;
        right)     ((active_col=$active_col + 1));
                if [ $active_col -ge $colmax ]; then active_col=$(( $colmax -1 )) ; fi;;
        all)    toggle_option_multicol -10 -10 ;;
        none)   toggle_option_multicol -100 -100 ;;
    esac
done

# cursor position back to normal
cursor_to $lastrow
printf &quot;\n&quot;
cursor_blink_on

eval $return_value='(&quot;${selected[@]}&quot;)'
clear

}

function multiselect_43m { # little helpers for terminal print control and key input ESC=$( printf "\033") cursor_blink_on() { printf "$ESC[?25h"; } cursor_blink_off() { printf "$ESC[?25l"; } cursor_to() { printf "$ESC[$1;${2:-1}H"; } print_inactive() { printf "$2 $1 "; } print_active() { printf "$2 $ESC[7m $1 $ESC[27m"; } get_cursor_row() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${ROW#[}; } get_cursor_col() { IFS=';' read -sdR -p $'\E[6n' ROW COL; echo ${COL#[}; }

local return_value=$1
local colmax=$2
local offset=$3
local size=$4
shift 4

local options=(&quot;$@&quot;)
shift $size

for i in $(seq 0 $size); do
    unset options[$(( $i + $size ))]
done

local defaults=(&quot;$@&quot;)
shift $size

unset defaults[$size]

local title=&quot;$@&quot;

local options=("${!tmp_options}")

local defauts=("${!tmp_defaults}")

local LINES=$( tput lines )
local COLS=$( tput cols )

clear

checkwinsize $(( ${#options[@]}/$colmax )) $LINES

echo ${#options[@]}/$colmax

exit

err=`checkwinsize $(( ${#options[@]}/$colmax )) $(( $LINES - 2)); echo $?`

if [[ ! $err == 0 ]]; then
    echo &quot;La taille de votre fenêtre est de $LINES lignes, incompatible avec le menu de ${#_liste[@]} items...&quot;
        cursor_to $lastrow
    exit
fi 

local selected=()
for ((i=0; i&lt;${#options[@]}; i++)); do
    if [[ ${defaults[i]} = &quot;true&quot; ]]; then
        selected+=(&quot;true&quot;)
    else
        selected+=(&quot;false&quot;)
    fi
    printf &quot;\n&quot;
done

cursor_to $(( $LINES - 2 ))
printf &quot;_%.s&quot; $(seq $COLS)
echo -e &quot;$bleuclair / $title / | $vertfonce select : key [space] | (un)select all : key ([n])[a] | move : arrow up/down/left/right or keys k/j/l/h | validation : [enter] $neutre\n&quot; | column  -t -s '|'

# determine current screen position for overwriting the options
local lastrow=`get_cursor_row`
local lastcol=`get_cursor_col`
local startrow=1
local startcol=1

# ensure cursor and input echoing back on upon a ctrl+c during read -s
trap &quot;cursor_blink_on; stty echo; printf '\n'; exit&quot; 2
cursor_blink_off

key_input() {
    local key
    IFS= read -rsn1 key 2&gt;/dev/null &gt;&amp;2
    if [[ $key = &quot;&quot;      ]]; then echo enter; fi;
    if [[ $key = $'\x20' ]]; then echo space; fi;
    if [[ $key = &quot;k&quot; ]]; then echo up; fi;
    if [[ $key = &quot;j&quot; ]]; then echo down; fi;
    if [[ $key = &quot;h&quot; ]]; then echo left; fi;
    if [[ $key = &quot;l&quot; ]]; then echo right; fi;
    if [[ $key = &quot;a&quot; ]]; then echo all; fi;
    if [[ $key = &quot;n&quot; ]]; then echo none; fi;
    if [[ $key = $'\x1b' ]]; then
        read -rsn2 key
        if [[ $key = [A || $key = k ]]; then echo up;    fi;
        if [[ $key = [B || $key = j ]]; then echo down;  fi;
        if [[ $key = [C || $key = l ]]; then echo right;  fi;
        if [[ $key = [D || $key = h ]]; then echo left;  fi;
    fi 
}

toggle_option() {
    local option=$1
    if [[ ${selected[option]} == true ]]; then
        selected[option]=false
    else
        selected[option]=true
    fi
}

toggle_option_multicol() {
    local option_row=$1
    local option_col=$2

if [[ $option_row -eq -10 ]] &amp;&amp; [[ $option_row -eq -10 ]]; then
    for ((option=0;option&lt;${#selected[@]};option++)); do
                selected[option]=true
    done
else
    if [[ $option_row -eq -100 ]] &amp;&amp; [[ $option_row -eq -100 ]]; then
        for ((option=0;option&lt;${#selected[@]};option++)); do
                    selected[option]=false
        done
    else
        option=$(( $option_col + $option_row * $colmax )) 

            if [[ ${selected[option]} == true ]]; then
                    selected[option]=false
            else
                selected[option]=true
            fi
        fi
fi

}

print_options_multicol() {
    # print options by overwriting the last lines
    local curr_col=$1
    local curr_row=$2
    local curr_idx=0

    local idx=0
    local row=0
    local col=0

curr_idx=$(( $curr_col + $curr_row * $colmax ))

    for option in &quot;${options[@]}&quot;; do
        local prefix=&quot;[ ]&quot;
        if [[ ${selected[idx]} == true ]]; then
          prefix=&quot;[\e[38;5;46m✔\e[0m]&quot;
        fi

        row=$(( $idx/$colmax ))
    col=$(( $idx - $row * $colmax ))

        cursor_to $(( $startrow + $row + 1)) $(( $offset * $col + 1))
        if [ $idx -eq $curr_idx ]; then
            print_active &quot;$option&quot; &quot;$prefix&quot;
        else
            print_inactive &quot;$option&quot; &quot;$prefix&quot;
        fi
        ((idx++))
    done
}


local active_row=0
local active_col=0
while true; do
    print_options_multicol $active_col $active_row 

    # user key control
    case `key_input` in
        space)  toggle_option_multicol $active_row $active_col;;
        enter)  print_options_multicol -1 -1; break;;
        up)     ((active_row--));
                if [ $active_row -lt 0 ]; then active_row=0; fi;;
        down)   ((active_row++));
                if [ $active_row -ge $(( ${#options[@]} / $colmax ))  ]; then active_row=$(( ${#options[@]} / $colmax )); fi;;
        left)     ((active_col=$active_col - 1));
                if [ $active_col -lt 0 ]; then active_col=0; fi;;
        right)     ((active_col=$active_col + 1));
                if [ $active_col -ge $colmax ]; then active_col=$(( $colmax -1 )) ; fi;;
        all)    toggle_option_multicol -10 -10 ;;
        none)   toggle_option_multicol -100 -100 ;;
    esac
done

# cursor position back to normal
cursor_to $lastrow
printf &quot;\n&quot;
cursor_blink_on

eval $return_value='(&quot;${selected[@]}&quot;)'
clear

}

example_menu.sh:

#!/bin/bash

if [ -e ./menu.sh ]; then source ./menu.sh else echo "script menu.sh introuvable dans le répertoire courant" exit fi

LINES=$( tput lines ) COLS=$( tput cols )

clear

#Définition de mes listes

for ((i=0; i<128; i++)); do _liste[i]="Choix $i" _preselection_liste[i]=false done

colmax=3 offset=$(( $COLS / $colmax ))

VERSION=echo $BASH_VERSION | awk -F\( '{print $1}' | awk -F. '{print $1&quot;.&quot;$2}'

if [ $(echo "$VERSION >= 4.3" | bc -l) -eq 1 ]; then multiselect_43p result $colmax $offset _liste _preselection_liste "CHOIX DU DEPOT" else multiselect_43m result $colmax $offset ${#_liste[@]} "${_liste[@]}" "${_preselection_liste[@]}" "CHOIX DU DEPOT" fi

idx=0 dbg=1 status=1 for option in "${_liste[@]}"; do if [[ ${result[idx]} == true ]]; then if [ $dbg -eq 0 ]; then echo -e "$option\t=> ${result[idx]}" fi TARGET=echo $TARGET ${option} status=0 fi
((idx++)) done

if [ $status -eq 0 ] ; then echo -e "$vertfonce Choix des items validé :\n$vertclair $TARGET $neutre" else echo -e "$rougefonce Aucun choix d'items détecté... $neutre" exit fi

while true; do case key_input in enter) break;; esac done

clear

Screenshot

Satnur
  • 31
0

Here are some great solutions for building an interactive shell menu; especially those by @miu and @alexanderklimitschek. I was searching for something similar but with a little bit less code and it had to be usable nativley with ZSH (with ZSH shebang #!/usr/bin/env zsh). Furthermore I didn't want a dependency like dialog.

But all those really cool scripts here and on similar sites are written for pure bash. That makes them imcompatible to ZSH due to things like array indexing, escape sequence for Enter and some other keys or differences in the builtin read commands. Thus, I had to write one for ZSH myself. I took those simple bash approach from the user @Guss at AskUbuntu and adapted it for ZSH. Maybe somebody having a similar need for pure ZSH scripts can use it as well.

#!/usr/bin/env zsh

############################################################################

zsh script which offers interactive selection menu

based on the answer by Guss on https://askubuntu.com/a/1386907/1771279

function choose_from_menu() { local prompt="$1" outvar="$2" shift shift # count had to be assigned the pure number of arguments local options=("$@") cur=1 count=$# index=0 local esc=$(echo -en "\033") # cache ESC as test doesn't allow esc codes echo -n "$prompt\n\n" # measure the rows of the menu, needed for erasing those rows when moving # the selection menu_rows=$# total_rows=$(($menu_rows + 1)) while true do index=1 for o in "${options[@]}" do if [[ "$index" == "$cur" ]] then echo -e " \033[38;5;41m>\033[0m\033[38;5;35m$o\033[0m" # mark & highlight the current option else echo " $o" fi index=$(( $index + 1 )) done printf "\n" # set mark for cursor printf "\033[s" # read in pressed key (differs from bash read syntax) read -s -r -k key if [[ $key == k ]] # move up then cur=$(( $cur - 1 )) [ "$cur" -lt 1 ] && cur=1 # make sure to not move out of selections scope elif [[ $key == j ]] # move down then cur=$(( $cur + 1 )) [ "$cur" -gt $count ] && cur=$count # make sure to not move out of selections scope elif [[ "${key}" == $'\n' || $key == '' ]] # zsh inserts newline, \n, for enter - ENTER then break fi # move back to saved cursor position printf "\033[u" # erase all lines of selections to build them again with new positioning for ((i = 0; i < $total_rows; i++)); do printf "\033[2k\r" printf "\033[F" done done # pass choosen selection to main body of script eval $outvar="'${options[$cur]}'" }

explicitly declare selections array makes it safer

declare -a selections selections=( "Selection A" "Selection B" "Selection C" "Selection D" "Selection E" )

call function with arguments:

$1: Prompt text. newline characters are possible

$2: Name of variable which contains the selected choice

$3: Pass all selections to the function

choose_from_menu "Please make a choice:" selected_choice "${selections[@]}" echo "Selected choice: $selected_choice"

Here a little demo. Move to a line with j and k and select the option with Enter:

demo of zsh menu

lukeflo
  • 111