Files
Pathfinder 9dd1ecada8 Quiet dnf transactions: spinner + one-line summary, log on failure
ui_quiet_run wraps every install_packages/install_optional/group
install/remove and the system update: while gum is available and the
run is interactive, dnf output is hidden behind a spinner, then a
single 'Installing: <pkgs> (N packages)' line. Full output is appended
to $SETUP_DNF_LOG (/tmp/fedora-setup-dnf-<uid>.log); on failure the
last 20 lines print in red so nothing is lost. Unattended and no-gum
runs keep plain full output (logs/cron contract).

Also: fallback ui_choose accepts 'x' to exit (muscle memory from the
old menu), fixed a stray stdout newline leaking into captured answers
(same class as the earlier '\nno' bug), fixed pluralization.

Harness: piped fallback, pty gum run incl. a failing VirtualBox pick
(red failure + log tail, menu survives, subsequent qemu install ok),
unattended regression.
2026-10-27 19:20:31 +01:00

171 lines
4.7 KiB
Bash

# shellcheck shell=bash
#------------------------------------------
# lib/ui.sh — gum-based TUI with plain-text fallback
#
# Every ui_* helper has three behaviors:
# 1. ASSUME_YES (unattended) -> returns the default, no UI at all
# 2. gum available + a TTY -> styled TUI (menus, confirms, spinners)
# 3. otherwise -> the classic numbered prompts
#
# The wrappers print the ANSWER on stdout (like the old ask_* did), so call
# sites never change based on which mode is active.
#------------------------------------------
if [[ -n "${_SB_UI_LOADED:-}" ]]; then
return 0
fi
_SB_UI_LOADED=1
# have_gum lives in lib/common.sh (install_packages needs it too).
# Single choice from a list.
# ui_choose "<header>" "<prompt>" "<default>" item1 item2 ...
# prints the chosen item verbatim; prints the default when unattended.
ui_choose() {
local header="$1" prompt="$2" default="$3"
shift 3
local items=("$@")
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
if have_gum; then
# gum choose returns 130 on Esc/Ctrl-C; treat as default rather than
# killing the whole install.
local sel
sel=$(gum choose --header "$header"$'\n'"$prompt" "${items[@]}") || sel="$default"
echo "${sel:-$default}"
return 0
fi
# classic fallback: numbered list. ALL display goes to stderr — stdout is
# captured by the caller ($(ui_choose ...)) and must carry ONLY the answer
# (a stray printf newline once made choice='\nno' and broke every case match).
printf "\n" >&2
color_cyan "$header" >&2
local i=1 it
for it in "${items[@]}"; do
echo " $i) $it" >&2
i=$((i+1))
done
local n=${#items[@]} answer
while true; do
if ! read -t 90 -rp "$prompt [1-$n] (default: shown above): " answer; then
printf "\n" >&2
answer=""
fi
# empty/timeout -> the default item (its position in the list)
if [[ -z "$answer" ]]; then
local k=0
for it in "${items[@]}"; do
k=$((k+1))
[[ "$it" == "$default" ]] && { echo "$it"; return 0; }
done
echo "${items[-1]}"
return 0
fi
if [[ "${answer,,}" == "x" ]]; then
echo "$default"
return 0
fi
if [[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <= n )); then
echo "${items[$((answer-1))]}"
return 0
fi
err "Invalid choice: $answer"
done
}
# Yes/no. ui_confirm "<prompt>" "<yes|no default>" -> prints yes/no
ui_confirm() {
local prompt="$1" default="${2:-no}"
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
if have_gum; then
if gum confirm "$prompt"; then
echo "yes"
else
echo "no"
fi
return 0
fi
printf "\n" >&2
read -t 60 -rp "$prompt [y/n] (default: $default): " answer || printf "\n" >&2
answer="${answer,,}"
[[ -z "$answer" ]] && answer="$default"
case "$answer" in
y|yes) echo "yes" ;;
*) echo "no" ;;
esac
}
# Multi-select from a list (one per line on stdout; empty = none).
# ui_choose_multi "<header>" "<prompt>" item1 item2 ...
ui_choose_multi() {
local header="$1" prompt="$2"
shift 2
local items=("$@")
if [[ "$ASSUME_YES" == "true" ]]; then
return 0
fi
if have_gum; then
gum choose --no-limit --header "$header"$'\n'"$prompt" "${items[@]}" || true
return 0
fi
printf "\n" >&2
color_cyan "$header" >&2
local i=1 it
for it in "${items[@]}"; do
echo " $i) $it" >&2
i=$((i+1))
done
echo " x) None / back" >&2
local sel picks=() pick
read -t 90 -rp "Pick one or more (comma-separate, default: none): " sel || printf "\n"
[[ "${sel,,}" == "x" || -z "$sel" ]] && return 0
IFS=',' read -ra picks <<< "$sel"
for pick in "${picks[@]}"; do
pick="${pick// /}"
[[ "$pick" =~ ^[0-9]+$ ]] || continue
(( pick >= 1 && pick <= ${#items[@]} )) && echo "${items[$((pick-1))]}"
done
}
# Spinner around a long command. Falls back to running it plainly.
# ui_run "Updating system..." sudo dnf update -y
ui_run() {
local msg="$1"
shift
if have_gum; then
if gum spin --title "$msg" --show-output -- "$@"; then
ok "$msg"
else
local rc=$?
color_red " Failed: $msg"
return "$rc"
fi
return 0
fi
info "$msg"
"$@"
local rc=$?
if (( rc == 0 )); then
ok "$msg"
else
color_red " Failed: $msg"
fi
return "$rc"
}