lib/cli/ui/prompt/interactive_options.rb in cli-ui-1.2.1 vs lib/cli/ui/prompt/interactive_options.rb in cli-ui-1.2.2

- old
+ new

@@ -1,5 +1,6 @@ +# coding: utf-8 require 'io/console' module CLI module UI module Prompt @@ -8,10 +9,13 @@ 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 + # For more than 9 options, hitting 'e', ':', or 'G' will enter select + # mode allowing the user to type in longer numbers + # Pressing 'f' or '/' will allow the user to filter the results # # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif # # ==== Example Usage: # @@ -40,76 +44,133 @@ @active = 1 @marker = '>' @answer = nil @state = :root @multiple = multiple + # Indicate that an extra line (the "metadata" line) is present and + # the terminal output should be drawn over when processing user input + @displaying_metadata = false + @filter = '' # 0-indexed array representing if selected # @options[0] is selected if @chosen[0] @chosen = Array.new(@options.size) { false } if multiple @redraw = true + @presented_options = [] end # Calls the +InteractiveOptions+ and asks the question # Usually used from +self.call+ # def call + calculate_option_line_lengths CLI::UI.raw { print(ANSI.hide_cursor) } while @answer.nil? render_options process_input_until_redraw_required reset_position end clear_output + @answer ensure CLI::UI.raw do print(ANSI.show_cursor) end end private - def reset_position + def calculate_option_line_lengths + @terminal_width_at_calculation_time = CLI::UI::Terminal.width + # 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 + + # since lines may be longer than the terminal is wide, we need to + # determine how many extra lines would be taken up by them + max_width = (@terminal_width_at_calculation_time - + @options.count.to_s.size - # Width of the displayed number + 5 - # Extra characters added during rendering + (@multiple ? 1 : 0) # Space for the checkbox, if rendered + ).to_f + + @option_lengths = @options.map do |text| + width = 1 if text.empty? + width ||= text + .split("\n") + .reject(&:empty?) + .map { |l| (l.length / max_width).ceil } + .reduce(&:+) + + width + end + end + + def reset_position(number_of_lines=num_lines) # 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) } + number_of_lines.times { print(ANSI.previous_line) } end end - def clear_output + def clear_output(number_of_lines=num_lines) CLI::UI.raw do # Write over all lines with whitespace - num_lines.times { puts(' ' * CLI::UI::Terminal.width) } + number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) } end - reset_position + reset_position number_of_lines + + # Update if metadata is being displayed + # This must be done _after_ the output is cleared or it won't draw over + # the entire output + @displaying_metadata = display_metadata? end + # Don't use this in place of +@displaying_metadata+, this updates too + # quickly to be useful when drawing to the screen. + def display_metadata? + filtering? or selecting? or has_filter? + 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 + calculate_option_line_lengths if terminal_width_changed? - # empty_option_count is needed since empty option titles are omitted - # from the line count when reject(&:empty?) is called + option_length = presented_options.reduce(0) do |total_length, (_, option_number)| + # Handle continuation markers and "Done" option when multiple is true + next total_length + 1 if option_number.nil? or option_number.zero? + total_length + @option_lengths[option_number - 1] + end - empty_option_count = options.count(&:empty?) - joined_options = options.join("\n") - joined_options.split("\n").reject(&:empty?).size + empty_option_count + option_length + (@displaying_metadata ? 1 : 0) end + def terminal_width_changed? + @terminal_width_at_calculation_time != CLI::UI::Terminal.width + end + ESC = "\e" + BACKSPACE = "\u007F" + CTRL_C = "\u0003" + CTRL_D = "\u0004" def up - min_pos = @multiple ? 0 : 1 - @active = @active - 1 >= min_pos ? @active - 1 : @options.length + active_index = @filtered_options.index { |_,num| num == @active } || 0 + + previous_visible = @filtered_options[active_index - 1] + previous_visible ||= @filtered_options.last + + @active = previous_visible ? previous_visible.last : -1 @redraw = true end def down - min_pos = @multiple ? 0 : 1 - @active = @active + 1 <= @options.length ? @active + 1 : min_pos + active_index = @filtered_options.index { |_,num| num == @active } || 0 + + next_visible = @filtered_options[active_index + 1] + next_visible ||= @filtered_options.first + + @active = next_visible ? next_visible.last : -1 @redraw = true end # n is 1-indexed selection # n == 0 if "Done" was selected in @multiple mode @@ -139,11 +200,40 @@ @active = @options.index(opt) + 1 @answer = @options.index(opt) + 1 @redraw = true end + def build_selection(char) + @active = (@active.to_s + char).to_i + @redraw = true + end + + def chop_selection + @active = @active.to_s.chop.to_i + @redraw = true + end + + def update_search(char) + @redraw = true + + # Control+D or Backspace on empty search closes search + if char == CTRL_D or (@filter.empty? and char == BACKSPACE) + @filter = '' + @state = :root + return + end + + if char == BACKSPACE + @filter.chop! + else + @filter += char + end + end + def select_current + # Prevent selection of invisible options + return unless presented_options.any? { |_,num| num == @active } select_n(@active) end def process_input_until_redraw_required @redraw = false @@ -151,41 +241,90 @@ end # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon def wait_for_user_input char = read_char + @last_char = char + + case char + when :timeout ; raise Interrupt # Timeout, use interrupt to simulate + when CTRL_C ; raise Interrupt + end + 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 'e', ':', 'G' ; start_line_select + when 'f', '/' ; start_filter + when ('0'..@options.size.to_s) ; select_n(char.to_i) when 'y', 'n' ; select_bool(char) when " ", "\r", "\n" ; select_current # <enter> - when "\u0003" ; raise Interrupt # Ctrl-c end + when :filter + case char + when ESC ; @state = :esc + when "\r", "\n" ; select_current + else ; update_search(char) + end + when :line_select + case char + when ESC ; @state = :esc + when 'k' ; up ; @state = :root + when 'j' ; down ; @state = :root + when 'e',':','G','q' ; stop_line_select + when '0'..'9' ; build_selection(char) + when BACKSPACE ; chop_selection # Pop last input on backspace + when ' ', "\r", "\n" ; select_current + end when :esc case char - when :timeout ; raise Interrupt # Timeout, use interrupt to simulate when '[' ; @state = :esc_bracket else ; raise Interrupt # unhandled escape sequence. end when :esc_bracket - @state = :root + @state = has_filter? ? :filter : :root case char - when :timeout ; raise Interrupt # Timeout, use interrupt to simulate when 'A' ; up when 'B' ; down else ; raise Interrupt # unhandled escape sequence. end end end # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon + def selecting? + @state == :line_select + end + + def filtering? + @state == :filter + end + + def has_filter? + !@filter.empty? + end + + def start_filter + @state = :filter + @redraw = true + end + + def start_line_select + @state = :line_select + @active = 0 + @redraw = true + end + + def stop_line_select + @state = :root + @active = 1 if @active.zero? + @redraw = true + end + def read_char raw_tty! do getc = $stdin.getc getc ? getc.chr : :timeout end @@ -203,13 +342,22 @@ def presented_options(recalculate: false) return @presented_options unless recalculate @presented_options = @options.zip(1..Float::INFINITY) + if has_filter? + @presented_options.select! { |option,_| option.downcase.include?(@filter.downcase) } + end + + # Used for selection purposes + @filtered_options = @presented_options.dup + @presented_options.unshift([DONE, 0]) if @multiple - while num_lines > max_options + ensure_visible_is_active if has_filter? + + while num_lines > max_lines # 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) @@ -221,36 +369,68 @@ end @presented_options end + def ensure_visible_is_active + unless presented_options.any? { |_, num| num == @active } + @active = presented_options.first&.last.to_i + end + end + def distance_from_selection_to_end - last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last - last_visible_option_number - @active + @presented_options.count - index_of_active_option end def distance_from_start_to_selection - first_visible_option_number = @presented_options[0].last || @presented_options[1].last - @active - first_visible_option_number + index_of_active_option end + def index_of_active_option + @presented_options.index { |_,num| num == @active }.to_i + 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 + def max_lines + CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible end def render_options + previously_displayed_lines = num_lines + + @displaying_metadata = display_metadata? + + options = presented_options(recalculate: true) + + clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines + max_num_length = (@options.size + 1).to_s.length - presented_options(recalculate: true).each do |choice, num| + metadata_text = if selecting? + select_text = @active + select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0 + "Select: #{select_text}" + elsif filtering? or has_filter? + filter_text = @filter + filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty? + "Filter: #{filter_text}" + end + + if metadata_text + CLI::UI.with_frame_color(:blue) do + puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}") + end + end + + options.each do |choice, num| is_chosen = @multiple && num && @chosen[num - 1] padding = ' ' * (max_num_length - num.to_s.length) message = " #{num}#{num ? '.' : ' '}#{padding}" @@ -259,21 +439,31 @@ 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") + message += format_choice(format, choice) if num == @active - message = message.split("\n").map.with_index do |l, idx| - idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}" - end.join("\n") + + color = (filtering? or selecting?) ? 'green' : 'blue' + message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n") end CLI::UI.with_frame_color(:blue) do - puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line + puts CLI::UI.fmt(message) end end + end + + def format_choice(format, choice) + eol = CLI::UI::ANSI.clear_to_end_of_line + lines = choice.split("\n") + + return eol if lines.empty? # Handle blank options + + lines.map! { |l| sprintf(format, l) + eol } + lines.join("\n") end end end end end