bash interactive cli menus

This was initially just going to be an import of my old post about interactive menus, but I decided to review my old code for this (and also my much older code in the original post) and decided that I could do better instead.

So the original code was:

#!/bin/bash

menu_main=("Second_Menu" "Third_Menu" "Fourth_Menu" "Exit")
menu_second=("Fifth_Menu" "Main_Menu")
menu_third=("Main_Menu")
menu_fourth=("Main_Menu")
menu_fifth=("Second_Menu")

function __menu__ () {
	k=${#@}
	j=0
	l=(${@})
	[[ ! ${e} ]] && e=0
	clear
	while [[ $j -lt $k ]]; do
		[[ ${e} == ${j} ]] && echo -ne "\e[41m" || echo -ne "\e[0m"
		echo -n "["; echo -n "${l[$j]}"; echo -e "]\e[0m"
		j=$[${j}+1]
	done
	read -s -n 1 userin
	case ${userin} in
		"B") [[ ${e} -lt $((${k}-1)) ]] && e=$[${e}+1]; __menu__ ${l[@]} ;;
		"A") [[ ${e} -gt 0 ]] && e=$[${e}-1]; __menu__ ${l[@]} ;;
		"") s=${e} ;;
		*) __menu__ ${l[@]} ;;
	esac
	userin=""
}

function __menu_second__ () {
	unset e s
	__menu__ ${menu_second[@]} 
	case ${s} in
		0) __menu_fifth__ ;;
		1) __menu_main__ ;;
	esac

}

function __menu_third__ () {
	unset e s
	__menu__ ${menu_third[@]}
	case ${s} in
		0) __menu_main__ ;;
	esac
}

function __menu_fourth__ () {
	unset e s
	__menu__ ${menu_fourth[@]}
	case ${s} in
		0) __menu_main__ ;;
	esac
}

function __menu_fifth__ () {
	unset e s
	__menu__ ${menu_fifth[@]}
	case ${s} in
		0) __menu_second__ ;;
	esac
}

function __menu_main__ () {
	unset e s
	__menu__ ${menu_main[@]}
	case ${s} in
		0) __menu_second__ ;;
		1) __menu_third__ ;;
		2) __menu_fourth__ ;;
		3) clear; exit 0 ;;
	esac

}

function __bootstrap__ () {
	clear
	__menu_main__
}

__bootstrap__

Part of my original post gives some insight into some of the limitations of this approach, so I was already aware of it being far from ideal.

There is a bit of hard coded formating in that (which I don’t like, but it will not doubt evolve over time), but this does exactly what I need. To use it, you need to do a bit of leg work (but much less so than repeating this over and over).

Something else to bear in mind — “A” and “B” pick up the up and down arrow keys no problem, but they also pick up shift + a and shift + b.

As you can no doubt tell, you still need to run your case statements on the end results — however, the menu itself is standardised so that the output is always 0-n depending on the menu titles you put in.

A big limitation at the moment is getting the menu title is based purely on arrays (which break spaces) so titles cannot contain spaces.

It’s also a bit limiting with the “refresh” in that it will always clear the screen — so you can’t echo before a command. However, this will be addressed pretty soon, I just wanted to get the initial example out there.

Not much changed really on my next iteration of the code, other than I added some better styling and ability to use page up / down.

function __menu__ () {
        k=${#@}
        j=0
        l=(${@})
        [[ ! ${e} ]] && e=0
        clear
        while [[ ${j} -lt ${k} ]]; do
                [[ ${e} == ${j} ]] && echo -ne "\e[0;30m\e[47m" || echo -ne "\e[0m"
                echo -n "[ "
                w=$(( 20 - ${#l[${j}]} ))
                [[ $(( ${w} % 2 )) -eq 1 ]] && echo -n " "
                w=$(( ${w} / 2 ))
                t=0
                while [[ ${t} -lt ${w} ]]; do
                        echo -n " "
                        t=$[${t}+1]
                done
                echo -n "${l[${j}]}"
                t=0
                while [[ ${t} -le ${w} ]]; do
                        echo -n " "
                        t=$[${t}+1]
                done
                echo -e " ]\e[0m"
                j=$[${j}+1]
        done
        read -s -n 1 userin
        case ${userin} in
                "B"|"6") [[ ${e} -lt $((${k}-1)) ]] && e=$[${e}+1]; __menu__ ${l[@]} ;;
                "A"|"5") [[ ${e} -gt 0 ]] && e=$[${e}-1]; __menu__ ${l[@]} ;;
                "") s=${e} ;;
                *) __menu__ ${l[@]} ;;
        esac
        userin=""
}

So now we move on to the newer code. And what I think is a much more elegant approach to it.

#!/bin/bash                                                                                                                                                                                                  [44/454]

