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.
- checksums.yaml +4 -4
- data/README.md +28 -17
- data/lib/cli/ui/ansi.rb +172 -129
- data/lib/cli/ui/color.rb +39 -20
- data/lib/cli/ui/formatter.rb +46 -21
- data/lib/cli/ui/frame/frame_stack.rb +30 -50
- data/lib/cli/ui/frame/frame_style/box.rb +16 -5
- data/lib/cli/ui/frame/frame_style/bracket.rb +19 -8
- data/lib/cli/ui/frame/frame_style.rb +84 -87
- data/lib/cli/ui/frame.rb +79 -32
- data/lib/cli/ui/glyph.rb +44 -31
- data/lib/cli/ui/os.rb +44 -48
- data/lib/cli/ui/printer.rb +65 -47
- data/lib/cli/ui/progress.rb +50 -33
- data/lib/cli/ui/prompt/interactive_options.rb +114 -68
- data/lib/cli/ui/prompt/options_handler.rb +8 -0
- data/lib/cli/ui/prompt.rb +168 -58
- data/lib/cli/ui/sorbet_runtime_stub.rb +169 -0
- data/lib/cli/ui/spinner/async.rb +15 -4
- data/lib/cli/ui/spinner/spin_group.rb +174 -36
- data/lib/cli/ui/spinner.rb +48 -28
- data/lib/cli/ui/stdout_router.rb +229 -47
- data/lib/cli/ui/terminal.rb +37 -25
- data/lib/cli/ui/truncater.rb +7 -2
- data/lib/cli/ui/version.rb +3 -1
- data/lib/cli/ui/widgets/base.rb +23 -4
- data/lib/cli/ui/widgets/status.rb +19 -1
- data/lib/cli/ui/widgets.rb +42 -23
- data/lib/cli/ui/wrap.rb +8 -1
- data/lib/cli/ui.rb +336 -188
- data/vendor/reentrant_mutex.rb +78 -0
- metadata +11 -9
@@ -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
|
-
#
|
15
|
-
#
|
16
|
-
#
|
17
|
-
#
|
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
|
-
|
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
|
-
|
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(
|
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
|
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
|
-
|
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(
|
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
|
-
|
290
|
+
done_count = 0
|
191
291
|
|
192
292
|
width = CLI::UI::Terminal.width
|
193
293
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
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
|
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
|
-
|
226
|
-
|
227
|
-
|
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
|
-
|
237
|
-
|
238
|
-
e = task.exception
|
369
|
+
title = task.title
|
239
370
|
out = task.stdout
|
240
371
|
err = task.stderr
|
241
372
|
|
242
|
-
|
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 ")}"
|
data/lib/cli/ui/spinner.rb
CHANGED
@@ -1,22 +1,33 @@
|
|
1
|
-
#
|
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.
|
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
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
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
|