lib/cli/ui/prompt/interactive_options.rb in cli-ui-1.5.1 vs lib/cli/ui/prompt/interactive_options.rb in cli-ui-2.0.0

- old
+ new

@@ -1,46 +1,64 @@ # coding: utf-8 + +# typed: true + require 'io/console' module CLI module UI module Prompt class InteractiveOptions + extend T::Sig + DONE = 'Done' CHECKBOX_ICON = { false => '☐', true => '☑' } - # Prompts the user with options - # Uses an interactive session to allow the user to pick an answer - # Can use arrows, y/n, numbers (1/2), and vim bindings to control - # For more than 9 options, hitting 'e', ':', or 'G' will enter select - # mode allowing the user to type in longer numbers - # Pressing 'f' or '/' will allow the user to filter the results - # - # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif - # - # ==== Example Usage: - # - # Ask an interactive question - # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python)) - # - def self.call(options, multiple: false, default: nil) - list = new(options, multiple: multiple, default: default) - selected = list.call - if multiple - selected.map { |s| options[s - 1] } - else - options[selected - 1] + class << self + extend T::Sig + + # Prompts the user with options + # Uses an interactive session to allow the user to pick an answer + # Can use arrows, y/n, numbers (1/2), and vim bindings to control + # For more than 9 options, hitting 'e', ':', or 'G' will enter select + # mode allowing the user to type in longer numbers + # Pressing 'f' or '/' will allow the user to filter the results + # + # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif + # + # ==== Example Usage: + # + # Ask an interactive question + # CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python)) + # + sig do + params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String]))) + .returns(T.any(String, T::Array[String])) end + def call(options, multiple: false, default: nil) + list = new(options, multiple: multiple, default: default) + selected = list.call + case selected + when Array + selected.map { |s| T.must(options[s - 1]) } + else + T.must(options[selected - 1]) + end + end end # Initializes a new +InteractiveOptions+ # Usually called from +self.call+ # # ==== Example Usage: # # CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python)) # + sig do + params(options: T::Array[String], multiple: T::Boolean, default: T.nilable(T.any(String, T::Array[String]))) + .void + end def initialize(options, multiple: false, default: nil) @options = options @active = 1 @marker = '>' @answer = nil @@ -58,16 +76,17 @@ else Array.new(@options.size) { false } end end @redraw = true - @presented_options = [] + @presented_options = T.let([], T::Array[[String, T.nilable(Integer)]]) end # Calls the +InteractiveOptions+ and asks the question # Usually used from +self.call+ # + sig { returns(T.any(Integer, T::Array[Integer])) } def call calculate_option_line_lengths CLI::UI.raw { print(ANSI.hide_cursor) } while @answer.nil? render_options @@ -83,10 +102,11 @@ end end private + sig { void } def calculate_option_line_lengths @terminal_width_at_calculation_time = CLI::UI::Terminal.width # options will be an array of questions but each option can be multi-line # so to get the # of lines, you need to join then split @@ -101,25 +121,26 @@ @option_lengths = @options.map do |text| width = 1 if text.empty? width ||= text .split("\n") .reject(&:empty?) - .map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil } - .reduce(&:+) + .sum { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil } width end end + sig { params(number_of_lines: Integer).void } def reset_position(number_of_lines = num_lines) # This will put us back at the beginning of the options # When we redraw the options, they will be overwritten CLI::UI.raw do number_of_lines.times { print(ANSI.previous_line) } end end + sig { params(number_of_lines: Integer).void } def clear_output(number_of_lines = num_lines) CLI::UI.raw do # Write over all lines with whitespace number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) } end @@ -131,45 +152,51 @@ @displaying_metadata = display_metadata? end # Don't use this in place of +@displaying_metadata+, this updates too # quickly to be useful when drawing to the screen. + sig { returns(T::Boolean) } def display_metadata? filtering? || selecting? || has_filter? end + sig { returns(Integer) } def num_lines calculate_option_line_lengths if terminal_width_changed? option_length = presented_options.reduce(0) do |total_length, (_, option_number)| # Handle continuation markers and "Done" option when multiple is true next total_length + 1 if option_number.nil? || option_number.zero? + total_length + @option_lengths[option_number - 1] end option_length + (@displaying_metadata ? 1 : 0) end + sig { returns(T::Boolean) } def terminal_width_changed? @terminal_width_at_calculation_time != CLI::UI::Terminal.width end ESC = "\e" BACKSPACE = "\u007F" CTRL_C = "\u0003" CTRL_D = "\u0004" + sig { void } def up active_index = @filtered_options.index { |_, num| num == @active } || 0 previous_visible = @filtered_options[active_index - 1] previous_visible ||= @filtered_options.last @active = previous_visible ? previous_visible.last : -1 @redraw = true end + sig { void } def down active_index = @filtered_options.index { |_, num| num == @active } || 0 next_visible = @filtered_options[active_index + 1] next_visible ||= @filtered_options.first @@ -178,10 +205,11 @@ @redraw = true end # n is 1-indexed selection # n == 0 if "Done" was selected in @multiple mode + sig { params(n: Integer).void } def select_n(n) if @multiple if n == 0 @answer = [] @chosen.each_with_index do |selected, i| @@ -198,28 +226,33 @@ @answer = n end @redraw = true end + sig { params(char: String).void } def select_bool(char) - return unless (@options - %w(yes no)).empty? - opt = @options.detect { |o| o.start_with?(char) } - @active = @options.index(opt) + 1 - @answer = @options.index(opt) + 1 + return unless (@options - ['yes', 'no']).empty? + + index = T.must(@options.index { |o| o.start_with?(char) }) + @active = index + 1 + @answer = index + 1 @redraw = true end + sig { params(char: String).void } def build_selection(char) @active = (@active.to_s + char).to_i @redraw = true end + sig { void } def chop_selection @active = @active.to_s.chop.to_i @redraw = true end + sig { params(char: String).void } def update_search(char) @redraw = true # Control+D or Backspace on empty search closes search if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE)) @@ -233,29 +266,32 @@ else @filter += char end end + sig { void } def select_current # Prevent selection of invisible options return unless presented_options.any? { |_, num| num == @active } + select_n(@active) end + sig { void } def process_input_until_redraw_required @redraw = false wait_for_user_input until @redraw end # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon + sig { void } def wait_for_user_input char = read_char @last_char = char case char - when :timeout ; raise Interrupt # Timeout, use interrupt to simulate - when CTRL_C ; raise Interrupt + when CTRL_C, nil ; raise Interrupt end max_digit = [@options.size, 9].min.to_s case @state when :root @@ -300,55 +336,63 @@ when 'D' ; # Ignore left key else ; raise Interrupt # unhandled escape sequence. end end end - # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon + # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon + sig { returns(T::Boolean) } def selecting? @state == :line_select end + sig { returns(T::Boolean) } def filtering? @state == :filter end + sig { returns(T::Boolean) } def has_filter? !@filter.empty? end + sig { void } def start_filter @state = :filter @redraw = true end + sig { void } def start_line_select @state = :line_select @active = 0 @redraw = true end + sig { void } def stop_line_select @state = :root @active = 1 if @active.zero? @redraw = true end + sig { returns(T.nilable(String)) } def read_char if $stdin.tty? && !ENV['TEST'] $stdin.getch # raw mode for tty else - $stdin.getc + $stdin.getc # returns nil at end of input end - rescue IOError + rescue Errno::EIO, Errno::EPIPE, IOError "\e" end + sig { params(recalculate: T::Boolean).returns(T::Array[[String, T.nilable(Integer)]]) } def presented_options(recalculate: false) return @presented_options unless recalculate - @presented_options = @options.zip(1..Float::INFINITY) + @presented_options = @options.zip(1..) if has_filter? @presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) } end # Used for selection purposes @@ -383,40 +427,48 @@ end @presented_options end + sig { void } def ensure_visible_is_active unless presented_options.any? { |_, num| num == @active } @active = presented_options.first&.last.to_i end end + sig { returns(Integer) } def distance_from_selection_to_end @presented_options.count - index_of_active_option end + sig { returns(Integer) } def distance_from_start_to_selection index_of_active_option end + sig { returns(Integer) } def index_of_active_option @presented_options.index { |_, num| num == @active }.to_i end + sig { void } def ensure_last_item_is_continuation_marker - @presented_options.push(['...', nil]) if @presented_options.last.last + @presented_options.push(['...', nil]) if @presented_options.last&.last end + sig { void } def ensure_first_item_is_continuation_marker - @presented_options.unshift(['...', nil]) if @presented_options.first.last + @presented_options.unshift(['...', nil]) if @presented_options.first&.last end + sig { returns(Integer) } def max_lines CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible end + sig { void } def render_options previously_displayed_lines = num_lines @displaying_metadata = display_metadata? @@ -434,15 +486,11 @@ filter_text = @filter filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty? "Filter: #{filter_text}" end - if metadata_text - CLI::UI.with_frame_color(:blue) do - puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}") - end - end + puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}") if metadata_text options.each do |choice, num| is_chosen = @multiple && num && @chosen[num - 1] && num != 0 padding = ' ' * (max_num_length - num.to_s.length) @@ -461,15 +509,14 @@ color = filtering? || selecting? ? 'green' : 'blue' message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n") end - CLI::UI.with_frame_color(:blue) do - puts CLI::UI.fmt(message) - end + puts CLI::UI.fmt(message) end end + sig { params(format: String, choice: String).returns(String) } def format_choice(format, choice) eol = CLI::UI::ANSI.clear_to_end_of_line lines = choice.split("\n") return eol if lines.empty? # Handle blank options