Solved The Impossible Challenge: Interactive Menus in sh?

Can anyone refer me to examples of interactive selection menus written in sh? I realize it would be easier to use {Dialog, Perl, Python, Bash, C}, but I'm wondering what's possible using only sh and the standard utilities. So far, I have a function that cycles through a set of options using tab and arrows, with enter to select the current item:

sh:
$ ./menu.sh
Select option from heredoc (1/3): vanilla
You selected index 0 (vanilla)

Select option from string (2/3): chocolate
You selected index 1 (chocolate)
$

Here's the function:

sh:
#!/bin/sh

select_one() {
    # select a line from $1 with optional prompt $2
    lines=$(echo "$1" | awk '{gsub(/\\n/,"\n"); print}')
    selected=0

    # count lines to display in $1 and exit early
    linect=$(($(echo "$lines" | wc -l)))
    if [ "$linect" -eq 0 ]; then
        echo "0"
        return 1
    elif [ "$linect" -eq 1 ]; then
        echo "0"
        return 0
    fi

    # save tty settings
    tty_settings=$(stty -g)
    stty -icanon min 1 time 0 -echo cbreak

    # clear the line by printing spaces
    cols=$(($(tput cols) - 1))
    el=$(printf "%*s" "$cols" "")

    while true; do
        # clear the line and display current selection
        idx=$((selected + 1))
        line=$(echo "$lines" | sed -n "${idx}p")
        printf "%s%s%s%s(%s/%s): %s" "$(tput cr)" "$el" "$(tput cr)" "$2" "$idx" "$linect" "$line" > /dev/tty

        # read one byte of input
        byte=$(dd if="$(tty)" bs=1 count=1 2>/dev/null)
        char=$(printf "%d" "'$byte'")

        # look for tabs, the last byte of arrows, and enter
        if [ "$char" = 9 ] || [ "$char" = 66 ] || [ "$char" = 67 ]; then
            selected=$((selected + 1))
        elif [ "$char" = 90 ] || [ "$char" = 65 ] || [ "$char" = 68 ]; then
            selected=$((selected - 1))
        elif [ "$char" = 39 ]; then
            break
        fi

        # wrap selected index
        selected=$(((selected + linect) % linect))
    done

    # restore tty settings
    stty "$tty_settings"

    echo "$selected"
    return 0
}

lines=$(cat <<'EOF'
vanilla
chocolate
strawberry
EOF
)
idx=$(select_one "$lines" "Select option from heredoc ")
line=$(echo "$lines" | awk '{gsub(/\\n/,"\n"); print}' | sed -n "$((idx + 1))p")
echo
echo "You selected index $idx ($line)"

echo

lines="vanilla\nchocolate\nstrawberry"
idx=$(select_one "$lines" "Select option from string ")
line=$(echo "$lines" | awk '{gsub(/\\n/,"\n"); print}' | sed -n "$((idx + 1))p")
echo
echo "You selected index $idx ($line)"
 

Here's example of BSDDialog it's written on C and have API so you can create a better looking forms. It's used in bsdconfig and bsdinstall

For native sh look those examples for case/esac:

And а real world example of case/esac inside if/else statement:
 
Counter question: are interactive menus really necessary for that script?
Usually shell scripts are the most useful and versatile, if they can be used as filters - i.e. are invoked with all needed options/arguments and hence can be used to build more complex commands by just piping output from other commands into them and/or their output into the next script/tool/file...

richardtoohey2 that tutorial is proper bourne shell, i.e. sh. It is specifically *not* a tutorial for the typical 'bash littered with other linuxisms'.
 
Bourne Shell Tutorial
Second reply: bash

The bourne shell was a "de-facto standard" Unix shell and the baseline for the POSIX sh specification. Nowadays, POSIX sh (which basically extends bourne) is more relevant as it's the baseline for most "minimal" shells, including FreeBSD's /bin/sh.

This has nothing to do with GNU's bash ("Bourne again shell") except for the name reference. Bash provides a superset of bourne and POSIX shell language.
 
On the thread topic:

This code gives me shudders... reading input directly from the tty (without even checking whether stdin is a tty) with dd(1) (why?) and then hardcoding some expected codepoints? Seriously?

It's certainly possible to write any kind of TUI based on terminal capabilities in shell script. Shell script is a turing-complete programming language and shell tools interfacing with terminal capabilities are available. But being possible doesn't make it a good idea. Doing it correctly will be very hard, to the extent of being "virtually impossible".

There's a good reason tools like dialog(1) exist.
 
I'm wondering what's possible using only sh and the standard utilities
This doesn't answer to your question directly because it introduces an external tool, for personal use cases if I want interactive selection (or just to make the process simpler) I do like textproc/fzy.

But if for some reason a serial console is involved then I tend to avoid fzy (like any TUI or ncurses tools eg. neovim vim) because it introduces weird display making the screen unreadable, then in that case I do use "while loop + case".
 
Yes, I understand; sh is the wrong tool for the job and was never intended for this. I'm not trying to get something done; I'm trying to explore what's possible using only sh and a limited set of standard utils, no matter how ugly it gets. If I just wanted to get something done, I'd use a better language (like Elixir). I'm not a sh expert, which is why I'm asking.

This code gives me shudders
Me too.

directly from the tty (without even checking whether stdin is a tty)
Thanks, that's good feedback. I'll check for tty and fail early if not. This is the kind of help I need.

