lib/cli/ui/prompt.rb in cli-ui-1.5.1 vs lib/cli/ui/prompt.rb in cli-ui-2.0.0
- old
+ new
@@ -1,6 +1,9 @@
# coding: utf-8
+
+# typed: true
+
require 'cli/ui'
require 'readline'
module Readline
unless const_defined?(:FILENAME_COMPLETION_PROC)
@@ -20,13 +23,14 @@
module CLI
module UI
module Prompt
autoload :InteractiveOptions, 'cli/ui/prompt/interactive_options'
autoload :OptionsHandler, 'cli/ui/prompt/options_handler'
- private_constant :InteractiveOptions, :OptionsHandler
class << self
+ extend T::Sig
+
# Ask a user a question with either free form answer or a set of answers (multiple choice)
# Can use arrows, y/n, numbers (1/2), and vim bindings to control multiple choice selection
# Do not use this method for yes/no questions. Use +confirm+
#
# * Handles free form answers (options are nil)
@@ -90,69 +94,94 @@
# handler.option('go') { |selection| selection }
# handler.option('ruby') { |selection| selection }
# handler.option('python') { |selection| selection }
# end
#
+ sig do
+ params(
+ question: String,
+ options: T.nilable(T::Array[String]),
+ default: T.nilable(T.any(String, T::Array[String])),
+ is_file: T::Boolean,
+ allow_empty: T::Boolean,
+ multiple: T::Boolean,
+ filter_ui: T::Boolean,
+ select_ui: T::Boolean,
+ options_proc: T.nilable(T.proc.params(handler: OptionsHandler).void),
+ ).returns(T.any(String, T::Array[String]))
+ end
def ask(
question,
options: nil,
default: nil,
- is_file: nil,
+ is_file: false,
allow_empty: true,
multiple: false,
filter_ui: true,
select_ui: true,
&options_proc
)
- if (options || block_given?) && ((default && !multiple) || is_file)
- raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
+ has_options = !!(options || block_given?)
+ if has_options && default && !multiple
+ raise(ArgumentError, 'conflicting arguments: default may not be provided with options when not multiple')
end
- if options && multiple && default && !(default - options).empty?
+ if has_options && is_file
+ raise(ArgumentError, 'conflicting arguments: is_file is only useful when options are not provided')
+ end
+
+ if options && multiple && default && !(Array(default) - options).empty?
raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
end
- if options || block_given?
+ if multiple && !has_options
+ raise(ArgumentError, 'conflicting arguments: options must be provided when multiple is true')
+ end
+
+ if !multiple && default.is_a?(Array)
+ raise(ArgumentError, 'conflicting arguments: multiple defaults may only be provided when multiple is true')
+ end
+
+ if has_options
ask_interactive(
question,
options,
multiple: multiple,
default: default,
filter_ui: filter_ui,
select_ui: select_ui,
&options_proc
)
else
- ask_free_form(question, default, is_file, allow_empty)
+ ask_free_form(question, T.cast(default, T.nilable(String)), is_file, allow_empty)
end
end
# Asks the user for a single-line answer, without displaying the characters while typing.
# Typically used for password prompts
#
# ==== Return Value
#
# The password, without a trailing newline.
# If the user simply presses "Enter" without typing any password, this will return an empty string.
+ sig { params(question: String).returns(String) }
def ask_password(question)
require 'io/console'
- CLI::UI.with_frame_color(:blue) do
- STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
+ STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
- # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
- # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
- password = STDIN.noecho do
- # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
- # " 123 \n".chomp => " 123 "
- STDIN.gets.chomp
- end
+ # noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
+ # No fancy Readline integration (like echoing back) is required for a password prompt anyway.
+ password = STDIN.noecho do
+ # Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
+ # " 123 \n".chomp => " 123 "
+ STDIN.gets.to_s.chomp
+ end
- STDOUT.puts # Complete the line
+ STDOUT.puts # Complete the line
- password
- end
+ password
end
# Asks the user a yes/no question.
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
#
@@ -161,16 +190,21 @@
# Confirmation question
# CLI::UI::Prompt.confirm('Is the sky blue?')
#
# CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
#
+ sig { params(question: String, default: T::Boolean).returns(T::Boolean) }
def confirm(question, default: true)
- ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
+ ask_interactive(question, default ? ['yes', 'no'] : ['no', 'yes'], filter_ui: false) == 'yes'
end
private
+ sig do
+ params(question: String, default: T.nilable(String), is_file: T::Boolean, allow_empty: T::Boolean)
+ .returns(String)
+ end
def ask_free_form(question, default, is_file, allow_empty)
if default && !allow_empty
raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
end
@@ -193,21 +227,32 @@
return line
end
end
end
+ sig do
+ params(
+ question: String,
+ options: T.nilable(T::Array[String]),
+ multiple: T::Boolean,
+ default: T.nilable(T.any(String, T::Array[String])),
+ filter_ui: T::Boolean,
+ select_ui: T::Boolean,
+ ).returns(T.any(String, T::Array[String]))
+ end
def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
options ||= if block_given?
handler = OptionsHandler.new
yield handler
handler.options
end
raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
- navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
+
+ navigate_text = if CLI::UI::OS.current.suggest_arrow_keys?
'Choose with ↑ ↓ ⏎'
else
"Navigate up with 'k' and down with 'j', press Enter to select"
end
@@ -221,50 +266,58 @@
print(ANSI.previous_line + ANSI.clear_to_end_of_line)
# Force StdoutRouter to prefix
print(ANSI.previous_line + "\n")
# reset the question to include the answer
- resp_text = resp
- if multiple
- resp_text = case resp.size
+ resp_text = case resp
+ when Array
+ case resp.size
when 0
'<nothing>'
when 1..2
resp.join(' and ')
else
"#{resp.size} items"
end
+ else
+ resp
end
puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
- return handler.call(resp) if block_given?
+ return T.must(handler).call(resp) if block_given?
+
resp
end
# Useful for stubbing in tests
+ sig do
+ params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(T::Array[String], String)))
+ .returns(T.any(T::Array[String], String))
+ end
def interactive_prompt(options, multiple: false, default: nil)
InteractiveOptions.call(options, multiple: multiple, default: default)
end
+ sig { params(default: String).void }
def write_default_over_empty_input(default)
CLI::UI.raw do
STDERR.puts(
CLI::UI::ANSI.cursor_up(1) +
"\r" +
CLI::UI::ANSI.cursor_forward(4) + # TODO: width
default +
- CLI::UI::Color::RESET.code
+ CLI::UI::Color::RESET.code,
)
end
end
+ sig { params(str: String).void }
def puts_question(str)
- CLI::UI.with_frame_color(:blue) do
- STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
- end
+ STDOUT.puts(CLI::UI.fmt('{{?}} ' + str))
end
+ sig { params(is_file: T::Boolean).returns(String) }
def readline(is_file: false)
if is_file
Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
Readline.completion_append_character = ''
else
@@ -274,14 +327,14 @@
# because Readline is a C library, CLI::UI's hooks into $stdout don't
# work. We could work around this by having CLI::UI use a pipe and a
# thread to manage output, but the current strategy feels like a
# better tradeoff.
- prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
+ prefix = CLI::UI::Frame.prefix
# If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
# not change the colour here.
prompt = prefix + CLI::UI.fmt('{{blue:> }}')
- prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
+ prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.use_color_prompt?
begin
line = Readline.readline(prompt, true)
print(CLI::UI::Color::RESET.code)
line.to_s.chomp