lib/tty/spinner/multi.rb
# frozen_string_literal: true
require "monitor"
require "forwardable"
require_relative "../spinner"
module TTY
class Spinner
# Used for managing multiple terminal spinners
#
# @api public
class Multi
include Enumerable
include MonitorMixin
extend Forwardable
def_delegators :@spinners, :each, :empty?, :length
DEFAULT_INSET = {
top: Gem.win_platform? ? "+ " : "\u250c ",
middle: Gem.win_platform? ? "|-- " : "\u251c\u2500\u2500 ",
bottom: Gem.win_platform? ? "|__ " : "\u2514\u2500\u2500 "
}.freeze
# The current count of all rendered rows
#
# @api public
attr_reader :rows
# Initialize a multispinner
#
# @example
# spinner = TTY::Spinner::Multi.new
#
# @param [String] message
# the optional message to print in front of the top level spinner
#
# @param [Hash] options
# @option options [Hash] :style
# keys :top :middle and :bottom can contain Strings that are used to
# indent the spinners. Ignored if message is blank
# @option options [Object] :output
# the object that responds to print call defaulting to stderr
# @option options [Boolean] :hide_cursor
# display or hide cursor
# @option options [Boolean] :clear
# clear ouptut when finished
# @option options [Float] :interval
# the interval for auto spinning
#
# @api public
def initialize(*args)
super()
@options = args.last.is_a?(::Hash) ? args.pop : {}
message = args.empty? ? nil : args.pop
@inset_opts = @options.delete(:style) { DEFAULT_INSET }
@rows = 0
@spinners = []
@spinners_count = 0
@top_spinner = nil
@last_spin_at = nil
unless message.nil?
@top_spinner = register(message, observable: false, row: next_row)
end
@callbacks = {
success: [],
error: [],
done: [],
spin: []
}
end
# Register a new spinner
#
# @param [String, TTY::Spinner] pattern_or_spinner
# the pattern used for creating spinner, or a spinner instance
#
# @api public
def register(pattern_or_spinner, **options, &job)
observable = options.delete(:observable) { true }
spinner = nil
synchronize do
spinner = create_spinner(pattern_or_spinner, options)
spinner.attach_to(self)
spinner.job(&job) if block_given?
observe(spinner) if observable
@spinners << spinner
@spinners_count += 1
if @top_spinner
@spinners.each { |sp| sp.redraw_indent if sp.spinning? || sp.done? }
end
end
spinner
end
# Create a spinner instance
#
# @api private
def create_spinner(pattern_or_spinner, options)
case pattern_or_spinner
when ::String
TTY::Spinner.new(
pattern_or_spinner,
@options.merge(options)
)
when ::TTY::Spinner
pattern_or_spinner
else
raise ArgumentError, "Expected a pattern or spinner, " \
"got: #{pattern_or_spinner.class}"
end
end
# Increase a row count
#
# @api public
def next_row
synchronize do
@rows += 1
end
end
# Get the top level spinner if it exists
#
# @return [TTY::Spinner] the top level spinner
#
# @api public
def top_spinner
raise "No top level spinner" if @top_spinner.nil?
@top_spinner
end
# Auto spin the top level spinner & all child spinners
# that have scheduled jobs
#
# @api public
def auto_spin
raise "No top level spinner" if @top_spinner.nil?
jobs = []
@spinners.each do |spinner|
if spinner.job?
spinner.auto_spin
jobs << Thread.new { spinner.execute_job }
end
end
jobs.each(&:join)
end
# Perform a single spin animation
#
# @api public
def spin
raise "No top level spinner" if @top_spinner.nil?
synchronize do
throttle { @top_spinner.spin }
end
end
# Pause all spinners
#
# @api public
def pause
@spinners.dup.each(&:pause)
end
# Resume all spinners
#
# @api public
def resume
@spinners.dup.each(&:resume)
end
# Find the number of characters to move into the line
# before printing the spinner
#
# @param [Integer] line_no
# the current spinner line number for which line inset is calculated
#
# @return [String]
# the inset
#
# @api public
def line_inset(line_no)
return "" if @top_spinner.nil?
if line_no == 1
@inset_opts[:top]
elsif line_no == @spinners_count
@inset_opts[:bottom]
else
@inset_opts[:middle]
end
end
# Check if all spinners are done
#
# @return [Boolean]
#
# @api public
def done?
synchronize do
(@spinners - [@top_spinner]).all?(&:done?)
end
end
# Check if all spinners succeeded
#
# @return [Boolean]
#
# @api public
def success?
synchronize do
(@spinners - [@top_spinner]).all?(&:success?)
end
end
# Check if any spinner errored
#
# @return [Boolean]
#
# @api public
def error?
synchronize do
(@spinners - [@top_spinner]).any?(&:error?)
end
end
# Stop all spinners
#
# @api public
def stop
@spinners.dup.each(&:stop)
end
# Stop all spinners with success status
#
# @api public
def success
@spinners.dup.each(&:success)
end
# Stop all spinners with error status
#
# @api public
def error
@spinners.dup.each(&:error)
end
# Listen on event
#
# @api public
def on(key, &callback)
unless @callbacks.key?(key)
raise ArgumentError, "The event #{key} does not exist. " \
" Use :spin, :success, :error, or :done instead"
end
@callbacks[key] << callback
self
end
private
# Check if this spinner should revolve to keep constant speed
# matching top spinner interval
#
# @api private
def throttle
sleep_time = 1.0 / @top_spinner.interval
return if @last_spin_at && Time.now - @last_spin_at < sleep_time
yield if block_given?
@last_spin_at = Time.now
end
# Fire an event
#
# @api private
def emit(key, *args)
@callbacks[key].each do |block|
block.call(*args)
end
end
# Observe spinner for events to notify top spinner of current state
#
# @param [TTY::Spinner] spinner
# the spinner to listen to for events
#
# @api private
def observe(spinner)
spinner.on(:spin, &spin_handler)
.on(:success, &success_handler)
.on(:error, &error_handler)
.on(:done, &done_handler)
end
# Handle spin event
#
# @api private
def spin_handler
proc do
spin if @top_spinner
emit(:spin)
end
end
# Handle the success state
#
# @api private
def success_handler
proc do
if success?
@top_spinner.success if @top_spinner
emit(:success)
end
end
end
# Handle the error state
#
# @api private
def error_handler
proc do
if error?
@top_spinner.error if @top_spinner
@fired ||= emit(:error) # fire once
end
end
end
# Handle the done state
#
# @api private
def done_handler
proc do
if done?
@top_spinner.stop if @top_spinner && !error? && !success?
emit(:done)
end
end
end
end # MultiSpinner
end # Spinner
end # TTY