# coding: utf-8
#--
# 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"
require "highline/menu/item"
class HighLine
#
# Menu objects encapsulate all the details of a call to
# {HighLine#choose 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
# Pass +false+ to _color_ to turn off HighLine::Menu's
# index coloring.
# Pass a color and the Menu's indices will be colored.
class << self
attr_writer :index_color
end
# Initialize it
self.index_color = false
# Returns color used for coloring Menu's indices
class << self
attr_reader :index_color
end
#
# Create an instance of HighLine::Menu. All customization is done
# through the passed block, which should call accessors, {#choice} and
# {#choices} as needed to define the Menu. Note that Menus are also
# {HighLine::Question Questions}, so all that functionality is available
# to the block as well.
#
# @example Implicit menu creation through HighLine#choose
# cli = HighLine.new
# answer = cli.choose do |menu|
# menu.prompt = "Please choose your favorite programming language? "
# menu.choice(:ruby) { say("Good choice!") }
# menu.choices(:python, :perl) { say("Not from around here, are you?") }
# end
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
# Used for coloring Menu indices.
# Set it to default. But you may override it.
@index_color = self.class.index_color
# Override Questions responses, we'll set our own.
@responses = {}
# Context for action code.
@highline = nil
yield self if block_given?
init_help if @shell && !@help.empty?
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:
#
# :index:: The user is allowed to type the numerical
# or alphabetical index for their selection.
# :index_or_name:: Allows both methods from the
# :index option and the
# :name option.
# :name:: 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
#
# The color of the index when displaying the menu. See Style class for
# available colors.
#
attr_accessor :index_color
#
# 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 @highline 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.
#
# @param name [#to_s] menu item title/header/name to be displayed.
# @param action [Proc] callback action to be run when the item is selected.
# @param help [String] help/hint string to be displayed.
# @return [void]
# @example (see HighLine::Menu#initialize)
# @example Use of help string on menu items
# cli = HighLine.new
# cli.choose do |menu|
# menu.shell = true
#
# menu.choice(:load, text: 'Load a file',
# help: "Load a file using your favourite editor.")
# menu.choice(:save, help: "Save data in file.")
# menu.choice(:quit, help: "Exit program.")
#
# menu.help("rules", "The rules of this system are as follows...")
# end
def choice(name, help = nil, text = nil, &action)
item = Menu::Item.new(name, text: text, help: help, action: action)
@items << item
@help.merge!(item.item_help)
update_responses # rebuild responses based on our settings
end
#
# This method helps reduce the namespaces in the original call,
# which would look like this: HighLine::Menu::Item.new(...)
# With #build_item, it looks like this: menu.build_item(...)
# @param *args splat args, the same args you would pass to an
# initialization of HighLine::Menu::Item
# @return [HighLine::Menu::Item] the menu item
def build_item(*args)
Menu::Item.new(*args)
end
#
# Adds an item directly to the menu. If you want more configuration
# or options, use this method
#
# @param item [Menu::Item] item containing choice fields and more
# @return [void]
def add_item(item)
@items << item
@help.merge!(item.item_help)
update_responses
end
#
# A shortcut for multiple calls to the sister method {#choice}. Be
# warned: 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.
# @param names [Array<#to_s>] menu item titles/headers/names to be
# displayed.
# @param action (see #choice)
# @return [void]
# @example (see HighLine::Menu#initialize)
#
# choice has more options available to you, like longer text or help (and
# of course, individual actions)
#
def choices(*names, &action)
names.each { |n| choice(n, &action) }
end
# Identical to {#choice}, but the item will not be listed for the user.
# @see #choice
# @param name (see #choice)
# @param help (see #choice)
# @param action (see #choice)
# @return (see #choice)
def hidden(name, help = nil, &action)
item = Menu::Item.new(name, text: name, help: help, action: action)
@hidden_items << item
@help.merge!(item.item_help)
end
#
# Sets the indexing style for this Menu object. Indexes are appended to
# menu items, when displayed in list form. The available settings are:
#
# :number:: Menu items will be indexed numerically, starting
# with 1. This is the default method of indexing.
# :letter:: Items will be indexed alphabetically, starting
# with a.
# :none:: No index will be appended to menu items.
# any String:: Will be used as the literal _index_.
#
# Setting the _index_ to :none or a literal String also adjusts
# _index_suffix_ to a single space and _select_by_ to :name.
# Because of this, you should make a habit of setting the _index_ first.
#
def index=(style)
@index = style
return unless @index == :none || @index.is_a?(::String)
# Default settings.
@index_suffix = " "
@select_by = :name
end
#
# Initializes the help system by adding a :help choice, some
# action code, and the default help listing.
#
def init_help
return if @items.include?(:help)
topics = @help.keys.sort
help_help =
if @help.include?("help")
@help["help"]
else
"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) %>"
end
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 "help"
# to override the default message. Mainly for internal use.
#
# @param topic [String] the menu item header/title/name to be associated
# with a help message.
# @param help [String] the help message to be associated with the menu
# item/title/name.
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:
#
# :list:: 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.
# :one_line:: 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.
# :menu_only:: Just the menu items, followed up by a likely
# short _prompt_.
# any ERb String:: Will be taken as the literal _layout_. This
# String can access header,
# menu and prompt, but is
# otherwise evaluated in the TemplateRenderer
# context so each method is properly delegated.
#
# If set to either :one_line, or :menu_only, _index_
# will default to :none and _flow_ will default to
# :inline.
#
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
case @select_by
when :index
map_items_by_index
when :name
map_items_by_name
else
map_items_by_index + map_items_by_name
end
end
def map_items_by_index
if @index == :letter
l_index = "`"
all_items.map { l_index.succ!.dup }
else
(1..all_items.size).map(&:to_s)
end
end
def map_items_by_name
all_items.map(&:name)
end
def all_items
@items + @hidden_items
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 {#choice}.
#
# @param highline_context [HighLine] a HighLine instance to be used
# as context.
# @param selection [String, Integer] index or title of the selected
# menu item.
# @param details additional parameter to be passed when in shell mode.
# @return [nil, Object] if @nil_on_handled is set it returns +nil+,
# else it returns the action return value.
def select(highline_context, selection, details = nil)
# add in any hidden menu commands
items = all_items
# Find the selected action.
selected_item = find_item_from_selection(items, selection)
# Run or return it.
@highline = highline_context
value_for_selected_item(selected_item, details)
end
def find_item_from_selection(items, selection)
if selection =~ /^\d+$/ # is a number?
get_item_by_number(items, selection)
else
get_item_by_letter(items, selection)
end
end
# Returns the menu item referenced by its index
# @param selection [Integer] menu item's index.
def get_item_by_number(items, selection)
items[selection.to_i - 1]
end
# Returns the menu item referenced by its title/header/name.
# @param selection [String] menu's title/header/name
def get_item_by_letter(items, selection)
item = items.find { |i| i.name == selection }
return item if item
# 97 is the "a" letter at ascii table
# Ex: For "a" it will return 0, and for "c" it will return 2
index = selection.ord - 97
items[index]
end
def value_for_selected_item(item, details)
if item.action
result = if @shell
item.action.call(item.name, details)
else
item.action.call(item.name)
end
@nil_on_handled ? nil : result
else
item.name
end
end
def gather_selected(highline_context, selections, details = nil)
@highline = highline_context
# add in any hidden menu commands
items = all_items
if selections.is_a?(Array)
value_for_array_selections(items, selections, details)
elsif selections.is_a?(Hash)
value_for_hash_selections(items, selections, details)
else
raise ArgumentError, "selections must be either Array or Hash"
end
end
def value_for_array_selections(items, selections, details)
# Find the selected items and return values
selected_items = selections.map do |selection|
find_item_from_selection(items, selection)
end
selected_items.map do |selected_item|
value_for_selected_item(selected_item, details)
end
end
def value_for_hash_selections(items, selections, details)
# Find the selected items and return in hash form
selections.each_with_object({}) do |(key, selection), memo|
selected_item = find_item_from_selection(items, selection)
memo[key] = value_for_selected_item(selected_item, details)
end
end
def decorate_index(index)
if index_color
HighLine.color(index, index_color)
else
index
end
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
@items.map.with_index { |item, ix| decorate_item(item.text.to_s, ix) }
end
def decorate_item(text, ix)
decorated, non_decorated = mark_for_decoration(text, ix)
decorate_index(decorated) + non_decorated
end
def mark_for_decoration(text, ix)
case @index
when :number
["#{ix + 1}#{@index_suffix}", text]
when :letter
["#{('a'.ord + ix).chr}#{@index_suffix}", text]
when :none
[text, ""]
else
["#{index}#{@index_suffix}", text]
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_s
case @layout
when :list
%(<%= header ? "#{header}:\n" : '' %>) +
parse_list +
show_default_if_any +
"<%= prompt %>"
when :one_line
%(<%= header ? "#{header}: " : '' %>) +
"<%= prompt %>" \
"(" + parse_list + ")" +
show_default_if_any +
"<%= prompt[/\s*$/] %>"
when :menu_only
parse_list +
show_default_if_any +
"<%= prompt %>"
else
@layout
end
end
def parse_list
"<%= list( menu, #{@flow.inspect},
#{@list_option.inspect} ) %>"
end
def show_default_if_any
default.to_s.empty? ? "" : "(#{default}) "
end
#
# This method will update the intelligent responses to account for
# Menu specific differences. Calls the superclass' (Question's)
# build_responses method, overriding its default arguments to specify
# 'options' will be used to populate choice lists.
#
def update_responses
build_responses(options)
end
end
end