Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ jobs:
- name: Run tool version tests
run: bash tests/test_tool_versions.sh

- name: Run TUI bash menu tests
run: bash tests/test_tui_bash.sh

- name: Flag test - --list (Linux)
run: bash setup.sh --list

Expand Down Expand Up @@ -183,6 +186,9 @@ jobs:
- name: Run tool version tests
run: bash tests/test_tool_versions.sh

- name: Run TUI bash menu tests
run: bash tests/test_tui_bash.sh

- name: Run idempotency test suite
run: bash tests/test_idempotency.sh

Expand Down
264 changes: 264 additions & 0 deletions scripts/linux/lib/tui.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# scripts/linux/lib/tui.sh -- Interactive Bash checkbox menu (#495 Slice 2)
#
# Sourced by setup.sh; never executed directly.
#
# Public interface:
# show_tool_menu <default_tools_array_name> <available_tools_array_name>
# Sets globals: _MENU_SELECTION (CSV), _MENU_CANCELLED (0|1)
#
# Test seam: set _TUI_ARROW_NAV_OVERRIDE=0|1 to force a specific nav path.
# ponytail: eval for bash 3.2 nameref compat; upgrade to local -n when 4.3 is minimum.

# ---------------------------------------------------------------------------
# _tui_render_list -- draw the full menu list to stdout
# $1: cursor index (-1 = no cursor indicator)
# $2: newline-separated tool names
# $3: space-separated checked flags (0|1 per tool, same order as $2)
# $4: space-separated is_default flags (0|1 per tool)
# ---------------------------------------------------------------------------
_tui_render_list() {
local cursor="$1"
local tools_nl="$2"
local checked_sp="$3"
local is_def_sp="$4"

local checked_arr=() is_def_arr=()
# shellcheck disable=SC2162
IFS=' ' read -ra checked_arr <<< "$checked_sp"
IFS=' ' read -ra is_def_arr <<< "$is_def_sp"

local i=0 tool checked is_def pointer label check_str
while IFS= read -r tool; do
[[ -z "$tool" ]] && { i=$((i+1)); continue; }
checked="${checked_arr[$i]:-0}"
is_def="${is_def_arr[$i]:-0}"
if [[ "$checked" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi
if [[ "$is_def" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi
if [[ "$cursor" -eq "$i" ]]; then pointer=">"; else pointer=" "; fi
printf '%s %s %s %s\n' "$pointer" "$check_str" "$tool" "$label"
i=$((i+1))
done <<< "$tools_nl"
}

# ---------------------------------------------------------------------------
# show_tool_menu -- main entry point
# $1: name of the defaults array variable (e.g. "DEFAULT_TOOLS")
# $2: name of the available-tools array variable
# Sets globals: _MENU_SELECTION (CSV of selected tools), _MENU_CANCELLED (0|1)
# ---------------------------------------------------------------------------
show_tool_menu() {
local _defaults_name="$1"
local _avail_name="$2"

_MENU_SELECTION=""
_MENU_CANCELLED=0

# Read arrays via eval (bash 3.2 compat; no local -n)
local defaults_csv avail_csv
# shellcheck disable=SC2086
eval "defaults_csv=\"\${${_defaults_name}[*]:-}\""
# shellcheck disable=SC2086
eval "avail_csv=\"\${${_avail_name}[*]:-}\""

local def_arr=() avail_arr=()
IFS=' ' read -ra def_arr <<< "$defaults_csv"
IFS=' ' read -ra avail_arr <<< "$avail_csv"

# Build ordered tool list: defaults (pre-checked) then opt-ins (unchecked, sorted)
local tools_nl="" checked_sp="" is_def_sp="" count=0

local d
for d in "${def_arr[@]}"; do
[[ -z "$d" ]] && continue
tools_nl="${tools_nl}${d}"$'\n'
checked_sp="${checked_sp}1 "
is_def_sp="${is_def_sp}1 "
count=$((count+1))
done

# Collect opt-ins (available but not default), sort, then append
local a in_def optin_nl=""
for a in "${avail_arr[@]}"; do
[[ -z "$a" ]] && continue
in_def=0
for d in "${def_arr[@]}"; do [[ "$d" == "$a" ]] && in_def=1 && break; done
[[ $in_def -eq 0 ]] && optin_nl="${optin_nl}${a}"$'\n'
done
if [[ -n "$optin_nl" ]]; then
local s
while IFS= read -r s; do
[[ -z "$s" ]] && continue
tools_nl="${tools_nl}${s}"$'\n'
checked_sp="${checked_sp}0 "
is_def_sp="${is_def_sp}0 "
count=$((count+1))
done < <(printf '%s' "$optin_nl" | sort)
fi

[[ $count -eq 0 ]] && return 0

# Version branch
local _use_arrow=1
if [[ -n "${_TUI_ARROW_NAV_OVERRIDE:-}" ]]; then
_use_arrow="$_TUI_ARROW_NAV_OVERRIDE"
else
local _bmaj _bmin
_bmaj="${BASH_VERSION%%.*}"
_bmin="${BASH_VERSION#*.}"; _bmin="${_bmin%%.*}"
(( _bmaj > 4 || (_bmaj == 4 && _bmin >= 2) )) || _use_arrow=0
fi

if [[ "$_use_arrow" == "1" ]]; then
_tui_arrow_mode "$tools_nl" "$checked_sp" "$is_def_sp" "$count"
else
_tui_numbered_mode "$tools_nl" "$checked_sp" "$is_def_sp" "$count"
fi
}

# ---------------------------------------------------------------------------
# _tui_arrow_redraw -- emit one in-place redraw: cursor-up N, clear-down, render
# $1: count (number of list lines = cursor-up delta)
# $2: cursor index
# $3: tools_nl
# $4: checked_arr space-joined
# $5: is_def_sp
# ---------------------------------------------------------------------------
_tui_arrow_redraw() {
local count="$1" cursor="$2" tools_nl="$3" checked_sp="$4" is_def_sp="$5"
printf '\033[%dA' "$count"
printf '\033[J'
_tui_render_list "$cursor" "$tools_nl" "$checked_sp" "$is_def_sp"
}

# ---------------------------------------------------------------------------
# _tui_arrow_mode -- bash >= 4.2: arrow keys, Space, Enter, q/ESC
# ---------------------------------------------------------------------------
_tui_arrow_mode() {
local tools_nl="$1" checked_sp="$2" is_def_sp="$3" count="$4"
local cursor=0

local checked_arr=()
IFS=' ' read -ra checked_arr <<< "$checked_sp"

printf '\n'
printf 'Arrow keys to move, Space to toggle, Enter to confirm, q/ESC to cancel.\n\n'
_tui_render_list "$cursor" "$tools_nl" "${checked_arr[*]}" "$is_def_sp"

local done_=0 k1 k2 k3 key
while [[ $done_ -eq 0 ]]; do
IFS= read -rsn1 k1 <>/dev/tty
key="$k1"
if [[ "$k1" == $'\033' ]]; then
IFS= read -rsn1 -t0.1 k2 <>/dev/tty || k2=""
IFS= read -rsn1 -t0.1 k3 <>/dev/tty || k3=""
key="${k1}${k2}${k3}"
fi

case "$key" in
$'\033[A') cursor=$(( cursor > 0 ? cursor - 1 : 0 )) ;;
$'\033[B') cursor=$(( cursor < count - 1 ? cursor + 1 : count - 1 )) ;;
' ')
if [[ "${checked_arr[cursor]}" == "1" ]]; then
checked_arr[cursor]=0
else
checked_arr[cursor]=1
fi
;;
'') done_=1 ;;
q|Q|$'\033')
_MENU_CANCELLED=1
printf '\nInstall cancelled.\n'
return 0
;;
esac

