gum TUI with plain-text fallback #6
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user