cli-ui 2.2.3 → 2.3.1

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,10 +1,13 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../work_queue'
2
5
 
3
6
  module CLI
4
7
  module UI
5
8
  module Spinner
6
9
  class SpinGroup
7
- DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK.to_s : CLI::UI::Glyph::X.to_s }
10
+ DEFAULT_FINAL_GLYPH = ->(success) { success ? CLI::UI::Glyph::CHECK : CLI::UI::Glyph::X }
8
11
 
9
12
  class << self
10
13
  extend T::Sig
@@ -47,6 +50,11 @@ module CLI
47
50
  # ==== Options
48
51
  #
49
52
  # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
53
+ # * +:interrupt_debrief+ - Automatically debrief on interrupt. Default to false
54
+ # * +:max_concurrent+ - Maximum number of concurrent tasks. Default is 0 (effectively unlimited)
55
+ # * +:work_queue+ - Custom WorkQueue instance. If not provided, a new one will be created
56
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
57
+ # or under Sorbet, IO or StringIO. Defaults to $stdout
50
58
  #
51
59
  # ==== Example Usage
52
60
  #
@@ -59,16 +67,31 @@ module CLI
59
67
  #
60
68
  # https://user-images.githubusercontent.com/3074765/33798558-c452fa26-dce8-11e7-9e90-b4b34df21a46.gif
61
69
  #
62
- sig { params(auto_debrief: T::Boolean).void }
63
- def initialize(auto_debrief: true)
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
79
+ def initialize(auto_debrief: true, interrupt_debrief: false, max_concurrent: 0, work_queue: nil, to: $stdout)
64
80
  @m = Mutex.new
65
- @consumed_lines = 0
66
81
  @tasks = []
82
+ @puts_above = []
67
83
  @auto_debrief = auto_debrief
84
+ @interrupt_debrief = interrupt_debrief
68
85
  @start = Time.new
86
+ @stopped = false
87
+ @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
+ )
69
92
  if block_given?
70
93
  yield self
71
- wait
94
+ wait(to: to)
72
95
  end
73
96
  end
74
97
 
@@ -81,6 +104,9 @@ module CLI
81
104
  sig { returns(T::Boolean) }
82
105
  attr_reader :success
83
106
 
107
+ sig { returns(T::Boolean) }
108
+ attr_reader :done
109
+
84
110
  sig { returns(T.nilable(Exception)) }
85
111
  attr_reader :exception
86
112
 
@@ -95,17 +121,18 @@ module CLI
95
121
  sig do
96
122
  params(
97
123
  title: String,
98
- final_glyph: T.proc.params(success: T::Boolean).returns(String),
124
+ final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)),
99
125
  merged_output: T::Boolean,
100
126
  duplicate_output_to: IO,
127
+ work_queue: WorkQueue,
101
128
  block: T.proc.params(task: Task).returns(T.untyped),
102
129
  ).void
103
130
  end
104
- def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, &block)
131
+ def initialize(title, final_glyph:, merged_output:, duplicate_output_to:, work_queue:, &block)
105
132
  @title = title
106
133
  @final_glyph = final_glyph
107
134
  @always_full_render = title =~ Formatter::SCAN_WIDGET
108
- @thread = Thread.new do
135
+ @future = work_queue.enqueue do
109
136
  cap = CLI::UI::StdoutRouter::Capture.new(
110
137
  merged_output: merged_output, duplicate_output_to: duplicate_output_to,
111
138
  ) { block.call(self) }
@@ -121,7 +148,12 @@ module CLI
121
148
  @force_full_render = false
122
149
  @done = false
123
150
  @exception = nil
124
- @success = false
151
+ @success = false
152
+ end
153
+
154
+ sig { params(block: T.proc.params(task: Task).void).void }
155
+ def on_done(&block)
156
+ @on_done = block
125
157
  end
126
158
 
127
159
  # Checks if a task is finished
@@ -129,18 +161,20 @@ module CLI
129
161
  sig { returns(T::Boolean) }
130
162
  def check
131
163
  return true if @done
132
- return false if @thread.alive?
164
+ return false unless @future.completed?
133
165
 
134
166
  @done = true
