piotrmurach/tty-prompt

View on GitHub
lib/tty/prompt/expander.rb

Summary

Maintainability
B
5 hrs
Test Coverage
# frozen_string_literal: true

require_relative "choices"

module TTY
  class Prompt
    # A class responsible for rendering expanding options
    # Used by {Prompt} to display key options question.
    #
    # @api private
    class Expander
      HELP_CHOICE = {
        key: "h",
        name: "print help",
        value: :help
      }.freeze

      # Names for delete keys
      DELETE_KEYS = %i[backspace delete].freeze

      # Create instance of Expander
      #
      # @api public
      def initialize(prompt, options = {})
        @prompt       = prompt
        @prefix       = options.fetch(:prefix) { @prompt.prefix }
        @default      = options.fetch(:default, 1)
        @auto_hint    = options.fetch(:auto_hint, false)
        @active_color = options.fetch(:active_color) { @prompt.active_color }
        @help_color   = options.fetch(:help_color) { @prompt.help_color }
        @quiet        = options.fetch(:quiet) { @prompt.quiet }
        @choices      = Choices.new
        @selected     = nil
        @done         = false
        @status       = :collapsed
        @hint         = nil
        @default_key  = false
      end

      def expanded?
        @status == :expanded
      end

      def collapsed?
        @status == :collapsed
      end

      def expand
        @status = :expanded
      end

      # Respond to submit event
      #
      # @api public
      def keyenter(_)
        if @input.nil? || @input.empty?
          @input = @choices[@default - 1].key
          @default_key = true
        end

        selected = select_choice(@input)

        if selected && selected.key.to_s == "h"
          expand
          @selected = nil
          @input = ""
        elsif selected
          @done = true
          @selected = selected
          @hint = nil
        else
          @input = ""
        end
      end
      alias keyreturn keyenter

      # Respond to key press event
      #
      # @api public
      def keypress(event)
        if DELETE_KEYS.include?(event.key.name)
          @input.chop! unless @input.empty?
        elsif event.value =~ /^[^\e\n\r]/
          @input += event.value
        end

        @selected = select_choice(@input)
        if @selected && !@default_key && collapsed?
          @hint = @selected.name
        end
      end

      # Select choice by given key
      #
      # @return [Choice]
      #
      # @api private
      def select_choice(key)
        @choices.find_by(:key, key)
      end

      # Set default value.
      #
      # @api public
      def default(value = (not_set = true))
        return @default if not_set

        @default = value
      end

      # Set quiet mode.
      #
      # @api public
      def quiet(value)
        @quiet = value
      end

      # Add a single choice
      #
      # @api public
      def choice(value, &block)
        if block
          @choices << value.update(value: block)
        else
          @choices << value
        end
      end

      # Add multiple choices
      #
      # @param [Array[Object]] values
      #   the values to add as choices
      #
      # @api public
      def choices(values)
        values.each { |val| choice(val) }
      end

      # Execute this prompt
      #
      # @api public
      def call(message, possibilities, &block)
        choices(possibilities)
        @message = message
        block.call(self) if block
        setup_defaults
        choice(HELP_CHOICE)
        @prompt.subscribe(self) do
          render
        end
      end

      private

      # Create possible keys with current choice highlighted
      #
      # @return [String]
      #
      # @api private
      def possible_keys
        keys = @choices.pluck(:key)
        default_key = keys[@default - 1]
        if @selected
          index = keys.index(@selected.key)
          keys[index] = @prompt.decorate(keys[index], @active_color)
        elsif @input.to_s.empty? && default_key
          keys[@default - 1] = @prompt.decorate(default_key, @active_color)
        end
        keys.join(",")
      end

      # @api private
      def render
        @input = ""
        until @done
          question = render_question
          @prompt.print(question)
          read_input
          @prompt.print(refresh(question.lines.count))
        end
        @prompt.print(render_question) unless @quiet
        answer
      end

      # @api private
      def answer
        @selected.value
      end

      # Render message with options
      #
      # @return [String]
      #
      # @api private
      def render_header
        header = ["#{@prefix}#{@message} "]
        if @done
          selected_item = @selected.name.to_s
          header << @prompt.decorate(selected_item, @active_color)
        elsif collapsed?
          header << %[(enter "h" for help) ]
          header << "[#{possible_keys}] "
          header << @input
        end
        header.join
      end

      # Show hint for selected option key
      #
      # return [String]
      #
      # @api private
      def render_hint
        "\n" + @prompt.decorate(">> ", @active_color) +
          @hint +
          @prompt.cursor.prev_line +
          @prompt.cursor.forward(@prompt.strip(render_header).size)
      end

      # Render question with menu
      #
      # @return [String]
      #
      # @api private
      def render_question
        load_auto_hint if @auto_hint
        header = render_header
        header << render_hint if @hint
        header << "\n" if @done

        if !@done && expanded?
          header << render_menu
          header << render_footer
        end
        header
      end

      def load_auto_hint
        if @hint.nil? && collapsed?
          if @selected
            @hint = @selected.name
          else
            if @input.empty?
              @hint = @choices[@default - 1].name
            else
              @hint = "invalid option"
            end
          end
        end
      end

      def render_footer
        "  Choice [#{@choices[@default - 1].key}]: #{@input}"
      end

      def read_input
        @prompt.read_keypress
      end

      # Refresh the current input
      #
      # @param [Integer] lines
      #
      # @return [String]
      #
      # @api private
      def refresh(lines)
        if (@hint && (!@selected || @done)) || (@auto_hint && collapsed?)
          @hint = nil
          @prompt.clear_lines(lines, :down) +
            @prompt.cursor.prev_line
        elsif expanded?
          @prompt.clear_lines(lines)
        else
          @prompt.clear_line
        end
      end

      # Render help menu
      #
      # @api private
      def render_menu
        output = ["\n"]
        @choices.each do |choice|
          chosen = %(#{choice.key} - #{choice.name})
          if @selected && @selected.key == choice.key
            chosen = @prompt.decorate(chosen, @active_color)
          end
          output << "  " + chosen + "\n"
        end
        output.join
      end

      def setup_defaults
        validate_choices
      end

      def validate_choices
        errors = []
        keys = []
        @choices.each do |choice|
          if choice.key.nil?
            errors << "Choice #{choice.name} is missing a :key attribute"
            next
          end
          if choice.key.length != 1
            errors << "Choice key `#{choice.key}` is more than one character long."
          end
          if choice.key.to_s == "h"
            errors << "Choice key `#{choice.key}` is reserved for help menu."
          end
          if keys.include?(choice.key)
            errors << "Choice key `#{choice.key}` is a duplicate."
          end
          keys << choice.key if choice.key
        end
        errors.each { |err| raise ConfigurationError, err }
      end
    end # Expander
  end # Prompt
end # TTY