From 02843dee743cc985bc16629f62f5848ddf437aae Mon Sep 17 00:00:00 2001 From: tw93 Date: Sat, 7 Feb 2026 10:23:35 +0800 Subject: [PATCH] feat(ui): add Ctrl+U support and optimize paginated menu performance --- lib/core/ui.sh | 2 + lib/ui/app_selector.sh | 26 +- lib/ui/menu_paginated.sh | 770 +++++++++++++++++++++------------------ 3 files changed, 428 insertions(+), 370 deletions(-) diff --git a/lib/core/ui.sh b/lib/core/ui.sh index e7c2597..eb0c76c 100755 --- a/lib/core/ui.sh +++ b/lib/core/ui.sh @@ -170,6 +170,7 @@ read_key() { case "$key" in $'\n' | $'\r') echo "ENTER" ;; $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U (often mapped from Cmd+Delete in terminals) $'\x1b') if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then if [[ "$rest" == "[" ]]; then @@ -230,6 +231,7 @@ read_key() { 'l' | 'L') echo "RIGHT" ;; $'\x03') echo "QUIT" ;; $'\x7f' | $'\x08') echo "DELETE" ;; + $'\x15') echo "CLEAR_LINE" ;; # Ctrl+U $'\x1b') if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then if [[ "$rest" == "[" ]]; then diff --git a/lib/ui/app_selector.sh b/lib/ui/app_selector.sh index 285ef04..3c15c72 100755 --- a/lib/ui/app_selector.sh +++ b/lib/ui/app_selector.sh @@ -12,9 +12,12 @@ format_app_display() { # Use common function from ui.sh to format last used time local compact_last_used compact_last_used=$(format_last_used_summary "$last_used") + if [[ -z "$compact_last_used" || "$compact_last_used" == "Unknown" || "$compact_last_used" == "Never" ]]; then + compact_last_used="..." + fi # Format size - local size_str="Unknown" + local size_str="..." [[ "$size" != "0" && "$size" != "" && "$size" != "Unknown" ]] && size_str="$size" # Calculate available width for app name based on terminal width @@ -114,10 +117,14 @@ select_apps_for_uninstall() { local epochs_csv="" local sizekb_csv="" local -a names_arr=() + local has_epoch_metadata=false + local has_size_metadata=false local idx=0 for app_data in "${apps_data[@]}"; do IFS='|' read -r epoch _ display_name _ size last_used size_kb <<< "$app_data" menu_options+=("$(format_app_display "$display_name" "$size" "$last_used" "$terminal_width" "$max_name_width")") + [[ "${epoch:-0}" =~ ^[0-9]+$ && "${epoch:-0}" -gt 0 ]] && has_epoch_metadata=true + [[ "${size_kb:-0}" =~ ^[0-9]+$ && "${size_kb:-0}" -gt 0 ]] && has_size_metadata=true if [[ $idx -eq 0 ]]; then epochs_csv="${epoch:-0}" sizekb_csv="${size_kb:-0}" @@ -143,8 +150,16 @@ select_apps_for_uninstall() { # - 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" + if [[ $has_epoch_metadata == true ]]; then + export MOLE_MENU_META_EPOCHS="$epochs_csv" + else + unset MOLE_MENU_META_EPOCHS + fi + if [[ $has_size_metadata == true ]]; then + export MOLE_MENU_META_SIZEKB="$sizekb_csv" + else + unset MOLE_MENU_META_SIZEKB + fi export MOLE_MENU_FILTER_NAMES="$names_newline" # Use paginated menu - result will be stored in MOLE_SELECTION_RESULT @@ -157,11 +172,6 @@ select_apps_for_uninstall() { unset MOLE_MENU_META_EPOCHS MOLE_MENU_META_SIZEKB # leave MOLE_MENU_SORT_DEFAULT untouched if user set it globally - # Refresh signal handling - if [[ $exit_code -eq 10 ]]; then - return 10 - fi - if [[ $exit_code -ne 0 ]]; then return 1 fi diff --git a/lib/ui/menu_paginated.sh b/lib/ui/menu_paginated.sh index 05316cd..e806edc 100755 --- a/lib/ui/menu_paginated.sh +++ b/lib/ui/menu_paginated.sh @@ -5,13 +5,13 @@ set -euo pipefail # Terminal control functions enter_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput smcup 2> /dev/null || true + if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then + tput smcup 2>/dev/null || true fi } leave_alt_screen() { - if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then - tput rmcup 2> /dev/null || true + if command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then + tput rmcup 2>/dev/null || true fi } @@ -22,13 +22,13 @@ _pm_get_terminal_height() { # Try stty size first (most reliable, real-time) # Use /dev/null | awk '{print $1}') + height=$(stty size /dev/null | awk '{print $1}') fi # Fallback to tput if [[ -z "$height" || $height -le 0 ]]; then - if command -v tput > /dev/null 2>&1; then - height=$(tput lines 2> /dev/null || echo "24") + if command -v tput >/dev/null 2>&1; then + height=$(tput lines 2>/dev/null || echo "24") else height=24 fi @@ -90,6 +90,7 @@ paginated_multi_select() { local sort_mode="${MOLE_MENU_SORT_MODE:-${MOLE_MENU_SORT_DEFAULT:-date}}" # date|name|size local sort_reverse="${MOLE_MENU_SORT_REVERSE:-false}" local filter_text="" # Filter keyword + local filter_text_lower="" # Metadata (optional) # epochs[i] -> last_used_epoch (numeric) for item i @@ -109,7 +110,7 @@ paginated_multi_select() { has_metadata="true" fi if [[ -n "${MOLE_MENU_FILTER_NAMES:-}" ]]; then - while IFS= read -r v; do filter_names+=("$v"); done <<< "$MOLE_MENU_FILTER_NAMES" + while IFS= read -r v; do filter_names+=("$v"); done <<<"$MOLE_MENU_FILTER_NAMES" has_filter_names="true" fi @@ -121,10 +122,20 @@ paginated_multi_select() { # Index mappings local -a orig_indices=() local -a view_indices=() + local -a filter_targets_lower=() local i for ((i = 0; i < total_items; i++)); do orig_indices[i]=$i view_indices[i]=$i + local filter_target + if [[ $has_filter_names == true && -n "${filter_names[i]:-}" ]]; then + filter_target="${filter_names[i]}" + else + filter_target="${items[i]}" + fi + local filter_target_lower + filter_target_lower=$(printf "%s" "$filter_target" | LC_ALL=C tr '[:upper:]' '[:lower:]') + filter_targets_lower[i]="$filter_target_lower" done local -a selected=() @@ -138,7 +149,7 @@ paginated_multi_select() { if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}" local -a initial_indices=() - IFS=',' read -ra initial_indices <<< "$cleaned_preselect" + IFS=',' read -ra initial_indices <<<"$cleaned_preselect" for idx in "${initial_indices[@]}"; do if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then # Only count if not already selected (handles duplicates) @@ -152,16 +163,16 @@ paginated_multi_select() { # Preserve original TTY settings so we can restore them reliably local original_stty="" - if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then - original_stty=$(stty -g 2> /dev/null || echo "") + if [[ -t 0 ]] && command -v stty >/dev/null 2>&1; then + original_stty=$(stty -g 2>/dev/null || echo "") fi restore_terminal() { show_cursor if [[ -n "${original_stty-}" ]]; then - stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true + stty "${original_stty}" 2>/dev/null || stty sane 2>/dev/null || stty echo icanon 2>/dev/null || true else - stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true + stty sane 2>/dev/null || stty echo icanon 2>/dev/null || true fi if [[ "${external_alt_screen:-false}" == false ]]; then leave_alt_screen @@ -171,8 +182,8 @@ paginated_multi_select() { # Cleanup function cleanup() { trap - EXIT INT TERM - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" restore_terminal } @@ -187,7 +198,7 @@ paginated_multi_select() { trap handle_interrupt INT TERM # Setup terminal - preserve interrupt character - stty -echo -icanon intr ^C 2> /dev/null || true + stty -echo -icanon intr ^C 2>/dev/null || true if [[ $external_alt_screen == false ]]; then enter_alt_screen # Clear screen once on entry to alt screen @@ -208,7 +219,7 @@ paginated_multi_select() { local -a segs=("$@") local cols="${COLUMNS:-}" - [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80) + [[ -z "$cols" ]] && cols=$(tput cols 2>/dev/null || echo 80) [[ "$cols" =~ ^[0-9]+$ ]] || cols=80 _strip_ansi_len() { @@ -240,75 +251,111 @@ paginated_multi_select() { printf "%s%s\n" "$clear_line" "$line" >&2 } - # Rebuild the view_indices applying filter and sort - rebuild_view() { - local -a active_indices=() - if [[ -n "$filter_text" ]]; then - local filter_lower - filter_lower=$(printf "%s" "$filter_text" | LC_ALL=C tr '[:upper:]' '[:lower:]') - for id in "${orig_indices[@]}"; do - local filter_target - if [[ $has_filter_names == true && -n "${filter_names[id]:-}" ]]; then - filter_target="${filter_names[id]}" - else - filter_target="${items[id]}" - fi - local target_lower - target_lower=$(printf "%s" "$filter_target" | LC_ALL=C tr '[:upper:]' '[:lower:]') - if [[ "$target_lower" == *"$filter_lower"* ]]; then - active_indices+=("$id") - fi - done - else - active_indices=("${orig_indices[@]}") + local sort_cache_key="" + local -a sorted_indices_cache=() + local filter_cache_key="" + local filter_cache_text_lower="" + local -a filter_cache_indices=() + + ensure_sorted_indices() { + local requested_key="${sort_mode}:${sort_reverse}:${has_metadata}" + if [[ "$requested_key" == "$sort_cache_key" && ${#sorted_indices_cache[@]} -gt 0 ]]; then + return fi - # Sort filtered results if [[ "$has_metadata" == "false" ]]; then - view_indices=("${active_indices[@]}") - elif [[ ${#active_indices[@]} -eq 0 ]]; then - view_indices=() + sorted_indices_cache=("${orig_indices[@]}") + sort_cache_key="$requested_key" + return + fi + + # Build sort key once; filtering should reuse this cached order. + local sort_key + if [[ "$sort_mode" == "date" ]]; then + # Date: ascending by default (oldest first) + sort_key="-k1,1n" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" + elif [[ "$sort_mode" == "size" ]]; then + # Size: descending by default (largest first) + sort_key="-k1,1nr" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n" else - # Build sort key - local sort_key - if [[ "$sort_mode" == "date" ]]; then - # Date: ascending by default (oldest first) - sort_key="-k1,1n" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr" - elif [[ "$sort_mode" == "size" ]]; then - # Size: descending by default (largest first) - sort_key="-k1,1nr" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n" + # Name: ascending by default (A to Z) + sort_key="-k1,1f" + [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + fi + + local tmpfile + tmpfile=$(mktemp 2>/dev/null) || tmpfile="" + if [[ -n "$tmpfile" ]]; then + local k id + for id in "${orig_indices[@]}"; do + case "$sort_mode" in + date) k="${epochs[id]:-0}" ;; + size) k="${sizekb[id]:-0}" ;; + name | *) k="${items[id]}|${id}" ;; + esac + printf "%s\t%s\n" "$k" "$id" >>"$tmpfile" + done + + sorted_indices_cache=() + while IFS=$'\t' read -r _key _id; do + [[ -z "$_id" ]] && continue + sorted_indices_cache+=("$_id") + done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2>/dev/null) + + rm -f "$tmpfile" + else + sorted_indices_cache=("${orig_indices[@]}") + fi + sort_cache_key="$requested_key" + } + + # Rebuild the view_indices applying filter over cached sort order + rebuild_view() { + ensure_sorted_indices + + if [[ -n "$filter_text_lower" ]]; then + local -a source_indices=() + if [[ "$filter_cache_key" == "$sort_cache_key" && + "$filter_text_lower" == "$filter_cache_text_lower"* && + ${#filter_cache_indices[@]} -gt 0 ]]; then + source_indices=("${filter_cache_indices[@]}") else - # Name: ascending by default (A to Z) - sort_key="-k1,1f" - [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr" + if [[ ${#sorted_indices_cache[@]} -gt 0 ]]; then + source_indices=("${sorted_indices_cache[@]}") + else + source_indices=() + fi fi - # Create temporary file for sorting - local tmpfile - tmpfile=$(mktemp 2> /dev/null) || tmpfile="" - if [[ -n "$tmpfile" ]]; then - local k id - for id in "${active_indices[@]}"; do - case "$sort_mode" in - date) k="${epochs[id]:-0}" ;; - size) k="${sizekb[id]:-0}" ;; - name | *) k="${items[id]}|${id}" ;; - esac - printf "%s\t%s\n" "$k" "$id" >> "$tmpfile" - done + view_indices=() + local id + for id in "${source_indices[@]}"; do + if [[ "${filter_targets_lower[id]:-}" == *"$filter_text_lower"* ]]; then + view_indices+=("$id") + fi + done - 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" 2> /dev/null) - - rm -f "$tmpfile" + filter_cache_key="$sort_cache_key" + filter_cache_text_lower="$filter_text_lower" + if [[ ${#view_indices[@]} -gt 0 ]]; then + filter_cache_indices=("${view_indices[@]}") else - # Fallback: no sorting - view_indices=("${active_indices[@]}") + filter_cache_indices=() + fi + else + if [[ ${#sorted_indices_cache[@]} -gt 0 ]]; then + view_indices=("${sorted_indices_cache[@]}") + else + view_indices=() + fi + filter_cache_key="$sort_cache_key" + filter_cache_text_lower="" + if [[ ${#view_indices[@]} -gt 0 ]]; then + filter_cache_indices=("${view_indices[@]}") + else + filter_cache_indices=() fi fi @@ -368,7 +415,10 @@ paginated_multi_select() { return 1 fi if [[ "$char" =~ ^[[:print:]]$ ]]; then + local char_lower + char_lower=$(printf "%s" "$char" | LC_ALL=C tr '[:upper:]' '[:lower:]') filter_text+="$char" + filter_text_lower+="$char_lower" rebuild_view cursor_pos=0 top_index=0 @@ -432,9 +482,9 @@ paginated_multi_select() { # Build sort status local sort_label="" case "$sort_mode" in - date) sort_label="Date" ;; - name) sort_label="Name" ;; - size) sort_label="Size" ;; + date) sort_label="Date" ;; + name) sort_label="Name" ;; + size) sort_label="Size" ;; esac local sort_status="${sort_label}" @@ -458,22 +508,21 @@ paginated_multi_select() { local reverse_arrow="↑" [[ "$sort_reverse" == "true" ]] && reverse_arrow="↓" - local refresh="${GRAY}R Refresh${NC}" local sort_ctrl="${GRAY}S ${sort_status}${NC}" local order_ctrl="${GRAY}O ${reverse_arrow}${NC}" local filter_ctrl="${GRAY}/ Filter${NC}" if [[ -n "$filter_text" ]]; then - local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}ESC Clear${NC}") + local -a _segs_filter=("${GRAY}Backspace${NC}" "${GRAY}Ctrl+U Clear${NC}" "${GRAY}ESC Clear${NC}") _print_wrapped_controls "$sep" "${_segs_filter[@]}" elif [[ "$has_metadata" == "true" ]]; then # With metadata: show sort controls local term_width="${COLUMNS:-}" - [[ -z "$term_width" ]] && term_width=$(tput cols 2> /dev/null || echo 80) + [[ -z "$term_width" ]] && term_width=$(tput cols 2>/dev/null || echo 80) [[ "$term_width" =~ ^[0-9]+$ ]] || term_width=80 # Full controls - local -a _segs=("$nav" "$space_select" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + local -a _segs=("$nav" "$space_select" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") # Calculate width local total_len=0 seg_count=${#_segs[@]} @@ -484,7 +533,7 @@ paginated_multi_select() { # Level 1: Remove "Space Select" if too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$sort_ctrl" "$order_ctrl" "$filter_ctrl" "$exit") total_len=0 seg_count=${#_segs[@]} @@ -495,14 +544,14 @@ paginated_multi_select() { # Level 2: Remove sort label if still too wide if [[ $total_len -gt $term_width ]]; then - _segs=("$nav" "$enter" "$refresh" "$order_ctrl" "$filter_ctrl" "$exit") + _segs=("$nav" "$enter" "$order_ctrl" "$filter_ctrl" "$exit") fi fi _print_wrapped_controls "$sep" "${_segs[@]}" else # Without metadata: basic controls - local -a _segs_simple=("$nav" "$space_select" "$enter" "$refresh" "$filter_ctrl" "$exit") + local -a _segs_simple=("$nav" "$space_select" "$enter" "$filter_ctrl" "$exit") _print_wrapped_controls "$sep" "${_segs_simple[@]}" fi printf "${clear_line}" >&2 @@ -527,300 +576,297 @@ paginated_multi_select() { key=$(read_key) case "$key" in - "QUIT") + "QUIT") + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + filter_text="" + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + else + cleanup + return 1 + fi + ;; + "UP") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + elif [[ $cursor_pos -gt 0 ]]; then + local old_cursor=$cursor_pos + ((cursor_pos--)) + local new_cursor=$cursor_pos + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - filter_text="" - unset MOLE_READ_KEY_FORCE_CHAR - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - else - cleanup - return 1 + draw_header fi - ;; - "UP") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : - elif [[ $cursor_pos -gt 0 ]]; then - local old_cursor=$cursor_pos - ((cursor_pos--)) - local new_cursor=$cursor_pos - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $top_index -gt 0 ]]; then + ((top_index--)) + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue + fi + ;; + "DOWN") + if [[ ${#view_indices[@]} -eq 0 ]]; then + : + else + local absolute_index=$((top_index + cursor_pos)) + 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 + local old_cursor=$cursor_pos + ((cursor_pos++)) + local new_cursor=$cursor_pos + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local old_row=$((old_cursor + 3)) + local new_row=$((new_cursor + 3)) + + printf "\033[%d;1H" "$old_row" >&2 + render_item "$old_cursor" false + printf "\033[%d;1H" "$new_row" >&2 + render_item "$new_cursor" true + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + continue + elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then + ((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)) + fi + + if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + draw_header + fi + + local start_idx=$top_index + local end_idx=$((top_index + items_per_page - 1)) + local visible_total=${#view_indices[@]} + [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) + + for ((i = start_idx; i <= end_idx; i++)); do + local row=$((i - start_idx + 3)) + printf "\033[%d;1H" "$row" >&2 + local is_current=false + [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true + render_item $((i - start_idx)) $is_current + done + + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + + prev_cursor_pos=$cursor_pos + prev_top_index=$top_index + continue fi + fi + fi + ;; + "SPACE") + local idx=$((top_index + cursor_pos)) + if [[ $idx -lt ${#view_indices[@]} ]]; then + local real="${view_indices[idx]}" + if [[ ${selected[real]} == true ]]; then + selected[real]=false + ((selected_count--)) + else + selected[real]=true + ((selected_count++)) + fi - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) + # Incremental update: only redraw header (for count) and current row + # Header is at row 1 + printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true + # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) + local item_row=$((cursor_pos + 3)) + printf "\033[%d;1H" "$item_row" >&2 + render_item "$cursor_pos" true - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 + # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) + printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - prev_cursor_pos=$cursor_pos - continue + continue # Skip full redraw + fi + ;; + "CHAR:s" | "CHAR:S") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + case "$sort_mode" in + date) sort_mode="name" ;; + name) sort_mode="size" ;; + size) sort_mode="date" ;; + esac + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:j") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + local absolute_index=$((top_index + cursor_pos)) + 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 ${#view_indices[@]} ]]; then + ((top_index++)) + fi + need_full_redraw=true + fi + fi + ;; + "CHAR:k") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ ${#view_indices[@]} -gt 0 ]]; then + if [[ $cursor_pos -gt 0 ]]; then + ((cursor_pos--)) + need_full_redraw=true elif [[ $top_index -gt 0 ]]; then ((top_index--)) - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue + need_full_redraw=true fi - ;; - "DOWN") - if [[ ${#view_indices[@]} -eq 0 ]]; then - : + fi + ;; + "CHAR:o" | "CHAR:O") + if handle_filter_char "${key#CHAR:}"; then + : # Handled as filter input + elif [[ "$has_metadata" == "true" ]]; then + if [[ "$sort_reverse" == "true" ]]; then + sort_reverse="false" else - local absolute_index=$((top_index + cursor_pos)) - 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 - local old_cursor=$cursor_pos - ((cursor_pos++)) - local new_cursor=$cursor_pos - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local old_row=$((old_cursor + 3)) - local new_row=$((new_cursor + 3)) - - printf "\033[%d;1H" "$old_row" >&2 - render_item "$old_cursor" false - printf "\033[%d;1H" "$new_row" >&2 - render_item "$new_cursor" true - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - continue - elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then - ((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)) - fi - - if [[ -n "$filter_text" || -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then - draw_header - fi - - local start_idx=$top_index - local end_idx=$((top_index + items_per_page - 1)) - local visible_total=${#view_indices[@]} - [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1)) - - for ((i = start_idx; i <= end_idx; i++)); do - local row=$((i - start_idx + 3)) - printf "\033[%d;1H" "$row" >&2 - local is_current=false - [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true - render_item $((i - start_idx)) $is_current - done - - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - prev_cursor_pos=$cursor_pos - prev_top_index=$top_index - continue - fi - fi + sort_reverse="true" fi - ;; - "SPACE") + rebuild_view + need_full_redraw=true + fi + ;; + "CHAR:/" | "CHAR:?") + if [[ -n "${MOLE_READ_KEY_FORCE_CHAR:-}" ]]; then + unset MOLE_READ_KEY_FORCE_CHAR + else + export MOLE_READ_KEY_FORCE_CHAR=1 + fi + need_full_redraw=true + ;; + "DELETE") + if [[ -n "$filter_text" ]]; then + filter_text="${filter_text%?}" + filter_text_lower="${filter_text_lower%?}" + if [[ -z "$filter_text" ]]; then + filter_text_lower="" + unset MOLE_READ_KEY_FORCE_CHAR + fi + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CLEAR_LINE") + if [[ -n "$filter_text" ]]; then + filter_text="" + filter_text_lower="" + rebuild_view + cursor_pos=0 + top_index=0 + need_full_redraw=true + fi + ;; + "CHAR:"*) + handle_filter_char "${key#CHAR:}" || true + ;; + "ENTER") + # Smart Enter behavior + # 1. Check if any items are already selected + local has_selection=false + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + has_selection=true + break + fi + done + + # 2. If nothing selected, auto-select current item + if [[ $has_selection == false ]]; then local idx=$((top_index + cursor_pos)) if [[ $idx -lt ${#view_indices[@]} ]]; then local real="${view_indices[idx]}" - if [[ ${selected[real]} == true ]]; then - selected[real]=false - ((selected_count--)) - else - selected[real]=true - ((selected_count++)) - fi + selected[real]=true + ((selected_count++)) + fi + fi - # Incremental update: only redraw header (for count) and current row - # Header is at row 1 - printf "\033[1;1H\033[2K${PURPLE_BOLD}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2 + # 3. Confirm and exit with current selections + local -a selected_indices=() + for ((i = 0; i < total_items; i++)); do + if [[ ${selected[i]} == true ]]; then + selected_indices+=("$i") + fi + done - # Redraw current item row (+3: row 1=header, row 2=blank, row 3=first item) - local item_row=$((cursor_pos + 3)) - printf "\033[%d;1H" "$item_row" >&2 - render_item "$cursor_pos" true + local final_result="" + if [[ ${#selected_indices[@]} -gt 0 ]]; then + local IFS=',' + final_result="${selected_indices[*]}" + fi - # Move cursor to footer to avoid visual artifacts (items + header + 2 blanks) - printf "\033[%d;1H" "$((items_per_page + 4))" >&2 - - continue # Skip full redraw - fi - ;; - "RETRY") - # 'R' toggles reverse order (only if metadata available) - if [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:s" | "CHAR:S") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - case "$sort_mode" in - date) sort_mode="name" ;; - name) sort_mode="size" ;; - size) sort_mode="date" ;; - esac - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:j") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - local absolute_index=$((top_index + cursor_pos)) - 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 ${#view_indices[@]} ]]; then - ((top_index++)) - fi - need_full_redraw=true - fi - fi - ;; - "CHAR:k") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ ${#view_indices[@]} -gt 0 ]]; then - if [[ $cursor_pos -gt 0 ]]; then - ((cursor_pos--)) - need_full_redraw=true - elif [[ $top_index -gt 0 ]]; then - ((top_index--)) - need_full_redraw=true - fi - fi - ;; - "CHAR:r" | "CHAR:R") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - else - cleanup - return 10 - fi - ;; - "CHAR:o" | "CHAR:O") - if handle_filter_char "${key#CHAR:}"; then - : # Handled as filter input - elif [[ "$has_metadata" == "true" ]]; then - if [[ "$sort_reverse" == "true" ]]; then - sort_reverse="false" - else - sort_reverse="true" - fi - rebuild_view - need_full_redraw=true - fi - ;; - "CHAR:/" | "CHAR:?") - export MOLE_READ_KEY_FORCE_CHAR=1 - need_full_redraw=true - ;; - "DELETE") - if [[ -n "$filter_text" ]]; then - filter_text="${filter_text%?}" - if [[ -z "$filter_text" ]]; then - unset MOLE_READ_KEY_FORCE_CHAR - fi - rebuild_view - cursor_pos=0 - top_index=0 - need_full_redraw=true - fi - ;; - "CHAR:"*) - handle_filter_char "${key#CHAR:}" || true - ;; - "ENTER") - # Smart Enter behavior - # 1. Check if any items are already selected - local has_selection=false - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - has_selection=true - break - fi - done - - # 2. If nothing selected, auto-select current item - if [[ $has_selection == false ]]; then - local idx=$((top_index + cursor_pos)) - if [[ $idx -lt ${#view_indices[@]} ]]; then - local real="${view_indices[idx]}" - selected[real]=true - ((selected_count++)) - fi - fi - - # 3. Confirm and exit with current selections - local -a selected_indices=() - for ((i = 0; i < total_items; i++)); do - if [[ ${selected[i]} == true ]]; then - selected_indices+=("$i") - fi - done - - local final_result="" - if [[ ${#selected_indices[@]} -gt 0 ]]; then - local IFS=',' - final_result="${selected_indices[*]}" - fi - - trap - EXIT INT TERM - MOLE_SELECTION_RESULT="$final_result" - export MOLE_MENU_SORT_MODE="$sort_mode" - export MOLE_MENU_SORT_REVERSE="$sort_reverse" - restore_terminal - return 0 - ;; + trap - EXIT INT TERM + MOLE_SELECTION_RESULT="$final_result" + export MOLE_MENU_SORT_MODE="${sort_mode:-name}" + export MOLE_MENU_SORT_REVERSE="${sort_reverse:-false}" + restore_terminal + return 0 + ;; esac # Drain any accumulated input after processing (e.g., mouse wheel events)