135
167
  begin
136
- status = @thread.join.status
137
- @success = (status == false)
138
- @success = false if @thread.value == TASK_FAILED
168
+ result = @future.value
169
+ @success = true
170
+ @success = false if result == TASK_FAILED
139
171
  rescue => exc
140
172
  @exception = exc
141
173
  @success = false
142
174
  end
143
175
 
176
+ @on_done&.call(self)
177
+
144
178
  @done
145
179
  end
146
180
 
@@ -165,7 +199,7 @@ module CLI
165
199
  sig { params(index: Integer, force: T::Boolean, width: Integer).returns(String) }
166
200
  def render(index, force = true, width: CLI::UI::Terminal.width)
167
201
  @m.synchronize do
168
- if force || @always_full_render || @force_full_render
202
+ if !CLI::UI.enable_cursor? || force || @always_full_render || @force_full_render
169
203
  full_render(index, width)
170
204
  else
171
205
  partial_render(index)
@@ -190,38 +224,51 @@ module CLI
190
224
  end
191
225
  end
192
226
 
193
- sig { void }
194
- def interrupt
195
- @thread.raise(Interrupt)
196
- end
197
-
198
227
  private
199
228
 
200
229
  sig { params(index: Integer, terminal_width: Integer).returns(String) }
201
230
  def full_render(index, terminal_width)
202
- prefix = inset +
203
- glyph(index) +
204
- CLI::UI::Color::RESET.code +
205
- ' '
231
+ o = +''
232
+
233
+ o << inset
234
+ o << glyph(index)
235
+ o << ' '
236
+
237
+ truncation_width = terminal_width - CLI::UI::ANSI.printing_width(o)
206
238
 
207
- truncation_width = terminal_width - CLI::UI::ANSI.printing_width(prefix)
239
+ o << CLI::UI.resolve_text(title, truncate_to: truncation_width)
240
+ o << ANSI.clear_to_end_of_line if CLI::UI.enable_cursor?
208
241
 
209
- prefix +
210
- CLI::UI.resolve_text(title, truncate_to: truncation_width) +
211
- "\e[K"
242
+ o
212
243
  end
213
244
 
214
245
  sig { params(index: Integer).returns(String) }
215
246
  def partial_render(index)
216
- CLI::UI::ANSI.cursor_forward(inset_width) + glyph(index) + CLI::UI::Color::RESET.code
247
+ o = +''
248
+
249
+ o << CLI::UI::ANSI.cursor_forward(inset_width)
250
+ o << glyph(index)
251
+
252
+ o
217
253
  end
218
254
 
219
255
  sig { params(index: Integer).returns(String) }
220
256
  def glyph(index)
221
257
  if @done
222
- @final_glyph.call(@success)
258
+ final_glyph = @final_glyph.call(@success)
259
+ if final_glyph.is_a?(Glyph)
260
+ CLI::UI.enable_color? ? final_glyph.to_s : final_glyph.char
261
+ else
262
+ final_glyph
263
+ end
264
+ elsif CLI::UI.enable_cursor?
265
+ if !@future.started?
266
+ CLI::UI.enable_color? ? Glyph::HOURGLASS.to_s : Glyph::HOURGLASS.char
267
+ else
268
+ CLI::UI.enable_color? ? GLYPHS[index] : RUNES[index]
269
+ end
223
270
  else
224
- GLYPHS[index]
271
+ Glyph::HOURGLASS.char
225
272
  end
226
273
  end
227
274
 
@@ -251,7 +298,7 @@ module CLI
251
298
  sig do
252
299
  params(
253
300
  title: String,
254
- final_glyph: T.proc.params(success: T::Boolean).returns(String),
301
+ final_glyph: T.proc.params(success: T::Boolean).returns(T.any(Glyph, String)),
255
302
  merged_output: T::Boolean,
256
303
  duplicate_output_to: IO,
257
304
  block: T.proc.params(task: Task).void,
@@ -270,23 +317,66 @@ module CLI
270
317
  final_glyph: final_glyph,
271
318
  merged_output: merged_output,
272
319
  duplicate_output_to: duplicate_output_to,
320
+ work_queue: @work_queue,
273
321
  &block
274
322
  )
