lib/tty/prompt.rb
# frozen_string_literal: true
require "forwardable"
require "pastel"
require "tty-cursor"
require "tty-reader"
require "tty-screen"
require_relative "prompt/answers_collector"
require_relative "prompt/confirm_question"
require_relative "prompt/errors"
require_relative "prompt/expander"
require_relative "prompt/enum_list"
require_relative "prompt/keypress"
require_relative "prompt/list"
require_relative "prompt/multi_list"
require_relative "prompt/multiline"
require_relative "prompt/mask_question"
require_relative "prompt/question"
require_relative "prompt/slider"
require_relative "prompt/statement"
require_relative "prompt/suggestion"
require_relative "prompt/symbols"
require_relative "prompt/utils"
require_relative "prompt/version"
module TTY
# A main entry for asking prompt questions.
class Prompt
extend Forwardable
# @api private
attr_reader :input
# @api private
attr_reader :output
attr_reader :reader
attr_reader :cursor
# Prompt prefix
#
# @example
# prompt = TTY::Prompt.new(prefix: [?])
#
# @return [String]
#
# @api private
attr_reader :prefix
# Theme colors
#
# @api private
attr_reader :active_color, :help_color, :error_color, :enabled_color
# Quiet mode
#
# @api private
attr_reader :quiet
# The collection of display symbols
#
# @example
# prompt = TTY::Prompt.new(symbols: {marker: ">"})
#
# @return [Hash]
#
# @api private
attr_reader :symbols
def_delegators :@pastel, :strip
def_delegators :@cursor, :clear_lines, :clear_line,
:show, :hide
def_delegators :@reader, :read_char, :read_keypress, :read_line,
:read_multiline, :on, :subscribe, :unsubscribe, :trigger,
:count_screen_lines
def_delegators :@output, :print, :puts, :flush
def self.messages
{
range?: "Value %{value} must be within the range %{in}",
valid?: "Your answer is invalid (must match %{valid})",
required?: "Value must be provided",
convert?: "Cannot convert `%{value}` to '%{type}' type"
}
end
# Initialize a Prompt
#
# @param [IO] :input
# the input stream
# @param [IO] :output
# the output stream
# @param [Hash] :env
# the environment variables
# @param [Hash] :symbols
# the symbols displayed in prompts such as :marker, :cross
# @param options [Boolean] :quiet
# enable quiet mode, don't re-echo the question
# @param [String] :prefix
# the prompt prefix, by default empty
# @param [Symbol] :interrupt
# handling of Ctrl+C key out of :signal, :exit, :noop
# @param [Boolean] :track_history
# disable line history tracking, true by default
# @param [Boolean] :enable_color
# enable color support, true by default
# @param [String,Proc] :active_color
# the color used for selected option
# @param [String,Proc] :help_color
# the color used for help text
# @param [String] :error_color
# the color used for displaying error messages
#
# @api public
def initialize(input: $stdin, output: $stdout, env: ENV, symbols: {},
prefix: "", interrupt: :error, track_history: true,
quiet: false, enable_color: nil, active_color: :green,
help_color: :bright_black, error_color: :red)
@input = input
@output = output
@env = env
@prefix = prefix
@enabled_color = enable_color
@active_color = active_color
@help_color = help_color
@error_color = error_color
@interrupt = interrupt
@track_history = track_history
@symbols = Symbols.symbols.merge(symbols)
@quiet = quiet
@cursor = TTY::Cursor
@pastel = enabled_color.nil? ? Pastel.new : Pastel.new(enabled: enabled_color)
@reader = TTY::Reader.new(
input: input,
output: output,
interrupt: interrupt,
track_history: track_history,
env: env
)
end
# Decorate a string with colors
#
# @param [String] :string
# the string to color
# @param [Array<Proc|Symbol>] :colors
# collection of color symbols or callable object
#
# @api public
def decorate(string, *colors)
if Utils.blank?(string) || @enabled_color == false || colors.empty?
return string
end
coloring = colors.first
if coloring.respond_to?(:call)
coloring.call(string)
else
@pastel.decorate(string, *colors)
end
end
# Invoke a question type of prompt
#
# @example
# prompt = TTY::Prompt.new
# prompt.invoke_question(Question, "Your name? ")
#
# @return [String]
#
# @api public
def invoke_question(object, message, **options, &block)
options[:messages] = self.class.messages
question = object.new(self, **options)
question.(message, &block)
end
# Ask a question.
#
# @example
# propmt = TTY::Prompt.new
# prompt.ask("What is your name?")
#
# @param [String] message
# the question to be asked
#
# @yieldparam [TTY::Prompt::Question] question
# further configure the question
#
# @yield [question]
#
# @return [TTY::Prompt::Question]
#
# @api public
def ask(message = "", **options, &block)
invoke_question(Question, message, **options, &block)
end
# Ask a question with a keypress answer
#
# @see #ask
#
# @api public
def keypress(message = "", **options, &block)
invoke_question(Keypress, message, **options, &block)
end
# Ask a question with a multiline answer
#
# @example
# prompt.multiline("Description?")
#
# @return [Array[String]]
#
# @api public
def multiline(message = "", **options, &block)
invoke_question(Multiline, message, **options, &block)
end
# Invoke a list type of prompt
#
# @example
# prompt = TTY::Prompt.new
# editors = %w(emacs nano vim)
# prompt.invoke_select(EnumList, "Select editor: ", editors)
#
# @return [String]
#
# @api public
def invoke_select(object, question, *args, &block)
options = Utils.extract_options!(args)
choices = if args.empty? && !block
possible = options.dup
options = {}
possible
elsif args.size == 1 && args[0].is_a?(Hash)
Utils.extract_options!(args)
else
args.flatten
end
list = object.new(self, **options)
list.(question, choices, &block)
end
# Ask masked question
#
# @example
# propmt = TTY::Prompt.new
# prompt.mask("What is your secret?")
#
# @return [TTY::Prompt::MaskQuestion]
#
# @api public
def mask(message = "", **options, &block)
invoke_question(MaskQuestion, message, **options, &block)
end
# Ask a question with a list of options
#
# @example
# prompt = TTY::Prompt.new
# prompt.select("What size?", %w(large medium small))
#
# @example
# prompt = TTY::Prompt.new
# prompt.select("What size?") do |menu|
# menu.choice :large
# menu.choices %w(:medium :small)
# end
#
# @param [String] question
# the question to ask
#
# @param [Array[Object]] choices
# the choices to select from
#
# @api public
def select(question, *args, &block)
invoke_select(List, question, *args, &block)
end
# Ask a question with multiple attributes activated
#
# @example
# prompt = TTY::Prompt.new
# choices = %w(Scorpion Jax Kitana Baraka Jade)
# prompt.multi_select("Choose your destiny?", choices)
#
# @param [String] question
# the question to ask
#
# @param [Array[Object]] choices
# the choices to select from
#
# @return [String]
#
# @api public
def multi_select(question, *args, &block)
invoke_select(MultiList, question, *args, &block)
end
# Ask a question with indexed list
#
# @example
# prompt = TTY::Prompt.new
# editors = %w(emacs nano vim)
# prompt.enum_select(EnumList, "Select editor: ", editors)
#
# @param [String] question
# the question to ask
#
# @param [Array[Object]] choices
# the choices to select from
#
# @return [String]
#
# @api public
def enum_select(question, *args, &block)
invoke_select(EnumList, question, *args, &block)
end
# A shortcut method to ask the user positive question and return
# true for "yes" reply, false for "no".
#
# @example
# prompt = TTY::Prompt.new
# prompt.yes?("Are you human?")
# # => Are you human? (Y/n)
#
# @return [Boolean]
#
# @api public
def yes?(message, **options, &block)
opts = { default: true }.merge(options)
question = ConfirmQuestion.new(self, **opts)
question.call(message, &block)
end
# A shortcut method to ask the user negative question and return
# true for "no" reply.
#
# @example
# prompt = TTY::Prompt.new
# prompt.no?("Are you alien?") # => true
# # => Are you human? (y/N)
#
# @return [Boolean]
#
# @api public
def no?(message, **options, &block)
opts = { default: false }.merge(options)
question = ConfirmQuestion.new(self, **opts)
!question.call(message, &block)
end
# Expand available options
#
# @example
# prompt = TTY::Prompt.new
# choices = [{
# key: "Y",
# name: "Overwrite",
# value: :yes
# }, {
# key: "n",
# name: "Skip",
# value: :no
# }]
# prompt.expand("Overwirte Gemfile?", choices)
#
# @return [Object]
# the user specified value
#
# @api public
def expand(message, *args, &block)
invoke_select(Expander, message, *args, &block)
end
# Ask a question with a range slider
#
# @example
# prompt = TTY::Prompt.new
# prompt.slider("What size?", min: 32, max: 54, step: 2)
# prompt.slider("What size?", [ 'xs', 's', 'm', 'l', 'xl' ])
#
# @param [String] question
# the question to ask
#
# @param [Array] choices
# the choices to display
#
# @return [String]
#
# @api public
def slider(question, choices = nil, **options, &block)
slider = Slider.new(self, **options)
slider.call(question, choices, &block)
end
# Print statement out. If the supplied message ends with a space or
# tab character, a new line will not be appended.
#
# @example
# say("Simple things.", color: :red)
#
# @param [String] message
#
# @return [String]
#
# @api public
def say(message = "", **options)
message = message.to_s
return if message.empty?
statement = Statement.new(self, **options)
statement.call(message)
end
# Print statement(s) out in red green.
#
# @example
# prompt.ok "Are you sure?"
# prompt.ok "All is fine!", "This is fine too."
#
# @param [Array] messages
#
# @return [Array] messages
#
# @api public
def ok(*args, **options)
opts = { color: :green }.merge(options)
args.each { |message| say(message, **opts) }
end
# Print statement(s) out in yellow color.
#
# @example
# prompt.warn "This action can have dire consequences"
# prompt.warn "Carefull young apprentice", "This is potentially dangerous"
#
# @param [Array] messages
#
# @return [Array] messages
#
# @api public
def warn(*args, **options)
opts = { color: :yellow }.merge(options)
args.each { |message| say(message, **opts) }
end
# Print statement(s) out in red color.
#
# @example
# prompt.error "Shutting down all systems!"
# prompt.error "Nothing is fine!", "All is broken!"
#
# @param [Array] messages
#
# @return [Array] messages
#
# @api public
def error(*args, **options)
opts = { color: :red }.merge(options)
args.each { |message| say(message, **opts) }
end
# Print debug information in terminal top right corner
#
# @example
# prompt.debug "info1", "info2"
#
# @param [Array] messages
#
# @retrun [nil]
#
# @api public
def debug(*messages)
longest = messages.max_by(&:length).size
width = TTY::Screen.width - longest
print cursor.save
messages.reverse_each do |msg|
print cursor.column(width) + cursor.up + cursor.clear_line_after
print msg
end
ensure
print cursor.restore
end
# Takes the string provided by the user and compare it with other possible
# matches to suggest an unambigous string
#
# @example
# prompt.suggest("sta", ["status", "stage", "commit", "branch"])
# # => "status, stage"
#
# @param [String] message
#
# @param [Array] possibilities
#
# @param [Hash] options
# @option options [String] :indent
# The number of spaces for indentation
# @option options [String] :single_text
# The text for a single suggestion
# @option options [String] :plural_text
# The text for multiple suggestions
#
# @return [String]
#
# @api public
def suggest(message, possibilities, **options)
suggestion = Suggestion.new(**options)
say(suggestion.suggest(message, possibilities))
end
# Gathers more than one aswer
#
# @example
# prompt.collect do
# key(:name).ask("Name?")
# end
#
# @return [Hash]
# the collection of answers
#
# @api public
def collect(**options, &block)
collector = AnswersCollector.new(self, **options)
collector.call(&block)
end
# Check if outputing to terminal
#
# @return [Boolean]
#
# @api public
def tty?
stdout.tty?
end
# Return standard in
#
# @api private
def stdin
$stdin
end
# Return standard out
#
# @api private
def stdout
$stdout
end
# Return standard error
#
# @api private
def stderr
$stderr
end
# Inspect this instance public attributes
#
# @return [String]
#
# @api public
def inspect
attributes = [
:prefix,
:quiet,
:enabled_color,
:active_color,
:error_color,
:help_color,
:input,
:output,
]
name = self.class.name
"#<#{name}#{attributes.map { |attr| " #{attr}=#{send(attr).inspect}" }.join}>"
end
end # Prompt
end # TTY