mirror of
https://github.com/tw93/Mole.git
synced 2026-02-10 07:54:18 +00:00
feat(menu): add sort (date/name/size), live filter, reverse; visible-only A/N; responsive footer (#34)
Paginated menu: - Sorting: press S/s to cycle Date → Name → Size; press R/r to reverse. - Live filter: press F/f to enter; case-insensitive substring; prefix with ' to anchor at start; DELETE to backspace; ENTER to apply; ESC to cancel. Shows "searching…" while rebuilding. - Selection scope: A (All) and N (None) now work on the currently visible items only (after filter/sort), not the entire list. - Footer: adds A/N to the help line and wraps only at ' | ' separators so labels are never broken; adapts to terminal width. - Internals: view_indices mapping for filtered/sorted view; glob-safe matching via _pm_escape_glob; drain_pending_input; robust stty restore; optional MOLE_MANAGED_ALT_SCREEN; cleanup unsets MOLE_READ_KEY_FORCE_CHAR; shellcheck clean. common.sh: - read_key supports a raw typing mode (MOLE_READ_KEY_FORCE_CHAR=1) emitting CHAR:<k>; ENTER/DELETE/ESC handled. - Uppercase A/N/R mappings (ALL/NONE/RETRY), printable-key detection, better ESC sequence handling. app_selector.sh: - Builds and exports per-item metadata CSV for epochs and size_kb via MOLE_MENU_META_EPOCHS and MOLE_MENU_META_SIZEKB; unsets them after the menu. - Menu options keep display text; sorting/filtering use metadata. uninstall.sh: - Computes app_size_kb using du -sk for numeric sorting while keeping human-readable size; writes it as the final field. - load_applications reads the new size_kb field. Notes: - Footer grew due to new commands; responsive wrapping prevents mid-word breaks. - ./tests/run.sh: only the two upstream failures remain (unchanged by this patch).
This commit is contained in:
@@ -245,7 +245,10 @@ scan_applications() {
|
|||||||
|
|
||||||
# Parallel size calculation
|
# Parallel size calculation
|
||||||
local app_size="N/A"
|
local app_size="N/A"
|
||||||
|
local app_size_kb="0"
|
||||||
if [[ -d "$app_path" ]]; then
|
if [[ -d "$app_path" ]]; then
|
||||||
|
# numeric size (KB) for sorting + human-readable for display
|
||||||
|
app_size_kb=$(du -sk "$app_path" 2> /dev/null | awk '{print $1}' || echo "0")
|
||||||
app_size=$(du -sh "$app_path" 2> /dev/null | cut -f1 || echo "N/A")
|
app_size=$(du -sh "$app_path" 2> /dev/null | cut -f1 || echo "N/A")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -297,7 +300,8 @@ scan_applications() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Write to output file atomically
|
# Write to output file atomically
|
||||||
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}" >> "$output_file"
|
# Fields: epoch|app_path|display_name|bundle_id|size_human|last_used|size_kb
|
||||||
|
echo "${last_used_epoch}|${app_path}|${display_name}|${bundle_id}|${app_size}|${last_used}|${app_size_kb}" >> "$output_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
export -f process_app_metadata
|
export -f process_app_metadata
|
||||||
@@ -380,8 +384,8 @@ load_applications() {
|
|||||||
selection_state=()
|
selection_state=()
|
||||||
|
|
||||||
# Read apps into array
|
# Read apps into array
|
||||||
while IFS='|' read -r epoch app_path app_name bundle_id size last_used; do
|
while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do
|
||||||
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used")
|
apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}")
|
||||||
selection_state+=(false)
|
selection_state+=(false)
|
||||||
done < "$apps_file"
|
done < "$apps_file"
|
||||||
|
|
||||||
|
|||||||
@@ -33,18 +33,44 @@ select_apps_for_uninstall() {
|
|||||||
|
|
||||||
# Build menu options
|
# Build menu options
|
||||||
local -a menu_options=()
|
local -a menu_options=()
|
||||||
|
# Prepare metadata (comma-separated) for sorting/filtering inside the menu
|
||||||
|
local epochs_csv=""
|
||||||
|
local sizekb_csv=""
|
||||||
|
local idx=0
|
||||||
for app_data in "${apps_data[@]}"; do
|
for app_data in "${apps_data[@]}"; do
|
||||||
# Ignore metadata fields not needed for menu display
|
# Keep extended field 7 (size_kb) if present
|
||||||
IFS='|' read -r _ _ display_name _ size last_used <<< "$app_data"
|
IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data"
|
||||||
menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")")
|
menu_options+=("$(format_app_display "$display_name" "$size" "$last_used")")
|
||||||
|
# Build csv lists (avoid trailing commas)
|
||||||
|
if [[ $idx -eq 0 ]]; then
|
||||||
|
epochs_csv="${epoch:-0}"
|
||||||
|
sizekb_csv="${size_kb:-0}"
|
||||||
|
else
|
||||||
|
epochs_csv+=",${epoch:-0}"
|
||||||
|
sizekb_csv+=",${size_kb:-0}"
|
||||||
|
fi
|
||||||
|
((idx++))
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Expose metadata for the paginated menu (optional inputs)
|
||||||
|
# - MOLE_MENU_META_EPOCHS: numeric last_used_epoch per item
|
||||||
|
# - MOLE_MENU_META_SIZEKB: numeric size in KB per item
|
||||||
|
# The menu will gracefully fallback if these are unset or malformed.
|
||||||
|
export MOLE_MENU_META_EPOCHS="$epochs_csv"
|
||||||
|
export MOLE_MENU_META_SIZEKB="$sizekb_csv"
|
||||||
|
# Optional: allow default sort override via env (date|name|size)
|
||||||
|
# export MOLE_MENU_SORT_DEFAULT="${MOLE_MENU_SORT_DEFAULT:-date}"
|
||||||
|
|
||||||
# Use paginated menu - result will be stored in MOLE_SELECTION_RESULT
|
# Use paginated menu - result will be stored in MOLE_SELECTION_RESULT
|
||||||
# Note: paginated_multi_select enters alternate screen and handles clearing
|
# Note: paginated_multi_select enters alternate screen and handles clearing
|
||||||
MOLE_SELECTION_RESULT=""
|
MOLE_SELECTION_RESULT=""
|
||||||
paginated_multi_select "Select Apps to Remove" "${menu_options[@]}"
|
paginated_multi_select "Select Apps to Remove" "${menu_options[@]}"
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
|
|
||||||
|
# Clean env leakage for safety
|
||||||
|
unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB
|
||||||
|
# leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally
|
||||||
|
|
||||||
if [[ $exit_code -ne 0 ]]; then
|
if [[ $exit_code -ne 0 ]]; then
|
||||||
echo "Cancelled"
|
echo "Cancelled"
|
||||||
return 1
|
return 1
|
||||||
@@ -56,11 +82,9 @@ select_apps_for_uninstall() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Build selected apps array (global variable in bin/uninstall.sh)
|
# Build selected apps array (global variable in bin/uninstall.sh)
|
||||||
# Clear existing selections - compatible with bash 3.2
|
|
||||||
selected_apps=()
|
selected_apps=()
|
||||||
|
|
||||||
# Parse indices and build selected apps array
|
# Parse indices and build selected apps array
|
||||||
# MOLE_SELECTION_RESULT is comma-separated list of indices from the paginated menu
|
|
||||||
IFS=',' read -r -a indices_array <<< "$MOLE_SELECTION_RESULT"
|
IFS=',' read -r -a indices_array <<< "$MOLE_SELECTION_RESULT"
|
||||||
|
|
||||||
for idx in "${indices_array[@]}"; do
|
for idx in "${indices_array[@]}"; do
|
||||||
|
|||||||
@@ -176,9 +176,29 @@ show_cursor() {
|
|||||||
# Keyboard input handling (simple and robust)
|
# Keyboard input handling (simple and robust)
|
||||||
read_key() {
|
read_key() {
|
||||||
local key rest
|
local key rest
|
||||||
# Use macOS bash 3.2 compatible read syntax
|
|
||||||
IFS= read -r -s -n 1 key || return 1
|
IFS= read -r -s -n 1 key || return 1
|
||||||
|
|
||||||
|
# Raw typing mode (filter): map most keys to CHAR:<key>
|
||||||
|
if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then
|
||||||
|
# Some terminals return empty on Enter with -n1
|
||||||
|
if [[ -z "$key" ]]; then
|
||||||
|
echo "ENTER"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "$key" in
|
||||||
|
$'\n'|$'\r') echo "ENTER" ;;
|
||||||
|
$'\x7f'|$'\x08') echo "DELETE" ;;
|
||||||
|
$'\x1b') echo "QUIT" ;; # ESC cancels filter
|
||||||
|
*)
|
||||||
|
case "$key" in
|
||||||
|
[[:print:]]) echo "CHAR:$key" ;;
|
||||||
|
*) echo "OTHER" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Some terminals can yield empty on Enter with -n1; treat as ENTER
|
# Some terminals can yield empty on Enter with -n1; treat as ENTER
|
||||||
if [[ -z "$key" ]]; then
|
if [[ -z "$key" ]]; then
|
||||||
echo "ENTER"
|
echo "ENTER"
|
||||||
@@ -189,14 +209,13 @@ read_key() {
|
|||||||
$'\n' | $'\r') echo "ENTER" ;;
|
$'\n' | $'\r') echo "ENTER" ;;
|
||||||
' ') echo "SPACE" ;;
|
' ') echo "SPACE" ;;
|
||||||
'q' | 'Q') echo "QUIT" ;;
|
'q' | 'Q') echo "QUIT" ;;
|
||||||
'a' | 'A') echo "ALL" ;;
|
'A') echo "ALL" ;;
|
||||||
'n' | 'N') echo "NONE" ;;
|
'N') echo "NONE" ;;
|
||||||
'd' | 'D') echo "DELETE" ;;
|
'R') echo "RETRY" ;;
|
||||||
'r' | 'R') echo "RETRY" ;;
|
|
||||||
'?') echo "HELP" ;;
|
'?') echo "HELP" ;;
|
||||||
'o' | 'O') echo "OPEN" ;;
|
'o' | 'O') echo "OPEN" ;;
|
||||||
$'\x03') echo "QUIT" ;; # Ctrl+C
|
$'\x03') echo "QUIT" ;; # Ctrl+C
|
||||||
$'\x7f' | $'\x08') echo "DELETE" ;; # Delete key (labeled "delete" on Mac, actually backspace)
|
$'\x7f' | $'\x08') echo "DELETE" ;; # Backspace/Delete key
|
||||||
$'\x1b')
|
$'\x1b')
|
||||||
# ESC sequence - could be arrow key, delete key, or ESC alone
|
# ESC sequence - could be arrow key, delete key, or ESC alone
|
||||||
# Read the next two bytes within 1s
|
# Read the next two bytes within 1s
|
||||||
@@ -241,7 +260,13 @@ read_key() {
|
|||||||
echo "QUIT"
|
echo "QUIT"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
*) echo "OTHER" ;;
|
*)
|
||||||
|
# Printable ASCII -> expose as CHAR:<key> (for live filtering)
|
||||||
|
case "$key" in
|
||||||
|
[[:print:]]) echo "CHAR:$key" ;;
|
||||||
|
*) echo "OTHER" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,27 @@ set -euo pipefail
|
|||||||
enter_alt_screen() { tput smcup 2> /dev/null || true; }
|
enter_alt_screen() { tput smcup 2> /dev/null || true; }
|
||||||
leave_alt_screen() { tput rmcup 2> /dev/null || true; }
|
leave_alt_screen() { tput rmcup 2> /dev/null || true; }
|
||||||
|
|
||||||
|
# Parse CSV into newline list (Bash 3.2)
|
||||||
|
_pm_parse_csv_to_array() {
|
||||||
|
local csv="${1:-}"
|
||||||
|
if [[ -z "$csv" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local IFS=','
|
||||||
|
for _tok in $csv; do
|
||||||
|
printf "%s\n" "$_tok"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Non-blocking input drain (bash 3.2)
|
||||||
|
drain_pending_input() {
|
||||||
|
local _k
|
||||||
|
# -t 0 is non-blocking; -n 1 consumes one byte at a time
|
||||||
|
while IFS= read -r -s -n 1 -t 0 _k; do
|
||||||
|
IFS= read -r -s -n 1 _k || break
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Main paginated multi-select menu function
|
# Main paginated multi-select menu function
|
||||||
paginated_multi_select() {
|
paginated_multi_select() {
|
||||||
local title="$1"
|
local title="$1"
|
||||||
@@ -27,6 +48,74 @@ paginated_multi_select() {
|
|||||||
local items_per_page=15
|
local items_per_page=15
|
||||||
local cursor_pos=0
|
local cursor_pos=0
|
||||||
local top_index=0
|
local top_index=0
|
||||||
|
local filter_query=""
|
||||||
|
local filter_mode="false" # filter mode toggle
|
||||||
|
local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size
|
||||||
|
local sort_reverse="false"
|
||||||
|
# Live query vs applied query
|
||||||
|
local applied_query=""
|
||||||
|
local searching="false"
|
||||||
|
|
||||||
|
# Metadata (optional)
|
||||||
|
# epochs[i] -> last_used_epoch (numeric) for item i
|
||||||
|
# sizekb[i] -> size in KB (numeric) for item i
|
||||||
|
local -a epochs=()
|
||||||
|
local -a sizekb=()
|
||||||
|
if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then
|
||||||
|
while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS")
|
||||||
|
fi
|
||||||
|
if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then
|
||||||
|
while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Index mappings
|
||||||
|
local -a orig_indices=()
|
||||||
|
local -a view_indices=()
|
||||||
|
local i
|
||||||
|
for ((i = 0; i < total_items; i++)); do
|
||||||
|
orig_indices[i]=$i
|
||||||
|
view_indices[i]=$i
|
||||||
|
done
|
||||||
|
|
||||||
|
# Escape for shell globbing without upsetting highlighters
|
||||||
|
_pm_escape_glob() {
|
||||||
|
local s="${1-}" out="" c
|
||||||
|
local i len=${#s}
|
||||||
|
for ((i=0; i<len; i++)); do
|
||||||
|
c="${s:i:1}"
|
||||||
|
case "$c" in
|
||||||
|
'\'|'*'|'?'|'['|']') out+="\\$c" ;;
|
||||||
|
*) out+="$c" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s' "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Case-insensitive: substring by default, prefix if query starts with '
|
||||||
|
_pm_match() {
|
||||||
|
local hay="$1" q="$2" anchored=0
|
||||||
|
if [[ "$q" == \'* ]]; then
|
||||||
|
anchored=1
|
||||||
|
q="${q:1}"
|
||||||
|
fi
|
||||||
|
q="$(_pm_escape_glob "$q")"
|
||||||
|
local pat
|
||||||
|
if [[ $anchored -eq 1 ]]; then
|
||||||
|
pat="${q}*"
|
||||||
|
else
|
||||||
|
pat="*${q}*"
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nocasematch
|
||||||
|
local ok=1
|
||||||
|
# shellcheck disable=SC2254 # intentional glob match with a computed pattern
|
||||||
|
case "$hay" in
|
||||||
|
$pat) ok=0 ;;
|
||||||
|
esac
|
||||||
|
shopt -u nocasematch
|
||||||
|
return $ok
|
||||||
|
}
|
||||||
|
|
||||||
local -a selected=()
|
local -a selected=()
|
||||||
|
|
||||||
# Initialize selection array
|
# Initialize selection array
|
||||||
@@ -67,6 +156,7 @@ paginated_multi_select() {
|
|||||||
cleanup() {
|
cleanup() {
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
restore_terminal
|
restore_terminal
|
||||||
|
unset MOLE_READ_KEY_FORCE_CHAR
|
||||||
}
|
}
|
||||||
|
|
||||||
# Interrupt handler
|
# Interrupt handler
|
||||||
@@ -92,27 +182,153 @@ paginated_multi_select() {
|
|||||||
# Helper functions
|
# Helper functions
|
||||||
print_line() { printf "\r\033[2K%s\n" "$1" >&2; }
|
print_line() { printf "\r\033[2K%s\n" "$1" >&2; }
|
||||||
|
|
||||||
|
# Print footer lines wrapping only at separators
|
||||||
|
_print_wrapped_controls() {
|
||||||
|
local sep="$1"; shift
|
||||||
|
local -a segs=("$@")
|
||||||
|
|
||||||
|
local cols="${COLUMNS:-}"
|
||||||
|
[[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80)
|
||||||
|
|
||||||
|
_strip_ansi_len() {
|
||||||
|
local text="$1"
|
||||||
|
local stripped
|
||||||
|
stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}')
|
||||||
|
printf "%d" "${#stripped}"
|
||||||
|
}
|
||||||
|
|
||||||
|
local line="" s candidate
|
||||||
|
local clear_line=$'\r\033[2K'
|
||||||
|
for s in "${segs[@]}"; do
|
||||||
|
if [[ -z "$line" ]]; then
|
||||||
|
candidate="$s"
|
||||||
|
else
|
||||||
|
candidate="$line${sep}${s}"
|
||||||
|
fi
|
||||||
|
if (( $(_strip_ansi_len "$candidate") > cols )); then
|
||||||
|
printf "%s%s\n" "$clear_line" "$line" >&2
|
||||||
|
line="$s"
|
||||||
|
else
|
||||||
|
line="$candidate"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf "%s%s\n" "$clear_line" "$line" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rebuild the view_indices applying filter and sort
|
||||||
|
rebuild_view() {
|
||||||
|
# Filter
|
||||||
|
local -a filtered=()
|
||||||
|
local effective_query=""
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
# Live editing: empty query -> show nothing; non-empty -> match
|
||||||
|
effective_query="$filter_query"
|
||||||
|
if [[ -z "$effective_query" ]]; then
|
||||||
|
filtered=()
|
||||||
|
else
|
||||||
|
local idx
|
||||||
|
for ((idx = 0; idx < total_items; idx++)); do
|
||||||
|
if _pm_match "${items[idx]}" "$effective_query"; then
|
||||||
|
filtered+=("$idx")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Normal mode: use applied query; empty -> show all
|
||||||
|
effective_query="$applied_query"
|
||||||
|
if [[ -z "$effective_query" ]]; then
|
||||||
|
filtered=("${orig_indices[@]}")
|
||||||
|
else
|
||||||
|
local idx
|
||||||
|
for ((idx = 0; idx < total_items; idx++)); do
|
||||||
|
if _pm_match "${items[idx]}" "$effective_query"; then
|
||||||
|
filtered+=("$idx")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sort
|
||||||
|
local tmpfile
|
||||||
|
tmpfile=$(mktemp) || tmpfile=""
|
||||||
|
if [[ -n "$tmpfile" ]]; then
|
||||||
|
: > "$tmpfile"
|
||||||
|
local k id
|
||||||
|
if [[ ${#filtered[@]} -gt 0 ]]; then
|
||||||
|
for id in "${filtered[@]}"; do
|
||||||
|
case "$sort_mode" in
|
||||||
|
date) k="${epochs[id]:-${id}}" ;;
|
||||||
|
size) k="${sizekb[id]:-0}" ;;
|
||||||
|
name|*) k="${items[id]}|${id}" ;;
|
||||||
|
esac
|
||||||
|
printf "%s\t%s\n" "$k" "$id" >> "$tmpfile"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build sort key once and stream results into view_indices
|
||||||
|
local sort_key
|
||||||
|
if [[ "$sort_mode" == "date" || "$sort_mode" == "size" ]]; then
|
||||||
|
sort_key="-k1,1n"
|
||||||
|
[[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr"
|
||||||
|
else
|
||||||
|
sort_key="-k1,1f"
|
||||||
|
[[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr"
|
||||||
|
fi
|
||||||
|
|
||||||
|
view_indices=()
|
||||||
|
while IFS=$'\t' read -r _key _id; do
|
||||||
|
[[ -z "$_id" ]] && continue
|
||||||
|
view_indices+=("$_id")
|
||||||
|
done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile")
|
||||||
|
|
||||||
|
rm -f "$tmpfile"
|
||||||
|
else
|
||||||
|
view_indices=("${filtered[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clamp cursor into visible range
|
||||||
|
local visible_count=${#view_indices[@]}
|
||||||
|
local max_top
|
||||||
|
if [[ $visible_count -gt $items_per_page ]]; then
|
||||||
|
max_top=$((visible_count - items_per_page))
|
||||||
|
else
|
||||||
|
max_top=0
|
||||||
|
fi
|
||||||
|
[[ $top_index -gt $max_top ]] && top_index=$max_top
|
||||||
|
local current_visible=$((visible_count - top_index))
|
||||||
|
[[ $current_visible -gt $items_per_page ]] && current_visible=$items_per_page
|
||||||
|
if [[ $cursor_pos -ge $current_visible ]]; then
|
||||||
|
cursor_pos=$((current_visible > 0 ? current_visible - 1 : 0))
|
||||||
|
fi
|
||||||
|
[[ $cursor_pos -lt 0 ]] && cursor_pos=0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initial view (default sort)
|
||||||
|
rebuild_view
|
||||||
|
|
||||||
render_item() {
|
render_item() {
|
||||||
local idx=$1 is_current=$2
|
# $1: visible row index (0..items_per_page-1 in current window)
|
||||||
|
# $2: is_current flag
|
||||||
|
local vrow=$1 is_current=$2
|
||||||
|
local idx=$((top_index + vrow))
|
||||||
|
local real="${view_indices[idx]:--1}"
|
||||||
|
[[ $real -lt 0 ]] && return
|
||||||
local checkbox="$ICON_EMPTY"
|
local checkbox="$ICON_EMPTY"
|
||||||
[[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID"
|
[[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID"
|
||||||
|
|
||||||
if [[ $is_current == true ]]; then
|
if [[ $is_current == true ]]; then
|
||||||
printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[idx]}" >&2
|
printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[real]}" >&2
|
||||||
else
|
else
|
||||||
printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2
|
printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Draw the complete menu
|
# Draw the complete menu
|
||||||
draw_menu() {
|
draw_menu() {
|
||||||
# Move to home position without clearing (reduces flicker)
|
|
||||||
printf "\033[H" >&2
|
printf "\033[H" >&2
|
||||||
|
|
||||||
# Clear each line as we go instead of clearing entire screen
|
|
||||||
local clear_line="\r\033[2K"
|
local clear_line="\r\033[2K"
|
||||||
|
|
||||||
# Count selections for header display
|
# Count selections
|
||||||
local selected_count=0
|
local selected_count=0
|
||||||
for ((i = 0; i < total_items; i++)); do
|
for ((i = 0; i < total_items; i++)); do
|
||||||
[[ ${selected[i]} == true ]] && ((selected_count++))
|
[[ ${selected[i]} == true ]] && ((selected_count++))
|
||||||
@@ -120,24 +336,71 @@ paginated_multi_select() {
|
|||||||
|
|
||||||
# Header
|
# Header
|
||||||
printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
|
printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
|
||||||
|
# Sort + Filter status
|
||||||
|
local sort_label=""
|
||||||
|
case "$sort_mode" in
|
||||||
|
date) sort_label="Date" ;;
|
||||||
|
name) sort_label="Name" ;;
|
||||||
|
size) sort_label="Size" ;;
|
||||||
|
esac
|
||||||
|
local arrow="↑"
|
||||||
|
[[ "$sort_reverse" == "true" ]] && arrow="↓"
|
||||||
|
|
||||||
if [[ $total_items -eq 0 ]]; then
|
local filter_label=""
|
||||||
printf "${clear_line}${GRAY}No items available${NC}\n" >&2
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
printf "${clear_line}\n" >&2
|
filter_label="${YELLOW}${filter_query:-}${NC}${GRAY} [editing]${NC}"
|
||||||
printf "${clear_line}${GRAY}Q/ESC${NC} Quit\n" >&2
|
else
|
||||||
printf "${clear_line}" >&2
|
if [[ -n "$applied_query" ]]; then
|
||||||
return
|
if [[ "$searching" == "true" ]]; then
|
||||||
|
filter_label="${GREEN}${applied_query}${NC}${GRAY} [searching…]${NC}"
|
||||||
|
else
|
||||||
|
filter_label="${GREEN}${applied_query}${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
filter_label="${GRAY}—${NC}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
printf "${clear_line}${GRAY}Sort:${NC} %s %s ${GRAY}|${NC} ${GRAY}Filter:${NC} %s\n" "$sort_label" "$arrow" "$filter_label" >&2
|
||||||
|
|
||||||
|
# Filter-mode hint line
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
printf "${clear_line}${GRAY}Tip:${NC} prefix with ${YELLOW}'${NC} to match from start\n" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $top_index -gt $((total_items - 1)) ]]; then
|
# Visible slice
|
||||||
if [[ $total_items -gt $items_per_page ]]; then
|
local visible_total=${#view_indices[@]}
|
||||||
top_index=$((total_items - items_per_page))
|
if [[ $visible_total -eq 0 ]]; then
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
# While editing: do not show "No items available"
|
||||||
|
for ((i = 0; i < items_per_page + 2; i++)); do
|
||||||
|
printf "${clear_line}\n" >&2
|
||||||
|
done
|
||||||
|
printf "${clear_line}${GRAY}Type to filter${NC} ${GRAY}|${NC} ${GRAY}Delete${NC} Backspace ${GRAY}|${NC} ${GRAY}Enter${NC} Apply ${GRAY}|${NC} ${GRAY}ESC${NC} Cancel\n" >&2
|
||||||
|
printf "${clear_line}" >&2
|
||||||
|
return
|
||||||
else
|
else
|
||||||
top_index=0
|
if [[ "$searching" == "true" ]]; then
|
||||||
|
printf "${clear_line}${GRAY}Searching…${NC}\n" >&2
|
||||||
|
for ((i = 0; i < items_per_page + 2; i++)); do
|
||||||
|
printf "${clear_line}\n" >&2
|
||||||
|
done
|
||||||
|
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}S/s${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F/f${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2
|
||||||
|
printf "${clear_line}" >&2
|
||||||
|
return
|
||||||
|
else
|
||||||
|
# Post-search: truly empty list
|
||||||
|
printf "${clear_line}${GRAY}No items available${NC}\n" >&2
|
||||||
|
for ((i = 0; i < items_per_page + 2; i++)); do
|
||||||
|
printf "${clear_line}\n" >&2
|
||||||
|
done
|
||||||
|
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}S${NC} Sort ${GRAY}|${NC} ${GRAY}R/r${NC} Reverse ${GRAY}|${NC} ${GRAY}F${NC} Filter ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2
|
||||||
|
printf "${clear_line}" >&2
|
||||||
|
return
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local visible_count=$((total_items - top_index))
|
local visible_count=$((visible_total - top_index))
|
||||||
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
||||||
[[ $visible_count -le 0 ]] && visible_count=1
|
[[ $visible_count -le 0 ]] && visible_count=1
|
||||||
if [[ $cursor_pos -ge $visible_count ]]; then
|
if [[ $cursor_pos -ge $visible_count ]]; then
|
||||||
@@ -150,13 +413,13 @@ paginated_multi_select() {
|
|||||||
# Items for current window
|
# Items for current window
|
||||||
local start_idx=$top_index
|
local start_idx=$top_index
|
||||||
local end_idx=$((top_index + items_per_page - 1))
|
local end_idx=$((top_index + items_per_page - 1))
|
||||||
[[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1))
|
[[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1))
|
||||||
|
|
||||||
for ((i = start_idx; i <= end_idx; i++)); do
|
for ((i = start_idx; i <= end_idx; i++)); do
|
||||||
[[ $i -lt 0 ]] && continue
|
[[ $i -lt 0 ]] && continue
|
||||||
local is_current=false
|
local is_current=false
|
||||||
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
|
[[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
|
||||||
render_item $i $is_current
|
render_item $((i - start_idx)) $is_current
|
||||||
done
|
done
|
||||||
|
|
||||||
# Fill empty slots to clear previous content
|
# Fill empty slots to clear previous content
|
||||||
@@ -166,11 +429,31 @@ paginated_multi_select() {
|
|||||||
printf "${clear_line}\n" >&2
|
printf "${clear_line}\n" >&2
|
||||||
done
|
done
|
||||||
|
|
||||||
# Clear any remaining lines at bottom
|
|
||||||
printf "${clear_line}\n" >&2
|
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
|
# Footer with wrapped controls
|
||||||
|
local sep=" ${GRAY}|${NC} "
|
||||||
# Clear one more line to ensure no artifacts
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
local -a _segs_filter=(
|
||||||
|
"${GRAY}Type to filter${NC}"
|
||||||
|
"${GRAY}Delete${NC} Backspace"
|
||||||
|
"${GRAY}Enter${NC} Apply"
|
||||||
|
"${GRAY}ESC${NC} Cancel"
|
||||||
|
)
|
||||||
|
_print_wrapped_controls "$sep" "${_segs_filter[@]}"
|
||||||
|
else
|
||||||
|
local -a _segs_normal=(
|
||||||
|
"${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate"
|
||||||
|
"${GRAY}Space${NC} Select"
|
||||||
|
"${GRAY}Enter${NC} Confirm"
|
||||||
|
"${GRAY}S/s${NC} Sort"
|
||||||
|
"${GRAY}R/r${NC} Reverse"
|
||||||
|
"${GRAY}F/f${NC} Filter"
|
||||||
|
"${GRAY}A${NC} All"
|
||||||
|
"${GRAY}N${NC} None"
|
||||||
|
"${GRAY}Q/ESC${NC} Quit"
|
||||||
|
)
|
||||||
|
_print_wrapped_controls "$sep" "${_segs_normal[@]}"
|
||||||
|
fi
|
||||||
printf "${clear_line}" >&2
|
printf "${clear_line}" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +467,13 @@ Help - Navigation Controls
|
|||||||
${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down
|
${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down
|
||||||
Space Select/deselect item
|
Space Select/deselect item
|
||||||
Enter Confirm selection
|
Enter Confirm selection
|
||||||
Q / ESC Exit
|
S Change sort mode (Date / Name / Size)
|
||||||
|
R Reverse current sort (asc/desc)
|
||||||
|
F Toggle filter mode, type to filter (case-insensitive; prefix with ' to match from start)
|
||||||
|
A Select all (visible items)
|
||||||
|
N Deselect all (visible items)
|
||||||
|
Delete Backspace filter (in filter mode)
|
||||||
|
Q / ESC Exit (ESC exits filter mode first)
|
||||||
|
|
||||||
Press any key to continue...
|
Press any key to continue...
|
||||||
EOF
|
EOF
|
||||||
@@ -194,15 +483,25 @@ EOF
|
|||||||
# Main interaction loop
|
# Main interaction loop
|
||||||
while true; do
|
while true; do
|
||||||
draw_menu
|
draw_menu
|
||||||
local key=$(read_key)
|
local key
|
||||||
|
key=$(read_key)
|
||||||
|
|
||||||
case "$key" in
|
case "$key" in
|
||||||
"QUIT")
|
"QUIT")
|
||||||
cleanup
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
return 1
|
filter_mode="false"
|
||||||
;;
|
unset MOLE_READ_KEY_FORCE_CHAR
|
||||||
|
filter_query=""
|
||||||
|
applied_query=""
|
||||||
|
top_index=0; cursor_pos=0
|
||||||
|
rebuild_view
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
cleanup
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
"UP")
|
"UP")
|
||||||
if [[ $total_items -eq 0 ]]; then
|
if [[ ${#view_indices[@]} -eq 0 ]]; then
|
||||||
:
|
:
|
||||||
elif [[ $cursor_pos -gt 0 ]]; then
|
elif [[ $cursor_pos -gt 0 ]]; then
|
||||||
((cursor_pos--))
|
((cursor_pos--))
|
||||||
@@ -211,19 +510,20 @@ EOF
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"DOWN")
|
"DOWN")
|
||||||
if [[ $total_items -eq 0 ]]; then
|
if [[ ${#view_indices[@]} -eq 0 ]]; then
|
||||||
:
|
:
|
||||||
else
|
else
|
||||||
local absolute_index=$((top_index + cursor_pos))
|
local absolute_index=$((top_index + cursor_pos))
|
||||||
if [[ $absolute_index -lt $((total_items - 1)) ]]; then
|
local last_index=$((${#view_indices[@]} - 1))
|
||||||
local visible_count=$((total_items - top_index))
|
if [[ $absolute_index -lt $last_index ]]; then
|
||||||
|
local visible_count=$((${#view_indices[@]} - top_index))
|
||||||
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
||||||
|
|
||||||
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
|
if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
|
||||||
((cursor_pos++))
|
((cursor_pos++))
|
||||||
elif [[ $((top_index + visible_count)) -lt $total_items ]]; then
|
elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
|
||||||
((top_index++))
|
((top_index++))
|
||||||
visible_count=$((total_items - top_index))
|
visible_count=$((${#view_indices[@]} - top_index))
|
||||||
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
[[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
|
||||||
if [[ $cursor_pos -ge $visible_count ]]; then
|
if [[ $cursor_pos -ge $visible_count ]]; then
|
||||||
cursor_pos=$((visible_count - 1))
|
cursor_pos=$((visible_count - 1))
|
||||||
@@ -234,27 +534,109 @@ EOF
|
|||||||
;;
|
;;
|
||||||
"SPACE")
|
"SPACE")
|
||||||
local idx=$((top_index + cursor_pos))
|
local idx=$((top_index + cursor_pos))
|
||||||
if [[ $idx -lt $total_items ]]; then
|
if [[ $idx -lt ${#view_indices[@]} ]]; then
|
||||||
if [[ ${selected[idx]} == true ]]; then
|
local real="${view_indices[idx]}"
|
||||||
selected[idx]=false
|
if [[ ${selected[real]} == true ]]; then
|
||||||
|
selected[real]=false
|
||||||
else
|
else
|
||||||
selected[idx]=true
|
selected[real]=true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
"ALL")
|
"ALL")
|
||||||
for ((i = 0; i < total_items; i++)); do
|
# Select only currently visible (filtered) rows
|
||||||
selected[i]=true
|
if [[ ${#view_indices[@]} -gt 0 ]]; then
|
||||||
done
|
for real in "${view_indices[@]}"; do
|
||||||
|
selected[real]=true
|
||||||
|
done
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
"NONE")
|
"NONE")
|
||||||
for ((i = 0; i < total_items; i++)); do
|
# Deselect only currently visible (filtered) rows
|
||||||
selected[i]=false
|
if [[ ${#view_indices[@]} -gt 0 ]]; then
|
||||||
done
|
for real in "${view_indices[@]}"; do
|
||||||
|
selected[real]=false
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"RETRY")
|
||||||
|
# 'R' toggles reverse order
|
||||||
|
if [[ "$sort_reverse" == "true" ]]; then
|
||||||
|
sort_reverse="false"
|
||||||
|
else
|
||||||
|
sort_reverse="true"
|
||||||
|
fi
|
||||||
|
rebuild_view
|
||||||
;;
|
;;
|
||||||
"HELP") show_help ;;
|
"HELP") show_help ;;
|
||||||
|
"CHAR:s"|"CHAR:S")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
local ch="${key#CHAR:}"
|
||||||
|
filter_query+="$ch"
|
||||||
|
else
|
||||||
|
case "$sort_mode" in
|
||||||
|
date) sort_mode="name" ;;
|
||||||
|
name) sort_mode="size" ;;
|
||||||
|
size) sort_mode="date" ;;
|
||||||
|
esac
|
||||||
|
rebuild_view
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"CHAR:f"|"CHAR:F")
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="f"
|
||||||
|
else
|
||||||
|
filter_mode="true"
|
||||||
|
export MOLE_READ_KEY_FORCE_CHAR=1
|
||||||
|
filter_query="" # start empty -> 0 results
|
||||||
|
top_index=0 # reset viewport
|
||||||
|
cursor_pos=0
|
||||||
|
rebuild_view
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"CHAR:r")
|
||||||
|
# lower-case r: behave like reverse when NOT in filter mode
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
filter_query+="r"
|
||||||
|
else
|
||||||
|
if [[ "$sort_reverse" == "true" ]]; then
|
||||||
|
sort_reverse="false"
|
||||||
|
else
|
||||||
|
sort_reverse="true"
|
||||||
|
fi
|
||||||
|
rebuild_view
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
"DELETE")
|
||||||
|
# Backspace filter
|
||||||
|
if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
|
||||||
|
filter_query="${filter_query%?}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
CHAR:*)
|
||||||
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
local ch="${key#CHAR:}"
|
||||||
|
# avoid accidental leading spaces
|
||||||
|
if [[ -n "$filter_query" || "$ch" != " " ]]; then
|
||||||
|
filter_query+="$ch"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
"ENTER")
|
"ENTER")
|
||||||
# Store result in global variable instead of returning via stdout
|
if [[ "$filter_mode" == "true" ]]; then
|
||||||
|
applied_query="$filter_query"
|
||||||
|
filter_mode="false"
|
||||||
|
unset MOLE_READ_KEY_FORCE_CHAR
|
||||||
|
top_index=0; cursor_pos=0
|
||||||
|
|
||||||
|
searching="true"
|
||||||
|
draw_menu # paint "searching..."
|
||||||
|
drain_pending_input # drop any extra keypresses (e.g., double-Enter)
|
||||||
|
rebuild_view
|
||||||
|
searching="false"
|
||||||
|
draw_menu
|
||||||
|
continue
|
||||||
|
fi
|
||||||
local -a selected_indices=()
|
local -a selected_indices=()
|
||||||
for ((i = 0; i < total_items; i++)); do
|
for ((i = 0; i < total_items; i++)); do
|
||||||
if [[ ${selected[i]} == true ]]; then
|
if [[ ${selected[i]} == true ]]; then
|
||||||
@@ -263,7 +645,6 @@ EOF
|
|||||||
done
|
done
|
||||||
|
|
||||||
# Allow empty selection - don't auto-select cursor position
|
# 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=""
|
local final_result=""
|
||||||
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
if [[ ${#selected_indices[@]} -gt 0 ]]; then
|
||||||
local IFS=','
|
local IFS=','
|
||||||
|
|||||||
Reference in New Issue
Block a user