cli-ui 1.5.1 → 2.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,35 +1,88 @@
1
+ # typed: true
2
+
1
3
  module CLI
2
4
  module UI
3
5
  module Spinner
4
6
  class SpinGroup
7
+ DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s }
8
+
9
+ class << self
10
+ extend T::Sig
11
+
12
+ sig { returns(Mutex) }
13
+ attr_reader :pause_mutex
14
+
15
+ sig { returns(T::Boolean) }
16
+ def paused?
17
+ @paused
18
+ end
19
+
20
+ sig do
21
+ type_parameters(:T)
22
+ .params(block: T.proc.returns(T.type_parameter(:T)))
23
+ .returns(T.type_parameter(:T))
24
+ end
25
+ def pause_spinners(&block)
26
+ previous_paused = T.let(nil, T.nilable(T::Boolean))
27
+ @pause_mutex.synchronize do
28
+ previous_paused = @paused
29
+ @paused = true
30
+ end
31
+ block.call
32
+ ensure
33
+ @pause_mutex.synchronize do
34
+ @paused = previous_paused
35
+ end
36
+ end
37
+ end
38
+
39
+ @pause_mutex = Mutex.new
40
+ @paused = false
41
+
42
+ extend T::Sig
43
+
5
44
  # Initializes a new spin group
6
45
  # This lets you add +Task+ objects to the group to multi-thread work
7
46
  #
8
47
  # ==== Options
9
48
  #
10
- # * +:auto_debrief+ - Automatically debrief exceptions? Default to true
49
+ # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
11
50
  #
12
51
  # ==== Example Usage
13
52
  #
14
- # spin_group = CLI::UI::SpinGroup.new
15
- # spin_group.add('Title') { |spinner| sleep 3.0 }
16
- # spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 }
17
- # spin_group.wait
53
+ # CLI::UI::SpinGroup.new do |spin_group|
54
+ # spin_group.add('Title') { |spinner| sleep 3.0 }
55
+ # spin_group.add('Title 2') { |spinner| sleep 3.0; spinner.update_title('New Title'); sleep 3.0 }
56
+ # end
18
57
  #
19
58
  # Output:
20
59
  #
21
60
  # https://user-images.githubusercontent.com/3074765/33798558-c452fa26-dce8-11e7-9e90-b4b34df21a46.gif
22
61
  #
62
+ sig { params(auto_debrief: T::Boolean).void }
23
63
  def initialize(auto_debrief: true)
24
64
  @m = Mutex.new
25
65
  @consumed_lines = 0
26
66
  @tasks = []
27
67
  @auto_debrief = auto_debrief
28
68
  @start = Time.new
69
+ if block_given?
70
+ yield self
71
+ wait
72
+ end
29
73
  end
30
74
 
31
75
  class Task
32
- attr_reader :title, :exception, :success, :stdout, :stderr
76
+ extend T::Sig
77
+
78
+ sig { returns(String) }
79
+ attr_reader :title, :stdout, :stderr
80
+
81
+ sig { returns(T::Boolean) }
82
+ attr_reader :success
83
+
84
+ sig { returns(T.nilable(Exception)) }
85
+ attr_reader :exception
33
86
 
34
87
  # Initializes a new Task
35
88
  # This is managed entirely internally by +SpinGroup+
@@ -39,11 +92,23 @@ module CLI
39
92
  # * +title+ - Title of the task
40
93
  # * +block+ - Block for the task, will be provided with an instance of the spinner
41
94
  #
42
- def initialize(title, &block)
95
+ sig do
96
+ params(
97
+ title: String,
98
+ final_glyph: T.proc.params(success: T::Boolean).returns(String),
99
+ merged_output: T::Boolean,
100
+ duplicate_output_to: IO,
101
+ block: T.proc.params(task: Task).returns(T.untyped),
102
+ ).void
103
+ end
104
+ def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, &block)
43
105
  @title = title
106
+ @final_glyph = final_glyph
44
107
  @always_full_render = title =~ Formatter::SCAN_WIDGET
45
108
  @thread = Thread.new do
