lib/tty/spinner.rb
# frozen_string_literal: true
require "monitor"
require "tty-cursor"
require_relative "spinner/version"
require_relative "spinner/formats"
module TTY
# Used for creating terminal spinner
#
# @api public
class Spinner
include Formats
include MonitorMixin
# @raised when attempting to join dead thread
NotSpinningError = Class.new(StandardError)
ECMA_CSI = "\x1b["
MATCHER = /:spinner/
TICK = "✔"
CROSS = "✖"
CURSOR_LOCK = Monitor.new
# The object that responds to print call defaulting to stderr
#
# @api public
attr_reader :output
# The current format type
#
# @return [String]
#
# @api public
attr_reader :format
# Whether to show or hide cursor
#
# @return [Boolean]
#
# @api public
attr_reader :hide_cursor
# The message to print before the spinner
#
# @return [String]
# the current message
#
# @api public
attr_reader :message
# Tokens for the message
#
# @return [Hash[Symbol, Object]]
# the current tokens
#
# @api public
attr_reader :tokens
# The amount of time between frames in auto spinning
#
# @api public
attr_reader :interval
# The current row inside the multi spinner
#
# @api public
attr_reader :row
# Initialize a spinner
#
# @example
# spinner = TTY::Spinner.new
#
# @param [String] message
# the message to print in front of the spinner
#
# @param [Hash] options
# @option options [String] :format
# the spinner format type defaulting to :spin_1
# @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? ? ":spinner" : args.pop
@tokens = {}
@format = options.fetch(:format) { :classic }
@output = options.fetch(:output) { $stderr }
@hide_cursor = options.fetch(:hide_cursor) { false }
@frames = options.fetch(:frames) do
fetch_format(@format.to_sym, :frames)
end
@clear = options.fetch(:clear) { false }
@success_mark = options.fetch(:success_mark) { TICK }
@error_mark = options.fetch(:error_mark) { CROSS }
@interval = options.fetch(:interval) do
fetch_format(@format.to_sym, :interval)
end
@row = options[:row]
@callbacks = Hash.new { |h, k| h[k] = [] }
@length = @frames.length
@thread = nil
@job = nil
@multispinner = nil
reset
end
# Reset the spinner to initial frame
#
# @api public
def reset
synchronize do
@current = 0
@done = false
@state = :stopped
@succeeded = false
@first_run = true
end
end
# Notifies the TTY::Spinner that it is running under a multispinner
#
# @param [TTY::Spinner::Multi] the multispinner that it is running under
#
# @api private
def attach_to(multispinner)
@multispinner = multispinner
end
# Whether the spinner has completed spinning
#
# @return [Boolean] whether or not the spinner has finished
#
# @api public
def done?
@done
end
# Whether the spinner is spinning
#
# @return [Boolean] whether or not the spinner is spinning
#
# @api public
def spinning?
@state == :spinning
end
# Whether the spinner is in the success state.
# When true the spinner is marked with a success mark.
#
# @return [Boolean] whether or not the spinner succeeded
#
# @api public
def success?
@succeeded == :success
end
# Whether the spinner is in the error state. This is only true
# temporarily while it is being marked with a failure mark.
#
# @return [Boolean] whether or not the spinner is erroring
#
# @api public
def error?
@succeeded == :error
end
# Register callback
#
# @param [Symbol] name
# the name for the event to listen for, e.i. :complete
#
# @return [self]
#
# @api public
def on(name, &block)
synchronize do
@callbacks[name] << block
end
self
end
# Start timer and unlock spinner
#
# @api public
def start
@started_at = Time.now
@done = false
reset
end
# Add job to this spinner
#
# @api public
def job(&work)
synchronize do
if block_given?
@job = work
else
@job
end
end
end
# Execute this spinner job
#
# @yield [TTY::Spinner]
#
# @api public
def execute_job
job.(self) if job?
end
# Check if this spinner has a scheduled job
#
# @return [Boolean]
#
# @api public
def job?
!@job.nil?
end
# Start automatic spinning animation
#
# @api public
def auto_spin
CURSOR_LOCK.synchronize do
start
sleep_time = 1.0 / @interval
spin
@thread = Thread.new do
sleep(sleep_time)
while @started_at
if Thread.current["pause"]
Thread.stop
Thread.current["pause"] = false
end
spin
sleep(sleep_time)
end
end
end
ensure
if @hide_cursor
write(TTY::Cursor.show, false)
end
end
# Checked if current spinner is paused
#
# @return [Boolean]
#
# @api public
def paused?
!!(@thread && @thread["pause"])
end
# Pause spinner automatic animation
#
# @param [String] mark
# the custom mark to replace the spinner
#
# @api public
def pause(mark: nil)
return if paused? || done?
synchronize do
data = message.gsub(MATCHER, mark || @frames[@current])
data = replace_tokens(data)
write(data, true)
@thread["pause"] = true if @thread
end
end
# Resume spinner automatic animation
#
# @api public
def resume
return unless paused?
@thread.wakeup if @thread
end
# Run spinner while executing job
#
# @param [String] stop_message
# the message displayed when block is finished
#
# @yield automatically animate and finish spinner
#
# @example
# spinner.run("Migrated DB") { ... }
#
# @api public
def run(stop_message = "", &block)
job(&block)
auto_spin
@work = Thread.new { execute_job }
@work.join
ensure
stop(stop_message)
end
# Duration of the spinning animation
#
# @return [Numeric]
#
# @api public
def duration
@started_at ? Time.now - @started_at : nil
end
# Join running spinner
#
# @param [Float] timeout
# the timeout for join
#
# @api public
def join(timeout = nil)
unless @thread
raise(NotSpinningError, "Cannot join spinner that is not running")
end
timeout ? @thread.join(timeout) : @thread.join
end
# Kill running spinner
#
# @api public
def kill
synchronize do
@thread.kill if @thread
end
end
# Perform a spin
#
# @return [String]
# the printed data
#
# @api public
def spin
return if done?
synchronize do
emit(:spin)
render
@current = (@current + 1) % @length
@state = :spinning
end
end
# Render spinner to the output
#
# @api private
def render
return if done?
if @hide_cursor && !spinning?
write(TTY::Cursor.hide)
end
data = message.gsub(MATCHER, @frames[@current])
data = replace_tokens(data)
write(data, true)
end
# Redraw the indent for this spinner, if it exists
#
# @api private
def redraw_indent
if @hide_cursor && !spinning?
write(TTY::Cursor.hide)
end
write("", false)
end
# Finish spining
#
# @param [String] stop_message
# the stop message to print
# @param [String] mark
# the custom mark to replace the spinner
#
# @api public
def stop(stop_message = "", mark: nil)
mon_enter
return if done?
clear_line
return if @clear
data = message.gsub(MATCHER, mark || next_char)
data = replace_tokens(data)
if !stop_message.empty?
data << " " + stop_message
end
write(data, false)
write("\n", false) unless @clear || @multispinner
ensure
@state = :stopped
@done = true
@started_at = nil
if @hide_cursor
write(TTY::Cursor.show, false)
end
emit(:done)
kill
mon_exit
end
# Retrieve next character
#
# @return [String]
#
# @api private
def next_char
if success?
@success_mark
elsif error?
@error_mark
else
@frames[@current - 1]
end
end
# Finish spinning and set state to :success
#
# @param [String] stop_message
# the message to display on success
# @param [String] mark
# the custom mark to replace the spinner
#
# @api public
def success(stop_message = "", mark: nil)
return if done?
synchronize do
@succeeded = :success
stop(stop_message, mark: mark)
emit(:success)
end
end
# Finish spinning and set state to :error
#
# @param [String] stop_message
# the message to display on error
# @param [String] mark
# the custom mark to replace the spinner
#
# @api public
def error(stop_message = "", mark: nil)
return if done?
synchronize do
@succeeded = :error
stop(stop_message, mark: mark)
emit(:error)
end
end
# Clear current line
#
# @api public
def clear_line
write(ECMA_CSI + "0m" + TTY::Cursor.clear_line)
end
# Update string formatting tokens
#
# @param [Hash[Symbol]] tokens
# the tokens used in formatting string
#
# @api public
def update(tokens)
synchronize do
clear_line if spinning?
@tokens.merge!(tokens)
end
end
# Log text above the current spinner
#
# @param [String] text
# the message to log out
#
# @api public
def log(text)
synchronize do
cleared_text = text.to_s.lines.map do |line|
TTY::Cursor.clear_line + line
end.join
write("#{cleared_text}#{"\n" unless cleared_text.end_with?("\n")}", false)
render
end
end
# Check if IO is attached to a terminal
#
# return [Boolean]
#
# @api public
def tty?
output.respond_to?(:tty?) && output.tty?
end
private
# Execute a block on the proper terminal line if the spinner is running
# under a multispinner. Otherwise, execute the block on the current line.
#
# @api private
def execute_on_line
if @multispinner
@multispinner.synchronize do
if @first_run
@row ||= @multispinner.next_row
yield if block_given?
output.print "\n"
@first_run = false
else
lines_up = (@multispinner.rows + 1) - @row
output.print TTY::Cursor.save
output.print TTY::Cursor.up(lines_up)
yield if block_given?
output.print TTY::Cursor.restore
end
end
elsif block_given?
yield
end
end
# Write data out to output
#
# @return [nil]
#
# @api private
def write(data, clear_first = false)
return unless tty? # write only to terminal
execute_on_line do
output.print(TTY::Cursor.column(1)) if clear_first
# If there's a top level spinner, print with inset
characters_in = @multispinner.line_inset(@row) if @multispinner
output.print("#{characters_in}#{data}")
output.flush
end
end
# Emit callback
#
# @api private
def emit(name, *args)
@callbacks[name].each do |callback|
callback.call(*args)
end
end
# Find frames by token name
#
# @param [Symbol] token
# the name for the frames
#
# @return [Array, String]
#
# @api private
def fetch_format(token, property)
if FORMATS.key?(token)
FORMATS[token][property]
else
raise ArgumentError, "Unknown format token `:#{token}`"
end
end
# Replace any token inside string
#
# @param [String] string
# the string containing tokens
#
# @return [String]
#
# @api private
def replace_tokens(string)
data = string.dup
@tokens.each do |name, val|
data.gsub!(/\:#{name}/, val.to_s)
end
data
end
end # Spinner
end # TTY