austb-tty-spinner 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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