#!/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="" cat < 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 # shellcheck disable=SC2059 # "Don't use variables in the printf format string. Use printf '..%s..' "$foo"." -- "$@" is a caller-supplied format string by design; this is a printf passthrough printf "$@" >&2 else # shellcheck disable=SC2059 # "Don't use variables in the printf format string. Use printf '..%s..' "$foo"." -- "$@" is a caller-supplied format string by design; this is a printf passthrough 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"