if [[ $done_ -eq 0 ]]; then
_tui_arrow_redraw "$count" "$cursor" "$tools_nl" "${checked_arr[*]}" "$is_def_sp"
fi
done

local i=0 tool sel=""
while IFS= read -r tool; do
[[ -z "$tool" ]] && { i=$((i+1)); continue; }
[[ "${checked_arr[$i]}" == "1" ]] && sel="${sel:+${sel},}${tool}"
i=$((i+1))
done <<< "$tools_nl"

_MENU_SELECTION="$sel"
}

# ---------------------------------------------------------------------------
# _tui_numbered_mode -- bash 3.2 fallback: type a number to toggle
# ---------------------------------------------------------------------------
_tui_numbered_mode() {
local tools_nl="$1" checked_sp="$2" is_def_sp="$3" count="$4"

local tool_arr=() checked_arr=() is_def_arr=()
while IFS= read -r t; do [[ -n "$t" ]] && tool_arr+=("$t"); done <<< "$tools_nl"
IFS=' ' read -ra checked_arr <<< "$checked_sp"
IFS=' ' read -ra is_def_arr <<< "$is_def_sp"

_tui_numbered_render tool_arr checked_arr is_def_arr "$count"

local done_=0 _input
while [[ $done_ -eq 0 ]]; do
IFS= read -r _input <>/dev/tty
case "$_input" in
'')
done_=1
;;
q|Q)
_MENU_CANCELLED=1
printf 'Install cancelled.\n'
return 0
;;
a|A)
local all_on=1 f
for f in "${checked_arr[@]}"; do [[ "$f" == "0" ]] && all_on=0 && break; done
local j
for j in $(seq 0 $((count-1))); do
[[ $all_on -eq 1 ]] && checked_arr[j]=0 || checked_arr[j]=1
done
_tui_numbered_render tool_arr checked_arr is_def_arr "$count"
;;
*)
if [[ "$_input" =~ ^[0-9]+$ ]]; then
local idx=$(( _input - 1 ))
if [[ $idx -ge 0 && $idx -lt $count ]]; then
if [[ "${checked_arr[idx]}" == "1" ]]; then
checked_arr[idx]=0
else
checked_arr[idx]=1
fi
_tui_numbered_render tool_arr checked_arr is_def_arr "$count"
fi
fi
;;
esac
done

