From c6e41cbf1e5c374028a241b9d66d4dd61bcf4236 Mon Sep 17 00:00:00 2001 From: Pathfinder Date: Tue, 27 Oct 2026 19:09:58 +0100 Subject: [PATCH] gum TUI with plain-text fallback (lib/ui.sh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/ui.sh | 174 ++++++++++++++++++++++++++++++++++++++++++++++ setup.sh | 203 +++++++++++++++++------------------------------------- 2 files changed, 239 insertions(+), 138 deletions(-) create mode 100644 lib/ui.sh diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100644 index 0000000..57b7f50 --- /dev/null +++ b/lib/ui.sh @@ -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 "
" "" "" 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 "" "" -> 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 "
" "" 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" +} diff --git a/setup.sh b/setup.sh index bb6e736..d5ea316 100755 --- a/setup.sh +++ b/setup.sh @@ -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