austb-tty-spinner 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +475 -0
- data/lib/tty-spinner.rb +4 -0
- data/lib/tty/spinner.rb +512 -0
- data/lib/tty/spinner/formats.rb +127 -0
- data/lib/tty/spinner/multi.rb +259 -0
- data/lib/tty/spinner/version.rb +7 -0
- metadata +101 -0
data/lib/tty-spinner.rb
ADDED
data/lib/tty/spinner.rb
ADDED
@@ -0,0 +1,512 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'tty-cursor'
|
5
|
+
|
6
|
+
require_relative 'spinner/version'
|
7
|
+
require_relative 'spinner/formats'
|
8
|
+
|
9
|
+
module TTY
|
10
|
+
# Used for creating terminal spinner
|
11
|
+
#
|
12
|
+
# @api public
|
13
|
+
class Spinner
|
14
|
+
include Formats
|
15
|
+
|
16
|
+
# @raised when attempting to join dead thread
|
17
|
+
NotSpinningError = Class.new(StandardError)
|
18
|
+
|
19
|
+
ECMA_CSI = "\x1b[".freeze
|
20
|
+
|
21
|
+
MATCHER = /:spinner/
|
22
|
+
TICK = '✔'.freeze
|
23
|
+
CROSS = '✖'.freeze
|
24
|
+
|
25
|
+
CURSOR_USAGE_LOCK = Monitor.new
|
26
|
+
|
27
|
+
# The object that responds to print call defaulting to stderr
|
28
|
+
#
|
29
|
+
# @api public
|
30
|
+
attr_reader :output
|
31
|
+
|
32
|
+
# The current format type
|
33
|
+
#
|
34
|
+
# @return [String]
|
35
|
+
#
|
36
|
+
# @api public
|
37
|
+
attr_reader :format
|
38
|
+
|
39
|
+
# Whether to show or hide cursor
|
40
|
+
#
|
41
|
+
# @return [Boolean]
|
42
|
+
#
|
43
|
+
# @api public
|
44
|
+
attr_reader :hide_cursor
|
45
|
+
|
46
|
+
# The message to print before the spinner
|
47
|
+
#
|
48
|
+
# @return [String]
|
49
|
+
# the current message
|
50
|
+
#
|
51
|
+
# @api public
|
52
|
+
attr_reader :message
|
53
|
+
|
54
|
+
# Tokens for the message
|
55
|
+
#
|
56
|
+
# @return [Hash[Symbol, Object]]
|
57
|
+
# the current tokens
|
58
|
+
#
|
59
|
+
# @api public
|
60
|
+
attr_reader :tokens
|
61
|
+
|
62
|
+
# Initialize a spinner
|
63
|
+
#
|
64
|
+
# @example
|
65
|
+
# spinner = TTY::Spinner.new
|
66
|
+
#
|
67
|
+
# @param [String] message
|
68
|
+
# the message to print in front of the spinner
|
69
|
+
#
|
70
|
+
# @param [Hash] options
|
71
|
+
# @option options [String] :format
|
72
|
+
# the spinner format type defaulting to :spin_1
|
73
|
+
# @option options [Object] :output
|
74
|
+
# the object that responds to print call defaulting to stderr
|
75
|
+
# @option options [Boolean] :hide_cursor
|
76
|
+
# display or hide cursor
|
77
|
+
# @option options [Boolean] :clear
|
78
|
+
# clear ouptut when finished
|
79
|
+
# @option options [Float] :interval
|
80
|
+
# the interval for auto spinning
|
81
|
+
#
|
82
|
+
# @api public
|
83
|
+
def initialize(*args)
|
84
|
+
options = args.last.is_a?(::Hash) ? args.pop : {}
|
85
|
+
@message = args.empty? ? ':spinner' : args.pop
|
86
|
+
@tokens = {}
|
87
|
+
|
88
|
+
@format = options.fetch(:format) { :classic }
|
89
|
+
@output = options.fetch(:output) { $stderr }
|
90
|
+
@hide_cursor = options.fetch(:hide_cursor) { false }
|
91
|
+
@frames = options.fetch(:frames) do
|
92
|
+
fetch_format(@format.to_sym, :frames)
|
93
|
+
end
|
94
|
+
@clear = options.fetch(:clear) { false }
|
95
|
+
@success_mark= options.fetch(:success_mark) { TICK }
|
96
|
+
@error_mark = options.fetch(:error_mark) { CROSS }
|
97
|
+
@interval = options.fetch(:interval) do
|
98
|
+
fetch_format(@format.to_sym, :interval)
|
99
|
+
end
|
100
|
+
|
101
|
+
@callbacks = Hash.new { |h, k| h[k] = [] }
|
102
|
+
@length = @frames.length
|
103
|
+
@current = 0
|
104
|
+
@done = false
|
105
|
+
@state = :stopped
|
106
|
+
@thread = nil
|
107
|
+
@job = nil
|
108
|
+
@multispinner= nil
|
109
|
+
@index = nil
|
110
|
+
@succeeded = false
|
111
|
+
@first_run = true
|
112
|
+
end
|
113
|
+
|
114
|
+
# Notifies the TTY::Spinner that it is running under a multispinner
|
115
|
+
#
|
116
|
+
# @param [TTY::Spinner::Multi] the multispinner that it is running under
|
117
|
+
# @param [Integer] the index of this spinner in the multispinner
|
118
|
+
#
|
119
|
+
# @api private
|
120
|
+
def add_multispinner(multispinner, index)
|
121
|
+
@multispinner = multispinner
|
122
|
+
@index = index
|
123
|
+
end
|
124
|
+
|
125
|
+
# Whether the spinner has completed spinning
|
126
|
+
#
|
127
|
+
# @return [Boolean] whether or not the spinner has finished
|
128
|
+
#
|
129
|
+
# @api public
|
130
|
+
def done?
|
131
|
+
@done
|
132
|
+
end
|
133
|
+
|
134
|
+
# Whether the spinner is spinning
|
135
|
+
#
|
136
|
+
# @return [Boolean] whether or not the spinner is spinning
|
137
|
+
#
|
138
|
+
# @api public
|
139
|
+
def spinning?
|
140
|
+
@state == :spinning
|
141
|
+
end
|
142
|
+
|
143
|
+
# Whether the spinner is in the success state.
|
144
|
+
# When true the spinner is marked with a success mark.
|
145
|
+
#
|
146
|
+
# @return [Boolean] whether or not the spinner succeeded
|
147
|
+
#
|
148
|
+
# @api public
|
149
|
+
def success?
|
150
|
+
@succeeded == :success
|
151
|
+
end
|
152
|
+
|
153
|
+
# Whether the spinner is in the error state. This is only true
|
154
|
+
# temporarily while it is being marked with a failure mark.
|
155
|
+
#
|
156
|
+
# @return [Boolean] whether or not the spinner is erroring
|
157
|
+
#
|
158
|
+
# @api public
|
159
|
+
def error?
|
160
|
+
@succeeded == :error
|
161
|
+
end
|
162
|
+
|
163
|
+
# Register callback
|
164
|
+
#
|
165
|
+
# @api public
|
166
|
+
def on(name, &block)
|
167
|
+
@callbacks[name] << block
|
168
|
+
self
|
169
|
+
end
|
170
|
+
|
171
|
+
# Start timer and unlock spinner
|
172
|
+
#
|
173
|
+
# @api public
|
174
|
+
def start
|
175
|
+
@started_at = Time.now
|
176
|
+
@done = false
|
177
|
+
reset
|
178
|
+
end
|
179
|
+
|
180
|
+
# Add job to this spinner
|
181
|
+
#
|
182
|
+
# @api public
|
183
|
+
def job(&work)
|
184
|
+
if block_given?
|
185
|
+
@job = work
|
186
|
+
else
|
187
|
+
@job
|
188
|
+
end
|
189
|
+
end
|
190
|
+
|
191
|
+
# Check if this spinner has a scheduled job
|
192
|
+
#
|
193
|
+
# @return [Boolean]
|
194
|
+
#
|
195
|
+
# @api public
|
196
|
+
def job?
|
197
|
+
!@job.nil?
|
198
|
+
end
|
199
|
+
|
200
|
+
# Start automatic spinning animation
|
201
|
+
#
|
202
|
+
# @api public
|
203
|
+
def auto_spin
|
204
|
+
CURSOR_USAGE_LOCK.synchronize do
|
205
|
+
start
|
206
|
+
sleep_time = 1.0 / @interval
|
207
|
+
|
208
|
+
spin
|
209
|
+
@thread = Thread.new do
|
210
|
+
sleep(sleep_time)
|
211
|
+
while @started_at
|
212
|
+
if Thread.current['pause']
|
213
|
+
Thread.stop
|
214
|
+
Thread.current['pause'] = false
|
215
|
+
end
|
216
|
+
spin
|
217
|
+
sleep(sleep_time)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
# Checked if current spinner is paused
|
224
|
+
#
|
225
|
+
# @return [Boolean]
|
226
|
+
#
|
227
|
+
# @api public
|
228
|
+
def paused?
|
229
|
+
!!(@thread && @thread['pause'])
|
230
|
+
end
|
231
|
+
|
232
|
+
# Pause spinner automatic animation
|
233
|
+
#
|
234
|
+
# @api public
|
235
|
+
def pause
|
236
|
+
return if paused?
|
237
|
+
|
238
|
+
@thread['pause'] = true if @thread
|
239
|
+
end
|
240
|
+
|
241
|
+
# Resume spinner automatic animation
|
242
|
+
#
|
243
|
+
# @api public
|
244
|
+
def resume
|
245
|
+
return unless paused?
|
246
|
+
|
247
|
+
@thread.wakeup if @thread
|
248
|
+
end
|
249
|
+
|
250
|
+
# Run spinner while executing job
|
251
|
+
#
|
252
|
+
# @param [String] stop_message
|
253
|
+
# the message displayed when block is finished
|
254
|
+
#
|
255
|
+
# @yield automatically animate and finish spinner
|
256
|
+
#
|
257
|
+
# @example
|
258
|
+
# spinner.run('Migrated DB') { ... }
|
259
|
+
#
|
260
|
+
# @api public
|
261
|
+
def run(stop_message = '', &block)
|
262
|
+
auto_spin
|
263
|
+
|
264
|
+
@work = Thread.new {
|
265
|
+
instance_eval(&block)
|
266
|
+
}
|
267
|
+
@work.join
|
268
|
+
ensure
|
269
|
+
stop(stop_message)
|
270
|
+
end
|
271
|
+
|
272
|
+
# Duration of the spinning animation
|
273
|
+
#
|
274
|
+
# @return [Numeric]
|
275
|
+
#
|
276
|
+
# @api public
|
277
|
+
def duration
|
278
|
+
@started_at ? Time.now - @started_at : nil
|
279
|
+
end
|
280
|
+
|
281
|
+
# Join running spinner
|
282
|
+
#
|
283
|
+
# @param [Float] timeout
|
284
|
+
# the timeout for join
|
285
|
+
#
|
286
|
+
# @api public
|
287
|
+
def join(timeout = nil)
|
288
|
+
unless @thread
|
289
|
+
raise(NotSpinningError, 'Cannot join spinner that is not running')
|
290
|
+
end
|
291
|
+
|
292
|
+
timeout ? @thread.join(timeout) : @thread.join
|
293
|
+
end
|
294
|
+
|
295
|
+
# Kill running spinner
|
296
|
+
#
|
297
|
+
# @api public
|
298
|
+
def kill
|
299
|
+
@thread.kill if @thread
|
300
|
+
end
|
301
|
+
|
302
|
+
# Perform a spin
|
303
|
+
#
|
304
|
+
# @return [String]
|
305
|
+
# the printed data
|
306
|
+
#
|
307
|
+
# @api public
|
308
|
+
def spin
|
309
|
+
return if @done
|
310
|
+
|
311
|
+
if @hide_cursor && !spinning?
|
312
|
+
write(TTY::Cursor.hide)
|
313
|
+
end
|
314
|
+
|
315
|
+
data = message.gsub(MATCHER, @frames[@current])
|
316
|
+
data = replace_tokens(data)
|
317
|
+
write(data, true)
|
318
|
+
@current = (@current + 1) % @length
|
319
|
+
@state = :spinning
|
320
|
+
data
|
321
|
+
end
|
322
|
+
|
323
|
+
# Redraw the indent for this spinner, if it exists
|
324
|
+
#
|
325
|
+
# @api private
|
326
|
+
def redraw_indent
|
327
|
+
if @hide_cursor && !spinning?
|
328
|
+
write(ECMA_CSI + DEC_TCEM + DEC_RST)
|
329
|
+
end
|
330
|
+
|
331
|
+
write("", false)
|
332
|
+
end
|
333
|
+
|
334
|
+
# Finish spining
|
335
|
+
#
|
336
|
+
# @param [String] stop_message
|
337
|
+
# the stop message to print
|
338
|
+
#
|
339
|
+
# @api public
|
340
|
+
def stop(stop_message = '')
|
341
|
+
return if done?
|
342
|
+
|
343
|
+
if @hide_cursor
|
344
|
+
write(TTY::Cursor.show, false)
|
345
|
+
end
|
346
|
+
return clear_line if @clear
|
347
|
+
|
348
|
+
data = message.gsub(MATCHER, next_char)
|
349
|
+
data = replace_tokens(data)
|
350
|
+
if !stop_message.empty?
|
351
|
+
data << ' ' + stop_message
|
352
|
+
end
|
353
|
+
|
354
|
+
write(data, true)
|
355
|
+
write("\n", false) unless @clear || @multispinner
|
356
|
+
ensure
|
357
|
+
@state = :stopped
|
358
|
+
@done = true
|
359
|
+
@started_at = nil
|
360
|
+
emit(:done)
|
361
|
+
kill
|
362
|
+
end
|
363
|
+
|
364
|
+
# Retrieve next character
|
365
|
+
#
|
366
|
+
# @return [String]
|
367
|
+
#
|
368
|
+
# @api private
|
369
|
+
def next_char
|
370
|
+
if success?
|
371
|
+
@success_mark
|
372
|
+
elsif error?
|
373
|
+
@error_mark
|
374
|
+
else
|
375
|
+
@frames[@current - 1]
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
# Finish spinning and set state to :success
|
380
|
+
#
|
381
|
+
# @api public
|
382
|
+
def success(stop_message = '')
|
383
|
+
return if done?
|
384
|
+
|
385
|
+
@succeeded = :success
|
386
|
+
stop(stop_message)
|
387
|
+
emit(:success)
|
388
|
+
end
|
389
|
+
|
390
|
+
# Finish spinning and set state to :error
|
391
|
+
#
|
392
|
+
# @api public
|
393
|
+
def error(stop_message = '')
|
394
|
+
return if done?
|
395
|
+
|
396
|
+
@succeeded = :error
|
397
|
+
stop(stop_message)
|
398
|
+
emit(:error)
|
399
|
+
end
|
400
|
+
|
401
|
+
# Clear current line
|
402
|
+
#
|
403
|
+
# @api public
|
404
|
+
def clear_line
|
405
|
+
write(ECMA_CSI + '0m' + TTY::Cursor.clear_line)
|
406
|
+
end
|
407
|
+
|
408
|
+
# Update string formatting tokens
|
409
|
+
#
|
410
|
+
# @param [Hash[Symbol]] tokens
|
411
|
+
# the tokens used in formatting string
|
412
|
+
#
|
413
|
+
# @api public
|
414
|
+
def update(tokens)
|
415
|
+
clear_line if spinning?
|
416
|
+
@tokens.merge!(tokens)
|
417
|
+
end
|
418
|
+
|
419
|
+
# Reset the spinner to initial frame
|
420
|
+
#
|
421
|
+
# @api public
|
422
|
+
def reset
|
423
|
+
@current = 0
|
424
|
+
@first_run = true
|
425
|
+
end
|
426
|
+
|
427
|
+
private
|
428
|
+
|
429
|
+
# Execute a block on the proper terminal line if the spinner is running
|
430
|
+
# under a multispinner. Otherwise, execute the block on the current line.
|
431
|
+
#
|
432
|
+
# @api private
|
433
|
+
def execute_on_line
|
434
|
+
if @multispinner
|
435
|
+
CURSOR_USAGE_LOCK.synchronize do
|
436
|
+
lines_up = @multispinner.count_line_offset(@index)
|
437
|
+
|
438
|
+
if @first_run
|
439
|
+
yield if block_given?
|
440
|
+
output.print "\n"
|
441
|
+
@first_run = false
|
442
|
+
else
|
443
|
+
output.print TTY::Cursor.save
|
444
|
+
output.print TTY::Cursor.up(lines_up)
|
445
|
+
yield if block_given?
|
446
|
+
output.print TTY::Cursor.restore
|
447
|
+
end
|
448
|
+
end
|
449
|
+
else
|
450
|
+
yield if block_given?
|
451
|
+
end
|
452
|
+
end
|
453
|
+
|
454
|
+
# Write data out to output
|
455
|
+
#
|
456
|
+
# @return [nil]
|
457
|
+
#
|
458
|
+
# @api private
|
459
|
+
def write(data, clear_first = false)
|
460
|
+
execute_on_line do
|
461
|
+
output.print(TTY::Cursor.column(1)) if clear_first
|
462
|
+
|
463
|
+
# If there's a top level spinner, print with inset
|
464
|
+
characters_in = @multispinner.nil? ? "" : @multispinner.line_inset(self)
|
465
|
+
|
466
|
+
output.print(characters_in + data)
|
467
|
+
output.flush
|
468
|
+
end
|
469
|
+
end
|
470
|
+
|
471
|
+
# Emit callback
|
472
|
+
#
|
473
|
+
# @api private
|
474
|
+
def emit(name, *args)
|
475
|
+
@callbacks[name].each do |block|
|
476
|
+
block.call(*args)
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
# Find frames by token name
|
481
|
+
#
|
482
|
+
# @param [Symbol] token
|
483
|
+
# the name for the frames
|
484
|
+
#
|
485
|
+
# @return [Array, String]
|
486
|
+
#
|
487
|
+
# @api private
|
488
|
+
def fetch_format(token, property)
|
489
|
+
if FORMATS.key?(token)
|
490
|
+
FORMATS[token][property]
|
491
|
+
else
|
492
|
+
raise ArgumentError, "Unknown format token `:#{token}`"
|
493
|
+
end
|
494
|
+
end
|
495
|
+
|
496
|
+
# Replace any token inside string
|
497
|
+
#
|
498
|
+
# @param [String] string
|
499
|
+
# the string containing tokens
|
500
|
+
#
|
501
|
+
# @return [String]
|
502
|
+
#
|
503
|
+
# @api private
|
504
|
+
def replace_tokens(string)
|
505
|
+
data = string.dup
|
506
|
+
@tokens.each do |name, val|
|
507
|
+
data.gsub!(/\:#{name}/, val)
|
508
|
+
end
|
509
|
+
data
|
510
|
+
end
|
511
|
+
end # Spinner
|
512
|
+
end # TTY
|