local i=0 sel=""
for i in $(seq 0 $((count-1))); do
[[ "${checked_arr[$i]}" == "1" ]] && sel="${sel:+${sel},}${tool_arr[$i]}"
done
_MENU_SELECTION="$sel"
}

# ---------------------------------------------------------------------------
# _tui_numbered_render -- print the numbered list (called before each prompt)
# Receives array NAMES for bash 3.2 compat (no local -n)
# ---------------------------------------------------------------------------
_tui_numbered_render() {
local _tname="$1" _cname="$2" _dname="$3" _cnt="$4"
printf '\nArrow keys unavailable (bash 3.2). Number to toggle, a=all, Enter=confirm, q=cancel.\n'
local i check_str label _t _c _d
for i in $(seq 0 $((_cnt-1))); do
# shellcheck disable=SC2086
eval "_t=\"\${${_tname}[$i]}\"; _c=\"\${${_cname}[$i]}\"; _d=\"\${${_dname}[$i]}\""
if [[ "$_c" == "1" ]]; then check_str="[x]"; else check_str="[ ]"; fi
if [[ "$_d" == "1" ]]; then label="(default)"; else label="(opt-in)"; fi
printf '%d. %s %s %s\n' "$((i+1))" "$check_str" "$_t" "$label"
done
printf '\n> '
}
39 changes: 34 additions & 5 deletions scripts/linux/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"

# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/log.sh"
# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/tui.sh"

# ---------------------------------------------------------------------------
# DEFAULT_TOOLS -- single ordered source of truth for a no-arg default run.
Expand Down Expand Up @@ -197,8 +199,7 @@ fi
# (Note: ARG_ONLY_SET / ARG_SKIP_SET handle empty-value sentinels; build_final_toolset validates.)

# ---------------------------------------------------------------------------
# Interactive guard. Slice 1 only detects whether a future menu may run.
# No menu is invoked until Slice 2.
# Interactive guard. Returns 0 (true) when the menu should run.
# ---------------------------------------------------------------------------
is_interactive() {
if [[ $ARG_NON_INTERACTIVE_SET -eq 1 || $ARG_ONLY_SET -eq 1 || $ARG_SKIP_SET -eq 1 ]]; then
Expand All @@ -217,9 +218,6 @@ is_interactive() {
return 0
}

if is_interactive; then
: # ponytail: detection-only ceiling for Slice 1; Slice 2 wires the Bash menu here.
fi

# ---------------------------------------------------------------------------
# Build FinalToolSet -- populates global FINAL_TOOLS (bash 3.2 safe: no
Expand Down Expand Up @@ -363,6 +361,37 @@ main() {
log_info "Platform: ${platform}"
log_info "Repo root: ${REPO_ROOT}"

if is_interactive; then
if [[ $ARG_SELECTION_FILE_SET -eq 1 ]]; then
# CI/test seam: read newline-delimited selection from file
_MENU_SELECTION=""
_MENU_CANCELLED=0
local _sel_line
while IFS= read -r _sel_line || [[ -n "$_sel_line" ]]; do
_sel_line="${_sel_line%$'\r'}"
[[ -z "$_sel_line" ]] && continue
_MENU_SELECTION="${_MENU_SELECTION:+${_MENU_SELECTION},}${_sel_line}"
done < "$ARG_SELECTION_FILE"
else
local _avail_for_menu=()
local _t
while IFS= read -r _t; do
[[ -n "$_t" ]] && _avail_for_menu+=("$_t")
done < <(get_available_tools)
show_tool_menu DEFAULT_TOOLS _avail_for_menu
fi

if [[ "${_MENU_CANCELLED:-0}" -eq 1 ]]; then
exit 0
fi
if [[ -z "${_MENU_SELECTION:-}" ]]; then
log_info "Nothing selected, exiting."
exit 0
fi
ARG_ONLY="$_MENU_SELECTION"
ARG_ONLY_SET=1
fi

build_final_toolset

local tool
Expand Down
Loading
Loading