It uses dd because I couldn't figure out how to read only one raw byte with read, and I never figured out how to read multiple bytes in one operation, which would be ideal. I don't think sh really supports raw input.

The function writes to the tty directly to reserve stdout for the return value. It could write the result to a global or a file or redirect it to a fifo or use the exit code instead, but I like the caller being able to use res=$(func arg arg)

hardcoding some expected codepoints? Seriously?
Is there a portable abstraction I could use instead? I don't know much about terminals in general.

There are many other things wrong: the read and render should be separated so it doesn't redraw on each byte read; tput el didn't clear the entire line so I'm overwriting it with spaces instead; etc; etc; etc. It's a mess, but that's part of the fun.

I changed the thread title to clarify.
 
Okay, the experiment is complete. This version collects multiple bytes to properly match the sequences for Arrows, Tabs, and Enter. It fixes the problems in the previous version and includes a test loop to display the bytes as it collects them. It works for me on FreeBSD, Ubuntu, Cygwin, and MinGW using the default shells (sh, bash). It probably won't work for you.

sh:
#!/bin/sh

select_one() (
    # select a line from $1 with optional prompt $2
    # (or pass 0 args to enter a key capture test loop for debugging)

    # exit early if stdin is not a tty
    if ! [ -t 0 ]; then
        echo "0"; return 1
    fi

    # save tty settings
    tty_settings=$(stty -g)
    stty -icanon min 1 time 0 -echo cbreak

    # constants
    _TAB="09;"
    _ENTER="0a;"
    _RIGHT="1b;5b;43;"
    _DOWN="1b;5b;42;"
    _LEFT="1b;5b;44;"
    _UP="1b;5b;41;"

    # enter key capture test loop if argc is 0
    if [ "$#" -eq 0 ]; then
        echo "Collect keystrokes (Press any key; Enter to reset buffer; ^C to exit)" > /dev/tty
        bytes=
        while true; do
            # read one byte of input
            byte=$(dd if=/dev/tty bs=1 count=1 2>/dev/null | od -An -vtx1 | tr -d ' \n')
            bytes="$bytes$byte;"
            echo "$bytes" > /dev/tty
            if [ -z "$byte" ]; then
                # ^C arrives here
                stty "$tty_settings"; exit
            elif [ "$byte;" = "$_ENTER" ]; then
                echo "Reset buffer..." > /dev/tty
                bytes=
            fi
        done
    fi

    # count lines to display in $1 and exit early
    lines=$(echo "$1" | awk '{gsub(/\\n/,"\n"); print}')
    linect=$(($(echo "$lines" | wc -l)))
    if [ "$linect" -eq 0 ]; then
        echo "0"; stty "$tty_settings"; return 1
    elif [ "$linect" -eq 1 ]; then
        echo "0"; stty "$tty_settings"; return 0
    fi

    # clear the line by printing spaces
    cols=$(($(tput cols) - 1))
    el=$(printf "%*s" "$cols" "")

    # track selection
    selected=0
    previous=

    while true; do
        # redraw when selection changes
        if [ "$selected" != "$previous" ]; then
            # wrap selected index into range
            selected=$(((selected + linect) % linect))

            # clear the line and display selection
            idx=$((selected + 1))
            line=$(echo "$lines" | sed -n "${idx}p")
            printf "%s%s%s%s(%s/%s): %s" "$(tput cr)" "$el" "$(tput cr)" "$2" "$idx" "$linect" "$line" > /dev/tty

            # save previous selection
            previous="$selected"
        fi

        finished=

        right="$_RIGHT"
        down="$_DOWN"
        left="$_LEFT"
        up="$_UP"

        while true; do
            # read one byte of input
            byte=$(dd if=/dev/tty bs=1 count=1 2>/dev/null | od -An -vtx1 | tr -d ' \n')

            if [ -z "$byte" ]; then
                # ^C arrives here
                stty "$tty_settings"; exit
            elif [ "$byte;" = "$_TAB" ]; then
                selected=$((selected + 1)); break
            elif [ "$byte;" = "$_ENTER" ]; then
                finished=1; break
            else
                right=$(echo "$right" | sed "s/^$byte;//")
                down=$(echo "$down" | sed "s/^$byte;//")
                if [ -z "$down" ] || [ -z "$right" ]; then
                    selected=$((selected + 1)); break
                fi
                left=$(echo "$left" | sed "s/^$byte;//")
                up=$(echo "$up" | sed "s/^$byte;//")
                if [ -z "$up" ] || [ -z "$left" ]; then
                    selected=$((selected - 1)); break
                fi
            fi
        done

        if [ -n "$finished" ]; then
            break
        fi
    done

    echo "$selected"; stty "$tty_settings"; return 0
)

# test heredoc
lines=$(cat <<'EOF'
vanilla
chocolate
strawberry
EOF
)
idx=$(select_one "$lines" "Select option from heredoc ")
line=$(echo "$lines" | awk '{gsub(/\\n/,"\n"); print}' | sed -n "$((idx + 1))p")
echo
echo "You selected index $idx ($line)"
echo

# test string
lines="vanilla\nchocolate\nstrawberry"
idx=$(select_one "$lines" "Select option from string ")
line=$(echo "$lines" | awk '{gsub(/\\n/,"\n"); print}' | sed -n "$((idx + 1))p")
echo
echo "You selected index $idx ($line)"
echo

# test key capture
select_one
 
Back
Top