46
- cap = CLI::UI::StdoutRouter::Capture.new(self, with_frame_inset: false, &block)
109
+ cap = CLI::UI::StdoutRouter::Capture.new(
110
+ merged_output: merged_output, duplicate_output_to: duplicate_output_to,
111
+ ) { block.call(self) }
47
112
  begin
48
113
  cap.run
49
114
  ensure
@@ -61,6 +126,7 @@ module CLI
61
126
 
62
127
  # Checks if a task is finished
63
128
  #
129
+ sig { returns(T::Boolean) }
64
130
  def check
65
131
  return true if @done
66
132
  return false if @thread.alive?
@@ -96,6 +162,7 @@ module CLI
96
162
  # * +force+ - force rerender of the task
97
163
  # * +width+ - current terminal width to format for
98
164
  #
165
+ sig { params(index: Integer, force: T::Boolean, width: Integer).returns(String) }
99
166
  def render(index, force = true, width: CLI::UI::Terminal.width)
100
167
  @m.synchronize do
101
168
  if force || @always_full_render || @force_full_render
@@ -114,6 +181,7 @@ module CLI
114
181
  #
115
182
  # * +title+ - title to change the spinner to
116
183
  #
184
+ sig { params(new_title: String).void }
117
185
  def update_title(new_title)
118
186
  @m.synchronize do
119
187
  @always_full_render = new_title =~ Formatter::SCAN_WIDGET
@@ -122,8 +190,14 @@ module CLI
122
190
  end
123
191
  end
124
192
 
193
+ sig { void }
194
+ def interrupt
195
+ @thread.raise(Interrupt)
196
+ end
197
+
125
198
  private
126
199
 
200
+ sig { params(index: Integer, terminal_width: Integer).returns(String) }
127
201
  def full_render(index, terminal_width)
128
202
  prefix = inset +
129
203
  glyph(index) +
@@ -137,22 +211,26 @@ module CLI
137
211
  "\e[K"
138
212
  end
139
213
 
214
+ sig { params(index: Integer).returns(String) }
140
215
  def partial_render(index)
141
216
  CLI::UI::ANSI.cursor_forward(inset_width) + glyph(index) + CLI::UI::Color::RESET.code
142
217
  end
143
218
 
219
+ sig { params(index: Integer).returns(String) }
144
220
  def glyph(index)
145
221
  if @done
146
- @success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s
222
+ @final_glyph.call(@success)
147
223
  else
148
224
  GLYPHS[index]
149
225
  end
150
226
  end
151
227
 
228
+ sig { returns(String) }
152
229
  def inset
153
230
  @inset ||= CLI::UI::Frame.prefix
154
231
  end
155
232
 
233
+ sig { returns(Integer) }
156
234
  def inset_width
157
235
  @inset_width ||= CLI::UI::ANSI.printing_width(inset)
158
236
  end
@@ -170,9 +248,30 @@ module CLI
170
248
  # spin_group.add('Title') { |spinner| sleep 1.0 }
171
249
  # spin_group.wait
172
250
  #
173
- def add(title, &block)
251
+ sig do
252
+ params(
253
+ title: String,
254
+ final_glyph: T.proc.params(success: T::Boolean).returns(String),
255
+ merged_output: T::Boolean,
256
+ duplicate_output_to: IO,
257
+ block: T.proc.params(task: Task).void,
258
+ ).void
259
+ end
260
+ def add(
261
+ title,
262
+ final_glyph: DEFAULT_FINAL_GLYPH,
263
+ merged_output: false,
264
+ duplicate_output_to: File.new(File::NULL, 'w'),
265
+ &block
266
+ )
174
267
  @m.synchronize do
175
- @tasks << Task.new(title, &block)
268
+ @tasks << Task.new(
269
+ title,
270
+ final_glyph: final_glyph,
271
+ merged_output: merged_output,
272
+ duplicate_output_to: duplicate_output_to,
273
+ &block
274
+ )
176
275
  end
177
276
  end
178
277
 
@@ -183,36 +282,41 @@ module CLI
183
282
  # spin_group.add('Title') { |spinner| sleep 1.0 }
