Skip to content

Commit

Permalink
ask: rewrote for new stdin/tty learnings, properly support no-TTY
Browse files Browse the repository at this point in the history
ask:
- if non-reactive, use alternative screen buffer
- add `--no-inline` option to force usage of alternative screen buffer if supported/reactive
- this solves the impossibility of clearing the accurate amount of lines in no-TTY mode, as `ssh -T` and pipe/redirect/CI result in different line counts due to `ssh -T` outputting the enter key as a visual new line

dorothy, setup-util-bash, setup-util-gh: use new `get-terminal-reactivity-support` command, and deprecate `is-interactive`

add a `terminals-and-tty.md` document with the learnings and reasoning

dorothy-warnings: prevent clearing of warnings causing warnings to be shown
  • Loading branch information
balupton committed Oct 25, 2024
1 parent 613cec8 commit d605711
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 81 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

function is_interactive() (
source "$DOROTHY/sources/bash.bash"
dorothy-warnings add --code='is-interactive' --bold=' has been deprecated in favor of ' --code='get-terminal-reactivity-support --quiet'

# =====================================
# Arguments
Expand Down Expand Up @@ -35,7 +36,7 @@ function is_interactive() (
# =====================================
# Action

get-terminal-stdin-tty-support --quiet && ! is-ci
get-terminal-reactivity-support --quiet
return
)

Expand Down
167 changes: 102 additions & 65 deletions commands/ask
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
#!/usr/bin/env bash

# read can set the default with [-i <default>] however that requries [-e] which uses readline, which requires stdin to not be programmatic
#
# if stdin is programmatic (ssh -T, CI, piped) then read behaves differently:
# [-p <prompt>] is discarded, no prompt is shown
# [-i <default>] is discarded, no default value is handled
# as such, do not pass such to read, do the prompt and handling of the default value ourself

function ask_test() (
source "$DOROTHY/sources/bash.bash"
echo-style --h1="TEST: $0"
Expand Down Expand Up @@ -136,7 +143,7 @@ function ask_() (
local item args=() option_question=()
local option_default='' option_confirm_default='yes' option_confirm_input='no'
local option_required='no' option_password='no'
local option_linger='no' option_timeout=''
local option_linger='no' option_timeout='' option_inline=''
while test "$#" -ne 0; do
item="$1"
shift
Expand Down Expand Up @@ -166,6 +173,9 @@ function ask_() (
'--no-linger'* | '--linger'*)
option_linger="$(get-flag-value --affirmative --fallback="$option_linger" -- "$item")"
;;
'--no-inline'* | '--inline'*)
option_inline="$(get-flag-value --affirmative --fallback="$option_inline" -- "$item")"
;;
'--timeout='*) option_timeout="${item#*=}" ;;
'--')
args+=("$@")
Expand Down Expand Up @@ -202,7 +212,7 @@ function ask_() (
load_dorothy_config 'styles.bash'

# refresh the styles
refresh_style_cache -- 'question_title_prompt' 'question_title_result' 'question_body' 'input_warning' 'input_error' 'icon_prompt' 'result_line' 'empty_line' 'commentary_nothing_provided' 'commentary_using_password' 'indent_blockquote' 'commentary_timeout_default' 'commentary_timeout_required' 'commentary_timeout_optional' 'commentary_input_failure' 'result_commentary_spacer' 'key' 'code'
refresh_style_cache -- 'question_title_prompt' 'question_title_result' 'question_body' 'input_warning' 'input_error' 'icon_prompt' 'result_line' 'empty_line' 'commentary_nothing_provided' 'commentary_using_password' 'indent_blockquote' 'commentary_timeout_default' 'commentary_timeout_required' 'commentary_timeout_optional' 'commentary_input_failure' 'result_commentary_spacer' 'key' 'code' 'alternative_screen_buffer' 'default_screen_buffer' 'clear_screen'

# style the question
local question_title_and_body_and_newline='' question_title_result=''
Expand All @@ -218,36 +228,65 @@ function ask_() (
# =====================================
# Action

# prepare result
# prepare
local RESULT="$option_default"

# adjust timeout to one minute if we have a default value, or if optional
if test -z "$option_timeout" && (is-value -- "$RESULT" || test "$option_required" = 'no'); then
option_timeout=60
fi

# adjust read args based on timeout
local read_args=('-r')
if test -n "$option_timeout"; then
read_args+=(
-t "$option_timeout"
)
fi

# adjust tty
local terminal_device_file is_interactive='yes'
local terminal_device_file terminal_reactive inline
terminal_device_file="$(get-terminal-device-file)"
if ! is-interactive; then
# [is-interactive] checks [get-terminal-stdin-tty-support --quiet] which correlates whether [read] supports native default handling
is_interactive='no'
terminal_reactive="$(get-terminal-reactivity-support)"
if [[ $terminal_reactive == 'no' || $option_inline == 'no' ]]; then
inline='no'
else
inline='yes'
fi

# adjust prompt
local input_prompt_and_newline=''
if test "$is_interactive" = 'no' -a -n "$option_default"; then
if test "$terminal_reactive" = 'no' -a -n "$option_default"; then
input_prompt_and_newline="Press ${style__key}ENTER${style__end__key} to use the default value of ${style__code}${option_default}${style__end__code}. Press ${style__key}ESC${style__end__key} then ${style__key}ENTER${style__end__key} to use no value."$'\n'
fi

# adjust timeout to one minute if we have a default value, or if optional
if test -z "$option_timeout" && (is-value -- "$RESULT" || test "$option_required" = 'no'); then
option_timeout=60
fi

# adjust read args based on timeout
local READ_RESULT READ_PROMPT="${question_title_and_body_and_newline}${input_prompt_and_newline}${style__icon_prompt}" READ_PROMPT_LINES CLEAR
if test "$inline" = 'no'; then
if test -n "$option_timeout"; then
function __read {
READ_RESULT=''
__print_string "${CLEAR}${READ_PROMPT}" >"$terminal_device_file"
IFS= read -rt "$option_timeout" READ_RESULT
return
}
else
function __read {
READ_RESULT=''
__print_string "${CLEAR}${READ_PROMPT}" >"$terminal_device_file"
IFS= read -r READ_RESULT
return
}
fi
else
READ_PROMPT_LINES="$(echo-clear-lines --count-only --here-string <<<"$READ_PROMPT")"
if test -n "$option_timeout"; then
function __read {
READ_RESULT=''
IFS= read -rt "$option_timeout" -ei "$RESULT" -p "${CLEAR}${READ_PROMPT}" READ_RESULT
return
}
else
function __read {
READ_RESULT=''
IFS= read -rei "$RESULT" -p "${CLEAR}${READ_PROMPT}" READ_RESULT
return
}
fi
fi

# helpers
local ASKED='no' commentary=''
function on_timeout {
Expand All @@ -266,62 +305,58 @@ function ask_() (
fi
}
function do_prompt { # has sideffects: RESULT, ASKED
local __read_status __input_result read_prompt="${question_title_and_body_and_newline}${input_prompt_and_newline}${style__icon_prompt}" read_prompt_lines=0 result_prompt result_prompt_lines=0 clear=''
read_prompt_lines="$(echo-clear-lines --count-only --here-string <<<"$read_prompt")"
local __read_status result_prompt result_prompt_lines

# reset clear, in case a choose/confirm failed and we are re-prompting
if test "$inline" = 'no'; then
CLEAR="$style__alternative_screen_buffer"
else
CLEAR=''
fi

ASKED='yes' # not local
while true; do
# reset
__read_status=0
__input_result=''
result_prompt=''
READ_RESULT=''

# adapt according to read mode
if test "$is_interactive" = 'no'; then
# under these circumstances, read behaves differently:
# [-p <prompt>] is discarded, no prompt is shown
# [-i <default>] is discarded, no default value is handled
# as such, do not pass such to read, do the prompt and handling of the default value ourself
__print_string "${clear}${read_prompt}" >"$terminal_device_file"
# trunk-ignore(shellcheck/SC2162)
IFS='' read "${read_args[@]}" __input_result || __read_status=$?
if test "$inline" = 'no'; then
__read || __read_status=$?
if test "$__read_status" -eq 0; then
# process the input result to the actual RESULT
if test -z "$__input_result" -o "$__input_result" = $'\n'; then
if test -z "$READ_RESULT" -o "$READ_RESULT" = $'\n'; then
# treat empty string and newline as default
:
elif [[ $__input_result =~ ^[[:cntrl:][:space:]]*$ ]]; then
# if it is only control characters (e.g. escape) and whitespace characters, then treat as empty
elif [[ $READ_RESULT =~ ^[[:cntrl:][:space:]]*$ ]]; then
# treat only control characters (e.g. escape) and whitespace characters as empty input
RESULT=''
else
# treat everything else as manual __input_result
RESULT="$__input_result"
# treat everything else as custom input
RESULT="$READ_RESULT"
fi
# the user has pressed enter. which will be added to the TTY, so trim it and trim a possibly very long input
result_prompt+="$RESULT"$'\n'
CLEAR="$style__clear_screen"
fi
else
# clear and prompt
# -i requires -e
# trunk-ignore(shellcheck/SC2162)
IFS= read "${read_args[@]}" -ei "$RESULT" -p "${clear}${read_prompt}" __input_result || __read_status=$?
result_prompt=''
__read || __read_status=$?

# update the value on successful read, and prepare the clear
# note if there was a default value, pressing enter will set [__input_result] to it
# note if there was a default value, pressing enter will set [READ_RESULT] to it
if test "$__read_status" -eq 0; then
# only update RESULT on successful read status, as [__input_result] will be empty on timeout
RESULT="$__input_result"
# only update RESULT on successful read status, as [READ_RESULT] will be empty on timeout
RESULT="$READ_RESULT"
# the user has pressed enter. which will be added to the TTY, so trim it and trim a possibly very long input
result_prompt+="$RESULT"$'\n'
fi # otherwise it has timed out. ctrl+c is not caught as we are not trapping it
fi

# prepare the new erasure, this is done like so, because it is quicker, which is important for multiple enter presses on --required
# it is quicker because processing the read_prompt takes time, as it has ANSI Escape Codes, which requires invoking deno behind the scenes for more advanced detection, so we do that processing once beforehand
# then we do the quick result processing here and combine the two
result_prompt_lines="$(echo-clear-lines --count-only --here-string <<<"$result_prompt")"
clear="$(echo-clear-lines --count="$((read_prompt_lines + result_prompt_lines))")"
# this is too slow: clear="$(echo-clear-lines --count-only --here-string <<<"$read_prompt$result_prompt")"
# prepare the new erasure, this is done like so, because it is quicker, which is important for multiple enter presses on --required
# it is quicker because processing the read_prompt takes time, as it has ANSI Escape Codes, which requires invoking deno behind the scenes for more advanced detection, so we do that processing once beforehand
# then we do the quick result processing here and combine the two
result_prompt_lines="$(echo-clear-lines --count-only --here-string <<<"$result_prompt")"
CLEAR="$(echo-clear-lines --count="$((READ_PROMPT_LINES + result_prompt_lines))")"
# this is too slow: clear="$(echo-clear-lines --count-only --here-string <<<"$read_prompt$result_prompt")"
fi

# handle the result
if test "$__read_status" -eq 142; then
Expand All @@ -342,8 +377,9 @@ function ask_() (
fi
done
# do the final erasure if necessary
if test -n "$clear"; then
__print_string "$clear" >"$terminal_device_file"
if [[ $inline == 'no' || -n $CLEAR ]]; then
CLEAR="$style__default_screen_buffer"
__print_string "$CLEAR" >"$terminal_device_file"
fi
# done
if test "$__read_status" -ne 0; then
Expand All @@ -352,7 +388,7 @@ function ask_() (
do_validate
}
function do_validate {
local choose_args=() choose_status prompt_status choice choices=()
local choose_args=() choose_status choice choices=()

# have we prompted?
if test "$ASKED" = 'no'; then
Expand Down Expand Up @@ -390,10 +426,11 @@ function ask_() (
--timeout="$option_timeout"
--label -- "${choices[@]}"
)

# choose and check for failure
local choose_status=0
eval_capture --statusvar=choose_status --stdoutvar=choice -- \
choose "${choose_args[@]}"

# check the confirmation
if test "$choose_status" -eq 60; then
on_timeout
return
Expand All @@ -419,10 +456,9 @@ function ask_() (
fi
fi

# prompt
# prompt and check for failure
local prompt_status=0
eval_capture --statusvar=prompt_status -- do_prompt

# check for failure
if test "$prompt_status" -ne 0; then
# timeout probably
on_timeout
Expand All @@ -434,9 +470,10 @@ function ask_() (
}

# act
eval_capture --statusvar=result_status -- do_validate
local validate_status=0
eval_capture --statusvar=validate_status -- do_validate
local render="$question_title_result$commentary"
if test "$result_status" -eq 0; then
if test "$validate_status" -eq 0; then
# success response
# inform if requested
if test "$option_linger" = 'yes'; then
Expand Down Expand Up @@ -464,7 +501,7 @@ function ask_() (
else
__print_string "$render" >/dev/stderr
fi
return "$result_status"
return "$validate_status"
fi
)

Expand Down
4 changes: 2 additions & 2 deletions commands/dorothy
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,7 @@ function dorothy_() (
# act
local hostname
hostname="$(get-hostname)"
if is-interactive && is-generic -- "$hostname"; then
if get-terminal-reactivity-support --quiet && is-generic -- "$hostname"; then
if confirm --bool --ppid=$$ -- "This machine's hostname is currently generic [$hostname], would you like to change it?"; then
hostname="$(
ask --linger --question='What should the new hostname be?'
Expand Down Expand Up @@ -972,7 +972,7 @@ function dorothy_() (
fi
fi
if test -z "$where"; then
if is-interactive; then
if get-terminal-reactivity-support --quiet; then
where="$(
choose --linger --required --label \
--question='Where do you want to store your Dorothy user configuration?' \
Expand Down
13 changes: 8 additions & 5 deletions commands/dorothy-warnings
Original file line number Diff line number Diff line change
Expand Up @@ -64,27 +64,30 @@ function dorothy_warnings() (
# =====================================
# Act

local file="$DOROTHY/state/warnings.txt"

function dorothy_warnings_check {
[[ -s "$DOROTHY/state/warnings.txt" ]]
[[ -s $file ]]
return
}
function dorothy_warnings_clear {
: >"$DOROTHY/state/warnings.txt"
: >"$file"
}
function dorothy_warnings_list {
echo-style --stderr \
--notice1='Dorothy has encountered warnings:' --newline \
--="$(echo-file -- "$DOROTHY/state/warnings.txt")" --newline \
--="$(echo-file -- "$file")" --newline \
--notice1='For help with these warnings, see: ' --code-notice1='https://github.com/bevry/dorothy/issues/185' --newline \
--notice1='To clear these warnings, run: ' --code-notice1='dorothy-warnings clear'
}
function dorothy_warnings_warn {
if [[ -N "$DOROTHY/state/warnings.txt" ]]; then
# -s and -N as if we have cleared the file, that is a new modification, so we need to check if it is actually non-empty AND modified
if [[ -s $file && -N $file ]]; then
dorothy_warnings_list
fi
}
function dorothy_warnings_add {
echo-style "${option_args[@]}" >>"$DOROTHY/state/warnings.txt"
echo-style "${option_args[@]}" >>"$file"
}

case "$action" in
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env bash

function get_terminal_stdin_tty_support() (
function get_terminal_reactivity_support() (
source "$DOROTHY/sources/bash.bash"

# =====================================
Expand All @@ -9,10 +9,10 @@ function get_terminal_stdin_tty_support() (
function help {
cat <<-EOF >/dev/stderr
ABOUT:
Get whether the terminal's stdin is attached to a TTY, outputting [yes] if so, otherwise [no].
Get whether the terminal is reactive to user STDIN changes, outputting [yes] if so, otherwise [no].
USAGE:
get-terminal-stdin-tty-support [...options]
get-terminal-reactivity-support [...options]
OPTIONS:
--quiet
Expand Down Expand Up @@ -46,7 +46,7 @@ function get_terminal_stdin_tty_support() (
# Action

function __check {
[[ -t 0 ]]
[[ -t 0 ]] # this fails on GitHub Actions, if it passes on a CI, then we should add: && ! is-ci
}

if test "$option_quiet" = 'yes'; then
Expand All @@ -61,5 +61,5 @@ function get_terminal_stdin_tty_support() (

# fire if invoked standalone
if test "$0" = "${BASH_SOURCE[0]}"; then
get_terminal_stdin_tty_support "$@"
get_terminal_reactivity_support "$@"
fi
Loading

0 comments on commit d605711

Please sign in to comment.