cli-ui 2.3.1 → 2.6.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.
@@ -10,23 +10,17 @@ module CLI
10
10
  DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK : CLI::UI::Glyph::X }
11
11
 
12
12
  class << self
13
- extend T::Sig
14
-
15
- sig { returns(Mutex) }
13
+ #: Mutex
16
14
  attr_reader :pause_mutex
17
15
 
18
- sig { returns(T::Boolean) }
16
+ #: -> bool
19
17
  def paused?
20
18
  @paused
21
19
  end
22
20
 
23
- sig do
24
- type_parameters(:T)
25
- .params(block: T.proc.returns(T.type_parameter(:T)))
26
- .returns(T.type_parameter(:T))
27
- end
21
+ #: [T] { -> T } -> T
28
22
  def pause_spinners(&block)
29
- previous_paused = T.let(nil, T.nilable(T::Boolean))
23
+ previous_paused = nil #: bool?
30
24
  @pause_mutex.synchronize do
31
25
  previous_paused = @paused
32
26
  @paused = true
@@ -42,8 +36,6 @@ module CLI
42
36
  @pause_mutex = Mutex.new
43
37
  @paused = false
44
38
 
45
- extend T::Sig
46
-
47
39
  # Initializes a new spin group
48
40
  # This lets you add +Task+ objects to the group to multi-thread work
49
41
  #
@@ -67,15 +59,7 @@ module CLI
67
59
  #
68
60
  # https://user-images.githubusercontent.com/3074765/33798558-c452fa26-dce8-11e7-9e90-b4b34df21a46.gif
69
61
  #
70
- sig do
71
- params(
72
- auto_debrief: T::Boolean,
73
- interrupt_debrief: T::Boolean,
74
- max_concurrent: Integer,
75
- work_queue: T.nilable(WorkQueue),
76
- to: IOLike,
77
- ).void
78
- end
62
+ #: (?auto_debrief: bool, ?interrupt_debrief: bool, ?max_concurrent: Integer, ?work_queue: WorkQueue?, ?to: io_like) -> void
79
63
  def initialize(auto_debrief: true, interrupt_debrief: false, max_concurrent: 0, work_queue: nil, to: $stdout)
80
64
  @m = Mutex.new
81
65
  @tasks = []
@@ -85,10 +69,7 @@ module CLI
85
69
  @start = Time.new
86
70
  @stopped = false
87
71
  @internal_work_queue = work_queue.nil?
88
- @work_queue = T.let(
89
- work_queue || WorkQueue.new(max_concurrent.zero? ? 1024 : max_concurrent),
90
- WorkQueue,
91
- )
72
+ @work_queue = work_queue || WorkQueue.new(max_concurrent.zero? ? 1024 : max_concurrent) #: WorkQueue
92
73
  if block_given?
93
74
  yield self
94
75
  wait(to: to)
@@ -96,20 +77,21 @@ module CLI
96
77
  end
97
78
 
98
79
  class Task
99
- extend T::Sig
100
-
101
- sig { returns(String) }
80
+ #: String
102
81
  attr_reader :title, :stdout, :stderr
103
82
 
104
- sig { returns(T::Boolean) }
83
+ #: bool
105
84
  attr_reader :success
106
85
 
107
- sig { returns(T::Boolean) }
86
+ #: bool
108
87
  attr_reader :done
109
88
 
110
- sig { returns(T.nilable(Exception)) }
89
+ #: Exception?
111
90
  attr_reader :exception
112
91
 
92
+ #: Integer?
93
+ attr_reader :progress_percentage
94
+
113
95
  # Initializes a new Task
114
96
  # This is managed entirely internally by +SpinGroup+
115
97
  #
@@ -118,16 +100,7 @@ module CLI
118
100
  # * +title+ - Title of the task
119
101
  # * +block+ - Block for the task, will be provided with an instance of the spinner
120
102
  #
121
- sig do
122
- params(
123
- title: String,
124
- final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)),
125
- merged_output: T::Boolean,
126
- duplicate_output_to: IO,
127
- work_queue: WorkQueue,
128
- block: T.proc.params(task: Task).returns(T.untyped),
129
- ).void
130
- end
103
+ #: (String title, final_glyph: ^(bool success) -> (Glyph | String), merged_output: bool, duplicate_output_to: IO, work_queue: WorkQueue) { (Task task) -> untyped } -> void
131
104
  def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, work_queue:, &block)
132
105
  @title = title
133
106
  @final_glyph = final_glyph
@@ -149,16 +122,18 @@ module CLI
149
122
  @done = false
