Skip to content

Commit

Permalink
Add support for multiple selection in prompt
Browse files Browse the repository at this point in the history
Returns an array of all selected options, potentially empty
  • Loading branch information
lavoiesl committed Sep 14, 2018
1 parent 34f794f commit 91300f5
Show file tree
Hide file tree
Showing 2 changed files with 70 additions and 24 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 ? "Toogle 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
65 changes: 49 additions & 16 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,15 @@ 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
@chosen = Array.new(@options.size) { false } if multiple
@redraw = true
end

# Calls the +InteractiveOptions+ and asks the question
Expand All @@ -42,7 +53,7 @@ def call
CLI::UI.raw { print(ANSI.hide_cursor) }
while @answer.nil?
render_options
wait_for_actionable_user_input
wait_for_redraw
reset_position
end
clear_output
Expand Down Expand Up @@ -89,30 +100,49 @@ 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

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
@chosen[n - 1] = !@chosen[n - 1]
end
else
@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 wait_for_redraw
@redraw = false
wait_for_user_input until @redraw
end

# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
Expand All @@ -125,10 +155,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 +200,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 +236,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 @@ -214,6 +246,7 @@ def render_options
presented_options(recalculate: true).each do |choice, num|
padding = ' ' * (max_num_length - num.to_s.length)
message = " #{num}#{num ? '.' : ' '}#{padding}"
message += " #{CHECKBOX_ICON[@chosen[num - 1]]} " if @multiple && num && num > 0
message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")

if num == @active
Expand Down

0 comments on commit 91300f5

Please sign in to comment.