lib/commander/user_interaction.rb



# frozen_string_literal: true

require 'tempfile'
require 'shellwords'

module Commander
  ##
  # = User Interaction
  #
  # Commander's user interaction module mixes in common
  # methods which extend HighLine's functionality such
  # as a #password method rather than calling #ask directly.

  module UI
    module_function

    #--
    # Auto include growl when available.
    #++

    begin
      require 'growl'
    rescue LoadError
      # Do nothing
    else
      include Growl
    end

    ##
    # Ask the user for a password. Specify a custom
    # _message_ other than 'Password: ' or override the
    # default _mask_ of '*'.

    def password(message = 'Password: ', mask = '*')
      pass = ask(message) { |q| q.echo = mask }
      pass = password message, mask if pass.nil? || pass.empty?
      pass
    end

    ##
    # Choose from a set array of _choices_.

    def choose(message = nil, *choices, &block)
      say message if message
      super(*choices, &block)
    end

    ##
    # 'Log' an _action_ to the terminal. This is typically used
    # for verbose output regarding actions performed. For example:
    #
    #   create  path/to/file.rb
    #   remove  path/to/old_file.rb
    #   remove  path/to/old_file2.rb
    #

    def log(action, *args)
      say format('%15s  %s', action, args.join(' '))
    end

    ##
    # 'Say' something using the OK color (green).
    #
    # === Examples
    #   say_ok 'Everything is fine'
    #   say_ok 'It is ok', 'This is ok too'
    #

    def say_ok(*args)
      args.each do |arg|
        say HighLine.default_instance.color(arg, :green)
      end
    end

    ##
    # 'Say' something using the WARNING color (yellow).
    #
    # === Examples
    #   say_warning 'This is a warning'
    #   say_warning 'Be careful', 'Think about it'
    #

    def say_warning(*args)
      args.each do |arg|
        say HighLine.default_instance.color(arg, :yellow)
      end
    end

    ##
    # 'Say' something using the ERROR color (red).
    #
    # === Examples
    #   say_error 'Everything is not fine'
    #   say_error 'It is not ok', 'This is not ok too'
    #

    def say_error(*args)
      args.each do |arg|
        say HighLine.default_instance.color(arg, :red)
      end
    end

    ##
    # 'Say' something using the specified color
    #
    # === Examples
    #   color 'I am blue', :blue
    #   color 'I am bold', :bold
    #   color 'White on Red', :white, :on_red
    #
    # === Notes
    #   You may use:
    #   * color:    black blue cyan green magenta red white yellow
    #   * style:    blink bold clear underline
    #   * highligh: on_<color>

    def color(*args)
      say HighLine.default_instance.color(*args)
    end

    ##
    # Speak _message_ using _voice_ at a speaking rate of _rate_
    #
    # Voice defaults to 'Alex', which is one of the better voices.
    # Speaking rate defaults to 175 words per minute
    #
    # === Examples
    #
    #   speak 'What is your favorite food? '
    #   food = ask 'favorite food?: '
    #   speak "Wow, I like #{food} too. We have so much in common."
    #   speak "I like #{food} as well!", "Victoria", 190
    #
    # === Notes
    #
    # * MacOS only
    #

    def speak(message, voice = :Alex, rate = 175)
      Thread.new { applescript "say #{message.inspect} using #{voice.to_s.inspect} speaking rate #{rate}" }
    end

    ##
    # Converse with speech recognition.
    #
    # Currently a "poorman's" DSL to utilize applescript and
    # the MacOS speech recognition server.
    #
    # === Examples
    #
    #   case converse 'What is the best food?', :cookies => 'Cookies', :unknown => 'Nothing'
    #   when :cookies
    #     speak 'o.m.g. you are awesome!'
    #   else
    #     case converse 'That is lame, shall I convince you cookies are the best?', :yes => 'Ok', :no => 'No', :maybe => 'Maybe another time'
    #     when :yes
    #       speak 'Well you see, cookies are just fantastic.'
    #     else
    #       speak 'Ok then, bye.'
    #     end
    #   end
    #
    # === Notes
    #
    # * MacOS only
    #

    def converse(prompt, responses = {})
      i, commands = 0, responses.map { |_key, value| value.inspect }.join(',')
      statement = responses.inject '' do |inner_statement, (key, value)|
        inner_statement <<
        (
          (i += 1) == 1 ?
          %(if response is "#{value}" then\n) :
          %(else if response is "#{value}" then\n)
        ) <<
        %(do shell script "echo '#{key}'"\n)
      end
      applescript(
        %(
        tell application "SpeechRecognitionServer"
          set response to listen for {#{commands}} with prompt "#{prompt}"
          #{statement}
          end if
        end tell
        )
      ).strip.to_sym
    end

    ##
    # Execute apple _script_.

    def applescript(script)
      `osascript -e "#{ script.gsub('"', '\"') }"`
    end

    ##
    # Normalize IO streams, allowing for redirection of
    # +input+ and/or +output+, for example:
    #
    #   $ foo              # => read from terminal I/O
    #   $ foo in           # => read from 'in' file, output to terminal output stream
    #   $ foo in out       # => read from 'in' file, output to 'out' file
    #   $ foo < in > out   # => equivalent to above (essentially)
    #
    # Optionally a +block+ may be supplied, in which case
    # IO will be reset once the block has executed.
    #
    # === Examples
    #
    #   command :foo do |c|
    #     c.syntax = 'foo [input] [output]'
    #     c.when_called do |args, options|
    #       # or io(args.shift, args.shift)
    #       io *args
    #       str = $stdin.gets
    #       puts 'input was: ' + str.inspect
    #     end
    #   end
    #

    def io(input = nil, output = nil, &block)
      orig_stdin, orig_stdout = $stdin, $stdout
      $stdin = File.new(input) if input
      $stdout = File.new(output, 'r+') if output
      return unless block

      yield
      $stdin, $stdout = orig_stdin, orig_stdout
      reset_io
    end

    ##
    # Find an editor available in path. Optionally supply the _preferred_
    # editor. Returns the name as a string, nil if none is available.

    def available_editor(preferred = nil)
      [preferred, ENV['EDITOR'], 'mate -w', 'vim', 'vi', 'emacs', 'nano', 'pico']
        .compact
        .find { |name| system("hash #{name.split.first} 2>&-") }
    end

    ##
    # Prompt an editor for input. Optionally supply initial
    # _input_ which is written to the editor.
    #
    # _preferred_editor_ can be hinted.
    #
    # === Examples
    #
    #   ask_editor                # => prompts EDITOR with no input
    #   ask_editor('foo')         # => prompts EDITOR with default text of 'foo'
    #   ask_editor('foo', 'mate -w')  # => prompts TextMate with default text of 'foo'
    #

    def ask_editor(input = nil, preferred_editor = nil)
      editor = available_editor preferred_editor
      program = Commander::Runner.instance.program(:name).downcase rescue 'commander'
      tmpfile = Tempfile.new program
      begin
        tmpfile.write input if input
        tmpfile.close
        system("#{editor} #{tmpfile.path.shellescape}") ? IO.read(tmpfile.path) : nil
      ensure
        tmpfile.unlink
      end
    end

    ##
    # Enable paging of output after called.

    def enable_paging
      return unless $stdout.tty?
      return unless Process.respond_to? :fork

      read, write = IO.pipe

      # Kernel.fork is not supported on all platforms and configurations.
      # As of Ruby 1.9, `Process.respond_to? :fork` should return false on
      # configurations that don't support it, but versions before 1.9 don't
      # seem to do this reliably and instead raise a NotImplementedError
      # (which is rescued below).

      if Kernel.fork
        $stdin.reopen read
        write.close
        read.close
        Kernel.select [$stdin]
        ENV['LESS'] = 'FSRX' unless ENV.key? 'LESS'
        pager = ENV['PAGER'] || 'less'
        exec pager rescue exec '/bin/sh', '-c', pager
      else
        # subprocess
        $stdout.reopen write
        $stderr.reopen write if $stderr.tty?
        write.close
        read.close
      end
    rescue NotImplementedError
    ensure
      write.close if write && !write.closed?
      read.close if read && !read.closed?
    end

    ##
    # Output progress while iterating _arr_.
    #
    # === Examples
    #
    #   uris = %w( http://vision-media.ca http://google.com )
    #   progress uris, :format => "Remaining: :time_remaining" do |uri|
    #     res = open uri
    #   end
    #

    def progress(arr, options = {})
      bar = ProgressBar.new arr.length, options
      bar.show
      arr.each { |v| bar.increment yield(v) }
    end

    ##
    # Implements ask_for_CLASS methods.

    module AskForClass
      DEPRECATED_CONSTANTS = %i[Config TimeoutError MissingSourceFile NIL TRUE FALSE Fixnum Bignum Data].freeze

      # define methods for common classes
      [Float, Integer, String, Symbol, Regexp, Array, File, Pathname].each do |klass|
        define_method "ask_for_#{klass.to_s.downcase}" do |prompt|
          HighLine.default_instance.ask(prompt, klass)
        end
      end

      def method_missing(method_name, *arguments, &block)
        if method_name.to_s =~ /^ask_for_(.*)/
          if arguments.count != 1
            fail ArgumentError, "wrong number of arguments (given #{arguments.count}, expected 1)"
          end

          prompt = arguments.first
          requested_class = Regexp.last_match[1]

          # All Classes that respond to #parse
          # Ignore constants that trigger deprecation warnings
          available_classes = (Object.constants - DEPRECATED_CONSTANTS).map do |const|
            Object.const_get(const)
          rescue RuntimeError
            # Rescue errors in Ruby 3 for SortedSet:
            # The `SortedSet` class has been extracted from the `set` library.
          end.compact.select do |const|
            const.instance_of?(Class) && const.respond_to?(:parse)
          end

          klass = available_classes.find { |k| k.to_s.downcase == requested_class }
          if klass
            HighLine.default_instance.ask(prompt, klass)
          else
            super
          end
        else
          super
        end
      end

      def respond_to_missing?(method_name, include_private = false)
        method_name.to_s.start_with?('ask_for_') || super
      end
    end

    ##
    # Substitute _hash_'s keys with their associated values in _str_.

    def replace_tokens(str, hash) #:nodoc:
      hash.inject(str) do |string, (key, value)|
        string.gsub ":#{key}", value.to_s
      end
    end

    ##
    # = Progress Bar
    #
    # Terminal progress bar utility. In its most basic form
    # requires that the developer specifies when the bar should
    # be incremented. Note that a hash of tokens may be passed to
    # #increment, (or returned when using Object#progress).
    #
    #   uris = %w(
    #     http://vision-media.ca
    #     http://yahoo.com
    #     http://google.com
    #     )
    #
    #   bar = Commander::UI::ProgressBar.new uris.length, options
    #   threads = []
    #   uris.each do |uri|
    #     threads << Thread.new do
    #       begin
    #         res = open uri
    #         bar.increment :uri => uri
    #       rescue Exception => e
    #         bar.increment :uri => "#{uri} failed"
    #       end
    #     end
    #   end
    #   threads.each { |t| t.join }
    #
    # The Object method #progress is also available:
    #
    #   progress uris, :width => 10 do |uri|
    #     res = open uri
    #     { :uri => uri } # Can now use :uri within :format option
    #   end
    #

    class ProgressBar
      ##
      # Creates a new progress bar.
      #
      # === Options
      #
      #   :title              Title, defaults to "Progress"
      #   :width              Width of :progress_bar
      #   :progress_str       Progress string, defaults to "="
      #   :incomplete_str     Incomplete bar string, defaults to '.'
      #   :format             Defaults to ":title |:progress_bar| :percent_complete% complete "
      #   :tokens             Additional tokens replaced within the format string
      #   :complete_message   Defaults to "Process complete"
      #
      # === Tokens
      #
      #   :title
      #   :percent_complete
      #   :progress_bar
      #   :step
      #   :steps_remaining
      #   :total_steps
      #   :time_elapsed
      #   :time_remaining
      #

      def initialize(total, options = {})
        @total_steps, @step, @start_time = total, 0, Time.now
        @title = options.fetch :title, 'Progress'
        @width = options.fetch :width, 25
        @progress_str = options.fetch :progress_str, '='
        @incomplete_str = options.fetch :incomplete_str, '.'
        @complete_message = options.fetch :complete_message, 'Process complete'
        @format = options.fetch :format, ':title |:progress_bar| :percent_complete% complete '
        @tokens = options.fetch :tokens, {}
      end

      ##
      # Completion percentage.

      def percent_complete
        if @total_steps.zero?
          100
        else
          @step * 100 / @total_steps
        end
      end

      ##
      # Time that has elapsed since the operation started.

      def time_elapsed
        Time.now - @start_time
      end

      ##
      # Estimated time remaining.

      def time_remaining
        (time_elapsed / @step) * steps_remaining
      end

      ##
      # Number of steps left.

      def steps_remaining
        @total_steps - @step
      end

      ##
      # Formatted progress bar.

      def progress_bar
        (@progress_str * (@width * percent_complete / 100)).ljust @width, @incomplete_str
      end

      ##
      # Generates tokens for this step.

      def generate_tokens
        {
          title: @title,
          percent_complete: percent_complete,
          progress_bar: progress_bar,
          step: @step,
          steps_remaining: steps_remaining,
          total_steps: @total_steps,
          time_elapsed: format('%0.2fs', time_elapsed),
          time_remaining: @step.positive? ? format('%0.2fs', time_remaining) : '',
        }.merge! @tokens
      end

      ##
      # Output the progress bar.

      def show
        return if finished?

        erase_line
        if completed?
          HighLine.default_instance.say UI.replace_tokens(@complete_message, generate_tokens) if @complete_message.is_a? String
        else
          HighLine.default_instance.say UI.replace_tokens(@format, generate_tokens) << ' '
        end
      end

      ##
      # Whether or not the operation is complete, and we have finished.

      def finished?
        @step == @total_steps + 1
      end

      ##
      # Whether or not the operation has completed.

      def completed?
        @step == @total_steps
      end

      ##
      # Increment progress. Optionally pass _tokens_ which
      # can be displayed in the output format.

      def increment(tokens = {})
        @step += 1
        @tokens.merge! tokens if tokens.is_a? Hash
        show
      end

      ##
      # Erase previous terminal line.

      def erase_line
        # highline does not expose the output stream
        HighLine.default_instance.instance_variable_get('@output').print "\r\e[K"
      end
    end
  end
end