#!/usr/local/bin/ruby -w

# menu.rb
#
#  Created by Gregory Thomas Brown on 2005-05-10.
#  Copyright 2005. All rights reserved.
#
#  This is Free Software.  See LICENSE and COPYING for details.

require "highline/question"

class HighLine
  # 
  # Menu objects encapsulate all the details of a call to HighLine.choose().
  # Using the accessors and Menu.choice() and Menu.choices(), the block passed
  # to HighLine.choose() can detail all aspects of menu display and control.
  # 
  class Menu < Question
    #
    # Create an instance of HighLine::Menu.  All customization is done
    # through the passed block, which should call accessors and choice() and
    # choices() as needed to define the Menu.  Note that Menus are also
    # Questions, so all that functionality is available to the block as
    # well.
    # 
    def initialize(  )
      #
      # Initialize Question objects with ignored values, we'll
      # adjust ours as needed.
      # 
      super("Ignored", [ ], &nil)    # avoiding passing the block along
      
      @items           = [ ]
      @hidden_items    = [ ]
      @help            = Hash.new("There's no help for that topic.")

      @index           = :number
      @index_suffix    = ". "
      @select_by       = :index_or_name
      @flow            = :rows
      @list_option     = nil
      @header          = nil
      @prompt          = "?  "
      @layout          = :list
      @shell           = false
      @nil_on_handled  = false
      
      # Override Questions responses, we'll set our own.
      @responses       = { }
      # Context for action code.
      @highline        = nil
      
      yield self if block_given?

      init_help if @shell and not @help.empty?
      update_responses     # rebuild responses based on our settings
    end

    #
    # An _index_ to append to each menu item in display.  See
    # Menu.index=() for details.
    # 
    attr_reader   :index
    #
    # The String placed between an _index_ and a menu item.  Defaults to
    # ". ".  Switches to " ", when _index_ is set to a String (like "-").
    #
    attr_accessor :index_suffix
    # 
    # The _select_by_ attribute controls how the user is allowed to pick a 
    # menu item.  The available choices are:
    # 
    # <tt>:index</tt>::          The user is allowed to type the numerical
    #                            or alphetical index for their selection.
    # <tt>:index_or_name</tt>::  Allows both methods from the
    #                            <tt>:index</tt> option and the
    #                            <tt>:name</tt> option.
    # <tt>:name</tt>::           Menu items are selected by typing a portion
    #                            of the item name that will be
    #                            auto-completed.
    # 
    attr_accessor :select_by
    # 
    # This attribute is passed directly on as the mode to HighLine.list() by
    # all the preset layouts.  See that method for appropriate settings.
    # 
    attr_accessor :flow
    #
    # This setting is passed on as the third parameter to HighLine.list()
    # by all the preset layouts.  See that method for details of its
    # effects.  Defaults to +nil+.
    # 
    attr_accessor :list_option
    #
    # Used by all the preset layouts to display title and/or introductory
    # information, when set.  Defaults to +nil+.
    # 
    attr_accessor :header
    #
    # Used by all the preset layouts to ask the actual question to fetch a
    # menu selection from the user.  Defaults to "?  ".
    # 
    attr_accessor :prompt
    #
    # An ERb _layout_ to use when displaying this Menu object.  See
    # Menu.layout=() for details.
    # 
    attr_reader   :layout
    #
    # When set to +true+, responses are allowed to be an entire line of
    # input, including details beyond the command itself.  Only the first
    # "word" of input will be matched against the menu choices, but both the
    # command selected and the rest of the line will be passed to provided
    # action blocks.  Defaults to +false+.
    # 
    attr_accessor :shell
    #
    # When +true+, any selected item handled by provided action code, will
    # return +nil+, instead of the results to the action code.  This may
    # prove handy when dealing with mixed menus where only the names of
    # items without any code (and +nil+, of course) will be returned.
    # Defaults to +false+.
    # 
    attr_accessor :nil_on_handled
    
    #
    # Adds _name_ to the list of available menu items.  Menu items will be
    # displayed in the order they are added.
    # 
    # An optional _action_ can be associated with this name and if provided,
    # it will be called if the item is selected.  The result of the method
    # will be returned, unless _nil_on_handled_ is set (when you would get
    # +nil+ instead).  In _shell_ mode, a provided block will be passed the
    # command chosen and any details that followed the command.  Otherwise,
    # just the command is passed.  The <tt>@highline</tt> variable is set to
    # the current HighLine context before the action code is called and can
    # thus be used for adding output and the like.
    # 
    def choice( name, help = nil, &action )
      @items << [name, action]
      
      @help[name.to_s.downcase] = help unless help.nil?
    end
    
    #
    # A shortcut for multiple calls to the sister method choice().  <b>Be
    # warned:</b>  An _action_ set here will apply to *all* provided
    # _names_.  This is considered to be a feature, so you can easily
    # hand-off interface processing to a different chunk of code.
    # 
    def choices( *names, &action )
      names.each { |n| choice(n, &action) }
    end

    # Identical to choice(), but the item will not be listed for the user.
    def hidden( name, help = nil, &action )
      @hidden_items << [name, action]
      
      @help[name.to_s.downcase] = help unless help.nil?
    end
    
    # 
    # Sets the indexing style for this Menu object.  Indexes are appended to
    # menu items, when displayed in list form.  The available settings are:
    # 
    # <tt>:number</tt>::   Menu items will be indexed numerically, starting
    #                      with 1.  This is the default method of indexing.
    # <tt>:letter</tt>::   Items will be indexed alphabetically, starting
    #                      with a.
    # <tt>:none</tt>::     No index will be appended to menu items.
    # <i>any String</i>::  Will be used as the literal _index_.
    # 
    # Setting the _index_ to <tt>:none</tt> a literal String, also adjusts
    # _index_suffix_ to a single space and _select_by_ to <tt>:none</tt>. 
    # Because of this, you should make a habit of setting the _index_ first.
    # 
    def index=( style )
      @index = style
      
      # Default settings.
      if @index == :none or @index.is_a?(String)
        @index_suffix = " "
        @select_by    = :name
      end
    end
    
    # 
    # Initializes the help system by adding a <tt>:help</tt> choice, some
    # action code, and the default help listing.
    # 
    def init_help(  )
      return if @items.include?(:help)
      
      topics    = @help.keys.sort
      help_help = @help.include?("help") ? @help["help"] :
                  "This command will display helpful messages about " +
                  "functionality, like this one.  To see the help for " +
                  "a specific topic enter:\n\thelp [TOPIC]\nTry asking " +
                  "for help on any of the following:\n\n" +
                  "<%= list(#{topics.inspect}, :columns_across) %>"
      choice(:help, help_help) do |command, topic|
        topic.strip!
        topic.downcase!
        if topic.empty?
          @highline.say(@help["help"])
        else
          @highline.say("= #{topic}\n\n#{@help[topic]}")
        end
      end
    end
    
    #
    # Used to set help for arbitrary topics.  Use the topic <tt>"help"</tt>
    # to override the default message.
    # 
    def help( topic, help )
      @help[topic] = help
    end
    
    # 
    # Setting a _layout_ with this method also adjusts some other attributes
    # of the Menu object, to ideal defaults for the chosen _layout_.  To
    # account for that, you probably want to set a _layout_ first in your
    # configuration block, if needed.
    # 
    # Accepted settings for _layout_ are:
    #
    # <tt>:list</tt>::         The default _layout_.  The _header_ if set
    #                          will appear at the top on its own line with
    #                          a trailing colon.  Then the list of menu
    #                          items will follow.  Finally, the _prompt_
    #                          will be used as the ask()-like question.
    # <tt>:one_line</tt>::     A shorter _layout_ that fits on one line.  
    #                          The _header_ comes first followed by a
    #                          colon and spaces, then the _prompt_ with menu
    #                          items between trailing parenthesis.
    # <tt>:menu_only</tt>::    Just the menu items, followed up by a likely
    #                          short _prompt_.
    # <i>any ERb String</i>::  Will be taken as the literal _layout_.  This
    #                          String can access <tt>@header</tt>, 
    #                          <tt>@menu</tt> and <tt>@prompt</tt>, but is
    #                          otherwise evaluated in the typical HighLine
    #                          context, to provide access to utilities like
    #                          HighLine.list() primarily.
    # 
    # If set to either <tt>:one_line</tt>, or <tt>:menu_only</tt>, _index_
    # will default to <tt>:none</tt> and _flow_ will default to
    # <tt>:inline</tt>.
    # 
    def layout=( new_layout )
      @layout = new_layout
      
      # Default settings.
      case @layout
      when :one_line, :menu_only
        self.index = :none
        @flow  = :inline
      end
    end

    #
    # This method returns all possible options for auto-completion, based
    # on the settings of _index_ and _select_by_.
    # 
    def options(  )
      # add in any hidden menu commands
      @items.concat(@hidden_items)
      
      by_index = if @index == :letter
        l_index = "`"
        @items.map { "#{l_index.succ!}" }
      else
        (1 .. @items.size).collect { |s| String(s) }
      end
      by_name = @items.collect { |c| c.first }

      case @select_by
      when :index then
        by_index
      when :name
        by_name
      else
        by_index + by_name
      end
    ensure
      # make sure the hidden items are removed, before we return
      @items.slice!(@items.size - @hidden_items.size, @hidden_items.size)
    end

    #
    # This method processes the auto-completed user selection, based on the
    # rules for this Menu object.  If an action was provided for the 
    # selection, it will be executed as described in Menu.choice().
    # 
    def select( highline_context, selection, details = nil )
      # add in any hidden menu commands
      @items.concat(@hidden_items)
      
      # Find the selected action.
      name, action = if selection =~ /^\d+$/
        @items[selection.to_i - 1]
      else
        l_index = "`"
        index = @items.map { "#{l_index.succ!}" }.index(selection)
        @items.find { |c| c.first == selection } or @items[index]
      end
      
      # Run or return it.
      if not @nil_on_handled and not action.nil?
        @highline = highline_context
        if @shell
          action.call(name, details)
        else
          action.call(name)
        end
      elsif action.nil?
        name
      else
        nil
      end
    ensure
      # make sure the hidden items are removed, before we return
      @items.slice!(@items.size - @hidden_items.size, @hidden_items.size)
    end
    
    #
    # Allows Menu objects to pass as Arrays, for use with HighLine.list().
    # This method returns all menu items to be displayed, complete with
    # indexes.
    # 
    def to_ary(  )
      case @index
      when :number
        @items.map { |c| "#{@items.index(c) + 1}#{@index_suffix}#{c.first}" }
      when :letter
        l_index = "`"
        @items.map { |c| "#{l_index.succ!}#{@index_suffix}#{c.first}" }
      when :none
        @items.map { |c| "#{c.first}" }
      else
        @items.map { |c| "#{index}#{@index_suffix}#{c.first}" }
      end
    end
    
    #
    # Allows Menu to behave as a String, just like Question.  Returns the
    # _layout_ to be rendered, which is used by HighLine.say().
    # 
    def to_str(  )
      case @layout
      when :list
        '<%= if @header.nil? then '' else "#{@header}:\n" end %>' +
        "<%= list( @menu, #{@flow.inspect},
                          #{@list_option.inspect} ) %>" +
        "<%= @prompt %>"
      when :one_line
        '<%= if @header.nil? then '' else "#{@header}:  " end %>' +
        "<%= @prompt %>" +
        "(<%= list( @menu, #{@flow.inspect},
                           #{@list_option.inspect} ) %>)" +
        "<%= @prompt[/\s*$/] %>"
      when :menu_only
        "<%= list( @menu, #{@flow.inspect},
                          #{@list_option.inspect} ) %><%= @prompt %>"
      else
        @layout
      end
    end      

    #
    # This method will update the intelligent responses to account for
    # Menu specific differences.  This overrides the work done by 
    # Question.build_responses().
    # 
    def update_responses(  )
      append_default unless default.nil?
      @responses = { :ambiguous_completion =>
                       "Ambiguous choice.  " +
                       "Please choose one of #{options.inspect}.",
                     :ask_on_error         =>
                       "?  ",
                     :invalid_type         =>
                       "You must enter a valid #{options}.",
                     :no_completion        =>
                       "You must choose one of " +
                       "#{options.inspect}.",
                     :not_in_range         =>
                       "Your answer isn't within the expected range " +
                       "(#{expected_range}).",
                     :not_valid            =>
                       "Your answer isn't valid (must match " +
                       "#{@validate.inspect})." }.merge(@responses)
    end
  end
end