184
283
  # spin_group.wait
185
284
  #
285
+ sig { returns(T::Boolean) }
186
286
  def wait
187
287
  idx = 0
188
288
 
189
289
  loop do
190
- all_done = true
290
+ done_count = 0
191
291
 
192
292
  width = CLI::UI::Terminal.width
193
293
 
194
- @m.synchronize do
195
- CLI::UI.raw do
196
- @tasks.each.with_index do |task, int_index|
197
- nat_index = int_index + 1
198
- task_done = task.check
199
- all_done = false unless task_done
200
-
201
- if nat_index > @consumed_lines
202
- print(task.render(idx, true, width: width) + "\n")
203
- @consumed_lines += 1
204
- else
205
- offset = @consumed_lines - int_index
206
- move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
207
- move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
208
-
209
- print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
294
+ self.class.pause_mutex.synchronize do
295
+ next if self.class.paused?
296
+
297
+ @m.synchronize do
298
+ CLI::UI.raw do
299
+ @tasks.each.with_index do |task, int_index|
300
+ nat_index = int_index + 1
301
+ task_done = task.check
302
+ done_count += 1 if task_done
303
+
304
+ if nat_index > @consumed_lines
305
+ print(task.render(idx, true, width: width) + "\n")
306
+ @consumed_lines += 1
307
+ else
308
+ offset = @consumed_lines - int_index
309
+ move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
310
+ move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
311
+
312
+ print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
313
+ end
210
314
  end
211
315
  end
212
316
  end
213
317
  end
214
318
 
215
- break if all_done
319
+ break if done_count == @tasks.size
216
320
 
217
321
  idx = (idx + 1) % GLYPHS.size
218
322
  Spinner.index = idx
@@ -222,24 +326,58 @@ module CLI
222
326
  if @auto_debrief
223
327
  debrief
224
328
  else
225
- @m.synchronize do
226
- @tasks.all?(&:success)
227
- end
329
+ all_succeeded?
330
+ end
331
+ rescue Interrupt
332
+ @tasks.each(&:interrupt)
333
+ raise
334
+ end
335
+
336
+ # Provide an alternative debriefing for failed tasks
337
+ sig do
338
+ params(
339
+ block: T.proc.params(title: String, exception: T.nilable(Exception), out: String, err: String).void,
340
+ ).void
341
+ end
342
+ def failure_debrief(&block)
343
+ @failure_debrief = block
344
+ end
345
+
346
+ # Provide a debriefing for successful tasks
347
+ sig do
348
+ params(
349
+ block: T.proc.params(title: String, out: String, err: String).void,
350
+ ).void
351
+ end
352
+ def success_debrief(&block)
353
+ @success_debrief = block
354
+ end
355
+
356
+ sig { returns(T::Boolean) }
357
+ def all_succeeded?
358
+ @m.synchronize do
359
+ @tasks.all?(&:success)
228
360
  end
229
361
  end
230
362
 
231
363
  # Debriefs failed tasks is +auto_debrief+ is true
232
364
  #
365
+ sig { returns(T::Boolean) }
233
366
  def debrief
234
367
  @m.synchronize do
235
368
  @tasks.each do |task|
236
- next if task.success
237
-
238
- e = task.exception
369
+ title = task.title
239
370
  out = task.stdout
240
371
  err = task.stderr
241
372
 
242
- CLI::UI::Frame.open('Task Failed: ' + task.title, color: :red, timing: Time.new - @start) do
373
+ if task.success
374
+ next @success_debrief&.call(title, out, err)
375
+ end
376
+
377
+ e = task.exception
378
+ next @failure_debrief.call(title, e, out, err) if @failure_debrief
379
+
380
+ CLI::UI::Frame.open('Task Failed: ' + title, color: :red, timing: Time.new - @start) do
243
381
  if e
244
382
  puts "#{e.class}: #{e.message}"
245
383
  puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
@@ -1,22 +1,33 @@
1
- # frozen-string-literal: true
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
2
4
  require 'cli/ui'
3
5
 
4
6
  module CLI
5
7
  module UI
