Files
fedora/lib/ui.sh
T
Pathfinder c6e41cbf1e gum TUI with plain-text fallback (lib/ui.sh)
Interactive install gets styled menus, confirms, multi-select and a
spinner around the system update when gum is available (dnf install
gum, installed optionally in the base section); every prompt falls
back to the classic numbered/yn prompts otherwise, and --yes runs
stay 100% non-interactive.

Call sites moved to ui_choose/ui_confirm/ui_choose_multi/ui_run:
DE + TWM menus (defaults preserved: none/none), SELinux + dotfiles
questions, advanced-menu category + multi-select app picker, reboot
prompt. Dead ask_choice/ask_yes_no removed.

Two bugs caught by pty-based harness testing before shipping:
- stdout is a pipe inside $(ui_...), so have_gum must test stdin +
  /dev/tty, never -t 1 (gum would never have fired on a real term).
- fallback display output must go to >&2 — a stray newline on stdout
  made choice='\nno' and broke every case match.
2026-10-27 19:09:58 +01:00

175 lines
4.9 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() {
command -v gum >/dev/null 2>&1 || return 1
# stdin must be interactive — it is inherited by $(...) subshells, so
# menus still get the real terminal. stdout must NOT be checked: it is
# a pipe by definition inside $(ui_choose ...). gum draws its UI on
# /dev/tty and prints only the answer to stdout (which we capture).
[ -t 0 ] || return 1
[ -w /dev/tty ] || return 1
return 0
}
# 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"; 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" =~ ^[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"
}