#!/bin/bash # progress - render a single-line progress bar with percentage completion # # Usage: # progress [options] # progress -h # # Options: # -w, --width N Width of the progress bar (default 50) # -p, --progress-char C Character for completed portion (default #) # -r, --remaining-char C Character for remaining portion (default space) # --stderr Force output to stderr # --stdout Force output to stdout # -h, --help Show help message _progress() ( local SCRIPT_NAME; SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="progress" ;; esac local current="" local max="" local bar_width=50 local progress_char="#" local remaining_char=" " local interactive=false local out_mode="auto" # auto (prefer stderr), stderr, stdout _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _show_help() { local s; [ -t 1 ] && s=$'\033[4m' || s="" local r; [ -t 1 ] && r=$'\033[24m' || r="" echo "NAME" echo " $SCRIPT_NAME - render a single-line progress bar with percentage completion" echo "SYNOPSIS" echo " $SCRIPT_NAME [${s}options${r}] ${s}current${r} ${s}max${r}" echo " $SCRIPT_NAME -h" echo "DESCRIPTION" echo " Renders a single-line progress bar showing ${s}current${r}/${s}max${r} as a" echo " percentage. Designed for shell loops. Output defaults to stderr so" echo " stdout stays clean for pipelines. In a TTY, the bar redraws in place" echo " via carriage return; in a non-TTY context, only the 100% completion" echo " line is emitted." echo "" echo " The bar width is automatically clamped to fit the terminal so the" echo " rendered line never wraps. Terminal width is detected in this order:" echo " stty size, \$COLUMNS, falling back to 80 columns." echo "OPTIONS" echo " CURRENT Current progress value (integer, clamped to 0..MAX)" echo " MAX Maximum value representing 100% (positive integer)" echo " -w, --width ${s}N${r} Bar width in characters (default 50)" echo " -p, --progress-char ${s}C${r} Character for the completed portion (default #)" echo " -r, --remaining-char ${s}C${r} Character for the remaining portion (default space)" echo " --stderr Force output to stderr" echo " --stdout Force output to stdout" echo " -h, --help Show this help message" echo "EXAMPLES" echo " for i in {1..10}; do $SCRIPT_NAME \"\$i\" 10; sleep 0.1; done" echo " $SCRIPT_NAME --width 30 --progress-char '=' --remaining-char '.' 50 100" echo " $SCRIPT_NAME --stdout 100 100" echo "CAVEATS" echo " In non-TTY contexts, output is suppressed until ${s}current${r} == ${s}max${r}" echo " to avoid spamming logs or pipelines." echo "EXIT STATUS" echo " 0 Success" echo " 2 Usage error (missing/invalid arg, bad flag value)" echo "DEPENDENCIES" echo " stty" } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } # Install cleanup trap before any return path (including --help) so all # exits run __unset. Helpers below haven't been defined yet, but unset -f # is silent on missing names, so listing them all up-front is fine _expand_short_opts "wpr" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED # Parse options while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help return 0 ;; --stderr) out_mode="stderr" && shift ;; --stdout) out_mode="stdout" && shift ;; -w|--width) # Width is the bar width, not the total printed line width [ "$2" ] || { _error "Missing value for width option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } bar_width="$2" && shift 2 ;; --width=*) bar_width="${1#*=}" [ -n "$bar_width" ] || { _error "Missing value for width option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -p|--progress-char) [ "$2" ] || { _error "Missing value for progress character option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } progress_char="$2" && shift 2 ;; --progress-char=*) progress_char="${1#*=}" [ -n "$progress_char" ] || { _error "Missing value for progress character option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -r|--remaining-char) [ "$2" ] || { _error "Missing value for remaining character option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } remaining_char="$2" && shift 2 ;; --remaining-char=*) remaining_char="${1#*=}" [ -n "$remaining_char" ] || { _error "Missing value for remaining character option. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; *) if [ -z "$current" ]; then current="$1" elif [ -z "$max" ]; then max="$1" else _error "Too many positional arguments. Run \`$SCRIPT_NAME -h\` for usage"; return 2; fi shift ;; esac done # Validate required arguments if [ -z "$current" ] || [ -z "$max" ]; then _error "Missing required positional parameter(s). Run \`$SCRIPT_NAME -h\` for usage" return 2 fi # Basic number input validation # current is allowed to be any integer; it will be clamped into range below case "$current" in -[0-9]*|[0-9]*) ;; *) _error "current must be an integer"; return 2 ;; esac # max is a hard invariant: must be a positive integer case "$max" in [0-9]*) [ "$max" -gt 0 ] || { _error "max must be > 0"; return 2; } ;; *) _error "max must be a positive integer"; return 2 ;; esac # bar_width is a hard invariant: must be a positive integer case "$bar_width" in [0-9]*) [ "$bar_width" -gt 0 ] || { _error "width must be > 0"; return 2; } ;; *) _error "width must be a positive integer"; return 2 ;; esac _select_out_fd() { case "$out_mode" in stderr) echo 2 ;; stdout) echo 1 ;; auto) # Prefer stderr so stdout stays clean for pipelines and command substitution # Stdout is often redirected while stderr remains a TTY if [ -t 2 ]; then echo 2 elif [ -t 1 ]; then echo 1 else echo 2 fi ;; esac } _is_tty_fd() { case "$1" in 1) [ -t 1 ] ;; 2) [ -t 2 ] ;; *) return 1 ;; esac } _get_term_cols() { # Returns terminal column count (integer), falling back to 80 # Prefers stty against /dev/tty, then $COLUMNS # /dev/tty uses the controlling terminal and avoids stdin/stdout redirection edge cases local cols="" local out="" if command -v stty >/dev/null 2>&1; then if out="$(stty size /dev/null)"; then cols="${out##* }" case "$cols" in ''|*[!0-9]*) cols="" ;; esac [ "$cols" ] && { echo "$cols"; return 0; } fi fi cols="$COLUMNS" case "$cols" in ''|*[!0-9]*) cols="" ;; esac [ "$cols" ] && { echo "$cols"; return 0; } # Default to 80 columns echo 80 return 0 } _print_on_fd() { # Usage: _print_on_fd FD FORMAT [ARGS...] # FORMAT is a printf format string local fd="$1" shift if [ "$fd" -eq 2 ]; then # Ignore ShellCheck: We are explicitly expecting a printf format string in the variable # shellcheck disable=SC2059 # "Don't use variables in the printf format string. Use printf '..%s..' "$foo"." printf "$@" >&2 else # Ignore ShellCheck: We are explicitly expecting a printf format string in the variable # shellcheck disable=SC2059 # "Don't use variables in the printf format string. Use printf '..%s..' "$foo"." printf "$@" fi } local out_fd; out_fd="$(_select_out_fd)" # Interactive mode depends on the chosen output stream being a TTY if _is_tty_fd "$out_fd"; then interactive=true fi # Clamp bar_width so the rendered line cannot wrap and break \r redraw behavior # Rendered format: "[" + bar + "] " + "%3d%%" # Fixed overhead = 1 + 2 + 5 = 8 if [ "$interactive" = true ]; then local term_cols; term_cols="$(_get_term_cols)" local fixed_width=8 local max_width=$((term_cols - fixed_width)) [ "$max_width" -lt 1 ] && max_width=1 [ "$bar_width" -gt "$max_width" ] && bar_width="$max_width" fi # Clamp current [ "$current" -lt 0 ] && current=0 [ "$current" -gt "$max" ] && current="$max" local progress=$((current * 100 / max)) local progress_count=$((progress * bar_width / 100)) local remaining_count=$((bar_width - progress_count)) # Build the bar without external processes local filled="" local empty="" printf -v filled "%*s" "$progress_count" "" printf -v empty "%*s" "$remaining_count" "" filled="${filled// /$progress_char}" empty="${empty// /$remaining_char}" if [ "$interactive" = true ]; then # Interactive redraw on the chosen fd _print_on_fd "$out_fd" "\r[%s%s] %3d%%" "$filled" "$empty" "$progress" if [ "$current" -eq "$max" ]; then _print_on_fd "$out_fd" "\n" fi else # Non-TTY: avoid spamming logs or pipelines if [ "$current" -eq "$max" ]; then _print_on_fd "$out_fd" "[%s%s] %3d%%\n" "$filled" "$empty" "$progress" fi fi ) _progress "$@" __progress_rc=$? unset -f _progress if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __progress_rc; return $__progress_rc" fi eval "unset __progress_rc; exit $__progress_rc"