bash: choose default from case when enter is pressed in a "select" prompt
Asked Answered
R

5

19

I'm prompting questions in a bash script like this:

optionsAudits=("Yep" "Nope")
    echo "Include audits?"
    select opt in "${optionsAudits[@]}"; do
        case $REPLY in
            1) includeAudits=true; break ;;
            2) includeAudits=false; break ;;
            "\n") echo "You pressed enter"; break ;; # <--- doesn't work
            *) echo "What's that?"; exit;;
        esac
    done

How can I select a default option when enter is pressed? The "\n" case does not catch the enter key.

Restrained answered 14/3, 2017 at 14:51 Comment(1)
An aside to future readers: The body of the select statement is unusual, because it examines the raw user input - $REPLY - rather than the select statement's output variable, $opt (which contains the chosen option's text, not the index).Bereave
B
10

To complement Aserre's helpful answer, which explains the problem with your code and offers an effective workaround, with background information and a generic, reusable custom select implementation that allows empty input:


Background information

To spell it out explicitly: select itself ignores empty input (just pressing Enter) and simply re-prompts - user code doesn't even get to run in response.

In fact, select uses the empty string to signal to user code that an invalid choice was typed.
That is, if the output variable - $opt, int this case - is empty inside the select statement, the implication is that an invalid choice index was typed by the user.

The output variable receives the chosen option's text - either 'Yep' or 'Nope' in this case - not the index typed by the user.

(By contrast, your code examines $REPLY instead of the output variable, which contains exactly what the user typed, which is the index in case of a valid choice, but may contain extra leading and trailing whitespace).

Note that in the event that you didn't want to allow empty input, you could simply indicate to the user in the prompt text that ^C (Ctrl+C) can be used to abort the prompt.


Generic custom select function that also accepts empty input

The following function closely emulates what select does while also allowing empty input (just pressing Enter). Note that the function intercepts invalid input, prints a warning, and re-prompts:

# Custom `select` implementation that allows *empty* input.
# Pass the choices as individual arguments.
# Output is the chosen item, or "", if the user just pressed ENTER.
# Example:
#    choice=$(selectWithDefault 'one' 'two' 'three')
selectWithDefault() {

  local item i=0 numItems=$# 

  # Print numbered menu items, based on the arguments passed.
  for item; do         # Short for: for item in "$@"; do
    printf '%s\n' "$((++i))) $item"
  done >&2 # Print to stderr, as `select` does.

  # Prompt the user for the index of the desired item.
  while :; do
    printf %s "${PS3-#? }" >&2 # Print the prompt string to stderr, as `select` does.
    read -r index
    # Make sure that the input is either empty or that a valid index was entered.
    [[ -z $index ]] && break  # empty input
    (( index >= 1 && index <= numItems )) 2>/dev/null || { echo "Invalid selection. Please try again." >&2; continue; }
    break
  done

  # Output the selected item, if any.
  [[ -n $index ]] && printf %s "${@: index:1}"

}

You could call it as follows:

# Print the prompt message and call the custom select function.
echo "Include audits (default is 'Nope')?"
optionsAudits=('Yep' 'Nope')
opt=$(selectWithDefault "${optionsAudits[@]}")

# Process the selected item.
case $opt in
  'Yep') includeAudits=true; ;;
  ''|'Nope') includeAudits=false; ;; # $opt is '' if the user just pressed ENTER
esac

Alternative implementation that lets the function itself handle the default logic:Thanks, RL-S

This implementation differs from the above in two respects:

  • It allows you to designate a default among the choices, by prefixing it with !, with the first choice becoming the default otherwise. The default choice is printed with a trailing [default] (and without the leading !). The function then translates empty input into the default choice.

  • The selected choice is returned as a 1-based index rather than the text. In other words: you can assume that a valid choice was made when the function returns, and that choice is indicated by its position among the choices given.

