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