lib/whirly.rb in whirly-0.1.1 vs lib/whirly.rb in whirly-0.2.0

- old
+ new

@@ -1,139 +1,250 @@ require_relative "whirly/version" require_relative "whirly/spinners" -require "paint" +require "unicode/display_width" -# TODO configure extra-line below -# TODO clear previous frame +begin + require "paint" +rescue LoadError +end module Whirly + @configured = false + CLI_COMMANDS = { hide_cursor: "\x1b[?25l", show_cursor: "\x1b[?25h", - } + }.freeze + DEFAULT_OPTIONS = { + ambiguous_character_width: 1, + ansi_escape_mode: "restore", + append_newline: true, + color: !!defined?(Paint), + color_change_rate: 30, + hide_cursor: true, + non_tty: false, + position: "normal", + remove_after_stop: false, + spinner: "whirly", + spinner_packs: [:whirly, :cli], + status: nil, + stream: $stdout, + }.freeze + + SOFT_DEFAULT_OPTIONS = { + interval: 100, + mode: "linear", + stop: nil, + }.freeze + class << self attr_accessor :status - end + attr_reader :options - def self.enabled? - @enabled + def enabled? + !!(defined?(@enabled) && @enabled) + end + + def configured? + !!(@configured) + end end - - def self.paused? - @paused - end - def self.start(stream: $stdout, - interval: nil, - spinner: "whirly", - use_color: defined?(Paint), - color_change_rate: 30, - status: nil, - hide_cursor: true, - non_tty: false) - # only actviate if we are on a real terminal (or forced) - return false unless stream.tty? || non_tty + # set spinner directly or lookup + def self.configure_spinner(spinner_option) + case spinner_option + when Hash + spinner = spinner_option.dup + when Enumerable + spinner = { "frames" => spinner_option.dup } + when Proc + spinner = { "proc" => spinner_option.dup } + else + spinner = nil + catch(:found){ + @options[:spinner_packs].each{ |spinner_pack| + spinners = Whirly::Spinners.const_get(spinner_pack.to_s.upcase) + if spinners[spinner_option] + spinner = spinners[spinner_option].dup + throw(:found) + end + } + } + end - # ensure cursor is visible after exit - at_exit{ @stream.print CLI_COMMANDS[:show_cursor] } if !defined?(@enabled) && hide_cursor + # validate spinner + if !spinner || (!spinner["frames"] && !spinner["proc"]) + raise(ArgumentError, "Whirly: Invalid spinner given") + end - # only activate once - return false if @enabled + spinner + end - # save options and preprocess - @enabled = true - @paused = false - @stream = stream - @status = status - if spinner.is_a? Hash - @spinner = spinner + # frames can be generated from enumerables or procs + def self.configure_frames(spinner) + if spinner["frames"] + case spinner["mode"] + when "swing" + frames = (spinner["frames"].to_a + spinner["frames"].to_a[1..-2].reverse).cycle + when "random" + frame_pool = spinner["frames"].to_a + frames = ->(){ frame_pool.sample } + when "reverse" + frames = spinner["frames"].to_a.reverse.cycle + else + frames = spinner["frames"].cycle + end + elsif spinner["proc"] + frames = spinner["proc"].dup else - @spinner = SPINNERS[spinner.to_s] + raise(ArgumentError, "Whirly: Invalid spinner given") end - raise(ArgumentError, "Whirly: Invalid spinner given") if !@spinner || (!@spinner["frames"] && !@spinner["proc"]) - @hide_cursor = hide_cursor - @interval = (interval || @spinner["interval"] || 100) * 0.001 - @frames = @spinner["frames"] && @spinner["frames"].cycle - @proc = @spinner["proc"] - # init color - if use_color - if defined?(Paint) - @color = "%.6x" % rand(16777216) - @color_directions = (0..2).map{ |e| rand(3) - 1 } - @color_change_rate = color_change_rate - else - warn "Whirly warning: Using colors requires the paint gem" + if frames.is_a? Proc + class << frames + alias next call end end + frames + end + + # save options and preprocess, set defaults if value is still unknown + def self.configure(**options) + if !defined?(@configured) || !@configured || !defined?(@options) || !@options + @options = DEFAULT_OPTIONS.dup + @configured = true + end + + @options.merge!(options) + + spinner = configure_spinner(@options[:spinner]) + spinner_overwrites = {} + spinner_overwrites["mode"] = @options[:mode] if @options.key?(:mode) + @frames = configure_frames(spinner.merge(spinner_overwrites)) + + @interval = (@options[:interval] || spinner["interval"] || SOFT_DEFAULT_OPTIONS[:interval]) * 0.001 + @stop = @options[:stop] || spinner["stop"] + @status = @options[:status] + end + + def self.start(**options) + # optionally overwrite configuration on start + configure(**options) + + # ensure cursor is visible after exit the program (only register for the very first time) + if (!defined?(@at_exit_handler_registered) || !@at_exit_handler_registered) && @options[:hide_cursor] + @at_exit_handler_registered = true + stream = @options[:stream] + at_exit{ stream.print CLI_COMMANDS[:show_cursor] } + end + + # only enable once + return false if defined?(@enabled) && @enabled + + # set status to enabled + @enabled = true + + # only do something if we are on a real terminal (or forced) + return false unless @options[:stream].tty? || @options[:non_tty] + + # init color + initialize_color if @options[:color] + # hide cursor - @stream.print CLI_COMMANDS[:hide_cursor] if @hide_cursor + @options[:stream].print CLI_COMMANDS[:hide_cursor] if @options[:hide_cursor] # start spinner loop @thread = Thread.new do + @current_frame = nil while true # it's just a spinner, no exact timing here next_color if @color render sleep(@interval) end end - # idiomatic block syntax + # idiomatic block syntax support if block_given? yield Whirly.stop end true end - def self.stop(delete = false) + def self.stop(stop_frame = nil) return false unless @enabled - @thread.terminate + @thread.terminate if @thread + render(stop_frame || @stop) if stop_frame || @stop + unrender if @options[:remove_after_stop] + @options[:stream].puts if @options[:append_newline] + @options[:stream].print CLI_COMMANDS[:show_cursor] if @options[:hide_cursor] @enabled = false - @stream.print CLI_COMMANDS[:show_cursor] if @hide_cursor - print "TODO" if delete - + true end - def self.pause - # unrender - @paused = true - @stream.print CLI_COMMANDS[:show_cursor] if @hide_cursor - if block_given? - yield - continue - end + def self.reset + at_exit_handler_registered = defined?(@at_exit_handler_registered) && @at_exit_handler_registered + instance_variables.each{ |iv| remove_instance_variable(iv) } + @at_exit_handler_registered = at_exit_handler_registered + @configured = false end - def self.continue - @stream.print CLI_COMMANDS[:hide_cursor] if @hide_cursor - @paused = false - end + # - - - def self.unrender return unless @current_frame - current_frame_size = @current_frame.size - @stream.print "\n\e[s#{' ' * current_frame_size}\e[u\e[1A" + case @options[:ansi_escape_mode] + when "restore" + @options[:stream].print(render_prefix + ( + ' ' * (Unicode::DisplayWidth.of(@current_frame, @options[:ambiguous_character_width]) + 1) + ) + render_suffix) + when "line" + @options[:stream].print "\e[1K" + end end - def self.render - return if @paused + def self.render(next_frame = nil) unrender - @current_frame = @proc ? @proc.call : @frames.next - @current_frame = Paint[@current_frame, @color] if @color + + @current_frame = next_frame || @frames.next + @current_frame = Paint[@current_frame, @color] if @options[:color] @current_frame += " #{@status}" if @status - # @stream.print "\e[s#{@current_frame}\e[u" - @stream.print "\n\e[s#{@current_frame}\e[u\e[1A" + + @options[:stream].print(render_prefix + @current_frame.to_s + render_suffix) end + def self.render_prefix + res = "" + res << "\n" if @options[:position] == "below" + res << "\e[s" if @options[:ansi_escape_mode] == "restore" + res << "\e[G" if @options[:ansi_escape_mode] == "line" + res + end + + def self.render_suffix + res = "" + res << "\e[u" if @options[:ansi_escape_mode] == "restore" + res << "\e[1A" if @options[:position] == "below" + res + end + + def self.initialize_color + if !defined?(Paint) + warn "Whirly warning: Using colors requires the paint gem" + else + @color = "%.6x" % rand(16777216) + @color_directions = (0..2).map{ |e| rand(3) - 1 } + end + end + def self.next_color @color = @color.scan(/../).map.with_index{ |c, i| - color_change = rand(@color_change_rate) * @color_directions[i] + color_change = rand(@options[:color_change_rate]) * @color_directions[i] nc = c.to_i(16) + color_change if nc <= 0 nc = 0 @color_directions[i] = rand(3) - 1 elsif nc >= 255