diff --git a/commands/choose-menu b/commands/choose-menu index 88ecdeae5..7114f8de0 100755 --- a/commands/choose-menu +++ b/commands/choose-menu @@ -170,126 +170,402 @@ function choose_menu() ( help 'Empty s were provided:' $'\n' "$(echo-verbose -- "${items[@]}")" fi - # prepare - local count last selections=() - count="${#items[@]}" - last="$((count - 1))" - mapfile -t selections < <(get-array "$count") - - # default selection - local cursor=0 - local index default + # prepare vars + # show the menu + # one hollow circle: ⚬ ○ ◯ ❍ + # two hollow circles: ◎ ⦾ ⊚ + # one hollow, one full: ☉ ⦿ ◉ + # one full: ● + # ▣ ▢ □ ⊡ + # ☑ ☒ ⌧ + # ✓ ✔ ✖ ✗ ✘ + local style_dim=$'\e[2m' style_reset=$'\e[0m' + local \ + action='' \ + bin_gfmt='' \ + bin_gfold='' \ + bin_gwc='' \ + can_cancel \ + cursor=0 \ + default \ + index \ + item_original \ + item_prefix \ + item_rendered \ + item_size \ + items_bundled \ + items_bundled_size=0 \ + items_last_index \ + items_total="${#items[@]}" \ + menu_header_shrunk='' \ + menu_header_size \ + menu_header='' \ + menu_hint_extras='' \ + menu_hint_shrunk='' \ + menu_hint_size \ + menu_hint_standard='' \ + menu_hint='' \ + menu_size \ + menu_skip_remainder \ + menu_skip_render='no' \ + menu_status=0 \ + menu_title='' \ + page_last_index=0 \ + page_start_index=0 \ + paging_needed \ + paging_supported='yes' \ + read_status \ + renders \ + selected_count=0 \ + selection \ + selections \ + size_columns_prior=0 \ + size_columns=80 \ + size_content=75 \ + size_rows_prior=0 \ + size_rows=10 \ + sizes \ + style_bold=$'\e[1m' \ + style_checked \ + style_help_begin="$style_dim" \ + style_help_end="$style_reset" \ + style_indent=' ' \ + style_key_begin \ + style_key_end=" $style_reset" \ + style_magenta=$'\e[35m' \ + style_selected \ + style_unchecked \ + tty_target + items_last_index="$((items_total - 1))" + mapfile -t selections < <(get-array "$items_total") + mapfile -t renders < <(get-array "$items_total") + mapfile -t sizes < <(get-array "$items_total") + + # prepare paging + tty_target="$(is-tty --fallback)" + if test "$tty_target" = '/dev/stderr' || command-missing tput; then + # fix [tput: No value for $TERM and no -T specified] errors when fetching columns and rows on CI + paging_supported='no' + # @todo multi-line items won't be style_indented properly, use fmt if it exists in this mode for item + else + if is-mac; then + bin_gfold="$(type -P 'gfold' 2>/dev/null || :)" + bin_gfmt="$(type -P 'gfmt' 2>/dev/null || :)" + bin_gwc="$(type -P 'gwc' 2>/dev/null || :)" + else + # we could support these on macos, however fmt does not support -t on macos (it is something different, so we'd have to manually do that) + bin_gfold="$(type -P 'fold' 2>/dev/null || :)" + bin_gfmt="$(type -P 'fmt' 2>/dev/null || :)" + bin_gwc="$(type -P 'wc' 2>/dev/null || :)" + fi + if test -z "$bin_gfold" -o -z "$bin_gfmt" -o -z "$bin_gwc"; then + paging_supported='no' + # don't bother installing, as that will require brew, and we might now yet have brew installed + fi + fi + + # select defaults + function select_defaults { + if test "${#defaults[@]}" -ne 0; then # bash v3 compat + for default in "${defaults[@]}"; do + for index in "${!items[@]}"; do + item="${items[index]}" + if test "$default" = "$item"; then + selections[index]='yes' + if test "$option_multi" = 'no'; then + cursor="$index" + page_start_index="$index" + fi + break + fi + done + done + fi + } if test "${#defaults[@]}" -ne 0; then # bash v3 compat + can_cancel='yes' if test "${#defaults[@]}" -gt 1 -a "$option_multi" = 'no'; then help 'Multiple defaults were provided, but --multi was not set.' fi - for default in "${defaults[@]}"; do - for index in "${!items[@]}"; do - item="${items[index]}" - if test "$default" = "$item"; then - selections[index]='yes' - if test "$option_multi" = 'no'; then - cursor="$index" - fi - break - fi - done - done + select_defaults + elif test "$option_required" = 'no'; then + can_cancel='yes' + else + can_cancel='no' fi - # commence - tty_start - local read_status menu_status=0 menu='' action='' tty_target - local magenta=$'\e[35m' bold=$'\e[1m' dim=$'\e[2m' reset=$'\e[0m' - local help_begin="$dim" help_end="$reset" key_begin key_end=" $reset" indent=' ' + # prepare renders if test "$use_colors" = 'no'; then - magenta='' - bold='' - dim='' - reset='' - help_begin='' - help_end='' - key_begin='[' - key_end=']' + style_bold='' + style_dim='' + style_help_begin='' + style_help_end='' + style_key_begin='[' + style_key_end=']' + style_magenta='' + style_reset='' elif test "$(get-terminal-theme || :)" = 'dark'; then - key_begin=$'\e[30m\e[47m ' # foreground black, background white + style_key_begin=$'\e[30m\e[47m ' # foreground black, background white else - key_begin=$'\e[107m ' # foreground default black, background intense white + style_key_begin=$'\e[107m ' # foreground default black, background intense white fi - tty_target="$(is-tty --fallback)" had_selected='no' - while test "$action" != 'done'; do - menu='' - - # question - if test -n "$option_question"; then - menu+="${bold}${option_question}${reset}"$'\n' + if test "$option_multi" = 'yes'; then + style_checked='▣' + style_unchecked='□' + style_selected='⊡' + else + if test "$option_required" = 'yes'; then + style_checked='●' + style_selected='●' + else + style_checked='☉' + style_selected='☉' fi + style_unchecked='○' + fi + if test "$option_hints" = 'yes'; then + if test "$option_multi" = 'no'; then + menu_hint_standard+="${style_help_begin}SELECT${style_help_end} ${style_key_begin}SPACE${style_key_end} ${style_key_begin}ENTER${style_key_end} ${style_key_begin}E${style_key_end}" + else + menu_hint_standard+="${style_help_begin}SELECT${style_help_end} ${style_key_begin}SPACE${style_key_end}" + menu_hint_standard+="${style_indent}${style_help_begin}CONFIRM${style_help_end} ${style_key_begin}ENTER${style_key_end} ${style_key_begin}E${style_key_end}" + fi + if test "$can_cancel" = 'yes'; then + # cancel restores defaults and leaves + menu_hint_standard+="${style_indent}${style_help_begin}CANCEL${style_help_end} ${style_key_begin}ESC${style_key_end} ${style_key_begin}Q${style_key_end}" + fi + if test "$items_total" -ne 1; then + # [⬆⬇⇧] have alignment issues, use [↑↓] + menu_hint_standard+="${style_indent}${style_help_begin}UP${style_help_end} ${style_key_begin}↑${style_key_end} ${style_key_begin}W${style_key_end} ${style_key_begin}K${style_key_end}" + menu_hint_standard+="${style_indent}${style_help_begin}DOWN${style_help_end} ${style_key_begin}↓${style_key_end} ${style_key_begin}S${style_key_end} ${style_key_begin}J${style_key_end}" + menu_hint_extras+="${style_indent}${style_help_begin}FIRST${style_help_end} ${style_key_begin}HOME${style_key_end} ${style_key_begin}A${style_key_end}" + menu_hint_extras+="${style_indent}${style_help_begin}LAST${style_help_end} ${style_key_begin}END${style_key_end} ${style_key_begin}D${style_key_end}" + if test "$option_multi" = 'yes'; then + menu_hint_extras+="${style_indent}${style_help_begin}ALL/NONE${style_help_end} ${style_key_begin}T${style_key_end}" + fi + fi + if test "${#defaults[@]}" -ne 0; then + menu_hint_extras+="${style_indent}${style_help_begin}DEFAULTS${style_help_end} ${style_key_begin}Z${style_key_end}" + fi + # tab and backspace undocumented + fi + if test -n "$option_question"; then + menu_header="${style_bold}${option_question}${style_reset}"$'\n' + fi - # show the menu - had_selected='no' - for index in "${!items[@]}"; do - if test "$index" -eq "$cursor"; then - menu+="$magenta>" - else - if test "${selections[index]-}" = 'yes'; then - menu+=" $magenta" - else - menu+=' ' + # commence + tty_start + + # this is to slow to recalculate on each interaction + function refresh_terminal_size { + if test "$paging_supported" = 'no'; then + paging_needed='no' + else + size_rows="$(tput lines)" + size_columns="$(tput cols)" + if test "$size_rows" -ne "$size_rows_prior" -o "$size_columns" -ne "$size_columns_prior"; then + size_content="$((size_columns - 5))" + # recalculate for new size + menu_header_shrunk="$(echo-trim-colors "$menu_header" | "$bin_gfold" -w "$size_columns")" + menu_header_size="$("$bin_gwc" -l <<<"${menu_header_shrunk}")" + menu_hint="${menu_hint_standard}${menu_hint_extras}" + menu_hint_shrunk="$(echo-trim-colors "$menu_hint" | "$bin_gfold" -w "$size_columns")" + menu_hint_size="$("$bin_gwc" -l <<<"${menu_hint_shrunk}")" + if test "$menu_hint_size" -gt 1; then + menu_hint="${menu_hint_standard}" + menu_hint_shrunk="$(echo-trim-colors "$menu_hint" | "$bin_gfold" -w "$size_columns")" + menu_hint_size="$("$bin_gwc" -l <<<"${menu_hint_shrunk}")" fi + # move start index to current item, as otherwise it could be out of range + if test "$size_rows" -lt "$size_rows_prior" -o "$size_columns" -lt "$size_columns_prior"; then + # @todo we should detect if showing everything is possible before resorting to this + page_start_index="$cursor" + page_last_index="$items_last_index" + fi + # reset paging for new size + paging_needed='maybe' + mapfile -t renders < <(get-array "$items_total") + mapfile -t sizes < <(get-array "$items_total") + # update priors + size_rows_prior="$size_rows" + size_columns_prior="$size_columns" fi - if test "$option_multi" = 'yes'; then - # one hollow circle: ⚬ ○ ◯ ❍ - # two hollow circles: ◎ ⦾ ⊚ - # one hollow, one full: ☉ ⦿ ◉ - # one full: ● - # ▣ ▢ □ ⊡ - # ☑ ☒ - # ✓ ✔ ✖ ✗ ✘ + fi + } + function action_jump { + # jump to number and select + if test "$action" -le 1; then + cursor=0 + elif test "$action" -le "$count"; then + cursor="$((action - 1))" + else + cursor="$items_last_index" + fi + action_select + } + function action_select { + # toggle single + if test "${selections[cursor]}" = 'yes'; then + selections[cursor]='' + else + selections[cursor]='yes' + fi + } + function action_up { + if test "$cursor" -eq 0; then + if test "$paging_needed" = 'yes'; then + page_start_index="$items_last_index" + fi + cursor="$items_last_index" + else + if test "$cursor" -eq "$page_start_index"; then + page_start_index="$((page_start_index - 1))" + fi + cursor="$((cursor - 1))" + fi + } + function action_down { + if test "$cursor" -eq "$items_last_index"; then + cursor=0 + page_start_index=0 + else + if test "$paging_needed" = 'yes' -a "$cursor" -eq "$page_last_index"; then + page_start_index="$((page_last_index + 1))" # ="$next_page_start_index" + fi + cursor="$((cursor + 1))" + fi + } + function action_first { + cursor=0 + page_start_index=0 + } + function action_last { + if test "$paging_needed" = 'yes'; then + page_start_index="$items_last_index" + fi + cursor="$items_last_index" + } + function action_select_and_confirm { + selections[cursor]='yes' + } + function action_toggle { + if test "${selections[cursor]}" = 'yes'; then + selections[cursor]='' + else + selections[cursor]='yes' + fi + } + function action_next { + # select and move to next line + selections[cursor]='yes' + action_down + } + function action_previous { + # unselect and move to prior line + selections[cursor]='' + action_up + } + function action_none { + for index in "${!items[@]}"; do + selections[index]='' + done + } + function action_all { + for index in "${!items[@]}"; do + selections[index]='yes' + done + } + function action_revert { + action_none + select_defaults + } + + while test "$action" != 'done'; do + if test "$menu_skip_render" = 'no'; then + refresh_terminal_size + items_bundled_size=0 + items_bundled='' + selected_count=0 + menu_skip_remainder='no' + for index in "${!items[@]}"; do if test "${selections[index]-}" = 'yes'; then - had_selected='yes' - menu+=" ⦿ " + selected_count=$((selected_count + 1)) + fi + if test "$index" -lt "$page_start_index" -o "$menu_skip_remainder" = 'yes'; then + continue + fi + # determine prefix + item_prefix='' + if test "$index" -eq "$cursor" -a "${selections[index]-}" = 'yes'; then + item_prefix+="$style_magenta> $style_checked " + elif test "${selections[index]-}" = 'yes'; then + item_prefix+="$style_magenta $style_checked " + elif test "$index" -eq "$cursor"; then + item_prefix+="$style_magenta> $style_selected " else - menu+=' ○ ' + item_prefix+=" $style_unchecked " fi - else - if test "${selections[index]-}" = 'yes'; then - had_selected='yes' - menu+=" $bold" + # determine paging + if test "$paging_needed" = 'no'; then + # paging is no longer needed, no recalculations needed + if test -n "${renders[index]}"; then + # use rendered item (in case it needed to be formatted if it exists) + items_bundled+="${item_prefix}${renders[index]}${style_reset}"$'\n' + else + # otehrwise use original item + items_bundled+="${item_prefix}${items[index]}${style_reset}"$'\n' + fi else - menu+=' ' + # paging is needed, so we must recalculate bundled size + if test -n "${renders[index]}"; then + item_rendered="${renders[index]}" + item_size="${sizes[index]}" + else + item_original="${items[index]}" + if test "${#item_original}" -lt "$size_content" && [[ $item_original != $'\n' && $item_original != $'\t' ]]; then + # no need to format item, as it is small enough + item_rendered="$item_original" + item_size=1 + renders[index]="$item_rendered" + sizes[index]="$item_size" + else + # need to format item, as it is too big + item_rendered="$("$bin_gfmt" -t -w "$size_content" <<<"$item_original")" + item_size="$("$bin_gwc" -l <<<"${item_original}"$'\n')" + renders[index]="$item_rendered" + sizes[index]="$item_size" + fi + fi + # calculate total menu size + menu_size="$((menu_header_size + items_bundled_size + item_size + menu_hint_size))" + if test "$menu_size" -gt "$size_rows"; then + # the menu would now be too large, so skip the rest + menu_skip_remainder='yes' + else + items_bundled+="${item_prefix}${item_rendered}${style_reset}"$'\n' + items_bundled_size="$((items_bundled_size + item_size))" + page_last_index="$index" + fi fi - fi - menu+="${items[index]}${reset}"$'\n' - done + done - # hints - if test "$option_hints" = 'yes'; then - if test "$option_multi" = 'no'; then - menu+="${help_begin}SELECT${help_end} ${key_begin}ENTER${key_end} ${key_begin}SPACE${key_end}" - else - menu+="${help_begin}SELECT${help_end} ${key_begin}SPACE${key_end}" - fi - if test "$count" -ne 1; then - # [⬆⬇⇧] have alignment issues, use [↑↓] - menu+="${indent}${help_begin}UP${help_end} ${key_begin}↑${key_end} ${key_begin}W${key_end} ${key_begin}K${key_end}" - menu+="${indent}${help_begin}DOWN${help_end} ${key_begin}↓${key_end} ${key_begin}S${key_end} ${key_begin}J${key_end}" - if test "$option_multi" = 'yes'; then - menu+="${indent}${help_begin}ALL${help_end} ${key_begin}A${key_end}" - menu+="${indent}${help_begin}NONE${help_end} ${key_begin}D${key_end}" + # output menu + if test "$paging_supported" = 'yes'; then + if test "$page_start_index" -ne 0 -o "$page_last_index" -ne "$items_last_index"; then + paging_needed='yes' + menu_title=$'\e]0;'"👋 $items_total items 🙌 showing $page_start_index to $page_last_index 💁‍♀️ hiding $((items_total - (page_last_index - page_start_index))) 🫣 selected $selected_count ✅"$'\a' + else + menu_title=$'\e]0;'"👋 $items_total items 🙌 selected $selected_count ✅"$'\a' + paging_needed='no' fi fi - if test "$option_required" = 'no'; then - menu+="${indent}${help_begin}CANCEL${help_end} ${key_begin}ESC${key_end}" - fi + printf '%s' "${menu_title}${menu_header}${items_bundled}${menu_hint}" >"$tty_target" fi - # output menu - printf '%s' "$menu" >"$tty_target" - # handle the response eval_capture --statusvar=read_status --stdoutvar=action -- read-key --timeout="$option_timeout" if test "$read_status" -eq 60; then - if test "$had_selected" = 'yes'; then + if test "$selected_count" -ne 0; then tty_clear echo-style --colors="$use_colors" --notice="Read timed out [$read_status], using selection." >/dev/stderr sleep 3 @@ -310,97 +586,69 @@ function choose_menu() ( break # out of the while loop fi - # reset selection if not multi - if test "$had_selected" = 'yes' -a "$option_multi" = 'no'; then + # style_reset selection if not multi + if test "$selected_count" -ne 0 -a "$option_multi" = 'no'; then + # erase all selects for index in "${!selections[@]}"; do selections[index]='' done fi - # handle special cases and remaps - # such as numbers, wasd, and vim movers + # perform action if is-digit "$action"; then - # number jump - if test "$action" -le 1; then - cursor=0 - elif test "$action" -le "$count"; then - cursor="$((action - 1))" - else - cursor="$last" - fi - action='space' - elif test "$action" = 'left' -o "$action" = 'h' -o "$action" = 'k' -o "$action" = 'w'; then - action='up' - elif test "$action" = 'right' -o "$action" = 'l' -o "$action" = 'j' -o "$action" = 's'; then - action='down' - elif test "$action" = 'd' -o "$action" = 'backspace'; then - action='none' - elif test "$action" = 'a'; then - action='all' - fi - - # control key - if test "$action" = 'up'; then - if test "$cursor" -eq 0; then - cursor="$last" - elif test "$cursor" -ne 0; then - cursor="$((cursor - 1))" - fi - elif test "$action" = 'down'; then - if test "$cursor" -eq "$last"; then - cursor=0 - elif test "$cursor" -ne "$last"; then - cursor="$((cursor + 1))" - fi - elif test "$action" = 'home'; then - cursor=0 - elif test "$action" = 'end'; then - cursor="$last" - elif test "$action" = 'none'; then - # if multi then unselect everything, other no-op (as any keypress by now would have cleared the non-multi selection) - if test "$option_multi" = 'yes'; then - for index in "${!items[@]}"; do - selections[index]='' - done - fi - elif test "$action" = 'all'; then - # if multi then select everything, other no-op (as any keypress by now would have cleared the non-multi selection) + action_jump + elif test "$action" = 'up' -o "$action" = 'left' -o "$action" = 'h' -o "$action" = 'k' -o "$action" = 'w'; then + action_up + elif test "$action" = 'down' -o "$action" = 'right' -o "$action" = 'l' -o "$action" = 'j' -o "$action" = 's'; then + action_down + elif test "$action" = 'space'; then if test "$option_multi" = 'yes'; then - for index in "${!items[@]}"; do - selections[index]='yes' - done + action_toggle + else + action_select_and_confirm + break fi - elif test "$action" = 'tab'; then - # select and move to next line - selections[cursor]='yes' - if test "$cursor" -eq "$last"; then - cursor=0 - elif test "$cursor" -lt "$last"; then - cursor="$((cursor + 1))" + elif test "$action" = 'enter' -o "$action" = 'e'; then + if test "$option_multi" = 'no'; then + action_select_and_confirm fi - elif test "$action" = 'space'; then - # toggle single - if test "${selections[cursor]}" = 'yes'; then - selections[cursor]='' + break + elif test "$action" = 'escape' -o "$action" = 'q'; then + if test "$option_multi" = 'no' -a "$option_required" = 'no'; then + : # don't revert else - selections[cursor]='yes' - if test "$option_multi" != 'yes'; then - break - fi - fi - elif test "$action" = 'enter'; then - if test "$option_multi" != 'yes'; then - selections[cursor]='yes' + action_revert fi break - elif test "$action" = 'escape'; then - # todo implement --required with --multi fallback properly here - if test "$option_required" = 'no'; then - break + elif test "$action" = 'home' -o "$action" = 'a'; then + action_first + elif test "$action" = 'end' -o "$action" = 'd'; then + action_last + elif test "$action" = 'z'; then + action_revert + elif test "$option_multi" = 'yes'; then + if test "$action" = 't'; then + if test "$selected_count" -eq "$items_total"; then + action_none + else + action_all + fi + elif test "$action" = 'tab'; then + action_next + elif test "$action" = 'backspace'; then + action_previous + else + # nothing done, no need to repeat, just need to read again + menu_skip_render='yes' + continue fi + else + # nothing done, no need to repeat, just need to read again + menu_skip_render='yes' + continue fi - - # no break, so repeat the menu + # repeat the menu + menu_skip_render='no' tty_clear done @@ -409,7 +657,6 @@ function choose_menu() ( # output the custom selections if test "$menu_status" -eq 0; then - local index selection for index in "${!selections[@]}"; do selection="${selections[index]}" if test "$selection" = 'yes'; then diff --git a/commands/choose-option b/commands/choose-option index cb677b883..f52e39a0a 100755 --- a/commands/choose-option +++ b/commands/choose-option @@ -442,7 +442,7 @@ function choose_option() ( if test "$action" = 'select'; then if test "${#filtered_visuals[@]}" -ne "${#visuals[@]}"; then unfiltered_index="${#filtered_visuals[@]}" - filtered_visuals+=('Select from the unfiltered options.') + filtered_visuals+=("Select this to see the $(("${#visuals[@]}" - "${#filtered_visuals[@]}")) unfiltered options.") fi # trigger the menu, and add each default individually, supporting multi-line visuals diff --git a/commands/is-tty b/commands/is-tty index ff5a79995..3b7870141 100755 --- a/commands/is-tty +++ b/commands/is-tty @@ -159,9 +159,10 @@ function is_tty() ( print_line '/dev/stderr' fi return 0 + elif (: /dev/tty) &>/dev/null; then + return 0 else - (: /dev/tty) &>/dev/null - return + return 1 fi ) diff --git a/commands/setup-utils b/commands/setup-utils index 7f5f30b9a..5a18a295a 100755 --- a/commands/setup-utils +++ b/commands/setup-utils @@ -67,6 +67,7 @@ function setup_utils() ( mapfile -t utils < <( choose-option --multi \ --question="Which utilities to install?" \ + --defaults="$(echo-lines -- "${SETUP_UTILS[@]}")" \ -- "${options[@]}" )