150
123
  @exception = nil
151
124
  @success = false
125
+ @progress_percentage = nil
126
+ @wants_progress_mode = false
152
127
  end
153
128
 
154
- sig { params(block: T.proc.params(task: Task).void).void }
129
+ #: { (Task task) -> void } -> void
155
130
  def on_done(&block)
156
131
  @on_done = block
157
132
  end
158
133
 
159
134
  # Checks if a task is finished
160
135
  #
161
- sig { returns(T::Boolean) }
136
+ #: -> bool
162
137
  def check
163
138
  return true if @done
164
139
  return false unless @future.completed?
@@ -196,7 +171,7 @@ module CLI
196
171
  # * +force+ - force rerender of the task
197
172
  # * +width+ - current terminal width to format for
198
173
  #
199
- sig { params(index: Integer, force: T::Boolean, width: Integer).returns(String) }
174
+ #: (Integer index, ?bool force, ?width: Integer) -> String
200
175
  def render(index, force = true, width: CLI::UI::Terminal.width)
201
176
  @m.synchronize do
202
177
  if !CLI::UI.enable_cursor? || force || @always_full_render || @force_full_render
@@ -215,7 +190,7 @@ module CLI
215
190
  #
216
191
  # * +title+ - title to change the spinner to
217
192
  #
218
- sig { params(new_title: String).void }
193
+ #: (String new_title) -> void
219
194
  def update_title(new_title)
220
195
  @m.synchronize do
221
196
  @always_full_render = new_title =~ Formatter::SCAN_WIDGET
@@ -224,9 +199,39 @@ module CLI
224
199
  end
225
200
  end
226
201
 
202
+ # Set progress percentage (0-100) and switch to progress mode
203
+ #: (Integer percentage) -> void
204
+ def set_progress(percentage) # rubocop:disable Naming/AccessorMethodName
205
+ @m.synchronize do
206
+ @progress_percentage = percentage.clamp(0, 100)
207
+ @wants_progress_mode = true
208
+ end
209
+ end
210
+
211
+ # Switch back to indeterminate mode
212
+ #: -> void
213
+ def clear_progress
214
+ @m.synchronize do
215
+ @progress_percentage = nil
216
+ @wants_progress_mode = false
217
+ end
218
+ end
219
+
220
+ # Check if this task wants progress mode
221
+ #: -> bool
222
+ def wants_progress_mode?
223
+ @m.synchronize { @wants_progress_mode }
224
+ end
225
+
226
+ # Get current progress percentage
227
+ #: -> Integer?
228
+ def current_progress
229
+ @m.synchronize { @progress_percentage }
230
+ end
231
+
227
232
  private
228
233
 
229
- sig { params(index: Integer, terminal_width: Integer).returns(String) }
234
+ #: (Integer index, Integer terminal_width) -> String
230
235
  def full_render(index, terminal_width)
231
236
  o = +''
232
237
 
@@ -242,7 +247,7 @@ module CLI
242
247
  o
243
248
  end
244
249
 
245
- sig { params(index: Integer).returns(String) }
250
+ #: (Integer index) -> String
246
251
  def partial_render(index)
247
252
  o = +''
248
253
 
@@ -252,7 +257,7 @@ module CLI
252
257
  o
253
258
  end
254
259
 
255
- sig { params(index: Integer).returns(String) }
260
+ #: (Integer index) -> String
256
261
  def glyph(index)
257
262
  if @done
258
263
  final_glyph = @final_glyph.call(@success)
@@ -272,12 +277,12 @@ module CLI
272
277
  end
273
278
  end
274
279
 
275
- sig { returns(String) }
280
+ #: -> String
276
281
  def inset
277
282
  @inset ||= CLI::UI::Frame.prefix
278
283
  end
279
284
 
280
- sig { returns(Integer) }
285
+ #: -> Integer
281
286
  def inset_width
282
287
  @inset_width ||= CLI::UI::ANSI.printing_width(inset)
283
288
  end
@@ -295,15 +300,7 @@ module CLI
295
300
  # spin_group.add('Title') { |spinner| sleep 1.0 }
296
301
  # spin_group.wait
297
302
  #
