#!/bin/bash # Paginated menu with arrow key navigation set -euo pipefail # Terminal control functions enter_alt_screen() { tput smcup 2> /dev/null || true; } leave_alt_screen() { tput rmcup 2> /dev/null || true; } # Main paginated multi-select menu function paginated_multi_select() { local title="$1" shift local -a items=("$@") local external_alt_screen=false if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then external_alt_screen=true fi # Validation if [[ ${#items[@]} -eq 0 ]]; then echo "No items provided" >&2 return 1 fi local total_items=${#items[@]} local items_per_page=15 local cursor_pos=0 local top_index=0 local -a selected=() # Initialize selection array for ((i = 0; i < total_items; i++)); do selected[i]=false done if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() IFS=',' read -ra initial_indices <<< "$cleaned_preselect" for idx in "${initial_indices[@]}"; do if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then selected[idx]=true fi done fi # Preserve original TTY settings so we can restore them reliably local original_stty="" if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then original_stty=$(stty -g 2> /dev/null || echo "") fi restore_terminal() { show_cursor if [[ -n "${original_stty-}" ]]; then stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true else stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen fi } # Cleanup function cleanup() { trap - EXIT INT TERM restore_terminal } # Interrupt handler handle_interrupt() { cleanup exit 130 # Standard exit code for Ctrl+C } trap cleanup EXIT trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character stty -echo -icanon intr ^C 2> /dev/null || true if [[ $external_alt_screen == false ]]; then enter_alt_screen # Clear screen once on entry to alt screen printf "\033[2J\033[H" >&2 else printf "\033[H" >&2 fi hide_cursor # Helper functions print_line() { printf "\r\033[2K%s\n" "$1" >&2; } render_item() { local idx=$1 is_current=$2 local checkbox="$ICON_EMPTY" [[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID" if [[ $is_current == true ]]; then printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[idx]}" >&2 else printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 fi } # Draw the complete menu draw_menu() { # Move to home position without clearing (reduces flicker) printf "\033[H" >&2 # Clear each line as we go instead of clearing entire screen local clear_line="\r\033[2K" # Count selections for header display local selected_count=0 for ((i = 0; i < total_items; i++)); do [[ ${selected[i]} == true ]] && ((selected_count++)) done # Header printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 if [[ $total_items -eq 0 ]]; then printf "${clear_line}${GRAY}No items available${NC}\n" >&2 printf "${clear_line}\n" >&2 printf "${clear_line}${GRAY}Q/ESC${NC} Quit\n" >&2 printf "${clear_line}" >&2 return fi if [[ $top_index -gt $((total_items - 1)) ]]; then if [[ $total_items -gt $items_per_page ]]; then top_index=$((total_items - items_per_page)) else top_index=0 fi fi local visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page [[ $visible_count -le 0 ]] && visible_count=1 if [[ $cursor_pos -ge $visible_count ]]; then cursor_pos=$((visible_count - 1)) [[ $cursor_pos -lt 0 ]] && cursor_pos=0 fi printf "${clear_line}\n" >&2 # Items for current window local start_idx=$top_index local end_idx=$((top_index + items_per_page - 1)) [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) for ((i = start_idx; i <= end_idx; i++)); do [[ $i -lt 0 ]] && continue local is_current=false [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true render_item $i $is_current done # Fill empty slots to clear previous content local items_shown=$((end_idx - start_idx + 1)) [[ $items_shown -lt 0 ]] && items_shown=0 for ((i = items_shown; i < items_per_page; i++)); do printf "${clear_line}\n" >&2 done # Clear any remaining lines at bottom printf "${clear_line}\n" >&2 printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 # Clear one more line to ensure no artifacts printf "${clear_line}" >&2 } # Show help screen show_help() { printf "\033[H\033[J" >&2 cat >&2 << EOF Help - Navigation Controls ========================== ${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down Space Select/deselect item Enter Confirm selection Q / ESC Exit Press any key to continue... EOF read -n 1 -s >&2 } # Main interaction loop while true; do draw_menu local key=$(read_key) case "$key" in "QUIT") cleanup return 1 ;; "UP") if [[ $total_items -eq 0 ]]; then : elif [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) elif [[ $top_index -gt 0 ]]; then ((top_index--)) fi ;; "DOWN") if [[ $total_items -eq 0 ]]; then : else local absolute_index=$((top_index + cursor_pos)) if [[ $absolute_index -lt $((total_items - 1)) ]]; then local visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then ((cursor_pos++)) elif [[ $((top_index + visible_count)) -lt $total_items ]]; then ((top_index++)) visible_count=$((total_items - top_index)) [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page if [[ $cursor_pos -ge $visible_count ]]; then cursor_pos=$((visible_count - 1)) fi fi fi fi ;; "SPACE") local idx=$((top_index + cursor_pos)) if [[ $idx -lt $total_items ]]; then if [[ ${selected[idx]} == true ]]; then selected[idx]=false else selected[idx]=true fi fi ;; "ALL") for ((i = 0; i < total_items; i++)); do selected[i]=true done ;; "NONE") for ((i = 0; i < total_items; i++)); do selected[i]=false done ;; "HELP") show_help ;; "ENTER") # Store result in global variable instead of returning via stdout local -a selected_indices=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then selected_indices+=("$i") fi done # Allow empty selection - don't auto-select cursor position # This fixes the bug where unselecting all items would still select the last cursor position local final_result="" if [[ ${#selected_indices[@]} -gt 0 ]]; then local IFS=',' final_result="${selected_indices[*]}" fi # Remove the trap to avoid cleanup on normal exit trap - EXIT INT TERM # Store result in global variable MOLE_SELECTION_RESULT="$final_result" # Manually cleanup terminal before returning restore_terminal return 0 ;; esac done } # Export function for external use if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then echo "This is a library file. Source it from other scripts." >&2 exit 1 fi