mirror of
https://github.com/tw93/Mole.git
synced 2026-02-05 03:54:44 +00:00
🎨 Refactor new feature uninstall
This commit is contained in:
157
lib/app_selector.sh
Executable file
157
lib/app_selector.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
# App selection functionality using the new menu system
|
||||
# This replaces the complex interactive_app_selection function
|
||||
|
||||
# Interactive app selection using the menu.sh library
|
||||
select_apps_for_uninstall() {
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build menu options from apps_data
|
||||
local -a menu_options=()
|
||||
for app_data in "${apps_data[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data"
|
||||
|
||||
# The size is already formatted (e.g., "91M", "2.1G"), so use it directly
|
||||
local size_str="Unknown"
|
||||
if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then
|
||||
size_str="$size"
|
||||
fi
|
||||
|
||||
# Format display name with better width control
|
||||
local display_name
|
||||
local max_name_length=25
|
||||
local truncated_name="$app_name"
|
||||
|
||||
# Truncate app name if too long
|
||||
if [[ ${#app_name} -gt $max_name_length ]]; then
|
||||
truncated_name="${app_name:0:$((max_name_length-3))}..."
|
||||
fi
|
||||
|
||||
# Create aligned display format
|
||||
display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used")
|
||||
menu_options+=("$display_name")
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🗑️ App Uninstaller"
|
||||
echo ""
|
||||
echo "Found ${#apps_data[@]} apps. Select apps to remove:"
|
||||
echo ""
|
||||
|
||||
# Load paginated menu system (arrow key navigation)
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/paginated_menu.sh"
|
||||
|
||||
# Use paginated multi-select menu with arrow key navigation
|
||||
local selected_indices
|
||||
selected_indices=$(paginated_multi_select "Select Apps to Remove" "${menu_options[@]}")
|
||||
local exit_code=$?
|
||||
|
||||
if [[ $exit_code -ne 0 ]]; then
|
||||
echo "Cancelled"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$selected_indices" ]]; then
|
||||
echo "No apps selected"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build selected_apps array from indices
|
||||
selected_apps=()
|
||||
for idx in $selected_indices; do
|
||||
# Validate that idx is a number
|
||||
if [[ "$idx" =~ ^[0-9]+$ ]]; then
|
||||
selected_apps+=("${apps_data[idx]}")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Selected ${#selected_apps[@]} apps"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Alternative simplified single-select interface for quick selection
|
||||
quick_select_app() {
|
||||
if [[ ${#apps_data[@]} -eq 0 ]]; then
|
||||
log_warning "No applications available for uninstallation"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Build menu options from apps_data (same as above)
|
||||
local -a menu_options=()
|
||||
for app_data in "${apps_data[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$app_data"
|
||||
|
||||
# The size is already formatted (e.g., "91M", "2.1G"), so use it directly
|
||||
local size_str="Unknown"
|
||||
if [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]]; then
|
||||
size_str="$size"
|
||||
fi
|
||||
|
||||
# Format display name with better width control
|
||||
local display_name
|
||||
local max_name_length=25
|
||||
local truncated_name="$app_name"
|
||||
|
||||
# Truncate app name if too long
|
||||
if [[ ${#app_name} -gt $max_name_length ]]; then
|
||||
truncated_name="${app_name:0:$((max_name_length-3))}..."
|
||||
fi
|
||||
|
||||
# Create aligned display format
|
||||
display_name=$(printf "%-${max_name_length}s %8s | %s" "$truncated_name" "($size_str)" "$last_used")
|
||||
menu_options+=("$display_name")
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "🗑️ Quick Uninstall"
|
||||
echo ""
|
||||
|
||||
# Use single-select menu
|
||||
if show_menu "Quick Uninstall" "${menu_options[@]}"; then
|
||||
local selected_idx=$?
|
||||
selected_apps=("${apps_data[selected_idx]}")
|
||||
echo "✅ Selected: ${menu_options[selected_idx]}"
|
||||
return 0
|
||||
else
|
||||
echo "❌ Operation cancelled"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show app selection mode menu
|
||||
show_app_selection_mode() {
|
||||
echo ""
|
||||
echo "🗑️ Application Uninstaller"
|
||||
echo ""
|
||||
|
||||
local mode_options=(
|
||||
"Batch Mode (select multiple apps with checkboxes)"
|
||||
"Quick Mode (select one app at a time)"
|
||||
"Exit Uninstaller"
|
||||
)
|
||||
|
||||
if show_menu "Choose uninstall mode:" "${mode_options[@]}"; then
|
||||
local mode=$?
|
||||
case $mode in
|
||||
0)
|
||||
select_apps_for_uninstall
|
||||
return $?
|
||||
;;
|
||||
1)
|
||||
quick_select_app
|
||||
return $?
|
||||
;;
|
||||
2)
|
||||
echo "Goodbye!"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "Operation cancelled"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
200
lib/batch_uninstall.sh
Executable file
200
lib/batch_uninstall.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Batch uninstall functionality with minimal confirmations
|
||||
# Replaces the overly verbose individual confirmation approach
|
||||
|
||||
# Find and list app-related files
|
||||
find_app_files() {
|
||||
local bundle_id="$1"
|
||||
local app_name="$2"
|
||||
local -a files_to_clean=()
|
||||
|
||||
# Application Support
|
||||
[[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name")
|
||||
[[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id")
|
||||
|
||||
# Caches
|
||||
[[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id")
|
||||
|
||||
# Preferences
|
||||
[[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
|
||||
|
||||
# Logs
|
||||
[[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name")
|
||||
[[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
|
||||
|
||||
# Saved Application State
|
||||
[[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState")
|
||||
|
||||
# Containers (sandboxed apps)
|
||||
[[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id")
|
||||
|
||||
# Group Containers
|
||||
while IFS= read -r -d '' container; do
|
||||
files_to_clean+=("$container")
|
||||
done < <(find ~/Library/Group\ Containers -name "*$bundle_id*" -type d -print0 2>/dev/null)
|
||||
|
||||
printf '%s\n' "${files_to_clean[@]}"
|
||||
}
|
||||
|
||||
# Calculate total size of files
|
||||
calculate_total_size() {
|
||||
local files="$1"
|
||||
local total_kb=0
|
||||
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -e "$file" ]]; then
|
||||
local size_kb=$(du -sk "$file" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
((total_kb += size_kb))
|
||||
fi
|
||||
done <<< "$files"
|
||||
|
||||
echo "$total_kb"
|
||||
}
|
||||
|
||||
# Batch uninstall with single confirmation
|
||||
batch_uninstall_applications() {
|
||||
local total_size_freed=0
|
||||
|
||||
if [[ ${#selected_apps[@]} -eq 0 ]]; then
|
||||
log_warning "No applications selected for uninstallation"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Pre-process: Check for running apps and calculate total impact
|
||||
local -a running_apps=()
|
||||
local total_estimated_size=0
|
||||
local -a app_details=()
|
||||
|
||||
echo "📋 Analyzing selected applications..."
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app"
|
||||
|
||||
# Check if app is running
|
||||
if pgrep -f "$app_name" >/dev/null 2>&1; then
|
||||
running_apps+=("$app_name")
|
||||
fi
|
||||
|
||||
# Calculate size for summary
|
||||
local app_size_kb=$(du -sk "$app_path" 2>/dev/null | awk '{print $1}' || echo "0")
|
||||
local related_files=$(find_app_files "$bundle_id" "$app_name")
|
||||
local related_size_kb=$(calculate_total_size "$related_files")
|
||||
local total_kb=$((app_size_kb + related_size_kb))
|
||||
((total_estimated_size += total_kb))
|
||||
|
||||
# Store details for later use
|
||||
app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$related_files")
|
||||
done
|
||||
|
||||
# Show summary and get batch confirmation
|
||||
echo ""
|
||||
echo "📊 Uninstallation Summary:"
|
||||
echo " • Applications to remove: ${#selected_apps[@]}"
|
||||
|
||||
if [[ $total_estimated_size -gt 1048576 ]]; then
|
||||
local size_display=$(echo "$total_estimated_size" | awk '{printf "%.2fGB", $1/1024/1024}')
|
||||
elif [[ $total_estimated_size -gt 1024 ]]; then
|
||||
local size_display=$(echo "$total_estimated_size" | awk '{printf "%.1fMB", $1/1024}')
|
||||
else
|
||||
local size_display="${total_estimated_size}KB"
|
||||
fi
|
||||
echo " • Estimated space to free: $size_display"
|
||||
|
||||
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
||||
echo " • ⚠️ Running apps that will be force-quit:"
|
||||
for app in "${running_apps[@]}"; do
|
||||
echo " - $app"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Selected applications:"
|
||||
for selected_app in "${selected_apps[@]}"; do
|
||||
IFS='|' read -r epoch app_path app_name bundle_id size last_used <<< "$selected_app"
|
||||
echo " • $app_name ($size)"
|
||||
done
|
||||
|
||||
echo ""
|
||||
read -p "🗑️ Proceed with uninstalling ALL ${#selected_apps[@]} applications? This cannot be undone. (Y/n): " -n 1 -r
|
||||
echo
|
||||
|
||||
if [[ $REPLY =~ ^[Nn]$ ]]; then
|
||||
log_info "Uninstallation cancelled by user"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Force quit running apps first (batch)
|
||||
if [[ ${#running_apps[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
log_info "Force quitting running applications..."
|
||||
for app_name in "${running_apps[@]}"; do
|
||||
echo " • Quitting $app_name..."
|
||||
pkill -f "$app_name" 2>/dev/null || true
|
||||
done
|
||||
echo " • Waiting 3 seconds for apps to close..."
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
# Perform uninstallations without individual confirmations
|
||||
echo ""
|
||||
log_info "Starting batch uninstallation..."
|
||||
local success_count=0
|
||||
local failed_count=0
|
||||
|
||||
for detail in "${app_details[@]}"; do
|
||||
IFS='|' read -r app_name app_path bundle_id total_kb related_files <<< "$detail"
|
||||
|
||||
echo ""
|
||||
echo "🗑️ Uninstalling: $app_name"
|
||||
|
||||
# Remove the application
|
||||
if rm -rf "$app_path" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} Removed application"
|
||||
|
||||
# Remove related files
|
||||
local files_removed=0
|
||||
while IFS= read -r file; do
|
||||
if [[ -n "$file" && -e "$file" ]]; then
|
||||
if rm -rf "$file" 2>/dev/null; then
|
||||
((files_removed++))
|
||||
fi
|
||||
fi
|
||||
done <<< "$related_files"
|
||||
|
||||
if [[ $files_removed -gt 0 ]]; then
|
||||
echo -e " ${GREEN}✓${NC} Cleaned $files_removed related files"
|
||||
fi
|
||||
|
||||
((total_size_freed += total_kb))
|
||||
((success_count++))
|
||||
((files_cleaned++))
|
||||
((total_items++))
|
||||
|
||||
else
|
||||
echo -e " ${RED}✗${NC} Failed to remove $app_name"
|
||||
((failed_count++))
|
||||
fi
|
||||
done
|
||||
|
||||
# Show final summary
|
||||
echo ""
|
||||
log_header "Uninstallation Complete"
|
||||
|
||||
if [[ $success_count -gt 0 ]]; then
|
||||
if [[ $total_size_freed -gt 1048576 ]]; then
|
||||
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.2fGB", $1/1024/1024}')
|
||||
elif [[ $total_size_freed -gt 1024 ]]; then
|
||||
local freed_display=$(echo "$total_size_freed" | awk '{printf "%.1fMB", $1/1024}')
|
||||
else
|
||||
local freed_display="${total_size_freed}KB"
|
||||
fi
|
||||
log_success "Successfully uninstalled $success_count applications"
|
||||
log_success "Freed $freed_display of disk space"
|
||||
fi
|
||||
|
||||
if [[ $failed_count -gt 0 ]]; then
|
||||
log_warning "$failed_count applications failed to uninstall"
|
||||
fi
|
||||
|
||||
((total_size_cleaned += total_size_freed))
|
||||
}
|
||||
248
lib/better_menu.sh
Executable file
248
lib/better_menu.sh
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Better menu system with proper terminal handling
|
||||
# Uses tried-and-true approach for better compatibility
|
||||
|
||||
# Terminal state management
|
||||
save_terminal() {
|
||||
stty -g 2>/dev/null || true
|
||||
}
|
||||
|
||||
restore_terminal() {
|
||||
stty "$(save_terminal)" 2>/dev/null || true
|
||||
printf '\033[?25h' >&2 # Show cursor
|
||||
printf '\033[0m' >&2 # Reset colors
|
||||
}
|
||||
|
||||
# Read a single key (handles arrow keys properly)
|
||||
read_key() {
|
||||
local key
|
||||
read -rsn1 key
|
||||
case "$key" in
|
||||
$'\033') # ESC sequence
|
||||
read -rsn2 key 2>/dev/null || key=""
|
||||
case "$key" in
|
||||
'[A') echo "UP" ;;
|
||||
'[B') echo "DOWN" ;;
|
||||
*) echo "ESC" ;;
|
||||
esac
|
||||
;;
|
||||
' ') echo "SPACE" ;;
|
||||
'') echo "ENTER" ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Multi-select menu with proper pagination
|
||||
multi_select_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
echo "Error: No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a selected=()
|
||||
local current=0
|
||||
local page_size=10
|
||||
local total=${#items[@]}
|
||||
|
||||
# Initialize selection array
|
||||
for ((i = 0; i < total; i++)); do
|
||||
selected[i]=false
|
||||
done
|
||||
|
||||
# Save terminal state
|
||||
local saved_state=""
|
||||
saved_state=$(save_terminal)
|
||||
trap 'test -n "$saved_state" && stty "$saved_state" 2>/dev/null; restore_terminal' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
# Calculate pagination
|
||||
local start_page=$((current / page_size))
|
||||
local start_idx=$((start_page * page_size))
|
||||
local end_idx=$((start_idx + page_size - 1))
|
||||
if [[ $end_idx -ge $total ]]; then
|
||||
end_idx=$((total - 1))
|
||||
fi
|
||||
|
||||
# Clear screen and show header
|
||||
printf '\033[2J\033[H' >&2
|
||||
echo "┌─── $title ───┐" >&2
|
||||
echo "│ Found $total items (Page $((start_page + 1)) of $(((total + page_size - 1) / page_size))) │" >&2
|
||||
echo "└─────────────────────────────────────────────┘" >&2
|
||||
echo "" >&2
|
||||
|
||||
# Show items for current page
|
||||
for ((i = start_idx; i <= end_idx; i++)); do
|
||||
local marker=" "
|
||||
local checkbox="☐"
|
||||
|
||||
if [[ $i -eq $current ]]; then
|
||||
marker="▶ "
|
||||
fi
|
||||
|
||||
if [[ ${selected[i]} == "true" ]]; then
|
||||
checkbox="☑"
|
||||
fi
|
||||
|
||||
printf "%s%s %s\n" "$marker" "$checkbox" "${items[i]}" >&2
|
||||
done
|
||||
|
||||
echo "" >&2
|
||||
echo "Controls: ↑/↓=Navigate Space=Select/Deselect Enter=Confirm Q=Quit" >&2
|
||||
|
||||
# Show selection summary
|
||||
local count=0
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [[ ${selected[i]} == "true" ]]; then
|
||||
((count++))
|
||||
fi
|
||||
done
|
||||
echo "Selected: $count items" >&2
|
||||
echo "" >&2
|
||||
|
||||
# Read key
|
||||
local key
|
||||
key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((current--))
|
||||
if [[ $current -lt 0 ]]; then
|
||||
current=$((total - 1))
|
||||
fi
|
||||
;;
|
||||
"DOWN")
|
||||
((current++))
|
||||
if [[ $current -ge $total ]]; then
|
||||
current=0
|
||||
fi
|
||||
;;
|
||||
"SPACE")
|
||||
if [[ ${selected[current]} == "true" ]]; then
|
||||
selected[current]=false
|
||||
else
|
||||
selected[current]=true
|
||||
fi
|
||||
;;
|
||||
"ENTER")
|
||||
# Build result string
|
||||
local result=""
|
||||
for ((i = 0; i < total; i++)); do
|
||||
if [[ ${selected[i]} == "true" ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean up and return
|
||||
restore_terminal
|
||||
echo "${result# }" # Remove leading space
|
||||
return 0
|
||||
;;
|
||||
"QUIT"|"ESC")
|
||||
restore_terminal
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Simple single-select menu
|
||||
single_select_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
echo "Error: No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local current=0
|
||||
local total=${#items[@]}
|
||||
|
||||
# Save terminal state
|
||||
local saved_state=""
|
||||
saved_state=$(save_terminal)
|
||||
trap 'test -n "$saved_state" && stty "$saved_state" 2>/dev/null; restore_terminal' EXIT INT TERM
|
||||
|
||||
while true; do
|
||||
# Clear screen and show header
|
||||
printf '\033[2J\033[H' >&2
|
||||
echo "┌─── $title ───┐" >&2
|
||||
echo "│ Choose one of $total items │" >&2
|
||||
echo "└────────────────────────────┘" >&2
|
||||
echo "" >&2
|
||||
|
||||
# Show all items
|
||||
for ((i = 0; i < total; i++)); do
|
||||
local marker=" "
|
||||
if [[ $i -eq $current ]]; then
|
||||
marker="▶ "
|
||||
fi
|
||||
printf "%s%s\n" "$marker" "${items[i]}" >&2
|
||||
done
|
||||
|
||||
echo "" >&2
|
||||
echo "Controls: ↑/↓=Navigate Enter=Select Q=Quit" >&2
|
||||
echo "" >&2
|
||||
|
||||
# Read key
|
||||
local key
|
||||
key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((current--))
|
||||
if [[ $current -lt 0 ]]; then
|
||||
current=$((total - 1))
|
||||
fi
|
||||
;;
|
||||
"DOWN")
|
||||
((current++))
|
||||
if [[ $current -ge $total ]]; then
|
||||
current=0
|
||||
fi
|
||||
;;
|
||||
"ENTER")
|
||||
restore_terminal
|
||||
echo "$current"
|
||||
return 0
|
||||
;;
|
||||
"QUIT"|"ESC")
|
||||
restore_terminal
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Demo function for testing
|
||||
demo() {
|
||||
echo "=== Multi-select Demo ===" >&2
|
||||
local result
|
||||
result=$(multi_select_menu "Test Multi-Select" "Option 1" "Option 2" "Option 3" "Option 4" "Option 5")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "You selected indices: $result" >&2
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
|
||||
echo "" >&2
|
||||
echo "=== Single-select Demo ===" >&2
|
||||
result=$(single_select_menu "Test Single-Select" "Choice A" "Choice B" "Choice C")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "You selected index: $result" >&2
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Run demo if script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo
|
||||
fi
|
||||
134
lib/common.sh
Normal file
134
lib/common.sh
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
# Mac Tools - Common Functions Library
|
||||
# Shared utilities and functions for all modules
|
||||
|
||||
# Color definitions
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
PURPLE='\033[0;35m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Logging functions
|
||||
log_info() { echo -e "${BLUE}$1${NC}"; }
|
||||
log_success() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
log_error() { echo -e "${RED}❌ $1${NC}"; }
|
||||
log_header() { echo -e "\n${PURPLE}▶ $1${NC}"; }
|
||||
|
||||
# System detection
|
||||
detect_architecture() {
|
||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||
echo "Apple Silicon"
|
||||
else
|
||||
echo "Intel"
|
||||
fi
|
||||
}
|
||||
|
||||
get_free_space() {
|
||||
df -h / | awk 'NR==2 {print $4}'
|
||||
}
|
||||
|
||||
# Common UI functions
|
||||
clear_screen() {
|
||||
printf '\033[2J\033[H'
|
||||
}
|
||||
|
||||
show_header() {
|
||||
local title="$1"
|
||||
local subtitle="$2"
|
||||
|
||||
clear_screen
|
||||
echo -e "${BLUE}$title${NC}"
|
||||
echo "================================================="
|
||||
if [[ -n "$subtitle" ]]; then
|
||||
echo -e "${PURPLE}$subtitle${NC}"
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Keyboard input handling (simple and robust)
|
||||
read_key() {
|
||||
local key rest
|
||||
IFS= read -rsn1 key || return 1
|
||||
|
||||
# Some terminals can yield empty on Enter with -n1; treat as ENTER
|
||||
if [[ -z "$key" ]]; then
|
||||
echo "ENTER"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
' ') echo " " ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
'a'|'A') echo "ALL" ;;
|
||||
'n'|'N') echo "NONE" ;;
|
||||
'?') echo "HELP" ;;
|
||||
$'\x1b')
|
||||
# Read the next two bytes within 1s; works well on macOS bash 3.2
|
||||
if IFS= read -rsn2 -t 1 rest 2>/dev/null; then
|
||||
case "$rest" in
|
||||
"[A") echo "UP" ;;
|
||||
"[B") echo "DOWN" ;;
|
||||
"[C") echo "RIGHT" ;;
|
||||
"[D") echo "LEFT" ;;
|
||||
*) echo "ESC" ;;
|
||||
esac
|
||||
else
|
||||
echo "ESC"
|
||||
fi
|
||||
;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Menu display helper
|
||||
show_menu_option() {
|
||||
local number="$1"
|
||||
local text="$2"
|
||||
local selected="$3"
|
||||
|
||||
if [[ "$selected" == "true" ]]; then
|
||||
echo -e "${BLUE}▶ $number. $text${NC}"
|
||||
else
|
||||
echo " $number. $text"
|
||||
fi
|
||||
}
|
||||
|
||||
# Error handling
|
||||
handle_error() {
|
||||
local message="$1"
|
||||
local exit_code="${2:-1}"
|
||||
|
||||
log_error "$message"
|
||||
exit "$exit_code"
|
||||
}
|
||||
|
||||
# File size utilities
|
||||
get_human_size() {
|
||||
local path="$1"
|
||||
du -sh "$path" 2>/dev/null | cut -f1 || echo "N/A"
|
||||
}
|
||||
|
||||
# Permission checks
|
||||
check_sudo() {
|
||||
if ! sudo -n true 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
request_sudo() {
|
||||
echo "This operation requires administrator privileges."
|
||||
echo -n "Please enter your password: "
|
||||
read -s password
|
||||
echo
|
||||
if echo "$password" | sudo -S true 2>/dev/null; then
|
||||
return 0
|
||||
else
|
||||
log_error "Invalid password or cancelled"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
367
lib/menu.sh
Executable file
367
lib/menu.sh
Executable file
@@ -0,0 +1,367 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple interactive menu selector with arrow key support
|
||||
# No external dependencies, compatible with most bash versions
|
||||
|
||||
declare -a menu_options=()
|
||||
declare -i selected=0
|
||||
declare -i menu_size=0
|
||||
|
||||
# ANSI escape sequences
|
||||
readonly ESC=$'\033'
|
||||
readonly UP="${ESC}[A"
|
||||
readonly DOWN="${ESC}[B"
|
||||
readonly ENTER=$'\n'
|
||||
readonly CLEAR_LINE="${ESC}[2K"
|
||||
readonly HIDE_CURSOR="${ESC}[?25l"
|
||||
readonly SHOW_CURSOR="${ESC}[?25h"
|
||||
|
||||
# Set terminal to raw mode for reading single characters
|
||||
setup_terminal() {
|
||||
# Block until at least 1 byte to avoid false ENTER on empty reads
|
||||
stty -echo -icanon min 1 time 0
|
||||
}
|
||||
|
||||
# Restore terminal to normal mode
|
||||
restore_terminal() {
|
||||
stty echo icanon
|
||||
printf "%s" "$SHOW_CURSOR"
|
||||
}
|
||||
|
||||
# Draw the menu
|
||||
draw_menu() {
|
||||
local force_full_redraw="${1:-true}"
|
||||
printf "%s" "$HIDE_CURSOR"
|
||||
|
||||
if [[ "$force_full_redraw" == "true" ]]; then
|
||||
# Full redraw: clear and redraw all lines
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE"
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ \033[1;32m%s\033[0m\n" "${menu_options[i]}"
|
||||
else
|
||||
printf " %s\n" "${menu_options[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size
|
||||
printf "${ESC}7" # Save cursor position
|
||||
else
|
||||
# Quick update: only update changed lines
|
||||
printf "${ESC}8" # Restore cursor position
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE"
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ \033[1;32m%s\033[0m\n" "${menu_options[i]}"
|
||||
else
|
||||
printf " %s\n" "${menu_options[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning
|
||||
printf "${ESC}[%dA" $menu_size
|
||||
printf "${ESC}7" # Save cursor position again
|
||||
fi
|
||||
}
|
||||
|
||||
# Read a single key
|
||||
read_key() {
|
||||
local key
|
||||
IFS= read -rsn1 key 2>/dev/null || return 1
|
||||
|
||||
case "$key" in
|
||||
$'\033')
|
||||
local key2 key3
|
||||
if IFS= read -rsn1 -t 0.2 key2 2>/dev/null; then
|
||||
if [[ "$key2" == "[" ]]; then
|
||||
if IFS= read -rsn1 -t 0.2 key3 2>/dev/null; then
|
||||
case "$key3" in
|
||||
'A') echo "UP" ;;
|
||||
'B') echo "DOWN" ;;
|
||||
'C') echo "RIGHT" ;;
|
||||
'D') echo "LEFT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
;;
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
' ') echo " " ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
*) echo "$key" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main menu function
|
||||
# Usage: show_menu "Title" "option1" "option2" "option3" ...
|
||||
show_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n\n" "$title"
|
||||
fi
|
||||
|
||||
# Initial draw
|
||||
draw_menu true
|
||||
|
||||
# Main loop
|
||||
local first_iteration=true
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_menu false # Quick update
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_menu false # Quick update
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selection
|
||||
printf "Selected: \033[1;32m%s\033[0m\n\n" "${menu_options[selected]}"
|
||||
|
||||
restore_terminal
|
||||
return $selected
|
||||
;;
|
||||
"q"|"Q")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
[0-9])
|
||||
# Jump to numbered option
|
||||
local num=$((key - 1))
|
||||
if [[ $num -ge 0 && $num -lt $menu_size ]]; then
|
||||
selected=$num
|
||||
draw_menu
|
||||
fi
|
||||
;;
|
||||
# Ignore other keys
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Multi-select menu function
|
||||
# Usage: show_multi_menu "Title" "option1" "option2" "option3" ...
|
||||
show_multi_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Array to track selected items
|
||||
declare -a selected_items=()
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
selected_items[i]=false
|
||||
done
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n" "$title" >&2
|
||||
printf "\033[0;36mUse SPACE to select/deselect, ENTER to confirm, Q to quit\033[0m\n\n" >&2
|
||||
fi
|
||||
|
||||
# Draw multi-select menu
|
||||
draw_multi_menu() {
|
||||
local force_full_redraw="${1:-true}"
|
||||
printf "%s" "$HIDE_CURSOR" >&2
|
||||
|
||||
if [[ "$force_full_redraw" == "true" ]]; then
|
||||
# Full redraw
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE" >&2
|
||||
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="\033[1;32m☑\033[0m"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
else
|
||||
printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
printf "${ESC}7" >&2 # Save cursor position
|
||||
else
|
||||
# Quick update
|
||||
printf "${ESC}8" >&2 # Restore cursor position
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE" >&2
|
||||
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="\033[1;32m☑\033[0m"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
else
|
||||
printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning and save position
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
printf "${ESC}7" >&2 # Save cursor position
|
||||
fi
|
||||
}
|
||||
|
||||
# Initial draw
|
||||
draw_multi_menu true
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
" ")
|
||||
# Toggle selection
|
||||
if [[ ${selected_items[selected]} == "true" ]]; then
|
||||
selected_items[selected]="false"
|
||||
else
|
||||
selected_items[selected]="true"
|
||||
fi
|
||||
draw_multi_menu false # Quick update
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selections to stderr so it doesn't interfere with return value
|
||||
local has_selection=false
|
||||
printf "Selected items:\n" >&2
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
printf " \033[1;32m%s\033[0m\n" "${menu_options[i]}" >&2
|
||||
has_selection=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $has_selection == "false" ]]; then
|
||||
printf " None\n" >&2
|
||||
fi
|
||||
printf "\n" >&2
|
||||
|
||||
restore_terminal
|
||||
|
||||
# Return selected indices as space-separated string
|
||||
local result=""
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
echo "${result# }" # Remove leading space
|
||||
return 0
|
||||
;;
|
||||
"q"|"Q"|"ESC")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Example usage function
|
||||
demo_menu() {
|
||||
echo "=== Single Select Demo ==="
|
||||
if show_menu "Choose an action:" "Install package" "Update system" "Clean cache" "Exit"; then
|
||||
local choice=$?
|
||||
echo "You selected option $choice"
|
||||
fi
|
||||
|
||||
echo -e "\n=== Multi Select Demo ==="
|
||||
local selections=$(show_multi_menu "Choose packages to install:" "git" "vim" "curl" "htop" "tree")
|
||||
if [[ $? -eq 0 && -n "$selections" ]]; then
|
||||
echo "Selected indices: $selections"
|
||||
# Convert indices to actual values
|
||||
local options=("git" "vim" "curl" "htop" "tree")
|
||||
echo "Selected packages:"
|
||||
for idx in $selections; do
|
||||
echo " - ${options[idx]}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# If script is run directly, show demo
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_menu
|
||||
fi
|
||||
300
lib/menu_backup.sh
Executable file
300
lib/menu_backup.sh
Executable file
@@ -0,0 +1,300 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple interactive menu selector with arrow key support
|
||||
# No external dependencies, compatible with most bash versions
|
||||
|
||||
declare -a menu_options=()
|
||||
declare -i selected=0
|
||||
declare -i menu_size=0
|
||||
|
||||
# ANSI escape sequences
|
||||
readonly ESC=$'\033'
|
||||
readonly UP="${ESC}[A"
|
||||
readonly DOWN="${ESC}[B"
|
||||
readonly ENTER=$'\n'
|
||||
readonly CLEAR_LINE="${ESC}[2K"
|
||||
readonly HIDE_CURSOR="${ESC}[?25l"
|
||||
readonly SHOW_CURSOR="${ESC}[?25h"
|
||||
|
||||
# Set terminal to raw mode for reading single characters
|
||||
setup_terminal() {
|
||||
stty -echo -icanon time 0 min 0
|
||||
}
|
||||
|
||||
# Restore terminal to normal mode
|
||||
restore_terminal() {
|
||||
stty echo icanon
|
||||
printf "%s" "$SHOW_CURSOR"
|
||||
}
|
||||
|
||||
# Draw the menu
|
||||
draw_menu() {
|
||||
printf "%s" "$HIDE_CURSOR"
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE"
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ \033[1;32m%s\033[0m\n" "${menu_options[i]}"
|
||||
else
|
||||
printf " %s\n" "${menu_options[i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning
|
||||
printf "${ESC}[%dA" $menu_size
|
||||
}
|
||||
|
||||
# Read a single key
|
||||
read_key() {
|
||||
local key
|
||||
IFS= read -r -n1 key 2>/dev/null
|
||||
|
||||
if [[ $key == $ESC ]]; then
|
||||
# Handle escape sequences
|
||||
IFS= read -r -n2 key 2>/dev/null
|
||||
case "$key" in
|
||||
'[A') echo "UP" ;;
|
||||
'[B') echo "DOWN" ;;
|
||||
*) echo "ESC" ;;
|
||||
esac
|
||||
elif [[ $key == "" ]]; then
|
||||
echo "ENTER"
|
||||
else
|
||||
echo "$key"
|
||||
fi
|
||||
}
|
||||
|
||||
# Main menu function
|
||||
# Usage: show_menu "Title" "option1" "option2" "option3" ...
|
||||
show_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n\n" "$title"
|
||||
fi
|
||||
|
||||
# Initial draw
|
||||
draw_menu
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_menu
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_menu
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selection
|
||||
printf "Selected: \033[1;32m%s\033[0m\n\n" "${menu_options[selected]}"
|
||||
|
||||
restore_terminal
|
||||
return $selected
|
||||
;;
|
||||
"q"|"Q")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
[0-9])
|
||||
# Jump to numbered option
|
||||
local num=$((key - 1))
|
||||
if [[ $num -ge 0 && $num -lt $menu_size ]]; then
|
||||
selected=$num
|
||||
draw_menu
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Multi-select menu function
|
||||
# Usage: show_multi_menu "Title" "option1" "option2" "option3" ...
|
||||
show_multi_menu() {
|
||||
local title="$1"
|
||||
shift
|
||||
|
||||
# Initialize menu options
|
||||
menu_options=("$@")
|
||||
menu_size=${#menu_options[@]}
|
||||
selected=0
|
||||
|
||||
# Array to track selected items
|
||||
declare -a selected_items=()
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
selected_items[i]=false
|
||||
done
|
||||
|
||||
# Check if we have options
|
||||
if [[ $menu_size -eq 0 ]]; then
|
||||
echo "Error: No menu options provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Setup terminal
|
||||
setup_terminal
|
||||
trap restore_terminal EXIT INT TERM
|
||||
|
||||
# Display title
|
||||
if [[ -n "$title" ]]; then
|
||||
printf "\n\033[1;34m%s\033[0m\n" "$title" >&2
|
||||
printf "\033[0;36mUse SPACE to select/deselect, ENTER to confirm, Q to quit\033[0m\n\n" >&2
|
||||
fi
|
||||
|
||||
# Draw multi-select menu
|
||||
draw_multi_menu() {
|
||||
printf "%s" "$HIDE_CURSOR" >&2
|
||||
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s" "$CLEAR_LINE" >&2
|
||||
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="\033[1;32m☑\033[0m"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
printf "▶ %s \033[1;32m%s\033[0m\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
else
|
||||
printf " %s %s\n" "$checkbox" "${menu_options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# Move cursor back to the beginning
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
}
|
||||
|
||||
# Initial draw
|
||||
draw_multi_menu
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
local key=$(read_key)
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((menu_size - 1))
|
||||
fi
|
||||
draw_multi_menu
|
||||
;;
|
||||
"DOWN")
|
||||
((selected++))
|
||||
if [[ $selected -ge $menu_size ]]; then
|
||||
selected=0
|
||||
fi
|
||||
draw_multi_menu
|
||||
;;
|
||||
" ")
|
||||
# Toggle selection
|
||||
if [[ ${selected_items[selected]} == "true" ]]; then
|
||||
selected_items[selected]="false"
|
||||
else
|
||||
selected_items[selected]="true"
|
||||
fi
|
||||
draw_multi_menu
|
||||
;;
|
||||
"ENTER")
|
||||
# Clear the menu
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
printf "\r%s\n" "$CLEAR_LINE" >&2
|
||||
done
|
||||
printf "${ESC}[%dA" $menu_size >&2
|
||||
|
||||
# Show selections to stderr so it doesn't interfere with return value
|
||||
local has_selection=false
|
||||
printf "Selected items:\n" >&2
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
printf " \033[1;32m%s\033[0m\n" "${menu_options[i]}" >&2
|
||||
has_selection=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $has_selection == "false" ]]; then
|
||||
printf " None\n" >&2
|
||||
fi
|
||||
printf "\n" >&2
|
||||
|
||||
restore_terminal
|
||||
|
||||
# Return selected indices as space-separated string
|
||||
local result=""
|
||||
for ((i = 0; i < menu_size; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
echo "${result# }" # Remove leading space
|
||||
return 0
|
||||
;;
|
||||
"q"|"Q")
|
||||
restore_terminal
|
||||
echo "Cancelled." >&2
|
||||
return 255
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Example usage function
|
||||
demo_menu() {
|
||||
echo "=== Single Select Demo ==="
|
||||
if show_menu "Choose an action:" "Install package" "Update system" "Clean cache" "Exit"; then
|
||||
local choice=$?
|
||||
echo "You selected option $choice"
|
||||
fi
|
||||
|
||||
echo -e "\n=== Multi Select Demo ==="
|
||||
local selections=$(show_multi_menu "Choose packages to install:" "git" "vim" "curl" "htop" "tree")
|
||||
if [[ $? -eq 0 && -n "$selections" ]]; then
|
||||
echo "Selected indices: $selections"
|
||||
# Convert indices to actual values
|
||||
local options=("git" "vim" "curl" "htop" "tree")
|
||||
echo "Selected packages:"
|
||||
for idx in $selections; do
|
||||
echo " - ${options[idx]}"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# If script is run directly, show demo
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_menu
|
||||
fi
|
||||
157
lib/native_menu.sh
Executable file
157
lib/native_menu.sh
Executable file
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple native bash menu using the built-in select command
|
||||
# This is the most reliable approach with zero dependencies
|
||||
|
||||
# Multi-select using native bash select with checkboxes simulation
|
||||
multi_select_native() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
echo "Error: No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=== $title ===" >&2
|
||||
echo "Select multiple items (enter numbers separated by spaces, or 'done' when finished):" >&2
|
||||
echo "" >&2
|
||||
|
||||
# Display items with numbers
|
||||
for ((i = 0; i < ${#items[@]}; i++)); do
|
||||
printf "%2d) %s\n" $((i + 1)) "${items[i]}" >&2
|
||||
done
|
||||
echo "" >&2
|
||||
|
||||
local -a selected_indices=()
|
||||
|
||||
while true; do
|
||||
echo "Currently selected: ${#selected_indices[@]} items" >&2
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
echo "Selected indices: ${selected_indices[*]}" >&2
|
||||
fi
|
||||
echo "" >&2
|
||||
|
||||
read -p "Enter selection (numbers, 'all', 'none', or 'done'): " -r input >&2
|
||||
|
||||
case "$input" in
|
||||
"done"|"")
|
||||
break
|
||||
;;
|
||||
"all")
|
||||
selected_indices=()
|
||||
for ((i = 0; i < ${#items[@]}; i++)); do
|
||||
selected_indices+=($i)
|
||||
done
|
||||
echo "Selected all ${#items[@]} items" >&2
|
||||
;;
|
||||
"none")
|
||||
selected_indices=()
|
||||
echo "Cleared all selections" >&2
|
||||
;;
|
||||
*)
|
||||
# Parse space-separated numbers
|
||||
read -ra nums <<< "$input"
|
||||
for num in "${nums[@]}"; do
|
||||
if [[ "$num" =~ ^[0-9]+$ ]] && [[ $num -ge 1 ]] && [[ $num -le ${#items[@]} ]]; then
|
||||
local idx=$((num - 1))
|
||||
# Check if already selected
|
||||
local already_selected=false
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
for selected in "${selected_indices[@]}"; do
|
||||
if [[ $selected -eq $idx ]]; then
|
||||
already_selected=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $already_selected == false ]]; then
|
||||
selected_indices+=($idx)
|
||||
echo "Added: ${items[idx]}" >&2
|
||||
else
|
||||
echo "Already selected: ${items[idx]}" >&2
|
||||
fi
|
||||
else
|
||||
echo "Invalid selection: $num (must be 1-${#items[@]})" >&2
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
echo "" >&2
|
||||
done
|
||||
|
||||
# Convert to space-separated string and return
|
||||
local result=""
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
result="$result $idx"
|
||||
done
|
||||
echo "${result# }" # Remove leading space
|
||||
else
|
||||
echo "" # Return empty string for no selections
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Simple single-select using native bash select
|
||||
single_select_native() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
if [[ ${#items[@]} -eq 0 ]]; then
|
||||
echo "Error: No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "=== $title ===" >&2
|
||||
|
||||
# Use PS3 to customize the select prompt
|
||||
local PS3="Please select an option (1-${#items[@]}): "
|
||||
|
||||
select item in "${items[@]}" "Cancel"; do
|
||||
if [[ -n "$item" ]]; then
|
||||
if [[ "$item" == "Cancel" ]]; then
|
||||
return 1
|
||||
else
|
||||
# Find the index of selected item
|
||||
for ((i = 0; i < ${#items[@]}; i++)); do
|
||||
if [[ "${items[i]}" == "$item" ]]; then
|
||||
echo "$i"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo "Invalid selection. Please try again." >&2
|
||||
fi
|
||||
done 2>&2 # Redirect select dialog to stderr
|
||||
}
|
||||
|
||||
# Demo function
|
||||
demo_native() {
|
||||
echo "=== Multi-select Demo ===" >&2
|
||||
local result
|
||||
result=$(multi_select_native "Choose Applications" "App 1" "App 2" "App 3" "App 4" "App 5")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "You selected indices: '$result'" >&2
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
|
||||
echo "" >&2
|
||||
echo "=== Single-select Demo ===" >&2
|
||||
result=$(single_select_native "Choose One App" "Option A" "Option B" "Option C")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "You selected index: $result" >&2
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Run demo if script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_native
|
||||
fi
|
||||
312
lib/paginated_menu.sh
Executable file
312
lib/paginated_menu.sh
Executable file
@@ -0,0 +1,312 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Proper paginated menu with arrow key navigation
|
||||
# 10 items per page, up/down to navigate, space to select, left/right to change pages
|
||||
|
||||
# Terminal control functions
|
||||
hide_cursor() { printf '\033[?25l' >&2; }
|
||||
show_cursor() { printf '\033[?25h' >&2; }
|
||||
clear_screen() { printf '\033[2J\033[H' >&2; }
|
||||
enter_alt_screen() { tput smcup >/dev/null 2>&1 || true; }
|
||||
leave_alt_screen() { tput rmcup >/dev/null 2>&1 || true; }
|
||||
disable_wrap() { printf '\033[?7l' >&2; } # disable line wrap
|
||||
enable_wrap() { printf '\033[?7h' >&2; }
|
||||
|
||||
# Read single key with arrow key support (macOS bash 3.2 friendly)
|
||||
read_key() {
|
||||
local key seq
|
||||
IFS= read -rsn1 key || return 1
|
||||
|
||||
# Some terminals may yield empty on Enter with -n1
|
||||
if [[ -z "$key" ]]; then
|
||||
echo "ENTER"
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
$'\033')
|
||||
# Read next two bytes within 1s: "[A", "[B", ...
|
||||
if IFS= read -rsn2 -t 1 seq 2>/dev/null; then
|
||||
case "$seq" in
|
||||
"[A") echo "UP" ;;
|
||||
"[B") echo "DOWN" ;;
|
||||
"[C") echo "RIGHT" ;;
|
||||
"[D") echo "LEFT" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
else
|
||||
echo "OTHER"
|
||||
fi
|
||||
;;
|
||||
' ') echo "SPACE" ;;
|
||||
$'\n'|$'\r') echo "ENTER" ;;
|
||||
'q'|'Q') echo "QUIT" ;;
|
||||
'a'|'A') echo "ALL" ;;
|
||||
'n'|'N') echo "NONE" ;;
|
||||
'?') echo "HELP" ;;
|
||||
*) echo "OTHER" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Paginated multi-select menu
|
||||
paginated_multi_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a items=("$@")
|
||||
|
||||
local total_items=${#items[@]}
|
||||
local items_per_page=10 # Reduced for better readability
|
||||
local total_pages=$(( (total_items + items_per_page - 1) / items_per_page ))
|
||||
local current_page=0
|
||||
local cursor_pos=0 # Position within current page (0-9)
|
||||
local -a selected=()
|
||||
|
||||
# Initialize selection array
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
selected[i]=false
|
||||
done
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
show_cursor
|
||||
stty echo 2>/dev/null || true
|
||||
stty icanon 2>/dev/null || true
|
||||
leave_alt_screen
|
||||
enable_wrap
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Setup terminal for optimal responsiveness
|
||||
stty -echo -icanon min 1 time 0 2>/dev/null || true
|
||||
enter_alt_screen
|
||||
disable_wrap
|
||||
hide_cursor
|
||||
|
||||
# Main display function
|
||||
first_draw=1
|
||||
# Helper: print one cleared line
|
||||
print_line() {
|
||||
printf "\r\033[2K%s\n" "$1" >&2
|
||||
}
|
||||
|
||||
# Helper: render one item line at given page position
|
||||
render_item_line() {
|
||||
local page_pos=$1
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local i=$((start_idx + page_pos))
|
||||
local checkbox="☐"
|
||||
local cursor_marker=" "
|
||||
[[ ${selected[i]} == true ]] && checkbox="☑"
|
||||
if [[ $page_pos -eq $cursor_pos ]]; then
|
||||
cursor_marker="▶ "
|
||||
printf "\r\033[2K\033[7m%s%s %s\033[0m\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2
|
||||
else
|
||||
printf "\r\033[2K%s%s %s\n" "$cursor_marker" "$checkbox" "${items[i]}" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Helper: move cursor to top-left anchor saved by tput sc
|
||||
to_anchor() { tput rc >/dev/null 2>&1 || true; }
|
||||
|
||||
# Full draw of entire screen - simplified for stability
|
||||
draw_menu() {
|
||||
# Always do full screen redraw for reliability
|
||||
clear_screen
|
||||
|
||||
# Simple header
|
||||
printf "%s\n" "$title" >&2
|
||||
printf "%s\n" "$(printf '=%.0s' $(seq 1 ${#title}))" >&2
|
||||
|
||||
# Status bar
|
||||
local selected_count=0
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
[[ ${selected[i]} == true ]] && ((selected_count++))
|
||||
done
|
||||
|
||||
printf "Page %d/%d │ Total: %d │ Selected: %d\n" \
|
||||
$((current_page + 1)) $total_pages $total_items $selected_count >&2
|
||||
print_line ""
|
||||
|
||||
# Calculate page boundaries
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local end_idx=$((start_idx + items_per_page - 1))
|
||||
[[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1))
|
||||
|
||||
# Display items for current page
|
||||
for ((i = start_idx; i <= end_idx; i++)); do
|
||||
local page_pos=$((i - start_idx))
|
||||
render_item_line "$page_pos"
|
||||
done
|
||||
|
||||
# Fill empty slots to always print items_per_page lines
|
||||
local items_on_page=$((end_idx - start_idx + 1))
|
||||
for ((i = items_on_page; i < items_per_page; i++)); do
|
||||
print_line ""
|
||||
done
|
||||
|
||||
print_line ""
|
||||
print_line "↑↓: Navigate | Space: Select | Enter: Confirm | Q: Exit"
|
||||
}
|
||||
|
||||
# Help screen
|
||||
show_help() {
|
||||
clear_screen
|
||||
echo "App Uninstaller - Help" >&2
|
||||
echo "======================" >&2
|
||||
echo >&2
|
||||
echo " ↑ / ↓ Navigate up/down" >&2
|
||||
echo " ← / → Previous/next page" >&2
|
||||
echo " Space Select/deselect app" >&2
|
||||
echo " Enter Confirm selection" >&2
|
||||
echo " A Select all" >&2
|
||||
echo " N Deselect all" >&2
|
||||
echo " Q Exit" >&2
|
||||
echo >&2
|
||||
read -p "Press any key to continue..." -n 1 >&2
|
||||
}
|
||||
|
||||
# Main loop - simplified to always do full redraws for stability
|
||||
while true; do
|
||||
draw_menu # Always full redraw to avoid display issues
|
||||
|
||||
local key=$(read_key)
|
||||
|
||||
# Immediate exit key
|
||||
if [[ "$key" == "QUIT" ]]; then
|
||||
cleanup
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$key" in
|
||||
"UP")
|
||||
if [[ $cursor_pos -gt 0 ]]; then
|
||||
((cursor_pos--))
|
||||
elif [[ $current_page -gt 0 ]]; then
|
||||
((current_page--))
|
||||
cursor_pos=$((items_per_page - 1))
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local end_idx=$((start_idx + items_per_page - 1))
|
||||
[[ $end_idx -ge $total_items ]] && cursor_pos=$((total_items - start_idx - 1))
|
||||
fi
|
||||
;;
|
||||
"DOWN")
|
||||
local start_idx=$((current_page * items_per_page))
|
||||
local items_on_page=$((total_items - start_idx))
|
||||
[[ $items_on_page -gt $items_per_page ]] && items_on_page=$items_per_page
|
||||
|
||||
if [[ $cursor_pos -lt $((items_on_page - 1)) ]]; then
|
||||
((cursor_pos++))
|
||||
elif [[ $current_page -lt $((total_pages - 1)) ]]; then
|
||||
((current_page++))
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"LEFT")
|
||||
if [[ $current_page -gt 0 ]]; then
|
||||
((current_page--))
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"RIGHT")
|
||||
if [[ $current_page -lt $((total_pages - 1)) ]]; then
|
||||
((current_page++))
|
||||
cursor_pos=0
|
||||
fi
|
||||
;;
|
||||
"PGUP")
|
||||
current_page=0
|
||||
cursor_pos=0
|
||||
;;
|
||||
"PGDOWN")
|
||||
current_page=$((total_pages - 1))
|
||||
cursor_pos=0
|
||||
;;
|
||||
"SPACE")
|
||||
local actual_idx=$((current_page * items_per_page + cursor_pos))
|
||||
if [[ $actual_idx -lt $total_items ]]; then
|
||||
if [[ ${selected[actual_idx]} == true ]]; then
|
||||
selected[actual_idx]=false
|
||||
else
|
||||
selected[actual_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")
|
||||
# If no items are selected, select the current item
|
||||
local has_selection=false
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
has_selection=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $has_selection == false ]]; then
|
||||
# Select current item under cursor
|
||||
local actual_idx=$((current_page * items_per_page + cursor_pos))
|
||||
if [[ $actual_idx -lt $total_items ]]; then
|
||||
selected[actual_idx]=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build result
|
||||
local result=""
|
||||
for ((i = 0; i < total_items; i++)); do
|
||||
if [[ ${selected[i]} == true ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
cleanup
|
||||
echo "${result# }"
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
# Ignore unrecognized keys - just continue the loop
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# Demo function
|
||||
demo_paginated() {
|
||||
echo "=== Paginated Multi-select Demo ===" >&2
|
||||
|
||||
# Create test data
|
||||
local test_items=()
|
||||
for i in {1..35}; do
|
||||
test_items+=("Application $i ($(( (RANDOM % 500) + 50 ))MB)")
|
||||
done
|
||||
|
||||
local result
|
||||
result=$(paginated_multi_select "Choose Applications to Uninstall" "${test_items[@]}")
|
||||
local exit_code=$?
|
||||
|
||||
if [[ $exit_code -eq 0 ]]; then
|
||||
if [[ -n "$result" ]]; then
|
||||
echo "Selected indices: $result" >&2
|
||||
echo "Count: $(echo $result | wc -w | tr -d ' ')" >&2
|
||||
else
|
||||
echo "No items selected" >&2
|
||||
fi
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Run demo if script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_paginated
|
||||
fi
|
||||
150
lib/simple_menu.sh
Normal file
150
lib/simple_menu.sh
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Simple, clean menu implementation that properly separates output
|
||||
|
||||
# Simple single-select menu - returns selected index
|
||||
simple_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a options=("$@")
|
||||
local selected=0
|
||||
local key
|
||||
|
||||
# Clear screen and show header
|
||||
clear >&2
|
||||
echo "=== $title ===" >&2
|
||||
echo "" >&2
|
||||
|
||||
while true; do
|
||||
# Show options
|
||||
for ((i = 0; i < ${#options[@]}; i++)); do
|
||||
if [[ $i -eq $selected ]]; then
|
||||
echo "▶ ${options[i]}" >&2
|
||||
else
|
||||
echo " ${options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
echo "" >&2
|
||||
echo "Use ↑/↓ to navigate, ENTER to select, Q to quit" >&2
|
||||
|
||||
# Read key
|
||||
read -rsn1 key
|
||||
case "$key" in
|
||||
$'\x1b')
|
||||
# Arrow key sequence
|
||||
read -rsn2 key
|
||||
case "$key" in
|
||||
'[A') # Up
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((${#options[@]} - 1))
|
||||
fi
|
||||
;;
|
||||
'[B') # Down
|
||||
((selected++))
|
||||
if [[ $selected -ge ${#options[@]} ]]; then
|
||||
selected=0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
'') # Enter
|
||||
echo "$selected"
|
||||
return 0
|
||||
;;
|
||||
'q'|'Q')
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clear screen for next iteration
|
||||
clear >&2
|
||||
echo "=== $title ===" >&2
|
||||
echo "" >&2
|
||||
done
|
||||
}
|
||||
|
||||
# Multi-select menu - returns space-separated indices
|
||||
simple_multi_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a options=("$@")
|
||||
local selected=0
|
||||
local -a selected_items=()
|
||||
local key
|
||||
|
||||
# Initialize selected items array
|
||||
for ((i = 0; i < ${#options[@]}; i++)); do
|
||||
selected_items[i]=false
|
||||
done
|
||||
|
||||
clear >&2
|
||||
echo "=== $title ===" >&2
|
||||
echo "" >&2
|
||||
|
||||
while true; do
|
||||
# Show options
|
||||
for ((i = 0; i < ${#options[@]}; i++)); do
|
||||
local checkbox="☐"
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
checkbox="☑"
|
||||
fi
|
||||
|
||||
if [[ $i -eq $selected ]]; then
|
||||
echo "▶ $checkbox ${options[i]}" >&2
|
||||
else
|
||||
echo " $checkbox ${options[i]}" >&2
|
||||
fi
|
||||
done
|
||||
echo "" >&2
|
||||
echo "Use ↑/↓ to navigate, SPACE to select/deselect, ENTER to confirm, Q to quit" >&2
|
||||
|
||||
# Read key
|
||||
read -rsn1 key
|
||||
case "$key" in
|
||||
$'\x1b')
|
||||
# Arrow key sequence
|
||||
read -rsn2 key
|
||||
case "$key" in
|
||||
'[A') # Up
|
||||
((selected--))
|
||||
if [[ $selected -lt 0 ]]; then
|
||||
selected=$((${#options[@]} - 1))
|
||||
fi
|
||||
;;
|
||||
'[B') # Down
|
||||
((selected++))
|
||||
if [[ $selected -ge ${#options[@]} ]]; then
|
||||
selected=0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
' ') # Space - toggle selection
|
||||
if [[ ${selected_items[selected]} == "true" ]]; then
|
||||
selected_items[selected]=false
|
||||
else
|
||||
selected_items[selected]=true
|
||||
fi
|
||||
;;
|
||||
'') # Enter - confirm
|
||||
local result=""
|
||||
for ((i = 0; i < ${#options[@]}; i++)); do
|
||||
if [[ ${selected_items[i]} == "true" ]]; then
|
||||
result="$result $i"
|
||||
fi
|
||||
done
|
||||
echo "${result# }" # Remove leading space
|
||||
return 0
|
||||
;;
|
||||
'q'|'Q')
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Clear screen for next iteration
|
||||
clear >&2
|
||||
echo "=== $title ===" >&2
|
||||
echo "" >&2
|
||||
done
|
||||
}
|
||||
268
lib/smart_menu.sh
Executable file
268
lib/smart_menu.sh
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Smart menu with pagination and search for large lists
|
||||
# Much better UX for handling many items
|
||||
|
||||
# Smart multi-select with search and pagination
|
||||
smart_multi_select() {
|
||||
local title="$1"
|
||||
shift
|
||||
local -a all_items=("$@")
|
||||
|
||||
if [[ ${#all_items[@]} -eq 0 ]]; then
|
||||
echo "Error: No items provided" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local -a selected_indices=()
|
||||
local -a filtered_items=()
|
||||
local -a filtered_indices=()
|
||||
local search_term=""
|
||||
local page_size=15
|
||||
local current_page=0
|
||||
|
||||
# Function to filter items based on search
|
||||
filter_items() {
|
||||
filtered_items=()
|
||||
filtered_indices=()
|
||||
|
||||
if [[ -z "$search_term" ]]; then
|
||||
# No search, show all items
|
||||
filtered_items=("${all_items[@]}")
|
||||
for ((i = 0; i < ${#all_items[@]}; i++)); do
|
||||
filtered_indices+=($i)
|
||||
done
|
||||
else
|
||||
# Filter items that contain search term (case insensitive)
|
||||
for ((i = 0; i < ${#all_items[@]}; i++)); do
|
||||
if [[ "${all_items[i],,}" == *"${search_term,,}"* ]]; then
|
||||
filtered_items+=("${all_items[i]}")
|
||||
filtered_indices+=($i)
|
||||
fi
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to display current page
|
||||
show_page() {
|
||||
local total_filtered=${#filtered_items[@]}
|
||||
local total_pages=$(( (total_filtered + page_size - 1) / page_size ))
|
||||
local start_idx=$((current_page * page_size))
|
||||
local end_idx=$((start_idx + page_size - 1))
|
||||
|
||||
if [[ $end_idx -ge $total_filtered ]]; then
|
||||
end_idx=$((total_filtered - 1))
|
||||
fi
|
||||
|
||||
printf '\033[2J\033[H' >&2
|
||||
echo "╭─────────────────────────────────────────────────────╮" >&2
|
||||
echo "│ $title" >&2
|
||||
echo "├─────────────────────────────────────────────────────┤" >&2
|
||||
echo "│ Total: ${#all_items[@]} | Filtered: $total_filtered | Selected: ${#selected_indices[@]} │" >&2
|
||||
|
||||
if [[ -n "$search_term" ]]; then
|
||||
echo "│ Search: '$search_term' │" >&2
|
||||
fi
|
||||
|
||||
if [[ $total_pages -gt 1 ]]; then
|
||||
echo "│ Page $(($current_page + 1)) of $total_pages │" >&2
|
||||
fi
|
||||
echo "╰─────────────────────────────────────────────────────╯" >&2
|
||||
echo "" >&2
|
||||
|
||||
if [[ $total_filtered -eq 0 ]]; then
|
||||
echo "No items match your search." >&2
|
||||
echo "" >&2
|
||||
else
|
||||
# Show items for current page
|
||||
for ((i = start_idx; i <= end_idx && i < total_filtered; i++)); do
|
||||
local item_idx=${filtered_indices[i]}
|
||||
local display_num=$((i + 1))
|
||||
|
||||
# Check if this item is selected
|
||||
local is_selected=false
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
for selected in "${selected_indices[@]}"; do
|
||||
if [[ $selected -eq $item_idx ]]; then
|
||||
is_selected=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $is_selected == true ]]; then
|
||||
printf "%3d) ✓ %s\n" "$display_num" "${filtered_items[i]}" >&2
|
||||
else
|
||||
printf "%3d) %s\n" "$display_num" "${filtered_items[i]}" >&2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >&2
|
||||
echo "Commands:" >&2
|
||||
echo " Numbers: Select items (e.g., '1-5', '1 3 7', '10-15')" >&2
|
||||
echo " /search: Filter items (e.g., '/chrome')" >&2
|
||||
echo " n/p: Next/Previous page | all: Select all | none: Clear all" >&2
|
||||
echo " done: Finish selection | quit: Cancel" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
# Main loop
|
||||
while true; do
|
||||
filter_items
|
||||
show_page
|
||||
|
||||
read -p "Enter command: " -r input >&2
|
||||
|
||||
case "$input" in
|
||||
"done"|"")
|
||||
break
|
||||
;;
|
||||
"quit"|"q")
|
||||
return 1
|
||||
;;
|
||||
"all")
|
||||
selected_indices=()
|
||||
for idx in "${filtered_indices[@]}"; do
|
||||
selected_indices+=($idx)
|
||||
done
|
||||
echo "Selected all filtered items (${#filtered_indices[@]})" >&2
|
||||
;;
|
||||
"none")
|
||||
selected_indices=()
|
||||
echo "Cleared all selections" >&2
|
||||
;;
|
||||
"n"|"next")
|
||||
local total_pages=$(( (${#filtered_items[@]} + page_size - 1) / page_size ))
|
||||
if [[ $((current_page + 1)) -lt $total_pages ]]; then
|
||||
((current_page++))
|
||||
else
|
||||
echo "Already on last page" >&2
|
||||
fi
|
||||
;;
|
||||
"p"|"prev")
|
||||
if [[ $current_page -gt 0 ]]; then
|
||||
((current_page--))
|
||||
else
|
||||
echo "Already on first page" >&2
|
||||
fi
|
||||
;;
|
||||
/*)
|
||||
# Search functionality
|
||||
search_term="${input#/}"
|
||||
current_page=0
|
||||
echo "Searching for: '$search_term'" >&2
|
||||
;;
|
||||
*)
|
||||
# Parse selection input
|
||||
parse_selection "$input"
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ "$input" != "n" && "$input" != "next" && "$input" != "p" && "$input" != "prev" ]] && sleep 1
|
||||
done
|
||||
|
||||
# Return selected indices
|
||||
local result=""
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
for idx in "${selected_indices[@]}"; do
|
||||
result="$result $idx"
|
||||
done
|
||||
echo "${result# }"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Parse selection input (supports ranges and individual numbers)
|
||||
parse_selection() {
|
||||
local input="$1"
|
||||
local start_idx=$((current_page * page_size))
|
||||
|
||||
# Split input by spaces
|
||||
read -ra parts <<< "$input"
|
||||
|
||||
for part in "${parts[@]}"; do
|
||||
if [[ "$part" =~ ^[0-9]+$ ]]; then
|
||||
# Single number
|
||||
local display_num=$part
|
||||
local array_idx=$((display_num - 1))
|
||||
|
||||
if [[ $array_idx -ge 0 && $array_idx -lt ${#filtered_items[@]} ]]; then
|
||||
local real_idx=${filtered_indices[array_idx]}
|
||||
toggle_selection "$real_idx"
|
||||
else
|
||||
echo "Invalid selection: $part (range: 1-${#filtered_items[@]})" >&2
|
||||
fi
|
||||
|
||||
elif [[ "$part" =~ ^([0-9]+)-([0-9]+)$ ]]; then
|
||||
# Range like 1-5
|
||||
local start_num=${BASH_REMATCH[1]}
|
||||
local end_num=${BASH_REMATCH[2]}
|
||||
|
||||
for ((num = start_num; num <= end_num; num++)); do
|
||||
local array_idx=$((num - 1))
|
||||
if [[ $array_idx -ge 0 && $array_idx -lt ${#filtered_items[@]} ]]; then
|
||||
local real_idx=${filtered_indices[array_idx]}
|
||||
toggle_selection "$real_idx"
|
||||
fi
|
||||
done
|
||||
|
||||
else
|
||||
echo "Invalid format: $part (use numbers, ranges like '1-5', or commands)" >&2
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Toggle selection of an item
|
||||
toggle_selection() {
|
||||
local idx=$1
|
||||
local already_selected=false
|
||||
local pos_to_remove=-1
|
||||
|
||||
# Check if already selected
|
||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||
for ((i = 0; i < ${#selected_indices[@]}; i++)); do
|
||||
if [[ ${selected_indices[i]} -eq $idx ]]; then
|
||||
already_selected=true
|
||||
pos_to_remove=$i
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $already_selected == true ]]; then
|
||||
# Remove from selection
|
||||
unset selected_indices[$pos_to_remove]
|
||||
selected_indices=("${selected_indices[@]}") # Reindex array
|
||||
echo "Removed: ${all_items[idx]}" >&2
|
||||
else
|
||||
# Add to selection
|
||||
selected_indices+=($idx)
|
||||
echo "Added: ${all_items[idx]}" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Demo function
|
||||
demo_smart() {
|
||||
local test_apps=()
|
||||
for i in {1..50}; do
|
||||
test_apps+=("Test App $i (${RANDOM}MB)")
|
||||
done
|
||||
|
||||
echo "=== Smart Multi-select Demo ===" >&2
|
||||
local result
|
||||
result=$(smart_multi_select "Choose Applications to Remove" "${test_apps[@]}")
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo "You selected indices: '$result'" >&2
|
||||
echo "Selected ${result// /,} out of ${#test_apps[@]} items" >&2
|
||||
else
|
||||
echo "Selection cancelled" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# Run demo if script is executed directly
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
demo_smart
|
||||
fi
|
||||
Reference in New Issue
Block a user