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