Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multiple selection in prompt #38

Merged
merged 1 commit into from
Sep 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'[email protected]_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