275
323
  end
276
324
  end
277
325
 
326
+ sig { void }
327
+ def stop
328
+ # If we already own the mutex (called from within another synchronized block),
329
+ # set stopped directly to avoid deadlock
330
+ if @m.owned?
331
+ return if @stopped
332
+
333
+ @stopped = true
334
+ else
335
+ @m.synchronize do
336
+ return if @stopped
337
+
338
+ @stopped = true
339
+ end
340
+ end
341
+ # Interrupt is thread-safe on its own, so we can call it outside the mutex
342
+ @work_queue.interrupt
343
+ end
344
+
345
+ sig { returns(T::Boolean) }
346
+ def stopped?
347
+ if @m.owned?
348
+ @stopped
349
+ else
350
+ @m.synchronize { @stopped }
351
+ end
352
+ end
353
+
278
354
  # Tells the group you're done adding tasks and to wait for all of them to finish
279
355
  #
356
+ # ==== Options
357
+ #
358
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
359
+ # or under Sorbet, IO or StringIO. Defaults to $stdout
360
+ #
280
361
  # ==== Example Usage:
281
362
  # spin_group = CLI::UI::SpinGroup.new
282
363
  # spin_group.add('Title') { |spinner| sleep 1.0 }
283
364
  # spin_group.wait
284
365
  #
285
- sig { returns(T::Boolean) }
286
- def wait
366
+ sig { params(to: IOLike).returns(T::Boolean) }
367
+ def wait(to: $stdout)
287
368
  idx = 0
288
369
 
370
+ consumed_lines = 0
371
+
372
+ @work_queue.close if @internal_work_queue
373
+
374
+ tasks_seen = @tasks.map { false }
375
+ tasks_seen_done = @tasks.map { false }
376
+
289
377
  loop do
378
+ break if stopped?
379
+
290
380
  done_count = 0
291
381
 
292
382
  width = CLI::UI::Terminal.width
@@ -296,21 +386,46 @@ module CLI
296
386
 
297
387
  @m.synchronize do
298
388
  CLI::UI.raw do
389
+ force_full_render = false
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?
403
+
404
+ force_full_render = true
405
+ end
406
+
299
407
  @tasks.each.with_index do |task, int_index|
300
408
  nat_index = int_index + 1
301
409
  task_done = task.check
302
410
  done_count += 1 if task_done
303
411
 
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)
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")
313
425
  end
426
+
427
+ tasks_seen[int_index] = true
428
+ tasks_seen_done[int_index] ||= task_done
314
429
  end
315
430
  end
316
431
  end
@@ -324,13 +439,21 @@ module CLI
324
439
  end
325
440
 
326
441
  if @auto_debrief
327
- debrief
442
+ debrief(to: to)
328
443
  else
329
444
  all_succeeded?
330
445
  end
331
446
  rescue Interrupt
332
- @tasks.each(&:interrupt)
333
- raise
447
+ @work_queue.interrupt
448
+ debrief(to: to) if @interrupt_debrief
449
+ stopped? ? false : raise
450
+ end
451
+
452
+ sig { params(message: String).void }
453
+ def puts_above(message)
454
+ @m.synchronize do
455
+ @puts_above << message
456
+ end
334
457
  end
335
458
 
336
459
  # Provide an alternative debriefing for failed tasks
@@ -362,10 +485,17 @@ module CLI
362
485
 
363
486
  # Debriefs failed tasks is +auto_debrief+ is true
364
487
  #
365
- sig { returns(T::Boolean) }
366
- def debrief
488
+ # ==== Options
489
+ #
490
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
491
+ # or under Sorbet, IO or StringIO. Defaults to $stdout
492
+ #
493
+ sig { params(to: IOLike).returns(T::Boolean) }
494
+ def debrief(to: $stdout)
367
495
  @m.synchronize do
368
496
  @tasks.each do |task|
497
+ next unless task.done
498
+
369
499
  title = task.title
370
500
  out = task.stdout
371
501
  err = task.stderr
@@ -374,22 +504,23 @@ module CLI
374
504
  next @success_debrief&.call(title, out, err)
375
505
  end
