leetbacoon
leetbacoon

Reputation: 1249

How do I chain multiple user prompts together with the ability to go back a prompt?

I am wondering how I can make a bash script that has multiple menus in it.

For example, here's what the user would see upon running it:

Type the number of choosing:
1-play
2-load
3-exit

1

What is your name:

::prev::

Type the number of choosing:
1-play
2-load
3-exit

1

What is your name:

Brad

Where are you from, Brad?

Pennsylvania

What is your favourite colour?
1-blue
2-red
3-green
4-grey
5-magenta

,sdfhljalk:J;

What is your favourite colour?
1-blue
2-red
3-green
4-grey
5-magenta

2

What is your favourite toy?

train

What would you like on your sandwich?

::prev::

What is your favourite toy?

~`!@#$%^& * ()_+=-{}[]|\"':;?/>.<,

What is your favourite toy?

::exit::

Exiting....

I apologize for it being long, I just want to cover all bases for the new game I'm going to be making. I want this to be the question to end all questions.

I want to be able to type ::prev:: wherever I am and have it go back to the previous question, and I'd like ::exit:: to exit the script wherever it is. Also, I'd like unrecognized input during questions with numbered responses to just reload the question without continuing, and for input containing characters that may cause a script break (something like :;!@# ...) to reload the question instead of breaking.

Any help is greatly appreciated!

By the way, I'm using OS X Yosemite

Upvotes: 2

Views: 851

Answers (1)

Adaline Simonian
Adaline Simonian

Reputation: 4818

First thing to do in this situation is to try and think of how, generally, you could implement something like this. Probably the biggest addition to complexity is using ::prev:: to go back a question. This means we need to represent the application state in some way such that we can move forward or backward.

Luckily, this is pretty simple: it's basically just an implementation of a stack that we need. Some visuals:

                                                                 ...
          <--pop--             <--pop-- Location prompt <--pop-- ...
                   Name prompt          Name prompt              ...
Main menu --push-> Main menu   --push-> Main menu       --push-> ...

This also means each individual piece of the program needs to be self-contained. We can easily do this in shell scripting with functions.

So we need several pieces:

  • Function which displays a menu and allows the user to choose a value.
  • Function which displays a text prompt and allows the user to choose a value.
  • Function which manages a stack that represents the state of the program.
  • Individual functions for each piece of the program.

Let's first write our menu prompt function. This part is pretty easy. Bash will do most of the work using the select loop, which prints a menu for us. We'll just wrap it so that we can handle custom logic, like expecting ::exit:: or ::prev:: and some pretty-printing.

function show_menu {
  echo "$1" # Print the prompt
  PS3='> ' # Set prompt string 3 to '> '
  select selection in "${menu_items[@]}" # Print menu using the menu_items array
  do
    if [[ "$REPLY" =~ ^(::exit::|::prev::)$ ]]; then
      # If the user types ::exit:: or ::prev::, exit the select loop
      # and return 1 from the function, with $selection containing
      # the command the user entered.
      selection="$REPLY"
      return 1
    fi
    # $selection will be blank if the user did not choose a valid menu item.
    if [ -z "$selection" ]; then
      # Display error message if $selection is blank
      echo 'Invalid input. Please choose an option from the menu.'
    else
      # Otherwise, return a success return code.
      return 0
    fi
  done
}

We can now use this function like so:

menu_items=('Item 1' 'Item 2' 'Item 3')
if ! show_menu 'Please choose an item from the menu below.'; then
  echo "You entered the command $selection."
fi

echo "You chose $selection."

Great! Now on to the next item on the agenda, writing the code that accepts text input from the user.

# Prompt for a required text value.
function prompt_req {
  # do...while
  while : ; do
    # Print the prompt on one line, then '> ' on the next.
    echo "$1"
    printf '> '
    read -r selection # Read user input into $selection
    if [ -z "$selection" ]; then
      # Show error if $selection is empty.
      echo 'A value is required.'
      continue
    elif [[ "$selection" =~ ^(::exit::|::prev::)$ ]]; then
      # Return non-success return code if ::exit:: or ::prev:: were entered.
      return 1
    elif [[ "$selection" =~ [^a-zA-Z0-9\'\ ] ]]; then
      # Make sure input only contains a whitelist of allowed characters.
      # If it has other characters, print an error and retry.
      echo "Invalid characters in input. Allowed characters are a-z, A-Z, 0-9, ', and spaces."
      continue
    fi
    # This break statement only runs if no issues were found with the input.
    # Exits the while loop and the function returns a success return code.
    break
  done
}

Great. This function works similarly to the first:

if ! prompt_req 'Please enter a value.'; then
  echo "You entered the command $selection."
fi

echo "You entered '$selection'."

Now that we have user input handled, we need to handle the program flow with our stack-managing function. This is fairly easy to implement in bash using an array.

When a part of the program runs and completes, it will ask the flow manager to run the next function. The flow manager will push the name of the next function onto stack, or rather, add it to the end of the array, and then run it. If ::prev:: is entered, it will pop the last function's name off of the stack, or remove the last element of the array, and then run the function before it.

Less talk, more code:

# Program flow manager
function run_funcs {
  # Define our "stack" with its initial value being the function name
  # passed directly to run_funcs
  funcs=("$1")
  # do...while
  while : ; do
    # Reset next_func
    next_func=''
    # Call the last function name in funcs.
    if "${funcs[${#funcs[@]}-1]}"; then
      # If the function returned 0, then no :: command was run by the user.
      if [ -z "$next_func" ]; then
        # If the function didn't set the next function to run, exit the program.
        exit 0
      else
        # Otherwise, add the next function to run to the funcs array. (push)
        funcs+=("$next_func")
      fi
    else
      # If the function returned a value other than 0, a command was run.
      # The exact command run will be in $selection
      if [ "$selection" == "::prev::" ]; then
        if [ ${#funcs[@]} -lt 2 ]; then
          # If there is only 1 function in funcs, then we can't go back
          # because there's no other function to call.
          echo 'There is no previous screen to return to.'
        else
          # Remove the last function from funcs. (pop)
          unset funcs[${#funcs[@]}-1]
        fi
      else
        # If $selection isn't ::prev::, then it's ::exit::
        exit 0
      fi
    fi
    # Add a line break between function calls to keep the output clean.
    echo
  done
}

Our run_funcs function expects:

  • to be called with the name of the first function to run, and
  • that each function that runs will output the name of the next function to run to next_func if execution of the program must proceed.

Alright. That should be pretty simple to work with. Let's actually write the program now:

function main_menu {
  menu_items=('Play' 'Load' 'Exit')
  if ! show_menu 'Please choose from the menu below.'; then
    return 1
  fi

  if [ "$selection" == 'Exit' ]; then
    exit 0
  fi

  if [ "$selection" == 'Load' ]; then
    # Logic to load game state
    echo 'Game loaded.'
  fi

  next_func='prompt_name'
}

function prompt_name {
  if ! prompt_req 'What is your name?'; then
    return 1
  fi
  name="$selection"

  next_func='prompt_location'
}

function prompt_location {
  if ! prompt_req "Where are you from, $name?"; then
    return 1
  fi
  location="$selection"

  next_func='prompt_colour'
}

function prompt_colour {
  menu_items=('Blue' 'Red' 'Green' 'Grey' 'Magenta')
  if ! show_menu 'What is your favourite colour?'; then
    return 1
  fi
  colour="$selection"

  next_func='prompt_toy'
}

function prompt_toy {
  if ! prompt_req 'What is your favourite toy?'; then
    return 1
  fi
  toy="$selection"

  next_func='print_player_info'
}

function print_player_info {
  echo "Your name is $name."
  echo "You are from $location."
  echo "Your favourite colour is $colour."
  echo "Your favourite toy is $toy."

  # next_func is not set, so the program will exit after running this function.
}

echo 'My Bash Game'
echo
# Start the program, with main_menu as an entry point.
run_funcs main_menu

Everything is in order now. Let's try out our program!

$ ./bash_game.sh
My Bash Game

Please choose from the menu below.
1) Play
2) Load
3) Exit
> ::prev::
There is no previous screen to return to.

Please choose from the menu below.
1) Play
2) Load
3) Exit
> 2
Game loaded.

What is your name?
> Stephanie

Where are you from, Stephanie?
> ::prev::

What is your name?
> Samantha

Where are you from, Samantha?
> Dubl*n
Invalid characters in input. Allowed characters are a-z, A-Z, 0-9, ', and spaces.
Where are you from, Samantha?
> Dublin

What is your favourite colour?
1) Blue
2) Red
3) Green
4) Grey
5) Magenta
> Aquamarine
Invalid input. Please choose an option from the menu.
> 8
Invalid input. Please choose an option from the menu.
> 1

What is your favourite toy?
> Teddie bear

Your name is Samantha.
You are from Dublin.
Your favourite colour is Blue.
Your favourite toy is Teddie bear.

And there you have it.

Upvotes: 2

Related Questions