cli-ui 2.2.3 → 2.3.0

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