mac_cleaner 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,688 @@
1
+ #!/bin/bash
2
+ # Paginated menu with arrow key navigation
3
+
4
+ set -euo pipefail
5
+
6
+ # Terminal control functions
7
+ enter_alt_screen() {
8
+ if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then
9
+ tput smcup 2> /dev/null || true
10
+ fi
11
+ }
12
+ leave_alt_screen() {
13
+ if command -v tput > /dev/null 2>&1 && [[ -t 1 ]]; then
14
+ tput rmcup 2> /dev/null || true
15
+ fi
16
+ }
17
+
18
+ # Parse CSV into newline list (Bash 3.2)
19
+ _pm_parse_csv_to_array() {
20
+ local csv="${1:-}"
21
+ if [[ -z "$csv" ]]; then
22
+ return 0
23
+ fi
24
+ local IFS=','
25
+ for _tok in $csv; do
26
+ printf "%s\n" "$_tok"
27
+ done
28
+ }
29
+
30
+ # Non-blocking input drain (bash 3.2)
31
+ drain_pending_input() {
32
+ local _k
33
+ # -t 0 is non-blocking; -n 1 consumes one byte at a time
34
+ while IFS= read -r -s -n 1 -t 0 _k; do
35
+ IFS= read -r -s -n 1 _k || break
36
+ done
37
+ }
38
+
39
+ # Main paginated multi-select menu function
40
+ paginated_multi_select() {
41
+ local title="$1"
42
+ shift
43
+ local -a items=("$@")
44
+ local external_alt_screen=false
45
+ if [[ "${MOLE_MANAGED_ALT_SCREEN:-}" == "1" || "${MOLE_MANAGED_ALT_SCREEN:-}" == "true" ]]; then
46
+ external_alt_screen=true
47
+ fi
48
+
49
+ # Validation
50
+ if [[ ${#items[@]} -eq 0 ]]; then
51
+ echo "No items provided" >&2
52
+ return 1
53
+ fi
54
+
55
+ local total_items=${#items[@]}
56
+ local items_per_page=15
57
+ local cursor_pos=0
58
+ local top_index=0
59
+ local filter_query=""
60
+ local filter_mode="false" # filter mode toggle
61
+ local sort_mode="${MOLE_MENU_SORT_DEFAULT:-date}" # date|name|size
62
+ local sort_reverse="false"
63
+ # Live query vs applied query
64
+ local applied_query=""
65
+ local searching="false"
66
+
67
+ # Metadata (optional)
68
+ # epochs[i] -> last_used_epoch (numeric) for item i
69
+ # sizekb[i] -> size in KB (numeric) for item i
70
+ local -a epochs=()
71
+ local -a sizekb=()
72
+ local has_metadata="false"
73
+ if [[ -n "${MOLE_MENU_META_EPOCHS:-}" ]]; then
74
+ while IFS= read -r v; do epochs+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_EPOCHS")
75
+ has_metadata="true"
76
+ fi
77
+ if [[ -n "${MOLE_MENU_META_SIZEKB:-}" ]]; then
78
+ while IFS= read -r v; do sizekb+=("${v:-0}"); done < <(_pm_parse_csv_to_array "$MOLE_MENU_META_SIZEKB")
79
+ has_metadata="true"
80
+ fi
81
+
82
+ # If no metadata, force name sorting and disable sorting controls
83
+ if [[ "$has_metadata" == "false" && "$sort_mode" != "name" ]]; then
84
+ sort_mode="name"
85
+ fi
86
+
87
+ # Index mappings
88
+ local -a orig_indices=()
89
+ local -a view_indices=()
90
+ local i
91
+ for ((i = 0; i < total_items; i++)); do
92
+ orig_indices[i]=$i
93
+ view_indices[i]=$i
94
+ done
95
+
96
+ # Escape for shell globbing without upsetting highlighters
97
+ _pm_escape_glob() {
98
+ local s="${1-}" out="" c
99
+ local i len=${#s}
100
+ for ((i = 0; i < len; i++)); do
101
+ c="${s:i:1}"
102
+ case "$c" in
103
+ '\' | '*' | '?' | '[' | ']') out+="\\$c" ;;
104
+ *) out+="$c" ;;
105
+ esac
106
+ done
107
+ printf '%s' "$out"
108
+ }
109
+
110
+ # Case-insensitive fuzzy match (substring search)
111
+ _pm_match() {
112
+ local hay="$1" q="$2"
113
+ q="$(_pm_escape_glob "$q")"
114
+ local pat="*${q}*"
115
+
116
+ shopt -s nocasematch
117
+ local ok=1
118
+ # shellcheck disable=SC2254 # intentional glob match with a computed pattern
119
+ case "$hay" in
120
+ $pat) ok=0 ;;
121
+ esac
122
+ shopt -u nocasematch
123
+ return $ok
124
+ }
125
+
126
+ local -a selected=()
127
+
128
+ # Initialize selection array
129
+ for ((i = 0; i < total_items; i++)); do
130
+ selected[i]=false
131
+ done
132
+
133
+ if [[ -n "${MOLE_PRESELECTED_INDICES:-}" ]]; then
134
+ local cleaned_preselect="${MOLE_PRESELECTED_INDICES//[[:space:]]/}"
135
+ local -a initial_indices=()
136
+ IFS=',' read -ra initial_indices <<< "$cleaned_preselect"
137
+ for idx in "${initial_indices[@]}"; do
138
+ if [[ "$idx" =~ ^[0-9]+$ && $idx -ge 0 && $idx -lt $total_items ]]; then
139
+ selected[idx]=true
140
+ fi
141
+ done
142
+ fi
143
+
144
+ # Preserve original TTY settings so we can restore them reliably
145
+ local original_stty=""
146
+ if [[ -t 0 ]] && command -v stty > /dev/null 2>&1; then
147
+ original_stty=$(stty -g 2> /dev/null || echo "")
148
+ fi
149
+
150
+ restore_terminal() {
151
+ show_cursor
152
+ if [[ -n "${original_stty-}" ]]; then
153
+ stty "${original_stty}" 2> /dev/null || stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true
154
+ else
155
+ stty sane 2> /dev/null || stty echo icanon 2> /dev/null || true
156
+ fi
157
+ if [[ "${external_alt_screen:-false}" == false ]]; then
158
+ leave_alt_screen
159
+ fi
160
+ }
161
+
162
+ # Cleanup function
163
+ cleanup() {
164
+ trap - EXIT INT TERM
165
+ restore_terminal
166
+ unset MOLE_READ_KEY_FORCE_CHAR
167
+ }
168
+
169
+ # Interrupt handler
170
+ handle_interrupt() {
171
+ cleanup
172
+ exit 130 # Standard exit code for Ctrl+C
173
+ }
174
+
175
+ trap cleanup EXIT
176
+ trap handle_interrupt INT TERM
177
+
178
+ # Setup terminal - preserve interrupt character
179
+ stty -echo -icanon intr ^C 2> /dev/null || true
180
+ if [[ $external_alt_screen == false ]]; then
181
+ enter_alt_screen
182
+ # Clear screen once on entry to alt screen
183
+ printf "\033[2J\033[H" >&2
184
+ else
185
+ printf "\033[H" >&2
186
+ fi
187
+ hide_cursor
188
+
189
+ # Helper functions
190
+ print_line() { printf "\r\033[2K%s\n" "$1" >&2; }
191
+
192
+ # Print footer lines wrapping only at separators
193
+ _print_wrapped_controls() {
194
+ local sep="$1"
195
+ shift
196
+ local -a segs=("$@")
197
+
198
+ local cols="${COLUMNS:-}"
199
+ [[ -z "$cols" ]] && cols=$(tput cols 2> /dev/null || echo 80)
200
+
201
+ _strip_ansi_len() {
202
+ local text="$1"
203
+ local stripped
204
+ stripped=$(printf "%s" "$text" | LC_ALL=C awk '{gsub(/\033\[[0-9;]*[A-Za-z]/,""); print}')
205
+ printf "%d" "${#stripped}"
206
+ }
207
+
208
+ local line="" s candidate
209
+ local clear_line=$'\r\033[2K'
210
+ for s in "${segs[@]}"; do
211
+ if [[ -z "$line" ]]; then
212
+ candidate="$s"
213
+ else
214
+ candidate="$line${sep}${s}"
215
+ fi
216
+ if (($(_strip_ansi_len "$candidate") > cols)); then
217
+ printf "%s%s\n" "$clear_line" "$line" >&2
218
+ line="$s"
219
+ else
220
+ line="$candidate"
221
+ fi
222
+ done
223
+ printf "%s%s\n" "$clear_line" "$line" >&2
224
+ }
225
+
226
+ # Rebuild the view_indices applying filter and sort
227
+ rebuild_view() {
228
+ # Filter
229
+ local -a filtered=()
230
+ local effective_query=""
231
+ if [[ "$filter_mode" == "true" ]]; then
232
+ # Live editing: empty query -> show all items
233
+ effective_query="$filter_query"
234
+ if [[ -z "$effective_query" ]]; then
235
+ filtered=("${orig_indices[@]}")
236
+ else
237
+ local idx
238
+ for ((idx = 0; idx < total_items; idx++)); do
239
+ if _pm_match "${items[idx]}" "$effective_query"; then
240
+ filtered+=("$idx")
241
+ fi
242
+ done
243
+ fi
244
+ else
245
+ # Normal mode: use applied query; empty -> show all
246
+ effective_query="$applied_query"
247
+ if [[ -z "$effective_query" ]]; then
248
+ filtered=("${orig_indices[@]}")
249
+ else
250
+ local idx
251
+ for ((idx = 0; idx < total_items; idx++)); do
252
+ if _pm_match "${items[idx]}" "$effective_query"; then
253
+ filtered+=("$idx")
254
+ fi
255
+ done
256
+ fi
257
+ fi
258
+
259
+ # Sort (skip if no metadata)
260
+ if [[ "$has_metadata" == "false" ]]; then
261
+ # No metadata: just use filtered list (already sorted by name naturally)
262
+ view_indices=("${filtered[@]}")
263
+ elif [[ ${#filtered[@]} -eq 0 ]]; then
264
+ view_indices=()
265
+ else
266
+ # Build sort key
267
+ local sort_key
268
+ if [[ "$sort_mode" == "date" ]]; then
269
+ # Date: ascending by default (oldest first)
270
+ sort_key="-k1,1n"
271
+ [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1nr"
272
+ elif [[ "$sort_mode" == "size" ]]; then
273
+ # Size: descending by default (largest first)
274
+ sort_key="-k1,1nr"
275
+ [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1n"
276
+ else
277
+ # Name: ascending by default (A to Z)
278
+ sort_key="-k1,1f"
279
+ [[ "$sort_reverse" == "true" ]] && sort_key="-k1,1fr"
280
+ fi
281
+
282
+ # Create temporary file for sorting
283
+ local tmpfile
284
+ tmpfile=$(mktemp 2> /dev/null) || tmpfile=""
285
+ if [[ -n "$tmpfile" ]]; then
286
+ local k id
287
+ for id in "${filtered[@]}"; do
288
+ case "$sort_mode" in
289
+ date) k="${epochs[id]:-0}" ;;
290
+ size) k="${sizekb[id]:-0}" ;;
291
+ name | *) k="${items[id]}|${id}" ;;
292
+ esac
293
+ printf "%s\t%s\n" "$k" "$id" >> "$tmpfile"
294
+ done
295
+
296
+ view_indices=()
297
+ while IFS=$'\t' read -r _key _id; do
298
+ [[ -z "$_id" ]] && continue
299
+ view_indices+=("$_id")
300
+ done < <(LC_ALL=C sort -t $'\t' $sort_key -- "$tmpfile" 2> /dev/null)
301
+
302
+ rm -f "$tmpfile"
303
+ else
304
+ # Fallback: no sorting
305
+ view_indices=("${filtered[@]}")
306
+ fi
307
+ fi
308
+
309
+ # Clamp cursor into visible range
310
+ local visible_count=${#view_indices[@]}
311
+ local max_top
312
+ if [[ $visible_count -gt $items_per_page ]]; then
313
+ max_top=$((visible_count - items_per_page))
314
+ else
315
+ max_top=0
316
+ fi
317
+ [[ $top_index -gt $max_top ]] && top_index=$max_top
318
+ local current_visible=$((visible_count - top_index))
319
+ [[ $current_visible -gt $items_per_page ]] && current_visible=$items_per_page
320
+ if [[ $cursor_pos -ge $current_visible ]]; then
321
+ cursor_pos=$((current_visible > 0 ? current_visible - 1 : 0))
322
+ fi
323
+ [[ $cursor_pos -lt 0 ]] && cursor_pos=0
324
+ }
325
+
326
+ # Initial view (default sort)
327
+ rebuild_view
328
+
329
+ render_item() {
330
+ # $1: visible row index (0..items_per_page-1 in current window)
331
+ # $2: is_current flag
332
+ local vrow=$1 is_current=$2
333
+ local idx=$((top_index + vrow))
334
+ local real="${view_indices[idx]:--1}"
335
+ [[ $real -lt 0 ]] && return
336
+ local checkbox="$ICON_EMPTY"
337
+ [[ ${selected[real]} == true ]] && checkbox="$ICON_SOLID"
338
+
339
+ if [[ $is_current == true ]]; then
340
+ printf "\r\033[2K${BLUE}${ICON_ARROW} %s %s${NC}\n" "$checkbox" "${items[real]}" >&2
341
+ else
342
+ printf "\r\033[2K %s %s\n" "$checkbox" "${items[real]}" >&2
343
+ fi
344
+ }
345
+
346
+ # Draw the complete menu
347
+ draw_menu() {
348
+ printf "\033[H" >&2
349
+ local clear_line="\r\033[2K"
350
+
351
+ # Count selections
352
+ local selected_count=0
353
+ for ((i = 0; i < total_items; i++)); do
354
+ [[ ${selected[i]} == true ]] && ((selected_count++))
355
+ done
356
+
357
+ # Header only
358
+ printf "${clear_line}${PURPLE}%s${NC} ${GRAY}%d/%d selected${NC}\n" "${title}" "$selected_count" "$total_items" >&2
359
+
360
+ # Visible slice
361
+ local visible_total=${#view_indices[@]}
362
+ if [[ $visible_total -eq 0 ]]; then
363
+ if [[ "$filter_mode" == "true" ]]; then
364
+ # While editing: do not show "No items available"
365
+ for ((i = 0; i < items_per_page + 2; i++)); do
366
+ printf "${clear_line}\n" >&2
367
+ done
368
+ 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
369
+ printf "${clear_line}" >&2
370
+ return
371
+ else
372
+ if [[ "$searching" == "true" ]]; then
373
+ printf "${clear_line}${GRAY}Searching…${NC}\n" >&2
374
+ for ((i = 0; i < items_per_page + 2; i++)); do
375
+ printf "${clear_line}\n" >&2
376
+ done
377
+ printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Nav ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}/${NC} Filter ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}Q${NC} Quit\n" >&2
378
+ printf "${clear_line}" >&2
379
+ return
380
+ else
381
+ # Post-search: truly empty list
382
+ printf "${clear_line}${GRAY}No items available${NC}\n" >&2
383
+ for ((i = 0; i < items_per_page + 2; i++)); do
384
+ printf "${clear_line}\n" >&2
385
+ done
386
+ printf "${clear_line}${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Nav ${GRAY}|${NC} ${GRAY}Space${NC} Select ${GRAY}|${NC} ${GRAY}Enter${NC} Confirm ${GRAY}|${NC} ${GRAY}/${NC} Filter ${GRAY}|${NC} ${GRAY}S${NC} Sort ${GRAY}|${NC} ${GRAY}Q${NC} Quit\n" >&2
387
+ printf "${clear_line}" >&2
388
+ return
389
+ fi
390
+ fi
391
+ fi
392
+
393
+ local visible_count=$((visible_total - top_index))
394
+ [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
395
+ [[ $visible_count -le 0 ]] && visible_count=1
396
+ if [[ $cursor_pos -ge $visible_count ]]; then
397
+ cursor_pos=$((visible_count - 1))
398
+ [[ $cursor_pos -lt 0 ]] && cursor_pos=0
399
+ fi
400
+
401
+ printf "${clear_line}\n" >&2
402
+
403
+ # Items for current window
404
+ local start_idx=$top_index
405
+ local end_idx=$((top_index + items_per_page - 1))
406
+ [[ $end_idx -ge $visible_total ]] && end_idx=$((visible_total - 1))
407
+
408
+ for ((i = start_idx; i <= end_idx; i++)); do
409
+ [[ $i -lt 0 ]] && continue
410
+ local is_current=false
411
+ [[ $((i - start_idx)) -eq $cursor_pos ]] && is_current=true
412
+ render_item $((i - start_idx)) $is_current
413
+ done
414
+
415
+ # Fill empty slots to clear previous content
416
+ local items_shown=$((end_idx - start_idx + 1))
417
+ [[ $items_shown -lt 0 ]] && items_shown=0
418
+ for ((i = items_shown; i < items_per_page; i++)); do
419
+ printf "${clear_line}\n" >&2
420
+ done
421
+
422
+ printf "${clear_line}\n" >&2
423
+
424
+ # Build sort and filter status
425
+ local sort_label=""
426
+ case "$sort_mode" in
427
+ date) sort_label="Date" ;;
428
+ name) sort_label="Name" ;;
429
+ size) sort_label="Size" ;;
430
+ esac
431
+ local arrow="↑"
432
+ [[ "$sort_reverse" == "true" ]] && arrow="↓"
433
+ local sort_status="${sort_label} ${arrow}"
434
+
435
+ local filter_status=""
436
+ if [[ "$filter_mode" == "true" ]]; then
437
+ filter_status="${YELLOW}${filter_query:-}${NC}"
438
+ elif [[ -n "$applied_query" ]]; then
439
+ filter_status="${GREEN}${applied_query}${NC}"
440
+ else
441
+ filter_status="${GRAY}—${NC}"
442
+ fi
443
+
444
+ # Footer with two lines: basic controls and advanced options
445
+ local sep=" ${GRAY}|${NC} "
446
+ if [[ "$filter_mode" == "true" ]]; then
447
+ # Filter mode: single line with all filter controls
448
+ local -a _segs_filter=(
449
+ "${GRAY}Filter Input:${NC} ${filter_status}"
450
+ "${GRAY}Delete${NC} Back"
451
+ "${GRAY}Enter${NC} Apply"
452
+ "${GRAY}/${NC} Clear"
453
+ "${GRAY}ESC${NC} Cancel"
454
+ )
455
+ _print_wrapped_controls "$sep" "${_segs_filter[@]}"
456
+ else
457
+ # Normal mode
458
+ if [[ "$has_metadata" == "true" ]]; then
459
+ # With metadata: two lines (basic + advanced)
460
+ local -a _segs_basic=(
461
+ "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate"
462
+ "${GRAY}Space${NC} Select"
463
+ "${GRAY}Enter${NC} Confirm"
464
+ "${GRAY}Q/ESC${NC} Quit"
465
+ )
466
+ _print_wrapped_controls "$sep" "${_segs_basic[@]}"
467
+ local -a _segs_advanced=(
468
+ "${GRAY}S${NC} ${sort_status}"
469
+ "${GRAY}R${NC} Reverse"
470
+ "${GRAY}/${NC} Filter"
471
+ )
472
+ _print_wrapped_controls "$sep" "${_segs_advanced[@]}"
473
+ else
474
+ # Without metadata: single line (basic only)
475
+ local -a _segs_simple=(
476
+ "${GRAY}${ICON_NAV_UP}/${ICON_NAV_DOWN}${NC} Navigate"
477
+ "${GRAY}Space${NC} Select"
478
+ "${GRAY}Enter${NC} Confirm"
479
+ "${GRAY}/${NC} Filter"
480
+ "${GRAY}Q/ESC${NC} Quit"
481
+ )
482
+ _print_wrapped_controls "$sep" "${_segs_simple[@]}"
483
+ fi
484
+ fi
485
+ printf "${clear_line}" >&2
486
+ }
487
+
488
+ # Main interaction loop
489
+ while true; do
490
+ draw_menu
491
+ local key
492
+ key=$(read_key)
493
+
494
+ case "$key" in
495
+ "QUIT")
496
+ if [[ "$filter_mode" == "true" ]]; then
497
+ filter_mode="false"
498
+ unset MOLE_READ_KEY_FORCE_CHAR
499
+ filter_query=""
500
+ applied_query=""
501
+ top_index=0
502
+ cursor_pos=0
503
+ rebuild_view
504
+ continue
505
+ fi
506
+ cleanup
507
+ return 1
508
+ ;;
509
+ "UP")
510
+ if [[ ${#view_indices[@]} -eq 0 ]]; then
511
+ :
512
+ elif [[ $cursor_pos -gt 0 ]]; then
513
+ ((cursor_pos--))
514
+ elif [[ $top_index -gt 0 ]]; then
515
+ ((top_index--))
516
+ fi
517
+ ;;
518
+ "DOWN")
519
+ if [[ ${#view_indices[@]} -eq 0 ]]; then
520
+ :
521
+ else
522
+ local absolute_index=$((top_index + cursor_pos))
523
+ local last_index=$((${#view_indices[@]} - 1))
524
+ if [[ $absolute_index -lt $last_index ]]; then
525
+ local visible_count=$((${#view_indices[@]} - top_index))
526
+ [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
527
+
528
+ if [[ $cursor_pos -lt $((visible_count - 1)) ]]; then
529
+ ((cursor_pos++))
530
+ elif [[ $((top_index + visible_count)) -lt ${#view_indices[@]} ]]; then
531
+ ((top_index++))
532
+ visible_count=$((${#view_indices[@]} - top_index))
533
+ [[ $visible_count -gt $items_per_page ]] && visible_count=$items_per_page
534
+ if [[ $cursor_pos -ge $visible_count ]]; then
535
+ cursor_pos=$((visible_count - 1))
536
+ fi
537
+ fi
538
+ fi
539
+ fi
540
+ ;;
541
+ "SPACE")
542
+ local idx=$((top_index + cursor_pos))
543
+ if [[ $idx -lt ${#view_indices[@]} ]]; then
544
+ local real="${view_indices[idx]}"
545
+ if [[ ${selected[real]} == true ]]; then
546
+ selected[real]=false
547
+ else
548
+ selected[real]=true
549
+ fi
550
+ fi
551
+ ;;
552
+ "RETRY")
553
+ # 'R' toggles reverse order (only if metadata available)
554
+ if [[ "$has_metadata" == "true" ]]; then
555
+ if [[ "$sort_reverse" == "true" ]]; then
556
+ sort_reverse="false"
557
+ else
558
+ sort_reverse="true"
559
+ fi
560
+ rebuild_view
561
+ fi
562
+ ;;
563
+ "CHAR:s" | "CHAR:S")
564
+ if [[ "$filter_mode" == "true" ]]; then
565
+ local ch="${key#CHAR:}"
566
+ filter_query+="$ch"
567
+ elif [[ "$has_metadata" == "true" ]]; then
568
+ # Cycle sort mode (only if metadata available)
569
+ case "$sort_mode" in
570
+ date) sort_mode="name" ;;
571
+ name) sort_mode="size" ;;
572
+ size) sort_mode="date" ;;
573
+ esac
574
+ rebuild_view
575
+ fi
576
+ ;;
577
+ "FILTER")
578
+ # Trigger filter mode with /
579
+ filter_mode="true"
580
+ export MOLE_READ_KEY_FORCE_CHAR=1
581
+ filter_query=""
582
+ top_index=0
583
+ cursor_pos=0
584
+ rebuild_view
585
+ ;;
586
+ "CHAR:f" | "CHAR:F")
587
+ if [[ "$filter_mode" == "true" ]]; then
588
+ filter_query+="${key#CHAR:}"
589
+ fi
590
+ ;;
591
+ "CHAR:r")
592
+ # lower-case r: behave like reverse when NOT in filter mode
593
+ if [[ "$filter_mode" == "true" ]]; then
594
+ filter_query+="r"
595
+ else
596
+ if [[ "$sort_reverse" == "true" ]]; then
597
+ sort_reverse="false"
598
+ else
599
+ sort_reverse="true"
600
+ fi
601
+ rebuild_view
602
+ fi
603
+ ;;
604
+ "DELETE")
605
+ # Backspace filter
606
+ if [[ "$filter_mode" == "true" && -n "$filter_query" ]]; then
607
+ filter_query="${filter_query%?}"
608
+ fi
609
+ ;;
610
+ CHAR:*)
611
+ if [[ "$filter_mode" == "true" ]]; then
612
+ local ch="${key#CHAR:}"
613
+ # Special handling for /: clear filter
614
+ if [[ "$ch" == "/" ]]; then
615
+ filter_query=""
616
+ rebuild_view
617
+ # avoid accidental leading spaces
618
+ elif [[ -n "$filter_query" || "$ch" != " " ]]; then
619
+ filter_query+="$ch"
620
+ fi
621
+ fi
622
+ ;;
623
+ "ENTER")
624
+ if [[ "$filter_mode" == "true" ]]; then
625
+ applied_query="$filter_query"
626
+ filter_mode="false"
627
+ unset MOLE_READ_KEY_FORCE_CHAR
628
+ top_index=0
629
+ cursor_pos=0
630
+
631
+ searching="true"
632
+ draw_menu # paint "searching..."
633
+ drain_pending_input # drop any extra keypresses (e.g., double-Enter)
634
+ rebuild_view
635
+ searching="false"
636
+ draw_menu
637
+ continue
638
+ fi
639
+ # In normal mode: smart Enter behavior
640
+ # 1. Check if any items are already selected
641
+ local has_selection=false
642
+ for ((i = 0; i < total_items; i++)); do
643
+ if [[ ${selected[i]} == true ]]; then
644
+ has_selection=true
645
+ break
646
+ fi
647
+ done
648
+
649
+ # 2. If nothing selected, auto-select current item
650
+ if [[ $has_selection == false ]]; then
651
+ local idx=$((top_index + cursor_pos))
652
+ if [[ $idx -lt ${#view_indices[@]} ]]; then
653
+ local real="${view_indices[idx]}"
654
+ selected[real]=true
655
+ fi
656
+ fi
657
+
658
+ # 3. Confirm and exit with current selections
659
+ local -a selected_indices=()
660
+ for ((i = 0; i < total_items; i++)); do
661
+ if [[ ${selected[i]} == true ]]; then
662
+ selected_indices+=("$i")
663
+ fi
664
+ done
665
+
666
+ local final_result=""
667
+ if [[ ${#selected_indices[@]} -gt 0 ]]; then
668
+ local IFS=','
669
+ final_result="${selected_indices[*]}"
670
+ fi
671
+
672
+ trap - EXIT INT TERM
673
+ MOLE_SELECTION_RESULT="$final_result"
674
+ restore_terminal
675
+ return 0
676
+ ;;
677
+ "HELP")
678
+ # Removed help screen, users can explore the interface
679
+ ;;
680
+ esac
681
+ done
682
+ }
683
+
684
+ # Export function for external use
685
+ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
686
+ echo "This is a library file. Source it from other scripts." >&2
687
+ exit 1
688
+ fi