diff --git a/lib/cli/ui/prompt.rb b/lib/cli/ui/prompt.rb index 3f106a38d..7fcc3bcb3 100644 --- a/lib/cli/ui/prompt.rb +++ b/lib/cli/ui/prompt.rb @@ -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 @@ -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? @@ -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 + "" + 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) diff --git a/lib/cli/ui/prompt/interactive_options.rb b/lib/cli/ui/prompt/interactive_options.rb index 96269782d..fdcd08367 100644 --- a/lib/cli/ui/prompt/interactive_options.rb +++ b/lib/cli/ui/prompt/interactive_options.rb @@ -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 @@ -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+ @@ -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 @@ -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 @@ -89,16 +100,32 @@ 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) @@ -106,13 +133,16 @@ def select_bool(char) 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 @@ -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 # - when "\u0003" ; raise Interrupt # Ctrl-c + when " ", "\r", "\n" ; select_current # + when "\u0003" ; raise Interrupt # Ctrl-c end when :esc case char @@ -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 @@ -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 @@ -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