#!/usr/local/bin/ruby -w
# highline.rb
#
# Created by James Edward Gray II on 2005-04-26.
# Copyright 2005 Gray Productions. All rights reserved.
#
# See HighLine for documentation.
#
# This is Free Software. See LICENSE and COPYING for details.
require "erb"
require "optparse"
require "stringio"
require "abbrev"
require "highline/compatibility"
require "highline/system_extensions"
require "highline/question"
require "highline/menu"
require "highline/color_scheme"
#
# A HighLine object is a "high-level line oriented" shell over an input and an
# output stream. HighLine simplifies common console interaction, effectively
# replacing puts() and gets(). User code can simply specify the question to ask
# and any details about user interaction, then leave the rest of the work to
# HighLine. When HighLine.ask() returns, you'll have the answer you requested,
# even if HighLine had to ask many times, validate results, perform range
# checking, convert types, etc.
#
class HighLine
# The version of the installed library.
VERSION = "1.5.1".freeze
# An internal HighLine error. User code does not need to trap this.
class QuestionError < StandardError
# do nothing, just creating a unique error type
end
# The setting used to disable color output.
@@use_color = true
# Pass +false+ to _setting_ to turn off HighLine's color escapes.
def self.use_color=( setting )
@@use_color = setting
end
# Returns true if HighLine is currently using color escapes.
def self.use_color?
@@use_color
end
# The setting used to disable EOF tracking.
@@track_eof = true
# Pass +false+ to _setting_ to turn off HighLine's EOF tracking.
def self.track_eof=( setting )
@@track_eof = setting
end
# Returns true if HighLine is currently tracking EOF for input.
def self.track_eof?
@@track_eof
end
# The setting used to control color schemes.
@@color_scheme = nil
# Pass ColorScheme to _setting_ to turn set a HighLine color scheme.
def self.color_scheme=( setting )
@@color_scheme = setting
end
# Returns the current color scheme.
def self.color_scheme
@@color_scheme
end
# Returns +true+ if HighLine is currently using a color scheme.
def self.using_color_scheme?
not @@color_scheme.nil?
end
#
# Embed in a String to clear all previous ANSI sequences. This *MUST* be
# done before the program exits!
#
CLEAR = "\e[0m"
# An alias for CLEAR.
RESET = CLEAR
# Erase the current line of terminal output.
ERASE_LINE = "\e[K"
# Erase the character under the cursor.
ERASE_CHAR = "\e[P"
# The start of an ANSI bold sequence.
BOLD = "\e[1m"
# The start of an ANSI dark sequence. (Terminal support uncommon.)
DARK = "\e[2m"
# The start of an ANSI underline sequence.
UNDERLINE = "\e[4m"
# An alias for UNDERLINE.
UNDERSCORE = UNDERLINE
# The start of an ANSI blink sequence. (Terminal support uncommon.)
BLINK = "\e[5m"
# The start of an ANSI reverse sequence.
REVERSE = "\e[7m"
# The start of an ANSI concealed sequence. (Terminal support uncommon.)
CONCEALED = "\e[8m"
# Set the terminal's foreground ANSI color to black.
BLACK = "\e[30m"
# Set the terminal's foreground ANSI color to red.
RED = "\e[31m"
# Set the terminal's foreground ANSI color to green.
GREEN = "\e[32m"
# Set the terminal's foreground ANSI color to yellow.
YELLOW = "\e[33m"
# Set the terminal's foreground ANSI color to blue.
BLUE = "\e[34m"
# Set the terminal's foreground ANSI color to magenta.
MAGENTA = "\e[35m"
# Set the terminal's foreground ANSI color to cyan.
CYAN = "\e[36m"
# Set the terminal's foreground ANSI color to white.
WHITE = "\e[37m"
# Set the terminal's background ANSI color to black.
ON_BLACK = "\e[40m"
# Set the terminal's background ANSI color to red.
ON_RED = "\e[41m"
# Set the terminal's background ANSI color to green.
ON_GREEN = "\e[42m"
# Set the terminal's background ANSI color to yellow.
ON_YELLOW = "\e[43m"
# Set the terminal's background ANSI color to blue.
ON_BLUE = "\e[44m"
# Set the terminal's background ANSI color to magenta.
ON_MAGENTA = "\e[45m"
# Set the terminal's background ANSI color to cyan.
ON_CYAN = "\e[46m"
# Set the terminal's background ANSI color to white.
ON_WHITE = "\e[47m"
#
# Create an instance of HighLine, connected to the streams _input_
# and _output_.
#
def initialize( input = $stdin, output = $stdout,
wrap_at = nil, page_at = nil )
@input = input
@output = output
self.wrap_at = wrap_at
self.page_at = page_at
@question = nil
@answer = nil
@menu = nil
@header = nil
@prompt = nil
@gather = nil
@answers = nil
@key = nil
end
include HighLine::SystemExtensions
# The current column setting for wrapping output.
attr_reader :wrap_at
# The current row setting for paging output.
attr_reader :page_at
#
# A shortcut to HighLine.ask() a question that only accepts "yes" or "no"
# answers ("y" and "n" are allowed) and returns +true+ or +false+
# (+true+ for "yes"). If provided a +true+ value, _character_ will cause
# HighLine to fetch a single character response. A block can be provided
# to further configure the question as in HighLine.ask()
#
# Raises EOFError if input is exhausted.
#
def agree( yes_or_no_question, character = nil )
ask(yes_or_no_question, lambda { |yn| yn.downcase[0] == ?y}) do |q|
q.validate = /\Ay(?:es)?|no?\Z/i
q.responses[:not_valid] = 'Please enter "yes" or "no".'
q.responses[:ask_on_error] = :question
q.character = character
yield q if block_given?
end
end
#
# This method is the primary interface for user input. Just provide a
# _question_ to ask the user, the _answer_type_ you want returned, and
# optionally a code block setting up details of how you want the question
# handled. See HighLine.say() for details on the format of _question_, and
# HighLine::Question for more information about _answer_type_ and what's
# valid in the code block.
#
# If @question is set before ask() is called, parameters are
# ignored and that object (must be a HighLine::Question) is used to drive
# the process instead.
#
# Raises EOFError if input is exhausted.
#
def ask( question, answer_type = String, &details ) # :yields: question
@question ||= Question.new(question, answer_type, &details)
return gather if @question.gather
# readline() needs to handle it's own output, but readline only supports
# full line reading. Therefore if @question.echo is anything but true,
# the prompt will not be issued. And we have to account for that now.
say(@question) unless (@question.readline and @question.echo == true)
begin
@answer = @question.answer_or_default(get_response)
unless @question.valid_answer?(@answer)
explain_error(:not_valid)
raise QuestionError
end
@answer = @question.convert(@answer)
if @question.in_range?(@answer)
if @question.confirm
# need to add a layer of scope to ask a question inside a
# question, without destroying instance data
context_change = self.class.new(@input, @output, @wrap_at, @page_at)
if @question.confirm == true
confirm_question = "Are you sure? "
else
# evaluate ERb under initial scope, so it will have
# access to @question and @answer
template = ERB.new(@question.confirm, nil, "%")
confirm_question = template.result(binding)
end
unless context_change.agree(confirm_question)
explain_error(nil)
raise QuestionError
end
end
@answer
else
explain_error(:not_in_range)
raise QuestionError
end
rescue QuestionError
retry
rescue ArgumentError, NameError => error
raise if error.is_a?(NoMethodError)
if error.message =~ /ambiguous/
# the assumption here is that OptionParser::Completion#complete
# (used for ambiguity resolution) throws exceptions containing
# the word 'ambiguous' whenever resolution fails
explain_error(:ambiguous_completion)
else
explain_error(:invalid_type)
end
retry
rescue Question::NoAutoCompleteMatch
explain_error(:no_completion)
retry
ensure
@question = nil # Reset Question object.
end
end
#
# This method is HighLine's menu handler. For simple usage, you can just
# pass all the menu items you wish to display. At that point, choose() will
# build and display a menu, walk the user through selection, and return
# their choice amoung the provided items. You might use this in a case
# statement for quick and dirty menus.
#
# However, choose() is capable of much more. If provided, a block will be
# passed a HighLine::Menu object to configure. Using this method, you can
# customize all the details of menu handling from index display, to building
# a complete shell-like menuing system. See HighLine::Menu for all the
# methods it responds to.
#
# Raises EOFError if input is exhausted.
#
def choose( *items, &details )
@menu = @question = Menu.new(&details)
@menu.choices(*items) unless items.empty?
# Set _answer_type_ so we can double as the Question for ask().
@menu.answer_type = if @menu.shell
lambda do |command| # shell-style selection
first_word = command.to_s.split.first || ""
options = @menu.options
options.extend(OptionParser::Completion)
answer = options.complete(first_word)
if answer.nil?
raise Question::NoAutoCompleteMatch
end
[answer.last, command.sub(/^\s*#{first_word}\s*/, "")]
end
else
@menu.options # normal menu selection, by index or name
end
# Provide hooks for ERb layouts.
@header = @menu.header
@prompt = @menu.prompt
if @menu.shell
selected = ask("Ignored", @menu.answer_type)
@menu.select(self, *selected)
else
selected = ask("Ignored", @menu.answer_type)
@menu.select(self, selected)
end
end
#
# This method provides easy access to ANSI color sequences, without the user
# needing to remember to CLEAR at the end of each sequence. Just pass the
# _string_ to color, followed by a list of _colors_ you would like it to be
# affected by. The _colors_ can be HighLine class constants, or symbols
# (:blue for BLUE, for example). A CLEAR will automatically be embedded to
# the end of the returned String.
#
# This method returns the original _string_ unchanged if HighLine::use_color?
# is +false+.
#
def color( string, *colors )
return string unless self.class.use_color?
colors.map! do |c|
if self.class.using_color_scheme? and self.class.color_scheme.include? c
self.class.color_scheme[c]
elsif c.is_a? Symbol
self.class.const_get(c.to_s.upcase)
else
c
end
end
"#{colors.flatten.join}#{string}#{CLEAR}"
end
#
# This method is a utility for quickly and easily laying out lists. It can
# be accessed within ERb replacements of any text that will be sent to the
# user.
#
# The only required parameter is _items_, which should be the Array of items
# to list. A specified _mode_ controls how that list is formed and _option_
# has different effects, depending on the _mode_. Recognized modes are:
#
# :columns_across:: _items_ will be placed in columns, flowing
# from left to right. If given, _option_ is the
# number of columns to be used. When absent,
# columns will be determined based on _wrap_at_
# or a default of 80 characters.
# :columns_down:: Identical to :columns_across, save
# flow goes down.
# :inline:: All _items_ are placed on a single line. The
# last two _items_ are separated by _option_ or
# a default of " or ". All other _items_ are
# separated by ", ".
# :rows:: The default mode. Each of the _items_ is
# placed on it's own line. The _option_
# parameter is ignored in this mode.
#
# Each member of the _items_ Array is passed through ERb and thus can contain
# their own expansions. Color escape expansions do not contribute to the
# final field width.
#
def list( items, mode = :rows, option = nil )
items = items.to_ary.map do |item|
ERB.new(item, nil, "%").result(binding)
end
case mode
when :inline
option = " or " if option.nil?
case items.size
when 0
""
when 1
items.first
when 2
"#{items.first}#{option}#{items.last}"
else
items[0..-2].join(", ") + "#{option}#{items.last}"
end
when :columns_across, :columns_down
max_length = actual_length(
items.max { |a, b| actual_length(a) <=> actual_length(b) }
)
if option.nil?
limit = @wrap_at || 80
option = (limit + 2) / (max_length + 2)
end
items = items.map do |item|
pad = max_length + (item.length - actual_length(item))
"%-#{pad}s" % item
end
row_count = (items.size / option.to_f).ceil
if mode == :columns_across
rows = Array.new(row_count) { Array.new }
items.each_with_index do |item, index|
rows[index / option] << item
end
rows.map { |row| row.join(" ") + "\n" }.join
else
columns = Array.new(option) { Array.new }
items.each_with_index do |item, index|
columns[index / row_count] << item
end
list = ""
columns.first.size.times do |index|
list << columns.map { |column| column[index] }.
compact.join(" ") + "\n"
end
list
end
else
items.map { |i| "#{i}\n" }.join
end
end
#
# The basic output method for HighLine objects. If the provided _statement_
# ends with a space or tab character, a newline will not be appended (output
# will be flush()ed). All other cases are passed straight to Kernel.puts().
#
# The _statement_ parameter is processed as an ERb template, supporting
# embedded Ruby code. The template is evaluated with a binding inside
# the HighLine instance, providing easy access to the ANSI color constants
# and the HighLine.color() method.
#
def say( statement )
statement = statement.to_str
return unless statement.length > 0
template = ERB.new(statement, nil, "%")
statement = template.result(binding)
statement = wrap(statement) unless @wrap_at.nil?
statement = page_print(statement) unless @page_at.nil?
if statement[-1, 1] == " " or statement[-1, 1] == "\t"
@output.print(statement)
@output.flush
else
@output.puts(statement)
end
end
#
# Set to an integer value to cause HighLine to wrap output lines at the
# indicated character limit. When +nil+, the default, no wrapping occurs. If
# set to :auto, HighLine will attempt to determing the columns
# available for the @output or use a sensible default.
#
def wrap_at=( setting )
@wrap_at = setting == :auto ? output_cols : setting
end
#
# Set to an integer value to cause HighLine to page output lines over the
# indicated line limit. When +nil+, the default, no paging occurs. If
# set to :auto, HighLine will attempt to determing the rows available
# for the @output or use a sensible default.
#
def page_at=( setting )
@page_at = setting == :auto ? output_rows : setting
end
#
# Returns the number of columns for the console, or a default it they cannot
# be determined.
#
def output_cols
return 80 unless @output.tty?
terminal_size.first
rescue
return 80
end
#
# Returns the number of rows for the console, or a default if they cannot be
# determined.
#
def output_rows
return 24 unless @output.tty?
terminal_size.last
rescue
return 24
end
private
#
# A helper method for sending the output stream and error and repeat
# of the question.
#
def explain_error( error )
say(@question.responses[error]) unless error.nil?
if @question.responses[:ask_on_error] == :question
say(@question)
elsif @question.responses[:ask_on_error]
say(@question.responses[:ask_on_error])
end
end
#
# Collects an Array/Hash full of answers as described in
# HighLine::Question.gather().
#
# Raises EOFError if input is exhausted.
#
def gather( )
@gather = @question.gather
@answers = [ ]
original_question = @question
@question.gather = false
case @gather
when Integer
@answers << ask(@question)
@gather -= 1
original_question.question = ""
until @gather.zero?
@question = original_question
@answers << ask(@question)
@gather -= 1
end
when String, Regexp
@answers << ask(@question)
original_question.question = ""
until (@gather.is_a?(String) and @answers.last.to_s == @gather) or
(@gather.is_a?(Regexp) and @answers.last.to_s =~ @gather)
@question = original_question
@answers << ask(@question)
end
@answers.pop
when Hash
@answers = { }
@gather.keys.sort.each do |key|
@question = original_question
@key = key
@answers[key] = ask(@question)
end
end
@answers
end
#
# Read a line of input from the input stream and process whitespace as
# requested by the Question object.
#
# If Question's _readline_ property is set, that library will be used to
# fetch input. *WARNING*: This ignores the currently set input stream.
#
# Raises EOFError if input is exhausted.
#
def get_line( )
if @question.readline
require "readline" # load only if needed
# capture say()'s work in a String to feed to readline()
old_output = @output
@output = StringIO.new
say(@question)
question = @output.string
@output = old_output
# prep auto-completion
Readline.completion_proc = lambda do |string|
@question.selection.grep(/\A#{Regexp.escape(string)}/)
end
# work-around ugly readline() warnings
old_verbose = $VERBOSE
$VERBOSE = nil
answer = @question.change_case(
@question.remove_whitespace(
Readline.readline(question, true) ) )
$VERBOSE = old_verbose
answer
else
raise EOFError, "The input stream is exhausted." if @@track_eof and
@input.eof?
@question.change_case(@question.remove_whitespace(@input.gets))
end
end
#
# Return a line or character of input, as requested for this question.
# Character input will be returned as a single character String,
# not an Integer.
#
# This question's _first_answer_ will be returned instead of input, if set.
#
# Raises EOFError if input is exhausted.
#
def get_response( )
return @question.first_answer if @question.first_answer?
if @question.character.nil?
if @question.echo == true and @question.limit.nil?
get_line
else
raw_no_echo_mode if stty = CHARACTER_MODE == "stty"
line = ""
backspace_limit = 0
begin
while character = (stty ? @input.getbyte : get_character(@input))
# honor backspace and delete
if character == 127 or character == 8
line.slice!(-1, 1)
backspace_limit -= 1
else
line << character.chr
backspace_limit = line.size
end
# looking for carriage return (decimal 13) or
# newline (decimal 10) in raw input
break if character == 13 or character == 10 or
(@question.limit and line.size == @question.limit)
if @question.echo != false
if character == 127 or character == 8
# only backspace if we have characters on the line to
# eliminate, otherwise we'll tromp over the prompt
if backspace_limit >= 0 then
@output.print("\b#{ERASE_CHAR}")
else
# do nothing
end
else
if @question.echo == true
@output.print(character.chr)
else
@output.print(@question.echo)
end
end
@output.flush
end
end
ensure
restore_mode if stty
end
if @question.overwrite
@output.print("\r#{ERASE_LINE}")
@output.flush
else
say("\n")
end
@question.change_case(@question.remove_whitespace(line))
end
elsif @question.character == :getc
@question.change_case(@input.getbyte.chr)
else
response = get_character(@input).chr
if @question.overwrite
@output.print("\r#{ERASE_LINE}")
@output.flush
else
echo = if @question.echo == true
response
elsif @question.echo != false
@question.echo
else
""
end
say("#{echo}\n")
end
@question.change_case(response)
end
end
#
# Page print a series of at most _page_at_ lines for _output_. After each
# page is printed, HighLine will pause until the user presses enter/return
# then display the next page of data.
#
# Note that the final page of _output_ is *not* printed, but returned
# instead. This is to support any special handling for the final sequence.
#
def page_print( output )
lines = output.scan(/[^\n]*\n?/)
while lines.size > @page_at
@output.puts lines.slice!(0...@page_at).join
@output.puts
# Return last line if user wants to abort paging
return (["...\n"] + lines.slice(-2,1)).join unless continue_paging?
end
return lines.join
end
#
# Ask user if they wish to continue paging output. Allows them to type "q" to
# cancel the paging process.
#
def continue_paging?
command = HighLine.new(@input, @output).ask(
"-- press enter/return to continue or q to stop -- "
) { |q| q.character = true }
command !~ /\A[qQ]\Z/ # Only continue paging if Q was not hit.
end
#
# Wrap a sequence of _lines_ at _wrap_at_ characters per line. Existing
# newlines will not be affected by this process, but additional newlines
# may be added.
#
def wrap( text )
wrapped = [ ]
text.each_line do |line|
while line =~ /([^\n]{#{@wrap_at + 1},})/
search = $1.dup
replace = $1.dup
if index = replace.rindex(" ", @wrap_at)
replace[index, 1] = "\n"
replace.sub!(/\n[ \t]+/, "\n")
line.sub!(search, replace)
else
line[@wrap_at, 0] = "\n"
end
end
wrapped << line
end
return wrapped.join
end
#
# Returns the length of the passed +string_with_escapes+, minus and color
# sequence escapes.
#
def actual_length( string_with_escapes )
string_with_escapes.gsub(/\e\[\d{1,2}m/, "").length
end
end