cli-ui 1.5.1 → 2.2.3

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.
@@ -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