dry-cli-ui 0.3.1 → 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +219 -18
- data/SPECIFICATION.md +71 -11
- data/lib/dry/cli/ui/configuration.rb +160 -0
- data/lib/dry/cli/ui/console.rb +76 -4
- data/lib/dry/cli/ui/status_bar.rb +314 -0
- data/lib/dry/cli/ui/terminal.rb +38 -2
- data/lib/dry/cli/ui/theme.rb +21 -6
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +300 -0
- data/lib/dry/cli/ui/widgets/multi_progress.rb +119 -0
- data/lib/dry/cli/ui/widgets/multi_spinner.rb +69 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +1 -1
- data/lib/dry/cli/ui/widgets/pool.rb +82 -0
- data/lib/dry/cli/ui/widgets/progress.rb +41 -6
- data/lib/dry/cli/ui/widgets/spinner.rb +11 -3
- data/lib/dry/cli/ui/widgets/tasks.rb +17 -61
- data/lib/dry/cli/ui/widgets.rb +4 -0
- data/lib/dry/cli/ui.rb +32 -0
- data/sig/dry/cli/ui.rbs +25 -0
- metadata +7 -1
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Runs several named jobs, by default all at once, under one headline,
|
|
10
|
+
# and shows the state of each on a row of its own. {MultiSpinner} and
|
|
11
|
+
# {MultiProgress} decide what a row says; this class runs the jobs and
|
|
12
|
+
# draws the rows.
|
|
13
|
+
#
|
|
14
|
+
# The block only declares the jobs; nothing runs until it returns, so
|
|
15
|
+
# every row, including those of jobs still waiting under a concurrency
|
|
16
|
+
# limit, is drawn before the first job starts.
|
|
17
|
+
#
|
|
18
|
+
# On an animated terminal the rows are drawn once and redrawn in place.
|
|
19
|
+
# Otherwise, or when there are more rows than the screen has, it prints
|
|
20
|
+
# `Title...`, then each job's outcome as it ends, then the headline's.
|
|
21
|
+
#
|
|
22
|
+
# When a job raises, jobs already running finish, jobs not yet started
|
|
23
|
+
# are marked skipped, the headline is marked failed, and the first error
|
|
24
|
+
# is re-raised.
|
|
25
|
+
class Multi
|
|
26
|
+
# What a job's row is prefixed with when rows are printed one by one.
|
|
27
|
+
PLAIN_INDENT = " "
|
|
28
|
+
|
|
29
|
+
# One declared job.
|
|
30
|
+
class Job
|
|
31
|
+
# @param label [String]
|
|
32
|
+
# @param work [Proc]
|
|
33
|
+
# @param handle [Object] what the work is given to report through
|
|
34
|
+
def initialize(label, work, handle)
|
|
35
|
+
@label = label
|
|
36
|
+
@work = work
|
|
37
|
+
@handle = handle
|
|
38
|
+
@state = :pending
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [String]
|
|
42
|
+
attr_reader :label
|
|
43
|
+
|
|
44
|
+
# @return [Proc]
|
|
45
|
+
attr_reader :work
|
|
46
|
+
|
|
47
|
+
# @return [Object] a {Line} or a {Progress::Handle}
|
|
48
|
+
attr_reader :handle
|
|
49
|
+
|
|
50
|
+
# @return [Symbol] one of the keys of {Theme::STATES}
|
|
51
|
+
attr_accessor :state
|
|
52
|
+
|
|
53
|
+
# @return [Float, nil] when the job started, by the widget's clock
|
|
54
|
+
attr_accessor :started
|
|
55
|
+
|
|
56
|
+
# @return [Float, nil] how long the job ran, once it has ended
|
|
57
|
+
attr_accessor :seconds
|
|
58
|
+
|
|
59
|
+
# @return [Object] what the work returned
|
|
60
|
+
attr_accessor :value
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param terminal [Terminal]
|
|
64
|
+
# @param clock [#call] returns monotonic seconds
|
|
65
|
+
# @param config [Configuration] where the spinner frames come from
|
|
66
|
+
def initialize(terminal, clock:, config: UI.config)
|
|
67
|
+
@terminal = terminal
|
|
68
|
+
@clock = clock
|
|
69
|
+
@config = config
|
|
70
|
+
@jobs = []
|
|
71
|
+
@state = :pending
|
|
72
|
+
@live = nil
|
|
73
|
+
@lock = Mutex.new
|
|
74
|
+
@frame = 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Declares the jobs with the block, then runs them.
|
|
78
|
+
#
|
|
79
|
+
# @param title [String] the headline above the jobs
|
|
80
|
+
# @param concurrent [Boolean, Integer] all at once, one at a time, or
|
|
81
|
+
# at most this many at once
|
|
82
|
+
# @yieldparam builder [Object] declares the jobs
|
|
83
|
+
# @return [Array<Object>] what each job returned, in declaration
|
|
84
|
+
# order; nil for a job that never ran
|
|
85
|
+
# @raise [ArgumentError] with an invalid concurrent
|
|
86
|
+
# @raise [Exception] the first error a job raised
|
|
87
|
+
def run(title, concurrent: true)
|
|
88
|
+
Pool.concurrency(concurrent)
|
|
89
|
+
yield builder
|
|
90
|
+
@title = title
|
|
91
|
+
@started = clock.call
|
|
92
|
+
ticker = start
|
|
93
|
+
begin
|
|
94
|
+
Pool.run(jobs, concurrent) { |job| execute(job) }
|
|
95
|
+
ensure
|
|
96
|
+
ticker&.shutdown
|
|
97
|
+
ticker&.wait_for_termination(1)
|
|
98
|
+
finish
|
|
99
|
+
end
|
|
100
|
+
jobs.map(&:value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @return [Terminal]
|
|
106
|
+
attr_reader :terminal
|
|
107
|
+
|
|
108
|
+
# @return [#call]
|
|
109
|
+
attr_reader :clock
|
|
110
|
+
|
|
111
|
+
# @return [Configuration]
|
|
112
|
+
attr_reader :config
|
|
113
|
+
|
|
114
|
+
# @return [Array<Job>]
|
|
115
|
+
attr_reader :jobs
|
|
116
|
+
|
|
117
|
+
# @return [String]
|
|
118
|
+
attr_reader :title
|
|
119
|
+
|
|
120
|
+
# @return [Float] when the headline started, by the clock
|
|
121
|
+
attr_reader :started
|
|
122
|
+
|
|
123
|
+
# @return [Mutex] held while a row changes or the rows are drawn
|
|
124
|
+
attr_reader :lock
|
|
125
|
+
|
|
126
|
+
# @return [Symbol] the headline's state
|
|
127
|
+
attr_accessor :state
|
|
128
|
+
|
|
129
|
+
# @return [Integer] the spinner frame to draw next
|
|
130
|
+
attr_accessor :frame
|
|
131
|
+
|
|
132
|
+
# @return [Object] what the declaration block is given: the
|
|
133
|
+
# subclass's Builder, appending to {#jobs}
|
|
134
|
+
def builder = self.class::Builder.new(jobs)
|
|
135
|
+
|
|
136
|
+
# Runs a job's work with its handle.
|
|
137
|
+
#
|
|
138
|
+
# @param job [Job]
|
|
139
|
+
# @return [Object] what the work returns
|
|
140
|
+
def call(job) = job.work.call(job.handle)
|
|
141
|
+
|
|
142
|
+
# @param job [Job]
|
|
143
|
+
# @return [#current, #total, nil] what a status bar reads the job's progress from
|
|
144
|
+
def progress_of(_job) = nil
|
|
145
|
+
|
|
146
|
+
# @param job [Job]
|
|
147
|
+
# @return [Boolean] whether the work reported a failure without raising
|
|
148
|
+
def reported_failure?(_job) = false
|
|
149
|
+
|
|
150
|
+
# What follows a running job's glyph.
|
|
151
|
+
#
|
|
152
|
+
# @param job [Job]
|
|
153
|
+
# @param width [Integer] the columns its label is padded to
|
|
154
|
+
# @return [String]
|
|
155
|
+
def running(job, _width) = job.label
|
|
156
|
+
|
|
157
|
+
# What follows a job's glyph once it has ended.
|
|
158
|
+
#
|
|
159
|
+
# @param job [Job]
|
|
160
|
+
# @return [String]
|
|
161
|
+
def summary(job) = job.label
|
|
162
|
+
|
|
163
|
+
# What follows the headline's glyph while jobs run.
|
|
164
|
+
#
|
|
165
|
+
# @param width [Integer] the columns the title is padded to
|
|
166
|
+
# @return [String]
|
|
167
|
+
def running_headline(_width) = title
|
|
168
|
+
|
|
169
|
+
# What follows the headline's glyph once every job has ended.
|
|
170
|
+
#
|
|
171
|
+
# @return [String]
|
|
172
|
+
def headline_summary = title
|
|
173
|
+
|
|
174
|
+
# Draws every row and starts the spinners turning when live, or
|
|
175
|
+
# prints the title otherwise.
|
|
176
|
+
#
|
|
177
|
+
# @return [Concurrent::TimerTask, nil]
|
|
178
|
+
def start
|
|
179
|
+
self.state = :running
|
|
180
|
+
unless live?
|
|
181
|
+
terminal.puts("#{title}...")
|
|
182
|
+
return
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
rows.each { |row| terminal.puts(row) }
|
|
186
|
+
Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }.tap(&:execute)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param job [Job]
|
|
190
|
+
# @return [void]
|
|
191
|
+
def execute(job)
|
|
192
|
+
job.started = clock.call
|
|
193
|
+
terminal.started(job, job.label, progress: progress_of(job))
|
|
194
|
+
change(job, :running)
|
|
195
|
+
ok = false
|
|
196
|
+
job.value = call(job)
|
|
197
|
+
ok = !reported_failure?(job)
|
|
198
|
+
ensure
|
|
199
|
+
terminal.finished(job, ok)
|
|
200
|
+
change(job, ok ? :done : :failed, seconds: clock.call - job.started)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Marks jobs that never started as skipped, and ends the headline.
|
|
204
|
+
#
|
|
205
|
+
# @return [void]
|
|
206
|
+
def finish
|
|
207
|
+
jobs.each { |job| change(job, :skipped) if job.state == :pending }
|
|
208
|
+
lock.synchronize do
|
|
209
|
+
self.state = jobs.all? { |job| job.state == :done } ? :done : :failed
|
|
210
|
+
@seconds = clock.call - started
|
|
211
|
+
live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds))
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @param job [Job]
|
|
216
|
+
# @param state [Symbol]
|
|
217
|
+
# @param seconds [Float, nil]
|
|
218
|
+
# @return [void]
|
|
219
|
+
def change(job, state, seconds: nil)
|
|
220
|
+
lock.synchronize do
|
|
221
|
+
job.state = state
|
|
222
|
+
job.seconds = seconds
|
|
223
|
+
if live?
|
|
224
|
+
redraw
|
|
225
|
+
elsif state != :running
|
|
226
|
+
terminal.puts("#{PLAIN_INDENT}#{row(job, 0)}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Whether to redraw in place: needs cursor movement, and every row on
|
|
232
|
+
# the screen, since the cursor cannot move above the top row.
|
|
233
|
+
#
|
|
234
|
+
# @return [Boolean]
|
|
235
|
+
def live?
|
|
236
|
+
@live = terminal.animated? && jobs.size + 1 < terminal.height if @live.nil?
|
|
237
|
+
@live
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @return [void]
|
|
241
|
+
def tick
|
|
242
|
+
lock.synchronize do
|
|
243
|
+
self.frame += 1
|
|
244
|
+
redraw
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [void]
|
|
249
|
+
def redraw
|
|
250
|
+
terminal.print(terminal.cursor.up(jobs.size + 1) + rows.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# The headline, then one row per job with its tree branch.
|
|
254
|
+
#
|
|
255
|
+
# @return [Array<String>]
|
|
256
|
+
def rows
|
|
257
|
+
width = label_width
|
|
258
|
+
branches = jobs.each_with_index.map { |_, index| index == jobs.size - 1 ? "└─ " : "├─ " }
|
|
259
|
+
[headline(width), *jobs.zip(branches).map { |job, branch| terminal.pastel.bright_black(branch) + row(job, width - branch.length) }]
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The columns every label is padded to, so what follows lines up.
|
|
263
|
+
#
|
|
264
|
+
# @return [Integer]
|
|
265
|
+
def label_width
|
|
266
|
+
[title.length, *jobs.map { |job| job.label.length + 3 }].max
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @param width [Integer]
|
|
270
|
+
# @return [String]
|
|
271
|
+
def headline(width)
|
|
272
|
+
return "#{glyph(state)} #{running_headline(width)}" if state == :running
|
|
273
|
+
|
|
274
|
+
elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(@seconds)})")}"
|
|
275
|
+
"#{glyph(state)} #{headline_summary}#{elapsed}"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @param job [Job]
|
|
279
|
+
# @param width [Integer] the columns its label is padded to
|
|
280
|
+
# @return [String]
|
|
281
|
+
def row(job, width)
|
|
282
|
+
return "#{glyph(:running)} #{running(job, width)}" if job.state == :running
|
|
283
|
+
|
|
284
|
+
elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(job.seconds)})")}" if job.seconds
|
|
285
|
+
"#{glyph(job.state)} #{job.state == :pending ? job.label : summary(job)}#{elapsed}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# A state's marker, `[✓]`; a turning spinner, `[⠏]`, for a running row, live.
|
|
289
|
+
#
|
|
290
|
+
# @param state [Symbol]
|
|
291
|
+
# @return [String]
|
|
292
|
+
def glyph(state)
|
|
293
|
+
frames = config.spinner_frames
|
|
294
|
+
Theme.marker(terminal.pastel, state, (frames[frame % frames.size] if state == :running && live?))
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
module Widgets
|
|
7
|
+
# Several progress bars under one headline bar that counts them all,
|
|
8
|
+
# like TTY::ProgressBar::Multi. Each job is given a {Progress::Handle}.
|
|
9
|
+
#
|
|
10
|
+
# Every row shows a bar, a percentage, a count and an ETA while its job
|
|
11
|
+
# runs, and is replaced by `✓ label 120/120 (1.1s)` when it ends. The
|
|
12
|
+
# bar's characters come from {Configuration#bar_format}.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# ui.multi_progress("Downloading") do |m|
|
|
16
|
+
# files.each do |file|
|
|
17
|
+
# m.progress(file.name, total: file.size) do |bar|
|
|
18
|
+
# download(file) { |bytes| bar.advance(bytes) }
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# See {Multi} for how the jobs run and how the rows are drawn.
|
|
24
|
+
class MultiProgress < Multi
|
|
25
|
+
# What the declaration block is given.
|
|
26
|
+
class Builder
|
|
27
|
+
# @param jobs [Array<Multi::Job>] the list new jobs are appended to
|
|
28
|
+
def initialize(jobs)
|
|
29
|
+
@jobs = jobs
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Declares a job with a progress bar of its own.
|
|
33
|
+
#
|
|
34
|
+
# @param label [String]
|
|
35
|
+
# @param total [Integer] units of work
|
|
36
|
+
# @yieldparam progress [Progress::Handle] call `advance` as units complete
|
|
37
|
+
# @return [self]
|
|
38
|
+
# @raise [ArgumentError] without a block, or when total is not a non-negative Integer
|
|
39
|
+
def progress(label, total:, &work)
|
|
40
|
+
raise ArgumentError, "progress #{label.inspect} needs a block" unless work
|
|
41
|
+
raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
|
|
42
|
+
|
|
43
|
+
@jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil))
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @param job [Job]
|
|
51
|
+
# @return [Progress::Handle]
|
|
52
|
+
def progress_of(job) = job.handle
|
|
53
|
+
|
|
54
|
+
# @param job [Job]
|
|
55
|
+
# @param width [Integer]
|
|
56
|
+
# @return [String]
|
|
57
|
+
def running(job, width)
|
|
58
|
+
"#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started)}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param job [Job]
|
|
62
|
+
# @return [String]
|
|
63
|
+
def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total}"
|
|
64
|
+
|
|
65
|
+
# @param width [Integer]
|
|
66
|
+
# @return [String]
|
|
67
|
+
def running_headline(width)
|
|
68
|
+
"#{title.ljust(width)} #{meter(current, total, started)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [String]
|
|
72
|
+
def headline_summary = "#{title} #{current}/#{total}"
|
|
73
|
+
|
|
74
|
+
# @return [Integer] units completed across every job
|
|
75
|
+
def current = jobs.sum { |job| job.handle.current }
|
|
76
|
+
|
|
77
|
+
# @return [Integer] units across every job
|
|
78
|
+
def total = jobs.sum { |job| job.handle.total }
|
|
79
|
+
|
|
80
|
+
# `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
|
|
81
|
+
# the widest any row can show, so every count ends in one column.
|
|
82
|
+
#
|
|
83
|
+
# @param done [Integer]
|
|
84
|
+
# @param all [Integer]
|
|
85
|
+
# @param since [Float, nil] when the work started, by the clock
|
|
86
|
+
# @return [String]
|
|
87
|
+
def meter(done, all, since)
|
|
88
|
+
ratio = all.zero? ? 1.0 : done.fdiv(all)
|
|
89
|
+
bar = Progress.bar(terminal.pastel, config, ratio, bar_columns)
|
|
90
|
+
format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
|
|
91
|
+
bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all}".rjust(count_width), eta: eta(done, all, since))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The widest count any row shows: the headline's, once every job is done.
|
|
95
|
+
#
|
|
96
|
+
# @return [Integer]
|
|
97
|
+
def count_width = "#{total}/#{total}".length
|
|
98
|
+
|
|
99
|
+
# One width for every bar, so the headline's lines up with the jobs'.
|
|
100
|
+
#
|
|
101
|
+
# @return [Integer]
|
|
102
|
+
def bar_columns
|
|
103
|
+
[terminal.width - label_width - 3 - Progress::CHROME, Progress::MIN_BAR].max
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @param done [Integer]
|
|
107
|
+
# @param all [Integer]
|
|
108
|
+
# @param since [Float, nil]
|
|
109
|
+
# @return [String] the time left at the rate so far, or `--` before any progress
|
|
110
|
+
def eta(done, all, since)
|
|
111
|
+
return "--" if since.nil? || done.zero?
|
|
112
|
+
|
|
113
|
+
Duration.format((clock.call - since) / done * (all - done))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
module Widgets
|
|
7
|
+
# Several spinners under one headline, one per job, all turning at once.
|
|
8
|
+
#
|
|
9
|
+
# Each job is given a {Line}: its detail is drawn after the label while
|
|
10
|
+
# the job runs, on an animated terminal, and {Line#fail} ends it as
|
|
11
|
+
# `✗ label: reason` without raising. The headline ends `✓` when every
|
|
12
|
+
# job succeeded, and `✗` otherwise.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# ui.multi_spinner("Fetching", concurrent: 2) do |m|
|
|
16
|
+
# m.spinner("fonts") { fetch(:fonts) }
|
|
17
|
+
# m.spinner("images") { |line| fetch(:images) { |name| line.detail = name } }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# See {Multi} for how the jobs run and how the rows are drawn.
|
|
21
|
+
class MultiSpinner < Multi
|
|
22
|
+
# What the declaration block is given.
|
|
23
|
+
class Builder
|
|
24
|
+
# @param jobs [Array<Multi::Job>] the list new jobs are appended to
|
|
25
|
+
def initialize(jobs)
|
|
26
|
+
@jobs = jobs
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Declares a job with a spinner of its own.
|
|
30
|
+
#
|
|
31
|
+
# @param label [String]
|
|
32
|
+
# @yieldparam line [Line] reports on the work while it runs
|
|
33
|
+
# @return [self]
|
|
34
|
+
# @raise [ArgumentError] without a block
|
|
35
|
+
def spinner(label, &work)
|
|
36
|
+
raise ArgumentError, "spinner #{label.inspect} needs a block" unless work
|
|
37
|
+
|
|
38
|
+
@jobs << Multi::Job.new(label, work, Line.new)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# @param job [Job]
|
|
46
|
+
# @return [Object]
|
|
47
|
+
def call(job) = Line.call(job.work, job.handle)
|
|
48
|
+
|
|
49
|
+
# @param job [Job]
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def reported_failure?(job) = job.handle.failed?
|
|
52
|
+
|
|
53
|
+
# The label, then the detail when the job has one.
|
|
54
|
+
#
|
|
55
|
+
# @param job [Job]
|
|
56
|
+
# @return [String]
|
|
57
|
+
def running(job, _width)
|
|
58
|
+
detail = job.handle.detail
|
|
59
|
+
detail.empty? ? job.label : "#{job.label} #{detail}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param job [Job]
|
|
63
|
+
# @return [String] the label, with the reason once the job has failed
|
|
64
|
+
def summary(job) = job.handle.summary(job.label)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -16,7 +16,7 @@ module Dry
|
|
|
16
16
|
def self.line(terminal, state, label, seconds)
|
|
17
17
|
glyph, color = Theme::STATES.fetch(state)
|
|
18
18
|
pastel = terminal.pastel
|
|
19
|
-
"#{pastel.decorate(glyph, color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
|
|
19
|
+
"#{pastel.decorate(glyph, *color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Runs a list of items one at a time, all at once, or at most so many
|
|
10
|
+
# at once, for every widget that runs several pieces of work.
|
|
11
|
+
#
|
|
12
|
+
# When one raises, items already running finish, items not yet
|
|
13
|
+
# started are never started, and the first error is re-raised once
|
|
14
|
+
# everything running has stopped.
|
|
15
|
+
module Pool
|
|
16
|
+
# Checks a `concurrent:` setting.
|
|
17
|
+
#
|
|
18
|
+
# @param value [Boolean, Integer] true for all at once, false for one
|
|
19
|
+
# at a time, or the most that may run at once
|
|
20
|
+
# @return [Boolean, Integer] the value
|
|
21
|
+
# @raise [ArgumentError] for anything else
|
|
22
|
+
def self.concurrency(value)
|
|
23
|
+
return value if [true, false].include?(value) || (value.is_a?(Integer) && value.positive?)
|
|
24
|
+
|
|
25
|
+
raise ArgumentError, "concurrent must be true, false or a positive Integer, got #{value.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Runs the block for each item.
|
|
29
|
+
#
|
|
30
|
+
# @param items [Array]
|
|
31
|
+
# @param concurrent [Boolean, Integer] see {.concurrency}
|
|
32
|
+
# @yieldparam item [Object] one of the items
|
|
33
|
+
# @return [void]
|
|
34
|
+
# @raise [Exception] the first error any block raised
|
|
35
|
+
def self.run(items, concurrent, &)
|
|
36
|
+
return items.each(&) unless concurrent
|
|
37
|
+
|
|
38
|
+
futures = concurrent == true ? all_at_once(items, &) : at_most(concurrent, items, &)
|
|
39
|
+
futures.each(&:wait)
|
|
40
|
+
failed = futures.find(&:rejected?)
|
|
41
|
+
raise failed.reason if failed
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param items [Array]
|
|
45
|
+
# @return [Array<Concurrent::Promises::Future>] one per item
|
|
46
|
+
def self.all_at_once(items, &)
|
|
47
|
+
items.map { |item| Concurrent::Promises.future(item, &) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Workers that take items off a queue until it is empty, or until
|
|
51
|
+
# one of them raises.
|
|
52
|
+
#
|
|
53
|
+
# @param limit [Integer]
|
|
54
|
+
# @param items [Array]
|
|
55
|
+
# @return [Array<Concurrent::Promises::Future>] one per worker
|
|
56
|
+
def self.at_most(limit, items, &)
|
|
57
|
+
queue = Queue.new
|
|
58
|
+
items.each { |item| queue << item }
|
|
59
|
+
queue.close
|
|
60
|
+
stop = Concurrent::AtomicBoolean.new
|
|
61
|
+
Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, stop, &) } }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param queue [Queue] closed, so `pop` returns nil once it is empty
|
|
65
|
+
# @param stop [Concurrent::AtomicBoolean] set once any worker raises
|
|
66
|
+
# @return [void]
|
|
67
|
+
def self.work(queue, stop, &each)
|
|
68
|
+
ok = false
|
|
69
|
+
while (item = queue.pop) && stop.false?
|
|
70
|
+
each.call(item)
|
|
71
|
+
end
|
|
72
|
+
ok = true
|
|
73
|
+
ensure
|
|
74
|
+
stop.make_true unless ok
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private_class_method :all_at_once, :at_most, :work
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -46,17 +46,47 @@ module Dry
|
|
|
46
46
|
attr_writer :current
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# Columns kept for the
|
|
50
|
-
CHROME =
|
|
49
|
+
# Columns kept for the brackets, percentage, count and ETA around the bar.
|
|
50
|
+
CHROME = 36
|
|
51
51
|
|
|
52
52
|
# Narrowest bar drawn.
|
|
53
53
|
MIN_BAR = 10
|
|
54
54
|
|
|
55
|
+
# A bar between brackets, painted as the configuration says: the
|
|
56
|
+
# finished part in {Configuration#bar_color}, and all of it on
|
|
57
|
+
# {Configuration#bar_background}.
|
|
58
|
+
#
|
|
59
|
+
# @param pastel [Pastel::Delegator] a no-op when colour is off
|
|
60
|
+
# @param config [Configuration]
|
|
61
|
+
# @param ratio [Float] how much is finished, from 0 to 1
|
|
62
|
+
# @param columns [Integer] the bar's width inside the brackets
|
|
63
|
+
# @return [String]
|
|
64
|
+
def self.bar(pastel, config, ratio, columns)
|
|
65
|
+
filled = (ratio * columns).floor
|
|
66
|
+
"[#{complete(pastel, config) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param pastel [Pastel::Delegator]
|
|
70
|
+
# @param config [Configuration]
|
|
71
|
+
# @return [String] one finished cell, painted
|
|
72
|
+
def self.complete(pastel, config)
|
|
73
|
+
pastel.decorate(config.bar_complete, *[config.bar_color, config.bar_background].compact)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @param pastel [Pastel::Delegator]
|
|
77
|
+
# @param config [Configuration]
|
|
78
|
+
# @return [String] one unfinished cell, painted
|
|
79
|
+
def self.incomplete(pastel, config)
|
|
80
|
+
pastel.decorate(config.bar_incomplete, *[config.bar_background].compact)
|
|
81
|
+
end
|
|
82
|
+
|
|
55
83
|
# @param terminal [Terminal]
|
|
56
84
|
# @param clock [#call] returns monotonic seconds
|
|
57
|
-
|
|
85
|
+
# @param config [Configuration] where the bar's characters come from
|
|
86
|
+
def initialize(terminal, clock:, config: UI.config)
|
|
58
87
|
@terminal = terminal
|
|
59
88
|
@clock = clock
|
|
89
|
+
@config = config
|
|
60
90
|
end
|
|
61
91
|
|
|
62
92
|
# Runs the block with a progress bar.
|
|
@@ -72,6 +102,7 @@ module Dry
|
|
|
72
102
|
started = clock.call
|
|
73
103
|
bar = start(label, total)
|
|
74
104
|
handle = Handle.new(total, bar)
|
|
105
|
+
terminal.started(handle, label, progress: handle)
|
|
75
106
|
ok = false
|
|
76
107
|
result = yield handle
|
|
77
108
|
ok = true
|
|
@@ -79,6 +110,7 @@ module Dry
|
|
|
79
110
|
ensure
|
|
80
111
|
if handle
|
|
81
112
|
bar&.stop
|
|
113
|
+
terminal.finished(handle, ok)
|
|
82
114
|
summary = "#{label} #{handle.current}/#{total}"
|
|
83
115
|
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
|
|
84
116
|
end
|
|
@@ -92,6 +124,9 @@ module Dry
|
|
|
92
124
|
# @return [#call]
|
|
93
125
|
attr_reader :clock
|
|
94
126
|
|
|
127
|
+
# @return [Configuration]
|
|
128
|
+
attr_reader :config
|
|
129
|
+
|
|
95
130
|
# @param label [String]
|
|
96
131
|
# @param total [Integer]
|
|
97
132
|
# @return [TTY::ProgressBar, nil]
|
|
@@ -102,12 +137,12 @@ module Dry
|
|
|
102
137
|
end
|
|
103
138
|
|
|
104
139
|
TTY::ProgressBar.new(
|
|
105
|
-
"#{label} :bar :percent :current/:total ETA :eta",
|
|
140
|
+
"#{label} [:bar] :percent :current/:total ETA :eta",
|
|
106
141
|
total: total,
|
|
107
142
|
width: [terminal.width - label.length - CHROME, MIN_BAR].max,
|
|
108
143
|
output: terminal.io,
|
|
109
|
-
complete:
|
|
110
|
-
incomplete:
|
|
144
|
+
complete: Progress.complete(terminal.pastel, config),
|
|
145
|
+
incomplete: Progress.incomplete(terminal.pastel, config),
|
|
111
146
|
clear: true,
|
|
112
147
|
hide_cursor: true
|
|
113
148
|
)
|