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.
@@ -0,0 +1,4 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative 'tty/spinner'
4
+ require_relative 'tty/spinner/multi'
@@ -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