feat: lib/menu-lib.sh — category-neutral menu primitives (guard/run/pick/ask_value); install list += new libs

This commit is contained in:
Your Name
2026-08-24 14:44:12 -04:00
parent b144fe8853
commit f61766b074
2 changed files with 170 additions and 1 deletions
+169
View File
@@ -0,0 +1,169 @@
# lib/menu-lib.sh — category-neutral interactive menu primitives.
#
# The generic half of the former share-lib interactive layer (Pattern B),
# extracted so any `pos` tool can share one interaction vocabulary: a tty
# guard, a looping boxed menu, a type-to-filter picker and a prompt with
# optional default. Display goes to stderr, results to stdout; reads are
# stdin-based and fail closed (EOF / no terminal → rc 1, never a hang),
# so the functions are safe under the dispatcher's logging tee and inside
# command substitution.
#
# Contracts (all of them, no exceptions):
# * Defines ONLY `menu_*` functions — sourcing never clobbers a tool's own
# helpers (same discipline as lib/notify.sh).
# * Requires common.sh to be sourced by the CALLER for colored output;
# CYAN/RESET get empty guarded fallbacks here so the lib also works
# standalone-sourced (plain text instead of color — never an error).
# * NEVER exits and never terminates the caller: every function returns,
# failures are signalled through the return code.
# * Display goes to stderr, results go to stdout — any function whose result
# is meant to be command-substituted prints ONLY the result on stdout.
# * Performs NO file writes of its own.
#
# Function index:
# menu_guard rc 0 iff stdin is a terminal
# menu_run <title> <item...> numbered menu loop → chosen index
# menu_pick <prompt> <item...> type-to-filter picker → chosen index
# menu_ask_value <label> [default] prompted value → entered text
# ── Colors (guarded fallbacks; a sourced common.sh wins) ──────
CYAN="${CYAN:-}"
RESET="${RESET:-}"
# ── Terminal guard ────────────────────────────────────────────
# rc 0 iff stdin is a tty · rc 1 otherwise, with a one-line pointer to the
# scriptable subcommands. Tools that must fail hard without a TTY call this
# before entering the loop.
menu_guard() {
if [ -t 0 ]; then
return 0
fi
printf '[!] Interactive menu needs a terminal — use a subcommand instead (see --help).\n' >&2
return 1
}
# ── Numbered menu loop (firewall-precedent style) ──────────────
# Renders a section-box title + `%2d)` items + separator to stderr and reads
# `Choose: `. stdout carries the chosen index ONLY.
# rc 0 valid pick (index on stdout)
# rc 1 quit (`0`/`q`/`Q`), EOF, or no terminal — callers treat this as a
# clean menu exit (tools that must fail hard without a TTY call
# menu_guard themselves before entering the loop).
menu_run() {
local title="$1"; shift
local -a items=("$@")
if ! menu_guard; then
return 1
fi
local opt i
while true; do
{
echo
echo "${CYAN}════════════════════════════════════════════${RESET}"
echo "${CYAN} ${title}${RESET}"
echo "${CYAN}════════════════════════════════════════════${RESET}"
for ((i = 0; i < ${#items[@]}; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${items[$i]}"
done
printf ' %2d) %s\n' 0 "Exit"
echo "----------------------------------------"
} >&2
if ! read -rp "Choose: " opt; then
return 1 # EOF — clean menu exit
fi
case "$opt" in
"") ;; # empty input → redraw
0 | q | Q) return 1 ;;
*)
if [[ "$opt" =~ ^[0-9]+$ ]] && (( opt >= 1 && opt <= ${#items[@]} )); then
echo "$opt"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# ── Type-to-filter picker ──────────────────────────────────────
# Lists items on stderr; numeric choice → index (into the FULL item list) on
# stdout; non-numeric input filters case-insensitively and redisplays with a
# "-- N of M match 'text' --" banner; empty or `/` while filtered clears back
# to the full list; zero matches warn and redisplay.
# rc 0 picked · rc 1 back/cancel (`0`/`q`/`b`, EOF) — never out of bounds.
menu_pick() {
local prompt="${1:-Pick}"; shift
local -a items=("$@")
if [ "${#items[@]}" -eq 0 ]; then
return 1
fi
if ! [ -t 0 ]; then
printf '[!] Interactive picker needs a terminal.\n' >&2
return 1
fi
local filter="" ans i n total=${#items[@]}
local -a shown=() orig=()
while true; do
shown=()
orig=()
for ((i = 0; i < total; i++)); do
if [ -z "$filter" ] || [[ "${items[$i],,}" == *"${filter,,}"* ]]; then
shown+=("${items[$i]}")
orig+=("$((i + 1))")
fi
done
n=${#shown[@]}
{
echo
if [ -n "$filter" ]; then
printf -- "-- %d of %d match '%s' --\n" "$n" "$total" "$filter"
else
printf -- "-- %d available --\n" "$total"
fi
if [ "$n" -eq 0 ]; then
printf '[!] no matches — enter nothing or / to clear the filter\n' >&2
else
for ((i = 0; i < n; i++)); do
printf ' %2d) %s\n' $((i + 1)) "${shown[$i]}"
done
fi
} >&2
if ! read -rp "${prompt} [1-${n}], text=filter, 0=back " ans; then
return 1 # EOF — cancel
fi
case "$ans" in
"") [ -z "$filter" ] || filter="" ; continue ;;
"/") filter="" ; continue ;;
0 | q | Q | b | B) return 1 ;;
*[!0-9]*)
filter="$ans"
continue
;;
*)
if (( ans >= 1 && ans <= n )); then
echo "${orig[$((ans - 1))]}"
return 0
fi
echo "Unknown choice." >&2
;;
esac
done
}
# ── Prompted value with optional default ───────────────────────
# Prints "<label> [<default>]: " (read -p sends prompts to stderr) and echoes
# the entered value or the default when the answer is empty.
# rc 0 value on stdout · rc 1 EOF, or empty answer with no default.
menu_ask_value() {
local label="$1" def="${2:-}" val pr="$1"
[ -n "$def" ] && pr="$pr [$def]"
if ! read -rp "${pr}: " val; then
return 1 # EOF — cancel
fi
if [ -z "$val" ]; then
[ -n "$def" ] || return 1
echo "$def"
return 0
fi
echo "$val"
}