diff --git a/setup/tools/generate-modules.sh b/setup/tools/generate-modules.sh index 8f1f545..5fc587b 100755 --- a/setup/tools/generate-modules.sh +++ b/setup/tools/generate-modules.sh @@ -60,19 +60,19 @@ for i in "${ACTIVE_IDS[@]}"; do done # -- module-checklist (tui-install.sh and generate-answerfile.sh) ------------- -# Builds the full SELECTED_APPS=$(dialog ...) call including open/close. +# Builds the full SELECTED_APPS=$(ui_checklist ...) call including open/close. +# ui_checklist (defined in tui-install.sh) is a plain-bash replacement for the +# `dialog --checklist` widget; it takes the same tag/desc/state triplets. build_checklist_tui() { local out - out=' SELECTED_APPS=$(dialog --backtitle "$BACKTITLE" \\\n' - out+=' --title " Applications " \\\n' - out+=' --checklist "Optional applications — installed after base components:" "$_APP_H" 76 "$_APP_LIST_H" \\\n' + out=' SELECTED_APPS=$(ui_checklist " Applications " "Optional applications — installed after base components:" \\\n' for i in "${ACTIVE_IDS[@]}"; do local id="${M_IDS[$i]}" desc="${M_DESCS[$i]}" def="${M_DEFAULTS[$i]}" local padded padded=$(printf '%-20s' "$id") out+=" \"${id}\" \"${padded} ${desc}\" ${def} \\\\\n" done - out+=' 3>&1 1>&2 2>&3) || SELECTED_APPS=""\n' + out+=' ) || SELECTED_APPS=""\n' printf '%s' "$out" } diff --git a/setup/tui-install.sh b/setup/tui-install.sh index bf1dc61..a10e598 100755 --- a/setup/tui-install.sh +++ b/setup/tui-install.sh @@ -30,49 +30,165 @@ ANSWERFILE_MODE=false # Enable unattended mode only when the answerfile is actually present on disk. [[ -f "$ANSWERFILE" ]] && ANSWERFILE_MODE=true -BACKTITLE="the_miro's Arch Dotfiles" +# ── Cyberqueer CLI palette ──────────────────────────────────────────────────── +# Plain ANSI escape codes used by the prompt helpers below. These keep the +# magenta/cyan "cyberqueer" look without depending on the `dialog` binary, so +# the installer runs on a bare console with nothing but bash + coreutils. +# tput is not used here because it may be unavailable on a minimal TTY. +C_RESET=$'\033[0m' +C_TITLE=$'\033[1;35m' # bold magenta — section headers +C_RULE=$'\033[35m' # magenta — separators +C_ACCENT=$'\033[36m' # cyan — prompts and selected marks +C_BOLD=$'\033[1m' +C_DIM=$'\033[2m' -# ── Terminal dimensions (bare console safe) ─────────────────────────────────── -# tput may fail on a bare TTY with no TERM set; fall back to safe 80×24 defaults -# so dialog sizing arithmetic never operates on empty strings. -TERM_H=$(tput lines 2>/dev/null || echo 24) -TERM_W=$(tput cols 2>/dev/null || echo 80) +# ── Plain-CLI prompt helpers ────────────────────────────────────────────────── +# These reimplement the subset of `dialog` widgets the installer needs using +# only `read`. Widgets that return a value (input/menu/checklist/form) print +# their UI to stderr and the chosen value(s) to stdout, mirroring dialog's +# `3>&1 1>&2 2>&3` fd-swap convention so existing `$(...)` capture sites work +# unchanged. All interactive reads come from /dev/tty so the helpers keep +# working even when stdin is redirected. -# ── Cyberqueer dialog theme ─────────────────────────────────────────────────── -# dialog reads its color scheme from $DIALOGRC at startup. -# Writing it to TMP_D (rather than ~/.dialogrc) avoids permanently altering -# the user's existing dialog configuration on the live system. -export DIALOGRC="$TMP_D/dialogrc" -# The heredoc uses single-quoted 'EOF' so no variable expansion happens inside; -# every value is a literal dialog color tuple: (FG, BG, BOLD). -cat > "$DIALOGRC" <<'EOF' -use_shadow = ON -use_colors = ON -screen_color = (BLACK,BLACK,ON) -shadow_color = (BLACK,BLACK,ON) -title_color = (MAGENTA,BLACK,ON) -border_color = (MAGENTA,BLACK,ON) -button_active_color = (BLACK,MAGENTA,ON) -button_inactive_color = (WHITE,BLACK,OFF) -button_key_active_color = (BLACK,CYAN,ON) -button_key_inactive_color = (CYAN,BLACK,ON) -button_label_active_color = (BLACK,MAGENTA,ON) -button_label_inactive_color = (WHITE,BLACK,OFF) -inputbox_color = (WHITE,BLACK,OFF) -inputbox_border_color = (MAGENTA,BLACK,ON) -menubox_color = (WHITE,BLACK,OFF) -menubox_border_color = (MAGENTA,BLACK,ON) -item_color = (WHITE,BLACK,OFF) -item_selected_color = (BLACK,MAGENTA,ON) -tag_color = (CYAN,BLACK,ON) -tag_selected_color = (BLACK,CYAN,ON) -tag_key_color = (CYAN,BLACK,ON) -tag_key_selected_color = (BLACK,CYAN,ON) -check_color = (WHITE,BLACK,OFF) -check_selected_color = (BLACK,MAGENTA,ON) -uarrow_color = (MAGENTA,BLACK,ON) -darrow_color = (MAGENTA,BLACK,ON) -EOF +ui_header() { + # Styled section header printed to the given fd (default stdout). + printf '\n%s──── %s ────%s\n\n' "$C_TITLE" "$1" "$C_RESET" +} + +ui_msgbox() { + # Informational box: print title + body, wait for Enter to acknowledge. + local title="$1" body="$2" + ui_header "$title" + printf '%b\n' "$body" + printf '\n%s Press Enter to continue…%s' "$C_DIM" "$C_RESET" + read -r _ %s' "$C_ACCENT" "$C_RESET" + } >&2 + read -r val &2 + read -r choice = 1 && choice <= ${#tags[@]} )); then + printf '%s' "${tags[$((choice - 1))]}" + return 0 + fi + done +} + +ui_checklist() { + # Multi-select list. Args: title prompt tag1 desc1 state1 tag2 desc2 state2 … + # state is "on"/"off" for the initial selection. The list is redrawn each + # round; the user toggles items by entering numbers and/or ranges + # (e.g. "1 3 5-8"), then presses Enter to confirm. Entering 'q' aborts + # (returns 1). The space-separated selected tags are printed to stdout. + local title="$1" prompt="$2"; shift 2 + local -a tags=() descs=() state=() + while [[ $# -gt 0 ]]; do tags+=("$1"); descs+=("$2"); state+=("$3"); shift 3; done + local n=${#tags[@]} i mark line tok lo hi j + while true; do + { + ui_header "$title" + printf '%b\n\n' "$prompt" + for i in "${!tags[@]}"; do + if [[ "${state[$i]}" == on ]]; then mark="${C_ACCENT}[x]${C_RESET}"; else mark='[ ]'; fi + printf ' %s %s%3d%s %s\n' "$mark" "$C_ACCENT" $((i + 1)) "$C_RESET" "${descs[$i]}" + done + printf '\n%s Toggle items by number/range (e.g. 1 3 5-8); Enter confirms, q aborts.%s\n' "$C_DIM" "$C_RESET" + printf '%s > %s' "$C_ACCENT" "$C_RESET" + } >&2 + read -r line = 1 && j <= n )) || continue + if [[ "${state[$((j - 1))]}" == on ]]; then state[$((j - 1))]=off; else state[$((j - 1))]=on; fi + done + done + done + local out="" + for i in "${!tags[@]}"; do + [[ "${state[$i]}" == on ]] && out+="${tags[$i]} " + done + printf '%s' "${out% }" +} + +ui_form() { + # Sequential field entry. Args: title prompt label1 default1 label2 default2 … + # Prints one entered value per line to stdout (blank field → its default), + # matching the newline-separated output of `dialog --form`. + local title="$1" prompt="$2"; shift 2 + { + ui_header "$title" + printf '%b\n' "$prompt" + } >&2 + local label def v out="" + while [[ $# -gt 0 ]]; do + label="$1"; def="$2"; shift 2 + printf '%s %s%s%s [%s]: ' "$C_ACCENT" "$C_BOLD" "$label" "$C_RESET$C_ACCENT" "$def" >&2 + printf '%s' "$C_RESET" >&2 + read -r v /dev/null && return - echo "dialog not found — installing..." - sudo pacman -S --noconfirm dialog || { echo "Failed to install dialog."; exit 1; } -} - require_jq() { - # Same bootstrap pattern as require_dialog: jq is only needed in answerfile mode - # but we install it on demand to avoid a hard dependency for interactive use. + # jq is only needed in answerfile mode, so we install it on demand rather + # than making it a hard dependency for interactive use. command -v jq &>/dev/null && return echo "jq not found — installing..." sudo pacman -S --noconfirm jq || { echo "Failed to install jq."; exit 1; } } die() { - # Fatal error helper: clear the dialog overlay before printing so the message + # Fatal error helper: clear the screen before printing so the message # is readable, then exit 1. Uses stderr to keep it out of any $() capture. clear printf "\n Error: %s\n\n" "$1" >&2 @@ -107,13 +215,11 @@ die() { warn() { # Non-fatal notice: in answerfile mode just log it; interactively show a - # dialog msgbox that blocks until the user acknowledges with OK. + # message box that blocks until the user acknowledges with Enter. if $ANSWERFILE_MODE; then printf "\n Warning: %s\n" "$1" | tee -a "$LOG" else - dialog --backtitle "$BACKTITLE" \ - --title " Module Conflict " \ - --msgbox "\n $1" 8 62 + ui_msgbox " Module Conflict " " $1" fi } @@ -128,7 +234,7 @@ run_module() { STEP=$(( STEP + 1 )) log_sep "[$STEP/$TOTAL] $label" - # Clear the dialog overlay so module output scrolls cleanly on the raw terminal. + # Clear the selection screen so module output scrolls cleanly on the raw terminal. clear printf "\n\033[1;35m [$STEP/$TOTAL] %s\033[0m\n" "$label" printf "\033[35m ─────────────────────────────────────────────\033[0m\n\n" @@ -146,11 +252,9 @@ run_module() { printf "\n Warning: %s exited with code %d — continuing.\n" "$label" "$rc" | tee -a "$LOG" else # Interactive mode: ask the user whether to abort or continue. - # 'dialog --yesno' returns 0 for Yes and 1 for No; the '|| { ...; exit 1; }' + # ui_yesno returns 0 for Yes and 1 for No; the '|| { ...; exit 1; }' # fires on No, aborting the install. - dialog --backtitle "$BACKTITLE" \ - --title " Module Failed " \ - --yesno "$label exited with code $rc.\n\nContinue anyway?" 8 54 \ + ui_yesno " Module Failed " "$label exited with code $rc.\n\nContinue anyway?" no \ || { clear; exit 1; } fi fi @@ -322,9 +426,6 @@ fi # the installer cannot function on this OS. command -v pacman &>/dev/null || die "pacman not found — Arch Linux required." -# Ensure the dialog binary is available before we try to use it. -require_dialog - if $ANSWERFILE_MODE; then load_answerfile printf "Answerfile mode: %s\n" "$ANSWERFILE" | tee -a "$LOG" @@ -341,13 +442,9 @@ if ! ping -c1 -W3 archlinux.org &>/dev/null; then # Give the user a chance to plug in a cable or run iwctl before we # re-test. A second failed ping offers a soft-abort rather than a # hard failure, in case the user wants to proceed with a local mirror. - dialog --backtitle "$BACKTITLE" \ - --title " No Network Detected " \ - --msgbox "\n No internet connection found.\n\n Wired: ensure the cable is plugged in.\n WiFi: switch to another TTY (Alt+F2)\n and run: iwctl\n\n Press OK once connected.\n" 13 58 + ui_msgbox " No Network Detected " " No internet connection found.\n\n Wired: ensure the cable is plugged in.\n WiFi: switch to another TTY (Alt+F2)\n and run: iwctl\n\n Reconnect, then press Enter." if ! ping -c1 -W3 archlinux.org &>/dev/null; then - dialog --backtitle "$BACKTITLE" \ - --title " Still Offline " \ - --yesno "\n Still no internet connection.\n\n Packages cannot be downloaded without network access.\n\n Continue anyway?" 11 58 \ + ui_yesno " Still Offline " " Still no internet connection.\n\n Packages cannot be downloaded without network access.\n\n Continue anyway?" no \ || { clear; echo "Aborted — no network."; exit 1; } fi fi @@ -360,9 +457,7 @@ printf "Dotfiles install: %s\nDotfiles dir: %s\n" "$(date)" "$DOTFILES_DIR" > # ── Welcome ─────────────────────────────────────────────────────────────────── if ! $ANSWERFILE_MODE; then - dialog --backtitle "$BACKTITLE" \ - --title " Welcome " \ - --msgbox "\n\ + ui_msgbox " Welcome " "\ the_miro's Arch dotfiles installer\n\ Cyberqueer · Wayland · Hyprland\n\ ─────────────────────────────────────────\n\ @@ -370,7 +465,7 @@ if ! $ANSWERFILE_MODE; then Arch Linux — network admin, development & gaming\n\ \n\ Source: $DOTFILES_DIR\n\ - Log: $LOG\n" 14 62 + Log: $LOG" fi # ── Hostname ────────────────────────────────────────────────────────────────── @@ -385,14 +480,10 @@ if $ANSWERFILE_MODE; then printf "Hostname (from answerfile + MAC): %s\n" "$HOSTNAME_SET" | tee -a "$LOG" fi else - # dialog --inputbox: 3>&1 1>&2 2>&3 swaps fd1 and fd2 so the user's typed - # value (written to stdout by dialog) is captured by the $() subshell, while - # dialog's UI (which needs stderr for the terminal) flows to the actual terminal. - # The '|| HOSTNAME_INPUT=""' handles the user pressing Esc (dialog returns 1). - HOSTNAME_INPUT=$(dialog --backtitle "$BACKTITLE" \ - --title " Hostname " \ - --inputbox "\n Hostname for this machine (leave blank to keep default).\n" 9 54 "" \ - 3>&1 1>&2 2>&3) || HOSTNAME_INPUT="" + # ui_input prints its prompt to stderr and the typed value to stdout, so the + # $() capture receives only the hostname. A blank line keeps the default. + HOSTNAME_INPUT=$(ui_input " Hostname " \ + " Hostname for this machine (leave blank to keep default).") || HOSTNAME_INPUT="" HOSTNAME_SET="$HOSTNAME_INPUT" fi @@ -408,30 +499,25 @@ fi if $ANSWERFILE_MODE; then COMPONENTS="$AF_COMPONENTS" else - # dialog --checklist args: height width list-height, then triplets of tag desc state. + # ui_checklist takes tag/desc/state triplets and echoes the selected tags. # All four components are pre-selected ("on") because they form the expected base. - # The 3>&1 1>&2 2>&3 fd swap captures dialog's output (the selected tags) via $(). - # Esc / Cancel returns exit code 1; the '|| { ...; exit 0; }' treats that as a clean abort. - COMPONENTS=$(dialog --backtitle "$BACKTITLE" \ - --title " Select Components " \ - --checklist "Space toggles · Enter confirms · Esc quits" 16 68 4 \ - "pkg" "Package managers yay · nvm · rust" on \ - "core" "Core packages 100+ base system packages" on \ - "svc" "Core services NetworkManager · cronie · fail2ban" on \ - "shell" "Shell setup zsh · nvim · yazi · micro · starship" on \ - 3>&1 1>&2 2>&3) || { clear; echo "Aborted."; exit 0; } + # Entering 'q' returns exit code 1; the '|| { ...; exit 0; }' treats that as a clean abort. + COMPONENTS=$(ui_checklist " Select Components " "Optional base components — all enabled by default:" \ + "pkg" "pkg Package managers yay · nvm · rust" on \ + "core" "core Core packages 100+ base system packages" on \ + "svc" "svc Core services NetworkManager · cronie · fail2ban" on \ + "shell" "shell Shell setup zsh · nvim · yazi · micro · starship" on) \ + || { clear; echo "Aborted."; exit 0; } fi # ── DE selection ────────────────────────────────────────────────────────────── if $ANSWERFILE_MODE; then DE="$AF_DE" else - # dialog --menu is a single-choice list; it outputs the selected tag. - # Esc returns exit code 1 — '|| DE="none"' defaults to skipping the DE, + # ui_menu is a single-choice list; it outputs the selected tag. + # 'q'/blank returns exit code 1 — '|| DE="none"' defaults to skipping the DE, # preserving whatever DE the user already has installed. - DE=$(dialog --backtitle "$BACKTITLE" \ - --title " Desktop Environment " \ - --menu "Select a desktop environment · Esc / none to skip:" 24 72 11 \ + DE=$(ui_menu " Desktop Environment " "Select a desktop environment · q / none to skip:" \ "hyprlua" "HyprLua — Hyprland with Lua config (recommended)" \ "niri" "Niri — scrollable-tiling Wayland compositor" \ "hyprland" "Hyprland — Wayland WM, hyprlang config (legacy)" \ @@ -441,23 +527,15 @@ else "cosmic" "COSMIC — Rust-built Wayland DE (System76)" \ "xfce" "XFCE — lightweight X11 DE" \ "lxqt" "LXQt — lightweight Qt X11 DE" \ - "none" "Skip DE installation" \ - 3>&1 1>&2 2>&3) || DE="none" + "none" "Skip DE installation") || DE="none" fi # ── Apps selection ──────────────────────────────────────────────────────────── if $ANSWERFILE_MODE; then SELECTED_APPS="$AF_APPS" else - # Cap the dialog box at 40 rows but shrink to fit smaller terminals. - # The list height is the dialog height minus 8 rows of chrome (title, borders, - # prompt, buttons), with a minimum of 4 to remain usable on tiny screens. - _APP_H=$(( TERM_H - 2 < 40 ? TERM_H - 2 : 40 )) - _APP_LIST_H=$(( _APP_H - 8 < 4 ? 4 : _APP_H - 8 )) # BEGIN GENERATED MODULES: module-checklist - SELECTED_APPS=$(dialog --backtitle "$BACKTITLE" \ - --title " Applications " \ - --checklist "Optional applications — installed after base components:" "$_APP_H" 76 "$_APP_LIST_H" \ + SELECTED_APPS=$(ui_checklist " Applications " "Optional applications — installed after base components:" \ "ollama" "ollama local LLM runner and API server" off \ "llama-cpp" "llama-cpp standalone LLM inference CLI and server" off \ "open-webui" "open-webui browser UI for Ollama and LLM backends" off \ @@ -538,7 +616,7 @@ else "qemu" "qemu full QEMU/KVM stack with virt-manager" off \ "freeipa-client" "freeipa-client sssd and ipa-client-install with auto-enrollment" off \ "freeipa-server" "freeipa-server interactive FreeIPA server setup with client generator" off \ - 3>&1 1>&2 2>&3) || SELECTED_APPS="" + ) || SELECTED_APPS="" # END GENERATED MODULES: module-checklist fi @@ -546,12 +624,10 @@ fi if $ANSWERFILE_MODE; then SHELL_RC="$AF_SHELL_RC" else - SHELL_RC=$(dialog --backtitle "$BACKTITLE" \ - --title " Shell Config for New Users " \ - --menu "\n Should new users on this machine inherit the dotfiles' rc files?\n (Controls what gets copied to /etc/skel)\n" 13 68 2 \ + SHELL_RC=$(ui_menu " Shell Config for New Users " \ + "Should new users on this machine inherit the dotfiles' rc files?\n (Controls what gets copied to /etc/skel)" \ "dotfiles" "Use the_miro's .zshrc / .bashrc / .vimrc from dotfiles" \ - "defaults" "Skip — use system defaults for new users" \ - 3>&1 1>&2 2>&3) || SHELL_RC="defaults" + "defaults" "Skip — use system defaults for new users") || SHELL_RC="defaults" fi # ── Confirmation (interactive mode only) ────────────────────────────────────── @@ -654,12 +730,8 @@ if ! $ANSWERFILE_MODE; then # END GENERATED MODULES: module-summary fi - # Size the confirmation dialog to the terminal, capped at 24 rows. - _CONF_H=$(( TERM_H - 2 < 24 ? TERM_H - 2 : 24 )) - dialog --backtitle "$BACKTITLE" \ - --title " Confirm Installation " \ - --yesno "\n Components to install:\n\n${SUMMARY}\n Log: $LOG\n\n Proceed?" \ - "$_CONF_H" 62 || { clear; echo "Aborted."; exit 0; } + ui_yesno " Confirm Installation " " Components to install:\n\n${SUMMARY}\n Log: $LOG" \ + || { clear; echo "Aborted."; exit 0; } fi # Pre-count all selected steps before installation starts so run_module() can @@ -846,20 +918,17 @@ if $ANSWERFILE_MODE; then bash "$DOTFILES_DIR/apply-theme.sh" "$TMP_COLORS" 2>&1 | tee -a "$LOG" || true fi else - # Interactive: show color form dialog - # dialog --form: each field is specified as label row col default frow fcol flen max. - # The fd swap (3>&1 1>&2 2>&3) captures the form output (one value per line) via $(). - # Esc / Cancel sets COLORWAY_RAW="" so the colorway step is simply skipped. - COLORWAY_RAW=$(dialog --backtitle "$BACKTITLE" \ - --title " Colorway (optional) " \ - --form "\n Customize theme colors — bare 6-digit hex, no #.\n Leave unchanged to skip colorway setup.\n" \ - 16 62 5 \ - "COLOR_TEXT " 1 1 "$DEF_TEXT" 1 18 10 7 \ - "COLOR_BG " 2 1 "$DEF_BG" 2 18 10 7 \ - "COLOR_HIGHLIGHT " 3 1 "$DEF_HIGHLIGHT" 3 18 10 7 \ - "COLOR_DARK " 4 1 "$DEF_DARK" 4 18 10 7 \ - "COLOR_RED " 5 1 "$DEF_RED" 5 18 10 7 \ - 3>&1 1>&2 2>&3) || COLORWAY_RAW="" + # Interactive: prompt for each color field in turn. + # ui_form emits one value per line (blank → default), captured via $(). + # Pressing Enter on every field leaves COLORWAY_RAW at the defaults, and the + # comparison below then skips the colorway step entirely. + COLORWAY_RAW=$(ui_form " Colorway (optional) " \ + " Customize theme colors — bare 6-digit hex, no #.\n Leave each field unchanged to skip colorway setup." \ + "COLOR_TEXT" "$DEF_TEXT" \ + "COLOR_BG" "$DEF_BG" \ + "COLOR_HIGHLIGHT" "$DEF_HIGHLIGHT" \ + "COLOR_DARK" "$DEF_DARK" \ + "COLOR_RED" "$DEF_RED") || COLORWAY_RAW="" if [[ -n "$COLORWAY_RAW" ]]; then # mapfile reads the newline-separated form output into an array. @@ -912,9 +981,7 @@ fi if $ANSWERFILE_MODE; then printf "\nDone. Log: %s\n" "$LOG" else - dialog --backtitle "$BACKTITLE" \ - --title " Done " \ - --msgbox "\n All selected components installed.\n\n Log: $LOG\n\n A reboot may be required for all changes to take effect.\n" 12 58 + ui_msgbox " Done " " All selected components installed.\n\n Log: $LOG\n\n A reboot may be required for all changes to take effect." clear printf "\n Done. Log: %s\n\n" "$LOG"