# Custom `select` implementation with support for a default choice
# that the user can make by pressing just ENTER.
# Pass the choices as individual arguments; e.g. `selectWithDefault Yes No``
# The first choice is the default choice, unless you designate
# one of the choices as the default with a leading '!', e.g.
# `selectWithDefault Yes !No`
# The default choice is printed with a trailing ' [default]'
# Output is the 1-based *index* of the selected choice, as shown
# in the UI.
# Example:
#    choice=$(selectWithDefault 'Yes|No|!Abort' )
selectWithDefault() {

  local item i=0 numItems=$# defaultIndex=0

  # Print numbered menu items, based on the arguments passed.
  for item; do         # Short for: for item in "$@"; do
    [[ "$item" == !* ]] && defaultIndex=$(( $i + 1)) && item="${item:1} [default]"
    printf '%s\n' "$((++i))) $item"
  done >&2 # Print to stderr, as `select` does.

  # Prompt the user for the index of the desired item.
  while :; do
    printf %s "${PS3-#? }" >&2 # Print the prompt string to stderr, as `select` does.
    read -r index
    # Make sure that the input is either empty or that a valid index was entered.
    [[ -z $index ]] && index=$defaultIndex && break  # empty input == default choice  
    (( index >= 1 && index <= numItems )) 2>/dev/null || { echo "Invalid selection. Please try again." >&2; continue; }
    break
  done

  # Output the selected *index* (1-based).
  printf $index

}

Sample call:

# Print the prompt message and call the custom select function,
# designating 'Abort' as the default choice.
echo "Include audits?"
ndx=$(selectWithDefault 'Yes' 'No', '!Abort')

case $ndx in
  1) echo "include";;
  2) echo "don't include";;
  3) echo "abort";;
esac

Optional reading: A more idiomatic version of your original code

Note: This code doesn't solve the problem, but shows more idiomatic use of the select statement; unlike the original code, this code re-displays the prompt if an invalid choice was made:

optionsAudits=("Yep" "Nope")
echo "Include audits (^C to abort)?"
select opt in "${optionsAudits[@]}"; do
    # $opt being empty signals invalid input.
    [[ -n $opt ]] || { echo "What's that? Please try again." >&2; continue; }
    break # a valid choice was made, exit the prompt.
done

case $opt in  # $opt now contains the *text* of the chosen option
  'Yep')
     includeAudits=true
     ;;
  'Nope') # could be just `*` in this case.
     includeAudits=false
     ;;
esac

Note:

  • The case statement was moved out of the select statement, because the latter now guarantees that only valid inputs can be made.

  • The case statement tests the output variable ($opt) rather than the raw user input ($REPLY), and that variable contains the choice text, not its index.

Bereave answered 14/3, 2017 at 15:49 Comment(8)
I hardly know where to start :) Could you explain the last line that is returning the selected value from the function?Restrained
[[ -n $index ]] tests the value of variable $index for "non-emptiness" (-n); if it is nonempty (i.e., has a value), then the RHS of the && control operator is evaluated. printf %s "${@: index:1}" prints the positional parameter (argument) with index $index: $@ is the array of all positional parameters (arguments passed to the function, i.e., the choices). Slicing syntax ${<array-var>[@] <index>:<length>} extracts the element of interest. Note that special array $@ - unlike regular Bash arrays - is 1-based.Bereave
Could I pass the select options to the function as an array so that I could also pass to it which option is the default?Restrained
Without trickery you can't pass an array as such to a function. Passing an array such as "${optionsAudits[@]}" (see my update) to the function actually passes its elements as individual arguments (positional parameters). Adding a mechanism to designate the default element would complicate the function, and I'm not sure if it's worth doing. If you do want to tackle it: either designate the 1st or last positional parameter as the default value, or add a unique suffix to the default element to mark it as such (which the function would need to parse and remove).Bereave
Yes I see, seems like a difficult issue in bash. One more question: Why doesn't "echo" work inside this function? Why the need for the ">&2" in order to get things printed to stdout/stderr?Restrained
btw, ^C will abort the entire script. The user can use ^D (or more generically, whatever <EOF> is on their terminal) to cause select to finish and go to the next line of the script with the output variable still empty.Capital
If it's always the first option that's the default, how about having the function itself mark it? # mark default option with this string local defstr="[default] " # Print numbered menu items, based on the arguments passed. for (( i=1; i<=$#; ++i )); do printf '%i) %s%s\n' $i "$defstr" "${@:$i:1}" defstr="" done >&2Yevetteyew
@RL-S, moving the logic of translating empty input into a default choice into the function itself is a great idea, but I think that requires a mechanism that allows you to indicate which choice should be the default, as it won't always be the first (in the original use case it is the second one). Please see my update: I've added an alternative implementation with such a mechanism.Bereave
L
6

Your problem is due to the fact that select will ignore empty input. For your case, read will be more suitable, but you will lose the utility select provides for automated menu creation.

To emulate the behaviour of select, you could do something like that :

