#!/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