376
506
 
507
+ # exception will not be set if the wait loop is stopped before the task is checked
377
508
  e = task.exception
378
509
  next @failure_debrief.call(title, e, out, err) if @failure_debrief
379
510
 
380
511
  CLI::UI::Frame.open('Task Failed: ' + title, color: :red, timing: Time.new - @start) do
381
512
  if e
382
- puts "#{e.class}: #{e.message}"
383
- puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
513
+ to.puts("#{e.class}: #{e.message}")
514
+ to.puts("\tfrom #{e.backtrace.join("\n\tfrom ")}")
384
515
  end
385
516
 
386
517
  CLI::UI::Frame.divider('STDOUT')
387
518
  out = '(empty)' if out.nil? || out.strip.empty?
388
- puts out
519
+ to.puts(out)
389
520
 
390
521
  CLI::UI::Frame.divider('STDERR')
391
522
  err = '(empty)' if err.nil? || err.strip.empty?
392
- puts err
523
+ to.puts(err)
393
524
  end
394
525
  end
395
526
  @tasks.all?(&:success)
@@ -22,7 +22,7 @@ module CLI
22
22
 
23
23
  colors = [CLI::UI::Color::CYAN.code] * (RUNES.size / 2).ceil +
24
24
  [CLI::UI::Color::MAGENTA.code] * (RUNES.size / 2).to_i
25
- GLYPHS = colors.zip(RUNES).map(&:join)
25
+ GLYPHS = colors.zip(RUNES).map { |c, r| c + r + CLI::UI::Color::RESET.code }.freeze
26
26
 
27
27
  class << self
28
28
  extend T::Sig
@@ -62,6 +62,8 @@ module CLI
62
62
  # ==== Options
63
63
  #
64
64
  # * +:auto_debrief+ - Automatically debrief exceptions or through success_debrief? Default to true
65
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
66
+ # or under Sorbet, IO or StringIO. Defaults to $stdout.
65
67
  #
66
68
  # ==== Block
67
69
  #
@@ -72,13 +74,17 @@ module CLI
72
74
  # CLI::UI::Spinner.spin('Title') { sleep 1.0 }
73
75
  #
74
76
  sig do
75
- params(title: String, auto_debrief: T::Boolean, block: T.proc.params(task: SpinGroup::Task).void)
76
- .returns(T::Boolean)
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)
77
83
  end
78
- def spin(title, auto_debrief: true, &block)
84
+ def spin(title, auto_debrief: true, to: $stdout, &block)
79
85
  sg = SpinGroup.new(auto_debrief: auto_debrief)
80
86
  sg.add(title, &block)
81
- sg.wait
87
+ sg.wait(to: to)
82
88
  end
83
89
  end
84
90
  end
@@ -1,4 +1,5 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'cli/ui'
4
5
  require 'stringio'
@@ -18,7 +19,8 @@ module CLI
18
19
 
19
20
  sig { params(args: Object).returns(Integer) }
20
21
  def write(*args)
21
- args = args.map do |str|
22
+ strs = args.map do |obj|
23
+ str = obj.to_s
22
24
  if auto_frame_inset?
23
25
  str = str.dup # unfreeze
24
26
  str = str.to_s.force_encoding(Encoding::UTF_8)
@@ -31,13 +33,13 @@ module CLI
31
33
 
32
34
  # hook return of false suppresses output.
33
35
  if (hook = Thread.current[:cliui_output_hook])
34
- return 0 if hook.call(args.map(&:to_s).join, @name) == false
36
+ return 0 if hook.call(strs.join, @name) == false
35
37
  end
36
38
 
37
- ret = T.unsafe(@stream).write_without_cli_ui(*prepend_id(@stream, args))
39
+ ret = T.unsafe(@stream).write_without_cli_ui(*prepend_id(@stream, strs))
38
40
  if (dup = StdoutRouter.duplicate_output_to)
39
41
  begin
40
- T.unsafe(dup).write(*prepend_id(dup, args))
42
+ T.unsafe(dup).write(*prepend_id(dup, strs))
41
43
  rescue IOError
42
44
  # Ignore
43
45
  end
