#!/bin/bash # prompt - interactive prompt with default value and placeholder # # Usage: # . prompt [prompt] [default] [placeholder] # . prompt -h # # Must be sourced, not executed. Prompts the user for input and stores the # result (or default, if user presses Enter without typing) in the named # variable in the caller's shell. If placeholder is provided, it is shown in # gray and disappears on the first keypress; any "%DEFAULT%" inside the # placeholder is replaced with the default value. If no placeholder is given, # the default value is shown as the placeholder # Reject executed invocations EXCEPT for --help -- a stranger running # `prompt --help` (natural) should see help, not a rejection # $0 is /path/to/bash or -bash when sourced by bash; ZSH_EVAL_CONTEXT contains # "file" when sourced by zsh; either indicates a sourced context __prompt__basename="${0##*'/'}" if { [ "$BASH_VERSION" ] && [ "${__prompt__basename#'-'}" != "bash" ]; } || { [ "$ZSH_VERSION" ] && [ "${ZSH_EVAL_CONTEXT#*"file"}" = "$ZSH_EVAL_CONTEXT" ]; }; then case "$1" in -h|--help) : ;; # fall through to __prompt__main *) __prompt__name="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) __prompt__name="" ;; esac case "$__prompt__name" in ""|bash|sh|zsh|dash) __prompt__name="prompt" ;; esac echo "[ERR][$__prompt__name] Must be sourced, not executed. Run \`$__prompt__name -h\` for usage" >&2 unset __prompt__basename __prompt__name exit 2 ;; esac fi unset __prompt__basename __prompt__main() { local __prompt__name; __prompt__name="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) __prompt__name="" ;; esac case "$__prompt__name" in ""|bash|sh|zsh|dash) __prompt__name="prompt" ;; esac __prompt__show_help() { local s; [ -t 1 ] && s=$'\033[4m' || s="" local r; [ -t 1 ] && r=$'\033[24m' || r="" echo "NAME" echo " $__prompt__name - interactive prompt with default value and placeholder" echo "SYNOPSIS" echo " . $__prompt__name ${s}variable${r} [${s}prompt${r}] [${s}default${r}] [${s}placeholder${r}]" echo " . $__prompt__name -h" echo "DESCRIPTION" echo " Prompts the user for input with an optional gray placeholder that" echo " disappears on the first keypress. Stores the result (or default)" echo " in the named ${s}variable${r}. Must be sourced (not executed) because" echo " prompt sets a variable in your shell; an executed script runs in a" echo " subprocess that dies immediately, losing the assignment." echo " Any \"%DEFAULT%\" in ${s}placeholder${r} is replaced with ${s}default${r}." echo " When stdin is not a terminal (e.g. piped input), the placeholder" echo " rendering is skipped and a plain line read is performed; defaults" echo " still apply." echo "OPTIONS" echo " ${s}variable${r} Shell variable name to store the result in" echo " ${s}prompt${r} Text to display before input (e.g. \"Name: \")" echo " ${s}default${r} Value used when the user presses Enter without typing" echo " ${s}placeholder${r} Gray hint text (defaults to ${s}default${r} if omitted)" echo " -h, --help Show this help message" echo "EXIT STATUS" echo " 0 Success" echo " 2 Usage error (script was executed rather than sourced)" echo "DEPENDENCIES" echo " stty" } # __prompt__saved_stty is captured later, after we confirm we're on a TTY # The restore runs from the RETURN trap so every exit path -- normal, # signal, or error -- gets the terminal back, with no subprocess races in # the signal handler local __prompt__saved_stty="" __prompt__restore() { [ "$__prompt__saved_stty" ] && stty "$__prompt__saved_stty" 2>/dev/null } __prompt__unset() { unset -f __prompt__unset __prompt__show_help __prompt__restore } trap '__prompt__restore; __prompt__unset || echo "'"$__prompt__name"' trap failed!" >&2; trap - RETURN' RETURN case "$1" in -h|--help) __prompt__show_help; return 0 ;; esac # Called with no args -- show help (useful reminder; no args can't mean anything) if [ $# -eq 0 ]; then __prompt__show_help return 0 fi local __prompt__varname="$1" local __prompt__prompt_text="$2" local __prompt__default="$3" local __prompt__placeholder # If the 4th arg is given, use it (substituting %DEFAULT%); otherwise fall back to default value if [ "$4" ]; then __prompt__placeholder="${4//"%DEFAULT%"/"$__prompt__default"}" else __prompt__placeholder="$__prompt__default" fi # Non-TTY fallback: stdin isn't a terminal, so raw-mode rendering is meaningless # Print the prompt (no placeholder) and do a plain line read. Defaults still apply if [ ! -t 0 ]; then printf "%s" "$__prompt__prompt_text" local __prompt__input IFS= read -r __prompt__input # shellcheck disable=SC2034 # "foo appears unused. Verify it or export it." -- used via eval below __prompt__input="${__prompt__input:-"$__prompt__default"}" eval "$__prompt__varname"'="$__prompt__input"' return 0 fi # TTY path: raw-mode loop with inline-ghost placeholder # Save stty, then IGNORE SIGINT/SIGTERM entirely (trap ''). Reasoning: if we # let bash's outer interactive shell see the signal, readline's signal-cleanup # path runs *after* our RETURN trap's stty restore and clobbers it with its # own cached tty state, leaving the terminal in raw mode. Ignoring the signal # here keeps the shell out of the picture. The RETURN trap still handles the # normal path restore on every exit. Trade-off: Ctrl-C during prompt is a # no-op; user must press Enter (accepting default) to dismiss __prompt__saved_stty="$(stty -g 2>/dev/null)" trap '' INT trap '' TERM stty -echo -icanon min 1 time 0 2>/dev/null # Render prompt + gray placeholder, then move cursor back to the end of the prompt text printf "%s\033[90m%s\033[0m" "$__prompt__prompt_text" "$__prompt__placeholder" [ ${#__prompt__placeholder} -ne 0 ] && printf '\033[%dD' "${#__prompt__placeholder}" local __prompt__input="" local __prompt__ch local __prompt__placeholder_cleared=0 while IFS= read -r -n 1 __prompt__ch; do # Enter: read -n 1 returns empty on newline delimiter if [ -z "$__prompt__ch" ]; then break fi case "$__prompt__ch" in $'\b'|$'\177') # Backspace / DEL: only delete if we actually have input buffered if [ -n "$__prompt__input" ]; then __prompt__input="${__prompt__input%?}" # Erase one cell: back up, overwrite with space, back up again printf '\b \b' fi ;; $'\033') # ESC sequence (arrow keys, etc.): read and discard the next 2 bytes # Covers CSI sequences like ESC [ A. Lone ESC will block waiting for 2 bytes, # but plain Escape isn't a natural gesture in a text prompt local __prompt__drop IFS= read -r -n 1 __prompt__drop 2>/dev/null IFS= read -r -n 1 __prompt__drop 2>/dev/null ;; *) # First printable keypress: clear the gray placeholder if [ "$__prompt__placeholder_cleared" -eq 0 ] && [ ${#__prompt__placeholder} -ne 0 ]; then printf '\033[K' __prompt__placeholder_cleared=1 fi __prompt__input+="$__prompt__ch" printf "%s" "$__prompt__ch" ;; esac done # Terminate the input line visually (read consumed the newline, but the cursor is still on the prompt row) printf '\n' __prompt__restore trap - INT trap - TERM # shellcheck disable=SC2034 # "foo appears unused. Verify it or export it." -- used via eval below __prompt__input="${__prompt__input:-"$__prompt__default"}" eval "$__prompt__varname"'="$__prompt__input"' } __prompt__main "$@" __prompt__rc=$? unset -f __prompt__main if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __prompt__rc; return $__prompt__rc" fi eval "unset __prompt__rc; exit $__prompt__rc"