#!/bin/bash
optionsAudits=("Yep" "Nope")
while : #infinite loop. be sure to break out of it when a valid choice is made
do
    i=1
    echo "Include Audits?"
    #we recreate manually the menu here
    for o in  "${optionsAudits[@]}"; do
        echo "$i) $o"
        let i++
    done

    read reply
    #the user can either type the option number or copy the option text
    case $reply in
        "1"|"${optionsAudits[0]}") includeAudits=true; break;;
        "2"|"${optionsAudits[1]}") includeAudits=false; break;;
        "") echo "empty"; break;;
        *) echo "Invalid choice. Please choose an existing option number.";;
    esac
done
echo "choice : \"$reply\""
Loreenlorelei answered 14/3, 2017 at 15:27 Comment(1)
Instead of the cycle "for" you can use as well this workabout: "echo foo | select foo in "${optionsAudits[@]}"; do break; done" It produces more column menu, if you like...Disorderly
O
3

Updated answer:

echo "Include audits? 1) Yep, 2) Nope"
read ans
case $ans in
    Yep|1  )  echo "yes"; includeAudits=true; v=1 ;;
    Nope|2 )  echo "no"; includeAudits=false; v=2 ;;
    ""     )  echo "default - yes"; includeAudits=true; v=1 ;;
    *      )  echo "Whats that?"; exit ;;
esac

This accepts either "Yep" or "1" or "enter" to select yes, and accepts "Nope" or "2" for no, and throws away anything else. It also sets v to 1 or 2 depending on whether the user wanted yes or no.

Ormiston answered 14/3, 2017 at 15:17 Comment(7)
You haven't shown how to address a default value for ans.Accipiter
@chepner, eh? *) handles the empty-string through the *) case, which is exactly what the OP asked for. Though it's not fully equivalent to select, since it doesn't write out a prompt or map numeric to string results, so there's certainly room for improvement/extension.Cavicorn
* handles everything other than Yep or Nope, including invalid inputs. This also doesn't actually assign a default value to ans if the empty string is, indeed, the input.Accipiter
There's an exit after the *) which means that $ans doesn't get used after that... (which again, is what the OP was after...)Ormiston
Added prompt as per @CharlesDuffy's comment. I'm not sure OP was after the enumerated values, so I didn't add that.Ormiston
Sorry my question was not so clear. I was looking for the same functionality as I posted (being able to choose "1" or "2") with an additional option to press enter, in which case I would myself select option 1 or 2 for the user.Restrained
Ahh, yes, that does change things. You already have an answer, but I'll update mine anyways, in case it helps as well.Ormiston
L
0

This will do what you are asking for.

options=("option 1" "option 2");
while :
do
    echo "Select your option:"
    i=1;
    for opt in  "${options[@]}"; do
        echo "$i) $opt";
        let i++;
    done

    read reply
    case $reply in
        "1"|"${options[0]}"|"")
          doSomething1();
          break;;
        "2"|"${options[1]}")
          doSomething2();
          break;;
        *)
          echo "Invalid choice. Please choose 1 or 2";;
    esac
done
Linzer answered 30/5, 2020 at 15:4 Comment(1)
Your answer has a syntax error in it: line 14: syntax error near unexpected token `;' line 14: ` doSomething1();' If you remove the (); from lines 14 and 17, it works.Begum
D
0

Assuming that your default option is Yep:

#!/bin/bash
optionsAudits=("Yep" "Nope")
while : #infinite loop. be sure to break out of it when a valid choice is made
do
    i=1
    echo "Include Audits?: "
    #we recreate manually the menu here
    for o in  "${optionsAudits[@]}"; do
        echo "  $i) $o"
        let i++
    done

    read -rp "Audit option: " -iYep
    #the user can either type the option number or copy the option text
    case $REPLY in
        "1"|"${optionsAudits[0]}") includeAudits=true; break;;
        "2"|"${optionsAudits[1]}") includeAudits=false; break;;
        "") includeAudits=true; break;;
        *) echo "Invalid choice. Please choose an existing option number.";;
    esac
done
echo "choice : \"$REPLY\""
echo "includeAudits : \"$includeAudits\""

Noticed the line:

    read -rp "Audit option: " -eiYep

Also I pulled up $reply to $REPLY so that the case decision works better.

The output would now look like this upon hitting ENTER:

Include Audits?: 
  1) Yep
  2) Nope
Audit option: Yep
choice : ""
includeAudits : "true"
# 

As an enhancement over select, read -eiYep will supply Yep default value into the input buffer up front.

Only downside of fronting the default value is that one would have to press backspace a few times to enter in their own answer.

Dagger answered 21/12, 2021 at 22:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.