## vmenu
## USAGE:               vmenu [MENU_ITEMS]
vmenu() {

        declare USER_INPUT
        declare -i SELECTED_ITEM RETURN_ITEM TOTAL_ITEMS i j

        SELECTED_ITEM=0
        RETURN_ITEM=-1
        TOTAL_ITEMS=$(( ${#@} - 1 ))

        ## Catch interupts and clear the menu
        trap '
                for (( i=TOTAL_ITEMS; i >= 0; i-- )); do
                        printf "\033[1000D\033[K\033[1A"
                done
                printf "\033[?25h"
                exit 1
        ' SIGINT

        ## Hide the cursor
        printf "\033[?25l"

        while (( RETURN_ITEM == -1 )); do

                ## Print the menu
                i=0
                for MENU_ITEM in "${@}"; do
                        (( i == SELECTED_ITEM )) && printf "\e[0;30m\e[47m%s\e[0m" "$MENU_ITEM" || printf "%s" "$MENU_ITEM"
                        ## Clearing line work around for leaving remains of previous menu on same line
                        for (( j=0; j < 20; j++ )); do
                                printf " "
                        done
                        printf "\n"
                        (( i++ ))
                done

                ## Get arrow key input
                read -r -s -n 1 USER_INPUT
                case $USER_INPUT in
                        'B'|'6') (( SELECTED_ITEM < TOTAL_ITEMS )) && (( SELECTED_ITEM++ )) ;;
                        'A'|'5') (( SELECTED_ITEM > 0 )) && (( SELECTED_ITEM-- )) ;;
                        '') RETURN_ITEM=$SELECTED_ITEM ;;
                esac

                ## Clear extra lines
                while (( i > 0 )); do
                        printf "\033[1000D\033[K\033[1A"
                        (( i-- ))
                done

        done

        ## Unhide the cursor and return menu entry
        printf "\033[?25h"
        return $RETURN_ITEM

}

vmenu_level_one() {
        vmenu "Go Deep" "Exit"
        case $? in
                0) vmenu_level_two ;;
                1) exit 0 ;;
        esac
}

vmenu_level_two() {
        vmenu "Go Deeper" "Back up" "Reload this one" "Exit"
        case $? in
                0) vmenu_level_three ;;
                1) vmenu_level_one ;;
                2) vmenu_level_two ;;
                3) exit 0 ;;
        esac
}

vmenu_level_three() {
        vmenu "Go Even Deeper" "Back up" "Exit"
        case $? in
                0) printf "\033[1000D\033[KToo deep!\n" ;;
                1) vmenu_level_two ;;
                2) exit 0 ;;
        esac
}

vmenu_level_one

So one of the biggest differences in this new menu is it doesn’t use clear, at all. It also cleanly removes the whole menu when you quit. I also added a trap in case the user CTRL+C’s it.

The other biggest difference is it also has fewer while loops and doesn’t rely on recursiveness to reexecute the function on every key press. Instead it just keeps going until the RETURN_ITEM value is valid.

And the final significant difference is that it uses return codes instead of an arbitrary variable ($s) that also needs to be global for it to work. This is really handy and if you ever wanted to make the case statement somewhere else, you could simply put the results of $? into another variable yourself.

I also decided that the horizontal menu needed revisions too, but the old code is almost the same as the vertical menu, and really not worth mentioning.

However, the new code for horizontal menus is significantly different.

#!/bin/bash

## hmenu
## USAGE:               hmenu [MENU_ITEMS]
hmenu() {

        declare USER_INPUT
        declare -i SELECTED_ITEM RETURN_ITEM TOTAL_ITEMS i

        SELECTED_ITEM=0
        RETURN_ITEM=-1
        TOTAL_ITEMS=$(( ${#@} - 1 ))

        ## Catch interupts and clear the menu
        trap '
                printf "\033[1000D\033[K\033[?25h"
                exit 1
        ' SIGINT

        ## Hide the cursor
        printf "\033[?25l"

        while (( RETURN_ITEM == -1 )); do
                ## Print the menu
                i=0
                for MENU_ITEM in "${@}"; do
                        (( i == SELECTED_ITEM )) && printf "\e[0;30m\e[47m%s\e[0m" "$MENU_ITEM" || printf "%s" "$MENU_ITEM"
                        (( i < TOTAL_ITEMS )) && printf " | "
                        (( i++ ))
                done

                read -r -s -n 1 USER_INPUT
                case $USER_INPUT in
                        'C') (( SELECTED_ITEM < TOTAL_ITEMS )) && (( SELECTED_ITEM++ )) ;;
                        'D') (( SELECTED_ITEM > 0 )) && (( SELECTED_ITEM-- )) ;;
                        '') RETURN_ITEM=$SELECTED_ITEM ;;
                esac

                printf "\033[1000D\033[K"

        done

        ## Unhide the cursor and return menu entry
        printf "\033[?25h"
        return $RETURN_ITEM

}

hmenu_level_one() {
        hmenu "Go Deep" "Exit"
        case $? in
                0) hmenu_level_two ;;
                1) exit 0 ;;
        esac
}

hmenu_level_two() {
        hmenu "Go Deeper" "Back up" "Reload this one" "Exit"
        case $? in
                0) hmenu_level_three ;;
                1) hmenu_level_one ;;
                2) hmenu_level_two ;;
                3) exit 0 ;;
        esac
}

hmenu_level_three() {
        hmenu "Go Even Deeper" "Back up" "Exit"
        case $? in
                0) printf "Too deep!\n" ;;
                1) hmenu_level_two ;;
                2) exit 0 ;;
        esac
}

hmenu_level_one

This works exactly like my new vertical menu, but is even more simplified because it doesn’t require working out how many lines there are.