lib/cli/ui/prompt/interactive_options.rb in cli-ui-1.1.4 vs lib/cli/ui/prompt/interactive_options.rb in cli-ui-1.2.0
- old
+ new
@@ -2,10 +2,13 @@
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
#
# https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
@@ -13,57 +16,65 @@
# ==== Example Usage:
#
# 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+
# Usually called from +self.call+
#
# ==== Example Usage:
#
# 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
# Usually used from +self.call+
#
def call
CLI::UI.raw { print(ANSI.hide_cursor) }
while @answer.nil?
render_options
- wait_for_user_input
+ process_input_until_redraw_required
reset_position
end
clear_output
@answer
ensure
CLI::UI.raw do
print(ANSI.show_cursor)
- puts(ANSI.previous_line + ANSI.end_of_line)
end
end
private
def reset_position
# This will put us back at the beginning of the options
# When we redraw the options, they will be overwritten
CLI::UI.raw do
num_lines.times { print(ANSI.previous_line) }
- print(ANSI.previous_line + ANSI.end_of_line + "\n")
end
end
def clear_output
CLI::UI.raw do
@@ -72,57 +83,90 @@
end
reset_position
end
def num_lines
+ options = presented_options.map(&:first)
# @options will be an array of questions but each option can be multi-line
# so to get the # of lines, you need to join then split
# empty_option_count is needed since empty option titles are omitted
# from the line count when reject(&:empty?) is called
- empty_option_count = @options.count(&:empty?)
- joined_options = @options.join("\n")
+ empty_option_count = options.count(&:empty?)
+ joined_options = options.join("\n")
joined_options.split("\n").reject(&:empty?).size + empty_option_count
end
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 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
def wait_for_user_input
char = read_char
case @state
when :root
case char
when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
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
when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
when '[' ; @state = :esc_bracket
@@ -155,25 +199,79 @@
else
$stdin.raw { yield }
end
end
+ 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
+ # selection is closer to top than bottom, so trim a row from the bottom
+ ensure_last_item_is_continuation_marker
+ @presented_options.delete_at(-2)
+ else
+ # selection is closer to bottom than top, so trim a row from the top
+ ensure_first_item_is_continuation_marker
+ @presented_options.delete_at(1)
+ end
+ end
+
+ @presented_options
+ end
+
+ def distance_from_selection_to_end
+ last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last
+ last_visible_option_number - @active
+ end
+
+ def distance_from_start_to_selection
+ first_visible_option_number = @presented_options[0].last || @presented_options[1].last
+ @active - first_visible_option_number
+ end
+
+ def ensure_last_item_is_continuation_marker
+ @presented_options.push(["...", nil]) if @presented_options.last.last
+ end
+
+ 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
+
def render_options
max_num_length = (@options.size + 1).to_s.length
- @options.each_with_index do |choice, index|
- num = index + 1
+
+ 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}.#{padding}"
- message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
+ message = " #{num}#{num ? '.' : ' '}#{padding}"
+ 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|
idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
end.join("\n")
end
CLI::UI.with_frame_color(:blue) do
- puts CLI::UI.fmt(message)
+ puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line
end
end
end
end
end