@@ -0,0 +1,87 @@
1
+ # typed: true
2
+
3
+ module CLI
4
+ module UI
5
+ module Table
6
+ extend T::Sig
7
+
8
+ class << self
9
+ extend T::Sig
10
+
11
+ # Prints a formatted table to the specified output
12
+ # Automatically pads columns to align based on the longest cell in each column,
13
+ # ignoring the width of ANSI color codes.
14
+ #
15
+ # ==== Attributes
16
+ #
17
+ # * +table+ - (required) 2D array of strings representing the table data
18
+ #
19
+ # ==== Options
20
+ #
21
+ # * +:col_spacing+ - Number of spaces between columns. Defaults to 1
22
+ # * +:to+ - Target stream, like $stdout or $stderr. Can be anything with print and puts methods,
23
+ # or under Sorbet, IO or StringIO. Defaults to $stdout
24
+ #
25
+ # ==== Example
26
+ #
27
+ # CLI::UI::Table.puts_table([
28
+ # ["{{bold:header_1}}", "{{bold:header_2}}"],
29
+ # ["really_long_cell", "short"],
30
+ # ["row2", "row2"]
31
+ # ])
32
+ #
33
+ # Default Output:
34
+ # header_1 header_2
35
+ # really_long_cell short
36
+ # row2 row2
37
+ #
38
+ sig { params(table: T::Array[T::Array[String]], col_spacing: Integer, to: IOLike).void }
39
+ def puts_table(table, col_spacing: 1, to: $stdout)
40
+ col_sizes = table.transpose.map do |col|
41
+ col.map { |cell| CLI::UI::ANSI.printing_width(CLI::UI.resolve_text(cell)) }.max
42
+ end
43
+
44
+ table.each do |row|
45
+ padded_row = row.each_with_index.map do |cell, i|
46
+ col_size = T.must(col_sizes[i]) # guaranteed to be non-nil
47
+ cell_size = CLI::UI::ANSI.printing_width(CLI::UI.resolve_text(cell))
48
+ padded_cell = cell + ' ' * (col_size - cell_size)
49
+ padded_cell
50
+ end
51
+ CLI::UI.puts(padded_row.join(' ' * col_spacing), to: to)
52
+ end
53
+ end
54
+
55
+ # Captures a table's output as an array of strings without printing to the terminal
56
+ # Can be used to further manipulate or format the table output
57
+ #
58
+ # ==== Attributes
59
+ #
60
+ # * +table+ - (required) 2D array of strings representing the table data
61
+ #
62
+ # ==== Options
63
+ #
64
+ # * +:col_spacing+ - Number of spaces between columns. Defaults to 1
65
+ #
66
+ # ==== Returns
67
+ #
68
+ # * +Array[String]+ - Array of strings, each representing a row of the formatted table
69
+ #
70
+ # ==== Example
71
+ #
72
+ # CLI::UI::Table.capture_table([
73
+ # ["{{bold:header_1}}", "{{bold:header_2}}"],
74
+ # ["really_long_cell", "short"],
75
+ # ["row2", "row2"]
76
+ # ])
77
+ #
78
+ sig { params(table: T::Array[T::Array[String]], col_spacing: Integer).returns(T::Array[String]) }
79
+ def capture_table(table, col_spacing: 1)
80
+ strio = StringIO.new
81
+ puts_table(table, col_spacing: col_spacing, to: strio)
82
+ strio.string.lines.map(&:chomp)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -1,4 +1,5 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'cli/ui'
4
5
  require 'io/console'
@@ -1,7 +1,8 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  module CLI
4
5
  module UI
5
- VERSION = '2.2.3'
6
+ VERSION = '2.3.1'
6
7
  end
7
8
  end
@@ -1,4 +1,5 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  require('cli/ui')
4
5
 
@@ -1,4 +1,5 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  require('cli/ui')
4
5
 
@@ -80,7 +81,7 @@ module CLI
80
81
 
81
82
  sig { params(argstring: String, pattern: Regexp).void }
82
83
  def initialize(argstring, pattern)
83
- super
84
+ super(nil)
84
85
  @argstring = argstring
85
86
  @pattern = pattern
86
87
  end