cli-ui 1.5.1 → 2.2.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|