6
8
  module Spinner
9
+ extend T::Sig
10
+
7
11
  autoload :Async, 'cli/ui/spinner/async'
8
12
  autoload :SpinGroup, 'cli/ui/spinner/spin_group'
9
13
 
10
14
  PERIOD = 0.1 # seconds
11
15
  TASK_FAILED = :task_failed
12
16
 
13
- RUNES = CLI::UI::OS.current.supports_emoji? ? %w(⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏).freeze : %w(\\ | / - \\ | / -).freeze
17
+ RUNES = if CLI::UI::OS.current.use_emoji?
18
+ ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'].freeze
19
+ else
20
+ ['\\', '|', '/', '-', '\\', '|', '/', '-'].freeze
21
+ end
14
22
 
15
23
  colors = [CLI::UI::Color::CYAN.code] * (RUNES.size / 2).ceil +
16
24
  [CLI::UI::Color::MAGENTA.code] * (RUNES.size / 2).to_i
17
25
  GLYPHS = colors.zip(RUNES).map(&:join)
18
26
 
19
27
  class << self
28
+ extend T::Sig
29
+
30
+ sig { returns(T.nilable(Integer)) }
20
31
  attr_accessor(:index)
21
32
 
22
33
  # We use this from CLI::UI::Widgets::Status to render an additional
@@ -29,37 +40,46 @@ module CLI
29
40
  # While it would be possible to stitch through some connection between
30
41
  # the SpinGroup and the Widgets included in its title, this is simpler
31
42
  # in practice and seems unlikely to cause issues in practice.
43
+ sig { returns(String) }
32
44
  def current_rune
33
45
  RUNES[index || 0]
34
46
  end
35
47
  end
36
48
 
37
- # Adds a single spinner
38
- # Uses an interactive session to allow the user to pick an answer
39
- # Can use arrows, y/n, numbers (1/2), and vim bindings to control
40
- #
41
- # https://user-images.githubusercontent.com/3074765/33798295-d94fd822-dce3-11e7-819b-43e5502d490e.gif
42
- #
43
- # ==== Attributes
44
- #
45
- # * +title+ - Title of the spinner to use
46
- #
47
- # ==== Options
48
- #
49
- # * +:auto_debrief+ - Automatically debrief exceptions? Default to true
50
- #
51
- # ==== Block
52
- #
53
- # * *spinner+ - Instance of the spinner. Can call +update_title+ to update the user of changes
54
- #
55
- # ==== Example Usage:
56
- #
57
- # CLI::UI::Spinner.spin('Title') { sleep 1.0 }
58
- #
59
- def self.spin(title, auto_debrief: true, &block)
60
- sg = SpinGroup.new(auto_debrief: auto_debrief)
61
- sg.add(title, &block)
62
- sg.wait
49
+ class << self
50
+ extend T::Sig
51
+
52
+ # Adds a single spinner
53
+ # Uses an interactive session to allow the user to pick an answer
54
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
55
+ #
56
+ # https://user-images.githubusercontent.com/3074765/33798295-d94fd822-dce3-11e7-819b-43e5502d490e.gif
57
+ #
58
+ # ==== Attributes
59
+ #
60
+ # * +title+ - Title of the spinner to use
61
+ #
62
+ # ==== Options
63
+ #
64
+ # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
65
+ #
66
+ # ==== Block
67
+ #
68
+ # * *spinner+ - Instance of the spinner. Can call +update_title+ to update the user of changes
69
+ #
70
+ # ==== Example Usage:
71
+ #
72
+ # CLI::UI::Spinner.spin('Title') { sleep 1.0 }
73
+ #
74
+ sig do
75
+ params(title: String, auto_debrief: T::Boolean, block: T.proc.params(task: SpinGroup::Task).void)
76
+ .returns(T::Boolean)
77
+ end
78
+ def spin(title, auto_debrief: true, &block)
79
+ sg = SpinGroup.new(auto_debrief: auto_debrief)
80
+ sg.add(title, &block)
81
+ sg.wait
82
+ end
63
83
  end
64
84
  end
65
85
  end