From 405b40fb84cd33dbe5391c2bd51e00311194cf77 Mon Sep 17 00:00:00 2001 From: Sebastien Lavoie Date: Fri, 14 Sep 2018 11:18:17 -0400 Subject: [PATCH] Add support for multiple selection in prompt Returns an array of all selected options, potentially empty --- lib/cli/ui/prompt.rb | 29 +++++--- lib/cli/ui/prompt/interactive_options.rb | 85 +++++++++++++++++++----- test/cli/ui/prompt_test.rb | 24 +++---- 3 files changed, 100 insertions(+), 38 deletions(-) diff --git a/lib/cli/ui/prompt.rb b/lib/cli/ui/prompt.rb index 3f106a38..1327a1e0 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 ? "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 + "" + 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 96269782..a5541bb7 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,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 @@ -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 @@ -89,16 +102,37 @@ 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) @@ -106,13 +140,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 process_input_until_redraw_required + @redraw = false + wait_for_user_input until @redraw end # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon @@ -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 # - when "\u0003" ; raise Interrupt # Ctrl-c + when " ", "\r", "\n" ; select_current # + when "\u0003" ; raise Interrupt # Ctrl-c end when :esc case char @@ -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 @@ -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 @@ -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| diff --git a/test/cli/ui/prompt_test.rb b/test/cli/ui/prompt_test.rb index 84a22bfe..79320d87 100644 --- a/test/cli/ui/prompt_test.rb +++ b/test/cli/ui/prompt_test.rb @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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