gum TUI with plain-text fallback #6

Merged
yuri merged 1 commits from feat/gum-tui into main 2026-10-27 19:49:14 +01:00
2 changed files with 239 additions and 138 deletions
+174
View File
@@ -0,0 +1,174 @@
# 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"
}
+65 -138
View File
@@ -10,6 +10,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
# shellcheck source=lib/session.sh
source "$SCRIPT_DIR/lib/session.sh"
# shellcheck source=lib/ui.sh
source "$SCRIPT_DIR/lib/ui.sh"
#------------------------------------------
# Defaults + argument parsing
@@ -151,6 +153,12 @@ if ! command -v curl >/dev/null 2>&1; then
install_packages curl
fi
# gum drives the styled menus/spinners when a TTY is present; the script's
# plain-text fallback means this is an enhancement, not a dependency.
if ! pkg_installed gum; then
install_optional gum
fi
# config-manager lives in dnf5-plugins; every repo step below depends on it.
if ! dnf config-manager --help >/dev/null 2>&1; then
info "dnf config-manager missing. Installing dnf5-plugins..."
@@ -177,58 +185,14 @@ fi
info "Ensuring Flathub is available (system-wide)..."
sudo flatpak remote-add --if-not-exists --system flathub https://dl.flathub.org/repo/flathub.flatpakrepo
info "Updating the system..."
sudo dnf update -y
# Update the system (spinner when gum is present; output shown on failure)
ui_run "Updating the system" sudo dnf update -y
pause_if_debug
#------------------------------------------
# 2 Menu helpers
#------------------------------------------
# $1 = prompt, $2 = default answer, rest = valid answers
ask_choice() {
local prompt="$1" default="$2"; shift 2
local answer
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
while true; do
if ! read -t 60 -rp "$prompt (default: $default): " answer; then
printf "\n"
answer=""
fi
answer="${answer:-$default}"
answer="${answer,,}"
local v
for v in "$@"; do
if [[ "$answer" == "$v" ]]; then
echo "$answer"
return 0
fi
done
err "Invalid option: '$answer'. Choose from: $*"
done
}
# Ask a yes/no question; $2 is the default (yes|no). Honours --yes.
ask_yes_no() {
local prompt="$1" default="$2" answer
if [[ "$ASSUME_YES" == "true" ]]; then
echo "$default"
return 0
fi
while true; do
read -t 60 -rp "$prompt [y/n] (default: $default): " answer || printf "\n"
answer="${answer,,}"
[[ -z "$answer" ]] && answer="$default"
case "$answer" in
y|yes) echo "yes"; return 0 ;;
n|no) echo "no"; return 0 ;;
esac
err "Please answer y or n."
done
}
#------------------------------------------
# 3 System settings
#------------------------------------------
@@ -240,7 +204,7 @@ if [[ -z "$SELINUX_OPT" ]]; then
rule
printf "\n"
printf "\n"
choice=$(ask_yes_no "Disable SELinux permanently? (not recommended)" "no")
choice=$(ui_confirm "Disable SELinux permanently? (not recommended)" "no")
[[ "$choice" == "yes" ]] && SELINUX_DISABLED=true
printf "\n"
if findmnt -n -o FSTYPE / 2>/dev/null | grep -q btrfs; then
@@ -266,7 +230,7 @@ esac
DOTFILES=false
if [[ -z "$DOTFILES_OPT" ]]; then
printf "\n"
choice=$(ask_yes_no "Clone personal dotfiles (fedora-dots) into \$HOME?" "no")
choice=$(ui_confirm "Clone personal dotfiles (fedora-dots) into \$HOME?" "no")
[[ "$choice" == "yes" ]] && DOTFILES=true
else
case "$DOTFILES_OPT" in
@@ -281,23 +245,17 @@ pause_if_debug
# 4 Desktop environment selection
#------------------------------------------
if [[ -z "$DE" ]]; then
step "3 Desktop environment"
step "4 Desktop environment"
rule
printf "\n"
color_gray " This system is treated as a bare Fedora install — picking a DE"
color_gray " here is what gives you a graphical login."
printf "\n"
echo " 1) GNOME (gdm)"
echo " 2) Plasma (sddm)"
echo " 3) XFCE (sddm)"
echo " 4) None (TWM only — SDDM will be installed by the TWM module)"
printf "\n"
choice=$(ask_choice "Select desktop environment [1/2/3/4]" "4" 1 2 3 4)
color_gray " Bare Fedora install — picking a DE is what gives you a graphical login."
choice=$(ui_choose "Desktop environment" "Select desktop environment:" \
"None (TWM only, SDDM via TWM module)" \
"GNOME (gdm)" "Plasma (sddm)" "XFCE (sddm)" "None (TWM only, SDDM via TWM module)")
case "$choice" in
1) DE="gnome" ;;
2) DE="plasma" ;;
3) DE="xfce" ;;
4) DE="none" ;;
GNOME*) DE="gnome" ;;
Plasma*) DE="plasma" ;;
XFCE*) DE="xfce" ;;
*) DE="none" ;;
esac
fi
@@ -313,20 +271,16 @@ pause_if_debug
if [[ -z "$TWM" ]]; then
step "5 Tiling window manager"
rule
printf "\n"
echo " 1) Niri (Wayland, scrollable)"
echo " 2) Hyprland (Wayland, dynamic)"
echo " 3) MangoWC (Wayland, dwl-based, Terra repo)"
echo " 4) chadwm (X11, dwm fork, built from source)"
echo " 5) None"
printf "\n"
choice=$(ask_choice "Select tiling window manager [1/2/3/4/5]" "5" 1 2 3 4 5)
choice=$(ui_choose "Tiling window manager" "Select a tiling window manager:" \
"None" \
"Niri (Wayland, scrollable)" "Hyprland (Wayland, dynamic)" \
"MangoWC (Wayland, Terra repo)" "chadwm (X11, source build)" "None")
case "$choice" in
1) TWM="niri" ;;
2) TWM="hyprland" ;;
3) TWM="mango" ;;
4) TWM="chadwm" ;;
5) TWM="none" ;;
Niri*) TWM="niri" ;;
Hyprland*) TWM="hyprland" ;;
MangoWC*) TWM="mango" ;;
chadwm*) TWM="chadwm" ;;
*) TWM="none" ;;
esac
fi
@@ -443,26 +397,11 @@ ADV_Browsers=(
run_advanced_menu() {
local done_any=false
while true; do
printf "\n"
color_gray "Optional tools - select a category to pick from:"
printf "\n"
local i=1 cat
for cat in "${ADV_CATEGORIES[@]}"; do
echo " $i) $cat"
i=$((i+1))
done
echo " x) Done with optional tools"
local choice
if ! read -t 90 -rp "Category [1-${#ADV_CATEGORIES[@]}/x] (default: x): " choice; then
printf "\n"; choice="x"
fi
[[ "${choice,,}" == "x" || -z "$choice" ]] && break
if [[ ! "$choice" =~ ^[0-9]+$ ]] || (( choice < 1 || choice > ${#ADV_CATEGORIES[@]} )); then
err "Invalid choice: $choice"
continue
fi
cat="${ADV_CATEGORIES[$((choice-1))]}"
local cat
cat=$(ui_choose "Optional tools" "Select a category (last item exits):" \
"Done with optional tools" \
"${ADV_CATEGORIES[@]}" "Done with optional tools")
[[ "$cat" == "Done with optional tools" ]] && break
# apps for this category — nameref, because ${!var} on an array name
# would only give element 0 (that bug showed in testing: Browsers
@@ -473,41 +412,36 @@ run_advanced_menu() {
# shellcheck disable=SC2206
entries=("${entries_ref[@]}")
printf "\n"
color_cyan "$cat:"
local j=1 entry
local -a ids=() labels=()
# Labels for the picker (gum multi-select or numbered fallback).
# Entry format: id:Label:packages...
local entry rest lbl
local -a labels=()
for entry in "${entries[@]}"; do
ids+=("${entry%%:*}") # entry id
local rest="${entry#*:}"
labels+=("${rest%%:*}") # display label
echo " $j) ${labels[-1]}"
j=$((j+1))
rest="${entry#*:}"
labels+=("${rest%%:*}")
done
echo " x) Back"
local sel
if ! read -t 90 -rp "Install which? (comma-separate for multiple, default: back): " sel; then
printf "\n"; continue
fi
[[ "${sel,,}" == "x" || -z "$sel" ]] && continue
# ui_choose_multi prints the chosen labels (one per line).
local sel picked
sel=$(ui_choose_multi "$cat" "Install which? (multi-select, none = back):" "${labels[@]}")
[[ -z "$sel" ]] && continue
# Resolve picks to package lists. Entry format: id:Label:packages...
# Map chosen labels back to their package lists. Word-split each
# package list into SEPARATE args: quoting it whole makes dnf look for
# one package named "qemu-kvm libvirt ..." (seen on the VM).
local -a to_install=()
local pick n rest
IFS=',' read -ra picks <<< "$sel"
for pick in "${picks[@]}"; do
pick="${pick// /}"
[[ "$pick" =~ ^[0-9]+$ ]] || { err "Ignoring invalid pick: $pick"; continue; }
n=${#entries[@]}
if (( pick < 1 || pick > n )); then err "Ignoring out-of-range pick: $pick"; continue; fi
entry="${entries[$((pick-1))]}"
rest="${entry#*:}"; rest="${rest#*:}" # drop id and label -> packages
# word-split into SEPARATE packages: quoting $rest whole makes dnf
# look for one package named "qemu-kvm libvirt ..." (seen on the VM)
# shellcheck disable=SC2206
to_install+=(${rest})
done
while IFS= read -r picked; do
[[ -z "$picked" ]] && continue
for entry in "${entries[@]}"; do
rest="${entry#*:}"
lbl="${rest%%:*}"
if [[ "$lbl" == "$picked" ]]; then
rest="${entry#*:}"; rest="${rest#*:}" # drop id and label
# shellcheck disable=SC2206
to_install+=(${rest})
fi
done
done <<< "$sel"
if (( ${#to_install[@]} > 0 )); then
step "Installing from $cat: ${to_install[*]}"
@@ -626,18 +560,11 @@ if [[ "$ASSUME_YES" == "true" ]]; then
exit 0
fi
if read -t 15 -rp "Reboot now? [y/N]: " reboot_choice; then
case "${reboot_choice,,}" in
y|yes)
color_red "Rebooting now."
sudo reboot
;;
*)
color_yellow "Skipping reboot."
exit 0
;;
esac
reboot_choice=$(ui_confirm "Reboot now?" "no")
if [[ "$reboot_choice" == "yes" ]]; then
color_red "Rebooting now."
sudo reboot
else
color_yellow "No input detected. Skipping reboot for safety."
color_yellow "Skipping reboot."
exit 0
fi