298
- sig do
299
- params(
300
- title: String,
301
- final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)),
302
- merged_output: T::Boolean,
303
- duplicate_output_to: IO,
304
- block: T.proc.params(task: Task).void,
305
- ).void
306
- end
303
+ #: (String title, ?final_glyph: ^(bool success) -> (Glyph | String), ?merged_output: bool, ?duplicate_output_to: IO) { (Task task) -> void } -> void
307
304
  def add(
308
305
  title,
309
306
  final_glyph: DEFAULT_FINAL_GLYPH,
@@ -323,7 +320,7 @@ module CLI
323
320
  end
324
321
  end
325
322
 
326
- sig { void }
323
+ #: -> void
327
324
  def stop
328
325
  # If we already own the mutex (called from within another synchronized block),
329
326
  # set stopped directly to avoid deadlock
@@ -342,7 +339,7 @@ module CLI
342
339
  @work_queue.interrupt
343
340
  end
344
341
 
345
- sig { returns(T::Boolean) }
342
+ #: -> bool
346
343
  def stopped?
347
344
  if @m.owned?
348
345
  @stopped
@@ -363,93 +360,88 @@ module CLI
363
360
  # spin_group.add('Title') { |spinner| sleep 1.0 }
364
361
  # spin_group.wait
365
362
  #
366
- sig { params(to: IOLike).returns(T::Boolean) }
363
+ #: (?to: io_like) -> bool
367
364
  def wait(to: $stdout)
368
- idx = 0
365
+ result = false #: bool
369
366
 
370
- consumed_lines = 0
367
+ CLI::UI::ProgressReporter.with_progress(mode: :indeterminate, to: to, delay_start: true) do |reporter|
368
+ idx = 0
369
+ consumed_lines = 0
371
370
 
372
- @work_queue.close if @internal_work_queue
371
+ @work_queue.close if @internal_work_queue
373
372
 
374
- tasks_seen = @tasks.map { false }
375
- tasks_seen_done = @tasks.map { false }
373
+ tasks_seen = @tasks.map { false }
374
+ tasks_seen_done = @tasks.map { false }
376
375
 
377
- loop do
378
- break if stopped?
376
+ current_mode = :indeterminate #: Symbol
377
+ first_render = true #: bool
379
378
 
380
- done_count = 0
379
+ loop do
380
+ break if stopped?
381
381
 
382
- width = CLI::UI::Terminal.width
382
+ done_count = 0
383
+ width = CLI::UI::Terminal.width
383
384
 
384
- self.class.pause_mutex.synchronize do
385
- next if self.class.paused?
385
+ # Update progress mode based on task states
386
+ current_mode = update_progress_mode(reporter, current_mode, first_render)
386
387
 
387
- @m.synchronize do
388
- CLI::UI.raw do
389
- force_full_render = false
388
+ self.class.pause_mutex.synchronize do
389
+ next if self.class.paused?
390
390
 
391
- unless @puts_above.empty?
392
- to.print(CLI::UI::ANSI.cursor_up(consumed_lines)) if CLI::UI.enable_cursor?
393
- while (message = @puts_above.shift)
394
- to.print(CLI::UI::ANSI.insert_lines(message.lines.count)) if CLI::UI.enable_cursor?
395
- message.lines.each do |line|
396
- to.print(CLI::UI::Frame.prefix + CLI::UI.fmt(line))
397
- end
398
- to.print("\n")
399
- end
400
- # we descend with newlines rather than ANSI.cursor_down as the inserted lines may've
401
- # pushed the spinner off the front of the buffer, so we can't move back down below it
402
- to.print("\n" * consumed_lines) if CLI::UI.enable_cursor?
391
+ @m.synchronize do
392
+ CLI::UI.raw do
393
+ # Render any messages above the spinner
394
+ force_full_render = render_puts_above(to, consumed_lines)
403
395
 
404
- force_full_render = true
405
- end
406
-
407
- @tasks.each.with_index do |task, int_index|
408
- nat_index = int_index + 1
409
- task_done = task.check
410
- done_count += 1 if task_done
411
-
412
- if CLI::UI.enable_cursor?
413
- if nat_index > consumed_lines
414
- to.print(task.render(idx, true, width: width) + "\n")
415
- consumed_lines += 1
416
- else
417
- offset = consumed_lines - int_index
418
- move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
419
- move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
420
-
421
- to.print(move_to + task.render(idx, idx.zero? || force_full_render, width: width) + move_from)
422
- end
423
- elsif !tasks_seen[int_index] || (task_done && !tasks_seen_done[int_index])
424
- to.print(task.render(idx, true, width: width) + "\n")
425
- end
426
-
427
- tasks_seen[int_index] = true
428
- tasks_seen_done[int_index] ||= task_done
396
+ # Render all tasks
397
+ done_count, consumed_lines = render_tasks(
398
+ to: to,
399
+ tasks_seen: tasks_seen,
400
+ tasks_seen_done: tasks_seen_done,
401
+ consumed_lines: consumed_lines,
402
+ idx: idx,
403
+ force_full_render: force_full_render,
404
+ width: width,
405
+ )
429
406
  end
430
407
  end
431
408
  end
409
+
410
+ break if done_count == @tasks.size
411
+
412
+ # After first render, start the progress reporter in indeterminate mode
413
+ if first_render
414
+ reporter.force_set_indeterminate
415
+ first_render = false
416
+ end
417
+
418
+ idx = (idx + 1) % GLYPHS.size
419
+ Spinner.index = idx
420
+ sleep(PERIOD)
432
421
  end
433
422
 
434
- break if done_count == @tasks.size
423
+ # Show error state briefly if tasks failed
424
+ success = all_succeeded?
425
+ unless success
426
+ reporter.set_error
427
+ sleep(0.5)
428
+ end
435
429
 
436
- idx = (idx + 1) % GLYPHS.size
437
- Spinner.index = idx
438
- sleep(PERIOD)
430
+ result = if @auto_debrief
431
+ debrief(to: to)
432
+ else
433
+ all_succeeded?
434
+ end
439
435
  end
440
436
 
441
- if @auto_debrief
442
- debrief(to: to)
443
- else
444
- all_succeeded?
445
- end
437
+ result
446
438
  rescue Interrupt
447
439
  @work_queue.interrupt
448
440
  debrief(to: to) if @interrupt_debrief
449
441
  stopped? ? false : raise
450
442
  end
451
443
 
452
- sig { params(message: String).void }
444
+ #: (String message) -> void
453
445
  def puts_above(message)
454
446
  @m.synchronize do
455
447
  @puts_above << message
@@ -457,32 +449,110 @@ module CLI
457
449
  end
458
450
 
459
451
  # Provide an alternative debriefing for failed tasks
460
- sig do
461
- params(
462
- block: T.proc.params(title: String, exception: T.nilable(Exception), out: String, err: String).void,
463
- ).void
464
- end
452
+ #: { (String title, Exception? exception, String out, String err) -> void } -> void
465
453
  def failure_debrief(&block)
466
454
  @failure_debrief = block
467
455
  end
468
456
 
469
457
  # Provide a debriefing for successful tasks
470
- sig do
471
- params(
472
- block: T.proc.params(title: String, out: String, err: String).void,
473
- ).void
474
- end
458
+ #: { (String title, String out, String err) -> void } -> void
475
459
  def success_debrief(&block)
476
460
  @success_debrief = block
477
461
  end
478
462
 
479
- sig { returns(T::Boolean) }
463
+ #: -> bool
480
464
  def all_succeeded?
481
465
  @m.synchronize do
482
466
  @tasks.all?(&:success)
483
467
  end
484
468
  end
485
469
 
470
+ private
471
+
472
+ # Update progress reporter mode based on task progress states
473
+ #: (CLI::UI::ProgressReporter::Reporter reporter, Symbol current_mode, bool first_render) -> Symbol
474
+ def update_progress_mode(reporter, current_mode, first_render)
475
+ # Don't emit OSC on first iteration
476
+ return current_mode if first_render
477
+
478
+ # Check if any task wants progress mode
479
+ task_with_progress = @tasks.find(&:wants_progress_mode?)
480
+
481
+ if task_with_progress
482
+ progress = task_with_progress.current_progress
483
+ if progress
484
+ reporter.force_set_progress(progress)
485
+ if current_mode != :progress
486
+ # Switch to progress mode
487
+ :progress
488
+ else
489
+ # Update progress
490
+ current_mode
491
+ end
492
+ else
493
+ current_mode
494
+ end
495
+ elsif current_mode != :indeterminate
496
+ # No task wants progress, switch back to indeterminate
497
+ reporter.force_set_indeterminate
498
+ :indeterminate
499
+ else
500
+ current_mode
501
+ end
502
+ end
503
+
504
+ # Render messages that should appear above the spinner
505
+ #: (io_like to, Integer consumed_lines) -> bool
506
+ def render_puts_above(to, consumed_lines)
507
+ return false if @puts_above.empty?
508
+
509
+ to.print(CLI::UI::ANSI.cursor_up(consumed_lines)) if CLI::UI.enable_cursor?
510
+ while (message = @puts_above.shift)
511
+ to.print(CLI::UI::ANSI.insert_lines(message.lines.count)) if CLI::UI.enable_cursor?
512
+ message.lines.each do |line|
513
+ to.print(CLI::UI::Frame.prefix + CLI::UI.fmt(line))
514
+ end
515
+ to.print("\n")
516
+ end
517
+ # we descend with newlines rather than ANSI.cursor_down as the inserted lines may've
518
+ # pushed the spinner off the front of the buffer, so we can't move back down below it
519
+ to.print("\n" * consumed_lines) if CLI::UI.enable_cursor?
520
+
521
+ true # force full render needed
522
+ end
523
+
524
+ # Render all tasks
525
+ #: (to: io_like, tasks_seen: Array[bool], tasks_seen_done: Array[bool], consumed_lines: Integer, idx: Integer, force_full_render: bool, width: Integer) -> [Integer, Integer]
526
+ def render_tasks(to:, tasks_seen:, tasks_seen_done:, consumed_lines:, idx:, force_full_render:, width:)
527
+ done_count = 0
528
+
529
+ @tasks.each.with_index do |task, int_index|
530
+ nat_index = int_index + 1
531
+ task_done = task.check
532
+ done_count += 1 if task_done
533
+
534
+ if CLI::UI.enable_cursor?
535
+ if nat_index > consumed_lines
536
+ to.print(task.render(idx, true, width: width) + "\n")
537
+ consumed_lines += 1
538
+ else
539
+ offset = consumed_lines - int_index
540
+ move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
541
+ move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
542
+
543
+ to.print(move_to + task.render(idx, idx.zero? || force_full_render, width: width) + move_from)
544
+ end
545
+ elsif !tasks_seen[int_index] || (task_done && !tasks_seen_done[int_index])
546
+ to.print(task.render(idx, true, width: width) + "\n")
547
+ end
548
+
549
+ tasks_seen[int_index] = true
550
+ tasks_seen_done[int_index] ||= task_done
551
+ end
552
+
553
+ [done_count, consumed_lines]
554
+ end
555
+
486
556
  # Debriefs failed tasks is +auto_debrief+ is true
487
557
  #
488
558
  # ==== Options
@@ -490,7 +560,7 @@ module CLI
490
560
  # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
491
561
  # or under Sorbet, IO or StringIO. Defaults to $stdout
492
562
  #
493
- sig { params(to: IOLike).returns(T::Boolean) }
563
+ #: (?to: io_like) -> bool
494
564
  def debrief(to: $stdout)
495
565
  @m.synchronize do
496
566
  @tasks.each do |task|
@@ -6,8 +6,6 @@ require 'cli/ui'
6
6
  module CLI
7
7
  module UI
8
8
  module Spinner
9
- extend T::Sig
10
-
11
9
  autoload :Async, 'cli/ui/spinner/async'
12
10
  autoload :SpinGroup, 'cli/ui/spinner/spin_group'
13
11
 
@@ -25,9 +23,7 @@ module CLI
25
23
  GLYPHS = colors.zip(RUNES).map { |c, r| c + r + CLI::UI::Color::RESET.code }.freeze
26
24
 
27
25
  class << self
28
- extend T::Sig
29
-
30
- sig { returns(T.nilable(Integer)) }
26
+ #: Integer?
31
27
  attr_accessor(:index)
32
28
 
33
29
  # We use this from CLI::UI::Widgets::Status to render an additional
@@ -40,15 +36,13 @@ module CLI
40
36
  # While it would be possible to stitch through some connection between
41
37
  # the SpinGroup and the Widgets included in its title, this is simpler
42
38
  # in practice and seems unlikely to cause issues in practice.
43
- sig { returns(String) }
39
+ #: -> String
44
40
  def current_rune
45
41
  RUNES[index || 0]
46
42
  end
47
43
  end
48
44
 
49
45
  class << self
50
- extend T::Sig
51
-
52
46
  # Adds a single spinner
53
47
  # Uses an interactive session to allow the user to pick an answer
54
48
  # Can use arrows, y/n, numbers (1/2), and vim bindings to control
@@ -73,14 +67,7 @@ module CLI
73
67
  #
74
68
  # CLI::UI::Spinner.spin('Title') { sleep 1.0 }
75
69
  #
76
- sig do
77
- params(
78
- title: String,
79
- auto_debrief: T::Boolean,
80
- to: IOLike,
81
- block: T.proc.params(task: SpinGroup::Task).void,
82
- ).returns(T::Boolean)
83
- end
70
+ #: (String title, ?auto_debrief: bool, ?to: io_like) { (SpinGroup::Task task) -> void } -> bool
84
71
  def spin(title, auto_debrief: true, to: $stdout, &block)
85
72
  sg = SpinGroup.new(auto_debrief: auto_debrief)
86
73
  sg.add(title, &block)