Tuesday, March 30, 2021

Building a Menu in Bash, a Linux shell

So, I was looking to create a menu from a list, either in a text file or of files.

 

To do this, especially being relatively green to bash as a scripting tool. It just wasn't part of my life or career, but times are changing and I needed to dig in and learn bash so I'm exploring this. 

So this took me some time to put together, and I certainly do not know your level of experience but I hope to introduce you to at awesomeness of this little script.

To begin is, as with all bash scripts you start with the prerequisite:

#!/bin/bash


That Linux where to find the processor for the script and is important but trivial in this story. The next thing to do is to decide where your menu items are going to come from:

#array menu from text file:
    mapfile menu <./menu.lst

#array menu from command output
    #mapfile menu < <(ls -1)

You may chose from any of the above lines for mapfile, but only one. You can derive more sources of lists as your needs demand, but for now you can chose between a text file, menu.lst, and the files in the current folder, ls. The -1 (one) is a switch on ls that returns the file list in one column.

This would work fine except for one thing, the occasion that the list of items includes items with spaces, such as item 10 in the list, "moose droppings." To handle that before the mapfile command is used, we use:

IFS=$'\n'

This nifty little directive tells bash, and mapfile, that the delimiter is explicitly a newline character and to use that over a space which is the default.

Mapfile takes the information, piped from a file using the < or from a command using < <( x ) and maps it into an array. This is the substance of your list but if you need to add an item, before or after outside of this you may choose to use either of the following:

menu=("exit" "${menu[@]}")

menu+=("exit")

For my purposes I chose to use the first item which will prepend "menu" onto the array making item zero (0) a consistent choice that can be found and handled easily. The rest of the script is based on this foundation.

Displaying the menu:

  echo -e "Display Menu$\n"
  i=0
  for m in ${menu[@]}
  do
      fi="$(printf "%2d" $i)"
    
      echo -e "$fi) $m"
      i=$(( $i + 1 ))
  done |pr -t --columns=3 --width=120

Parts of this will seem obvious, echo... Display Menu, for example. That's good but let's break this down. We set the variable i to zero as the starting point of the count for the menu items. This will match the array content and we'll use this to create the item numbers.

The for..loop picks up a new variable, m, which is for the menu item, and the do..done is the structure enclosing the for..loop. The variable fi is a cosmetic twist that helps use build a consistent and pleasant appearance. Please try to keep your lists to less than 99 items as formatting seems to go haywire if we use 2 padded spaces rather than 2. Breaking that fi line down is as follows:

The printf command has formatting skills, and we're using %2d to represent a 2 character pad of a decimal (d) number, based on the value of i ($i). The output should give a consistent string length helping keep things neat as shown in the first screenshot.

The next step is to echo (show) the results of fi ($fi) along with the menu item, m ($m). We follow this up bu incrementing i, the count, by 1 (one):

i=$(( $i + 1 ))

The loop is done, but we want to capture all that hard work and out put it as columns. We use the pipe (|) to direct the output of the loop to the command pr. The parameters on pr parse and re-arrange the list to build out nice neat space-saving columns. As you can see we're aiming for a width of 120 characters, 3 columns, and tabulated output. You may want to use the Linux man page for pr to gain an idea of what this utility can do. The -t parameter removes headers from the output.

This will have generated the menu as shown above (colours are extra credit work) and now we move on the handling the input from the user. 

  m_length=$(( ${#menu[@]} - 1 ))
 
  read -t 1 -n 10000 discard
  echo -e ""
  read -p "Select from above (zero to exit): " opt

Of the few lines above, the first three are maintenance. The value of m_length is the size of the array, the number of options, which we'll need later. The first read line clears any buffered input to prevent processing buffered input from affecting an important choice. The echo is for aesthetics, a line for spacing.

The last read line will prompt for the user's choice based on the displayed list the response is loaded into the variable opt.

We're in the final stretch, processing the response stored in the variable $opt. In the following segment we determine if the option is valid, that the response is numeric and within the limits of the list. After that we handle zero (0), which is my exit option, and leave the default action to processing the menu item that was selected.

  re='^[0-9]+$'
  if [[ $opt =~ $re ]] && [[ $opt -lt $m_length ]] ; then
      case $opt in
        0)
            echo -e "Aborting..."
            exit;;
        *)
            next=1
            echo "Chosen: $opt = ${menu[$opt]}";;
      esac
  else
    echo -e "Invalid Response! ($opt)"
    sleep 1
  fi

So, re is defining the regEx to ensure the value is numeric, an integer, and is used in the if..else..fi structure that follows. In the if line we validate that the response is both an integer, using re, and is in the range of the menu items, 0~m_length.

The case..esac statement processes the in-scope results allowing use to process the zero, 0), as an exit and break down the remaining items, *), by name as needed. We simply print them in this case using the echo "Chosen: ..." line.

Please note the double semi-colons, ;;, that signify the end of the case option.

Now, we've stepped through this in a linear fashion, but this is best used with a loop and productive invalid response handling. Below is the complete script:

#!/bin/bash

IFS=$'\n'

#array menu from text file:
    #mapfile menu <./columnular2.lst

#array menu from command output
    #mapfile menu < <(ls | awk '{print $1}')
    mapfile menu < <(ls -1)

menu=("exit" "${menu[@]}")
#menu+=("exit")

next=0

until [ $next -eq 1 ]
do
  clear

  echo -e "Display Menu\n"
  i=0
  for m in ${menu[@]}
  do
      fi="$(printf "%2d" $i)"
    
      echo -e "$fi) $m"
      i=$(( $i + 1 ))
  done |pr -t --columns=3 --width=120

  m_length=$(( ${#menu[@]} - 1 ))
 
  read -t 1 -n 10000 discard
  echo -e ""
  read -p "Select from above (zero to exit): " opt

  re='^[0-9]+$'
  if [[ $opt =~ $re ]] && [[ $opt -lt $m_length ]] ; then
      case $opt in
        0)
            echo -e "Aborting..."
            exit;;
        *)
            next=1
            echo "Chosen: $opt = ${menu[$opt]}";;
      esac
  else
    echo -e "Invalid Response! ($opt)"
    sleep 1
  fi
done

Enjoy. I hope this helps someone out.


Dig for it! Google is your best friend.

I've never been an awesome coder, I'm more purpose-driven, I learn what I need to to make something I have in mind work. I'm also older and my retention is fading a bit so remembering the how is sometimes fogged by the loss of details.

Google is your best friend.

I'm not going to delve into the ethics and reputation of Alphabet, it's not my concern, but that search engine can be a life saver. That and notes. Electronic notes are some of the hardest to master if you're not one to master routine and protocol.

Why is Google your best friend?

It used to be that you'd need to read and scan a book, or several for technical answers. Now we have blogs like this and resources like Stack Overflow where people can share their knowledge and the people looking for information can find it because of a very capable search engine.

Don't be ashamed of not knowing, it's knowing where to find the answers more than having them all in your head. Though, retention is not overrated. Learn, explore, have a technical adventure but when you're coming up dry...


Google it.

There is no individual ownership when you are part of a team, it's the sum of the parts that makes you the RESILIENT team you need to be.