From bc1af7e35dde6d030775e958eeeab9df99d87aba Mon Sep 17 00:00:00 2001 From: Else00 <48489849+Else00@users.noreply.github.com> Date: Tue, 14 Oct 2025 03:40:47 +0200 Subject: [PATCH] feat(menu): add sort (date/name/size), live filter, reverse; visible-only A/N; responsive footer (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:; 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). --- bin/uninstall.sh | 10 +- lib/app_selector.sh | 32 ++- lib/common.sh | 39 +++- lib/paginated_menu.sh | 477 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 496 insertions(+), 62 deletions(-) diff --git a/bin/uninstall.sh b/bin/uninstall.sh index 942f2d1..135c8a4 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -245,7 +245,10 @@ scan_applications() { # Parallel size calculation local app_size="N/A" + local app_size_kb="0" 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") fi @@ -297,7 +300,8 @@ scan_applications() { fi # 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 @@ -380,8 +384,8 @@ load_applications() { selection_state=() # Read apps into array - while IFS='|' read -r epoch app_path app_name bundle_id size last_used; do - apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used") + 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|${size_kb:-0}") selection_state+=(false) done < "$apps_file" diff --git a/lib/app_selector.sh b/lib/app_selector.sh index c0a1830..5a86403 100755 --- a/lib/app_selector.sh +++ b/lib/app_selector.sh @@ -33,18 +33,44 @@ select_apps_for_uninstall() { # Build 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 - # Ignore metadata fields not needed for menu display - IFS='|' read -r _ _ display_name _ size last_used <<< "$app_data" + # Keep extended field 7 (size_kb) if present + IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" 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 + # 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 # Note: paginated_multi_select enters alternate screen and handles clearing MOLE_SELECTION_RESULT="" paginated_multi_select "Select Apps to Remove" "${menu_options[@]}" 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 echo "Cancelled" return 1 @@ -56,11 +82,9 @@ select_apps_for_uninstall() { fi # Build selected apps array (global variable in bin/uninstall.sh) - # Clear existing selections - compatible with bash 3.2 selected_apps=() # 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" for idx in "${indices_array[@]}"; do diff --git a/lib/common.sh b/lib/common.sh index 9ab9251..5eee8da 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -176,9 +176,29 @@ show_cursor() { # Keyboard input handling (simple and robust) read_key() { local key rest - # Use macOS bash 3.2 compatible read syntax IFS= read -r -s -n 1 key || return 1 + # Raw typing mode (filter): map most keys to CHAR: + 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 if [[ -z "$key" ]]; then echo "ENTER" @@ -189,14 +209,13 @@ read_key() { $'\n' | $'\r') echo "ENTER" ;; ' ') echo "SPACE" ;; 'q' | 'Q') echo "QUIT" ;; - 'a' | 'A') echo "ALL" ;; - 'n' | 'N') echo "NONE" ;; - 'd' | 'D') echo "DELETE" ;; - 'r' | 'R') echo "RETRY" ;; + 'A') echo "ALL" ;; + 'N') echo "NONE" ;; + 'R') echo "RETRY" ;; '?') echo "HELP" ;; 'o' | 'O') echo "OPEN" ;; $'\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') # ESC sequence - could be arrow key, delete key, or ESC alone # Read the next two bytes within 1s @@ -241,7 +260,13 @@ read_key() { echo "QUIT" fi ;; - *) echo "OTHER" ;; + *) + # Printable ASCII -> expose as CHAR: (for live filtering) + case "$key" in + [[:print:]]) echo "CHAR:$key" ;; + *) echo "OTHER" ;; + esac + ;; esac } diff --git a/lib/paginated_menu.sh b/lib/paginated_menu.sh index 4496a6b..c858656 100755 --- a/lib/paginated_menu.sh +++ b/lib/paginated_menu.sh @@ -7,6 +7,27 @@ set -euo pipefail enter_alt_screen() { tput smcup 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 paginated_multi_select() { local title="$1" @@ -27,6 +48,74 @@ paginated_multi_select() { local items_per_page=15 local cursor_pos=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&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() { - 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" - [[ ${selected[idx]} == true ]] && checkbox="$ICON_SOLID" + [[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID" 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 - printf "\r\033[2K %s %s\n" "$checkbox" "${items[idx]}" >&2 + printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2 fi } # Draw the complete menu draw_menu() { - # Move to home position without clearing (reduces flicker) printf "\033[H" >&2 - - # Clear each line as we go instead of clearing entire screen local clear_line="\r\033[2K" - # Count selections for header display + # Count selections local selected_count=0 for ((i = 0; i < total_items; i++)); do [[ ${selected[i]} == true ]] && ((selected_count++)) @@ -120,24 +336,71 @@ paginated_multi_select() { # Header 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 - printf "${clear_line}${GRAY}No items available${NC}\n" >&2 - printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}Q/ESC${NC} Quit\n" >&2 - printf "${clear_line}" >&2 - return + local filter_label="" + if [[ "$filter_mode" == "true" ]]; then + filter_label="${YELLOW}${filter_query:-}${NC}${GRAY} [editing]${NC}" + else + if [[ -n "$applied_query" ]]; then + 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 - if [[ $top_index -gt $((total_items - 1)) ]]; then - if [[ $total_items -gt $items_per_page ]]; then - top_index=$((total_items - items_per_page)) + # Visible slice + local visible_total=${#view_indices[@]} + 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 - 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 - 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 -le 0 ]] && visible_count=1 if [[ $cursor_pos -ge $visible_count ]]; then @@ -150,13 +413,13 @@ paginated_multi_select() { # Items for current window local start_idx=$top_index local end_idx=$((top_index + items_per_page - 1)) - [[ $end_idx -ge $total_items ]] && end_idx=$((total_items - 1)) + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) for ((i = start_idx; i <= end_idx; i++)); do [[ $i -lt 0 ]] && continue local is_current=false [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $i $is_current + render_item $((i - start_idx)) $is_current done # Fill empty slots to clear previous content @@ -166,11 +429,31 @@ paginated_multi_select() { printf "${clear_line}\n" >&2 done - # Clear any remaining lines at bottom printf "${clear_line}\n" >&2 - printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}Q/ESC${NC} Quit\n" >&2 - - # Clear one more line to ensure no artifacts + # Footer with wrapped controls + local sep=" ${GRAY}|${NC} " + 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 } @@ -184,7 +467,13 @@ Help - Navigation Controls ${ICON_NAV_UP} / ${ICON_NAV_DOWN} Navigate up/down Space Select/deselect item 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... EOF @@ -194,15 +483,25 @@ EOF # Main interaction loop while true; do draw_menu - local key=$(read_key) + local key + key=$(read_key) case "$key" in "QUIT") - cleanup - return 1 - ;; + if [[ "$filter_mode" == "true" ]]; then + 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") - if [[ $total_items -eq 0 ]]; then + if [[ ${#view_indices[@]} -eq 0 ]]; then : elif [[ $cursor_pos -gt 0 ]]; then ((cursor_pos--)) @@ -211,19 +510,20 @@ EOF fi ;; "DOWN") - if [[ $total_items -eq 0 ]]; then + if [[ ${#view_indices[@]} -eq 0 ]]; then : else local absolute_index=$((top_index + cursor_pos)) - if [[ $absolute_index -lt $((total_items - 1)) ]]; then - local visible_count=$((total_items - top_index)) + local last_index=$((${#view_indices[@]} - 1)) + 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 if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then ((cursor_pos++)) - elif [[ $((top_index + visible_count)) -lt $total_items ]]; then + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then ((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 if [[ $cursor_pos -ge $visible_count ]]; then cursor_pos=$((visible_count - 1)) @@ -234,27 +534,109 @@ EOF ;; "SPACE") local idx=$((top_index + cursor_pos)) - if [[ $idx -lt $total_items ]]; then - if [[ ${selected[idx]} == true ]]; then - selected[idx]=false + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + if [[ ${selected[real]} == true ]]; then + selected[real]=false else - selected[idx]=true + selected[real]=true fi fi ;; "ALL") - for ((i = 0; i < total_items; i++)); do - selected[i]=true - done + # Select only currently visible (filtered) rows + if [[ ${#view_indices[@]} -gt 0 ]]; then + for real in "${view_indices[@]}"; do + selected[real]=true + done + fi ;; "NONE") - for ((i = 0; i < total_items; i++)); do - selected[i]=false - done + # Deselect only currently visible (filtered) rows + if [[ ${#view_indices[@]} -gt 0 ]]; then + 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 ;; + "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") - # 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=() for ((i = 0; i < total_items; i++)); do if [[ ${selected[i]} == true ]]; then @@ -263,7 +645,6 @@ EOF done # Allow empty selection - don't auto-select cursor position - # This fixes the bug where unselecting all items would still select the last cursor position local final_result="" if [[ ${#selected_indices[@]} -gt 0 ]]; then local IFS=','