Skip to content

Commit

Permalink
Merge pull request #38 from Shopify/seb/multiple_prompt
Browse files Browse the repository at this point in the history
Add support for multiple selection in prompt
  • Loading branch information
lavoiesl authored Sep 19, 2018
2 parents 34f794f + 405b40f commit 721e9fc
Show file tree
Hide file tree
Showing 3 changed files with 100 additions and 38 deletions.
29 changes: 21 additions & 8 deletions lib/cli/ui/prompt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ class << self
# handler.option('python') { |selection| selection }
# end
#
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, &options_proc)
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, multiple: false, &options_proc)
if ((options || block_given?) && (default || is_file))
raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
end

if options || block_given?
ask_interactive(question, options, &options_proc)
ask_interactive(question, options, multiple: multiple, &options_proc)
else
ask_free_form(question, default, is_file, allow_empty)
end
Expand Down Expand Up @@ -121,7 +121,7 @@ def ask_free_form(question, default, is_file, allow_empty)
end
end

def ask_interactive(question, options = nil)
def ask_interactive(question, options = nil, multiple: false)
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?

options ||= if block_given?
Expand All @@ -131,23 +131,36 @@ def ask_interactive(question, options = nil)
end

raise(ArgumentError, 'insufficient options') if options.nil? || options.size < 2
puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
resp = interactive_prompt(options)
instructions = (multiple ? "Toggle options. " : "") + "Choose with ↑ ↓ ⏎"
puts_question("#{question} {{yellow:(#{instructions})}}")
resp = interactive_prompt(options, multiple: multiple)

# Clear the line, and reset the question to include the answer
print(ANSI.previous_line + ANSI.end_of_line + ' ')
print(ANSI.cursor_save)
print(' ' * CLI::UI::Terminal.width)
print(ANSI.cursor_restore)
puts_question("#{question} (You chose: {{italic:#{resp}}})")

resp_text = resp
if multiple
resp_text = case resp.size
when 0
"<nothing>"
when 1..2
resp.join(" and ")
else
"#{resp.size} items"
end
end
puts_question("#{question} (You chose: {{italic:#{resp_text}}})")

return handler.call(resp) if block_given?
resp
end

# Useful for stubbing in tests
def interactive_prompt(options)
InteractiveOptions.call(options)
def interactive_prompt(options, multiple: false)
InteractiveOptions.call(options, multiple: multiple)
end

def write_default_over_empty_input(default)
Expand Down
85 changes: 67 additions & 18 deletions lib/cli/ui/prompt/interactive_options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ module CLI
module UI
module Prompt
class InteractiveOptions
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
Expand All @@ -15,9 +18,14 @@ class InteractiveOptions
# Ask an interactive question
# CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
#
def self.call(options)
list = new(options)
options[list.call - 1]
def self.call(options, multiple: false)
list = new(options, multiple: multiple)
selected = list.call
if multiple
selected.map { |s| options[s - 1] }
else
options[selected - 1]
end
end

# Initializes a new +InteractiveOptions+
Expand All @@ -27,12 +35,17 @@ def self.call(options)
#
# CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
#
def initialize(options)
def initialize(options, multiple: false)
@options = options
@active = 1
@marker = '>'
@answer = nil
@state = :root
@multiple = multiple
# 0-indexed array representing if selected
# @options[0] is selected if @chosen[0]
@chosen = Array.new(@options.size) { false } if multiple
@redraw = true
end

# Calls the +InteractiveOptions+ and asks the question
Expand All @@ -42,7 +55,7 @@ def call
CLI::UI.raw { print(ANSI.hide_cursor) }
while @answer.nil?
render_options
wait_for_actionable_user_input
process_input_until_redraw_required
reset_position
end
clear_output
Expand Down Expand Up @@ -89,30 +102,54 @@ def num_lines
ESC = "\e"

def up
@active = @active - 1 >= 1 ? @active - 1 : @options.length
min_pos = @multiple ? 0 : 1
@active = @active - 1 >= min_pos ? @active - 1 : @options.length
@redraw = true
end

def down
@active = @active + 1 <= @options.length ? @active + 1 : 1
min_pos = @multiple ? 0 : 1
@active = @active + 1 <= @options.length ? @active + 1 : min_pos
@redraw = true
end

# n is 1-indexed selection
# n == 0 if "Done" was selected in @multiple mode
def select_n(n)
@active = n
@answer = n
if @multiple
if n == 0
@answer = []
@chosen.each_with_index do |selected, i|
@answer << i + 1 if selected
end
else
@active = n
@chosen[n - 1] = !@chosen[n - 1]
end
elsif n == 0
# Ignore pressing "0" when not in multiple mode
else
@active = n
@answer = n
end
@redraw = true
end

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
@redraw = true
end

def wait_for_actionable_user_input
last_active = @active
while @active == last_active && @answer.nil?
wait_for_user_input
end
def select_current
select_n(@active)
end

def process_input_until_redraw_required
@redraw = false
wait_for_user_input until @redraw
end

# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
Expand All @@ -125,10 +162,11 @@ def wait_for_user_input
when ESC ; @state = :esc
when 'k' ; up
when 'j' ; down
when '0' ; select_n(char.to_i)
when ('1'..@options.size.to_s) ; select_n(char.to_i)
when 'y', 'n' ; select_bool(char)
when " ", "\r", "\n" ; @answer = @active # <enter>
when "\u0003" ; raise Interrupt # Ctrl-c
when " ", "\r", "\n" ; select_current # <enter>
when "\u0003" ; raise Interrupt # Ctrl-c
end
when :esc
case char
Expand Down Expand Up @@ -169,6 +207,8 @@ def presented_options(recalculate: false)
return @presented_options unless recalculate

@presented_options = @options.zip(1..Float::INFINITY)
@presented_options.unshift([DONE, 0]) if @multiple

while num_lines > max_options
# try to keep the selection centered in the window:
if distance_from_selection_to_end > distance_from_start_to_selection
Expand Down Expand Up @@ -203,7 +243,6 @@ def ensure_first_item_is_continuation_marker
@presented_options.unshift(["...", nil]) if @presented_options.first.last
end


def max_options
@max_options ||= CLI::UI::Terminal.height - 2 # Keeps a one line question visible
end
Expand All @@ -212,9 +251,19 @@ def render_options
max_num_length = (@options.size + 1).to_s.length

presented_options(recalculate: true).each do |choice, num|
is_chosen = @multiple && num && @chosen[num - 1]

padding = ' ' * (max_num_length - num.to_s.length)
message = " #{num}#{num ? '.' : ' '}#{padding}"
message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")

format = "%s"
# If multiple, bold only selected. If not multiple, bold everything
format = "{{bold:#{format}}}" if !@multiple || is_chosen
format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
format = " #{format}"

message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
message += choice.split("\n").map { |l| sprintf(format, l) }.join("\n")

if num == @active
message = message.split("\n").map.with_index do |l, idx|
Expand Down
24 changes: 12 additions & 12 deletions test/cli/ui/prompt_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_confirm_sigint
Process.kill('INT', @pid)

expected_out = strip_heredoc(<<-EOF) + ' '
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. yes\e[K
2. no\e[K
\e[?25h\e[\e[C
Expand Down Expand Up @@ -73,7 +73,7 @@ def test_ask_interactive_sigint
Process.kill('INT', @pid)

expected_out = strip_heredoc(<<-EOF) + ' '
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[?25h\e[\e[C
Expand All @@ -84,7 +84,7 @@ def test_ask_interactive_sigint
def test_confirm_happy_path
_run('y') { assert Prompt.confirm('q') }
expected_out = strip_heredoc(<<-EOF) + ' '
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. yes\e[K
2. no\e[K
\e[\e[C
Expand All @@ -100,7 +100,7 @@ def test_confirm_happy_path
def test_confirm_invalid
_run(%w(r y n)) { Prompt.confirm('q') }
expected_out = strip_heredoc(<<-EOF) + ' '
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. yes\e[K
2. no\e[K
\e[\e[C
Expand All @@ -116,7 +116,7 @@ def test_confirm_invalid
def test_confirm_no_match_internal
_run('x', 'n') { Prompt.confirm('q') }
expected_out = strip_heredoc(<<-EOF) + ' '
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. yes\e[K
2. no\e[K
\e[\e[C
Expand Down Expand Up @@ -216,7 +216,7 @@ def test_ask_interactive_with_block
end
end
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[\e[C
Expand All @@ -234,7 +234,7 @@ def test_ask_interactive_with_number
Prompt.ask('q', options: %w(a b))
end
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[\e[C
Expand All @@ -252,7 +252,7 @@ def test_ask_interactive_with_vim_bound_arrows
Prompt.ask('q', options: %w(a b))
end
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[\e[C
Expand All @@ -273,7 +273,7 @@ def test_ask_interactive_select_using_space
Prompt.ask('q', options: %w(a b))
end
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[\e[C
Expand All @@ -296,7 +296,7 @@ def test_ask_interactive_escape
end

expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[?25h\e[\e[C
Expand All @@ -309,7 +309,7 @@ def test_ask_interactive_invalid_input
Prompt.ask('q', options: %w(a b))
end
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2. b\e[K
\e[\e[C
Expand All @@ -331,7 +331,7 @@ def test_ask_interactive_with_blank_option
end
blank = ''
expected_out = strip_heredoc(<<-EOF)
? q (choose with ↑ ↓ ⏎)
? q (Choose with ↑ ↓ ⏎)
\e[?25l> 1. a\e[K
2.#{blank}\e[K
\e[\e[C
Expand Down

0 comments on commit 721e9fc

Please sign in to comment.