dry-cli-ui 0.4.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -2
- data/README.md +458 -40
- data/examples/.envrc +1 -0
- data/examples/.gitignore +1 -0
- data/examples/Gemfile +1 -1
- data/examples/Gemfile.lock +5 -5
- data/examples/README.md +24 -10
- data/examples/bin/mycli +333 -102
- data/lib/dry/cli/ui/configuration.rb +3 -3
- data/lib/dry/cli/ui/console.rb +39 -10
- data/lib/dry/cli/ui/status_bar.rb +1 -1
- data/lib/dry/cli/ui/stop.rb +52 -0
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +63 -14
- data/lib/dry/cli/ui/widgets/multi_progress.rb +67 -20
- data/lib/dry/cli/ui/widgets/pool.rb +15 -11
- data/lib/dry/cli/ui/widgets/progress.rb +65 -19
- data/lib/dry/cli/ui.rb +1 -0
- data/sig/dry/cli/ui.rbs +10 -3
- metadata +4 -2
- data/SPECIFICATION.md +0 -411
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -75,7 +75,7 @@ module Dry
|
|
|
75
75
|
@config = config
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
# A framed panel. Given a level, it takes that level's title, colour
|
|
78
|
+
# A framed panel, preceded by a blank line. Given a level, it takes that level's title, colour
|
|
79
79
|
# and stream; without one it is untitled unless given a title, and goes
|
|
80
80
|
# to `out`.
|
|
81
81
|
#
|
|
@@ -91,7 +91,7 @@ module Dry
|
|
|
91
91
|
theme = level && Theme.level(level)
|
|
92
92
|
terminal = theme ? stream(theme) : out
|
|
93
93
|
widget = Widgets::Box.new(terminal, width: width || box_width)
|
|
94
|
-
terminal.print(widget.render(paragraphs, title: title || theme&.title, color: theme&.color))
|
|
94
|
+
terminal.print("\n#{widget.render(paragraphs, title: title || theme&.title, color: theme&.color)}")
|
|
95
95
|
nil
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -158,26 +158,30 @@ module Dry
|
|
|
158
158
|
# @param title [String] the headline
|
|
159
159
|
# @param concurrent [Boolean, Integer] all at once (the default), one at
|
|
160
160
|
# a time, or at most this many at once
|
|
161
|
+
# @param stop [Stop, nil] once set, no more jobs start; see {#stoppable}
|
|
161
162
|
# @yieldparam spinners [Widgets::MultiSpinner::Builder] declares each `spinner`
|
|
162
163
|
# @return [Array<Object>] what each job returned, in declaration order
|
|
163
164
|
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
164
|
-
def multi_spinner(title, concurrent: true, &)
|
|
165
|
+
def multi_spinner(title, concurrent: true, stop: nil, &)
|
|
165
166
|
raise ArgumentError, "multi_spinner needs a block" unless block_given?
|
|
166
167
|
|
|
167
|
-
Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
168
|
+
Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, stop: stop, &)
|
|
168
169
|
end
|
|
169
170
|
|
|
170
171
|
# Runs a block with a progress bar showing percent, count and ETA.
|
|
171
172
|
#
|
|
172
173
|
# @param label [String]
|
|
173
174
|
# @param total [Integer] units of work
|
|
175
|
+
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
176
|
+
# the configured `bar_color`
|
|
174
177
|
# @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
|
|
175
178
|
# @return [Object] whatever the block returns
|
|
176
|
-
# @raise [ArgumentError] without a block,
|
|
177
|
-
|
|
179
|
+
# @raise [ArgumentError] without a block, when total is not a
|
|
180
|
+
# non-negative Integer, or when color is not a Pastel style
|
|
181
|
+
def progress(label, total:, color: nil, &)
|
|
178
182
|
raise ArgumentError, "progress needs a block" unless block_given?
|
|
179
183
|
|
|
180
|
-
Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, &)
|
|
184
|
+
Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, color: color, &)
|
|
181
185
|
end
|
|
182
186
|
|
|
183
187
|
# Runs several jobs at once, each with a progress bar of its own,
|
|
@@ -194,13 +198,18 @@ module Dry
|
|
|
194
198
|
# @param title [String] the headline
|
|
195
199
|
# @param concurrent [Boolean, Integer] all at once (the default), one at
|
|
196
200
|
# a time, or at most this many at once
|
|
201
|
+
# @param count [Symbol] what the headline bar counts: `:units`, the sum
|
|
202
|
+
# of every bar (the default), or `:jobs`, how many jobs have ended
|
|
203
|
+
# @param total [Integer, nil] the headline bar's total, when the jobs'
|
|
204
|
+
# totals do not add up to it; nil to work it out
|
|
205
|
+
# @param stop [Stop, nil] once set, no more jobs start; see {#stoppable}
|
|
197
206
|
# @yieldparam bars [Widgets::MultiProgress::Builder] declares each `progress`
|
|
198
207
|
# @return [Array<Object>] what each job returned, in declaration order
|
|
199
|
-
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
200
|
-
def multi_progress(title, concurrent: true, &)
|
|
208
|
+
# @raise [ArgumentError] without a block, or with an invalid concurrent, count or total
|
|
209
|
+
def multi_progress(title, concurrent: true, count: :units, total: nil, stop: nil, &)
|
|
201
210
|
raise ArgumentError, "multi_progress needs a block" unless block_given?
|
|
202
211
|
|
|
203
|
-
Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
212
|
+
Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, count: count, total: total, stop: stop, &)
|
|
204
213
|
end
|
|
205
214
|
|
|
206
215
|
# Declares a tree of tasks, then runs it, showing each task's state
|
|
@@ -253,6 +262,26 @@ module Dry
|
|
|
253
262
|
StatusBar.new(err, others: others, title: title, hints: Array(hints), clock: clock, config: config).run(&)
|
|
254
263
|
end
|
|
255
264
|
|
|
265
|
+
# Runs the block with Ctrl-C asking for a stop instead of interrupting.
|
|
266
|
+
# Hand the stop to `multi_spinner` or `multi_progress` as `stop:`, and
|
|
267
|
+
# the jobs running finish while the rest are skipped. A second Ctrl-C
|
|
268
|
+
# interrupts as usual. See {Stop}.
|
|
269
|
+
#
|
|
270
|
+
# @example
|
|
271
|
+
# ui.stoppable do |stop|
|
|
272
|
+
# done = ui.multi_spinner("Fetching", concurrent: 4, stop: stop) { |m| ... }
|
|
273
|
+
# ui.info "Stopped after #{done.compact.size}" if stop.stopped?
|
|
274
|
+
# end
|
|
275
|
+
#
|
|
276
|
+
# @yieldparam stop [Stop]
|
|
277
|
+
# @return [Object] whatever the block returns
|
|
278
|
+
# @raise [ArgumentError] without a block
|
|
279
|
+
def stoppable(&)
|
|
280
|
+
raise ArgumentError, "stoppable needs a block" unless block_given?
|
|
281
|
+
|
|
282
|
+
Stop.new.trap(&)
|
|
283
|
+
end
|
|
284
|
+
|
|
256
285
|
# Prints a table to `out`.
|
|
257
286
|
#
|
|
258
287
|
# @example
|
|
@@ -284,7 +284,7 @@ module Dry
|
|
|
284
284
|
# @return [String, nil] a bar over every progress reported so far
|
|
285
285
|
def meter
|
|
286
286
|
progress = @finished + @running.values.filter_map(&:last)
|
|
287
|
-
total = progress.sum
|
|
287
|
+
total = progress.sum { |item| item.total || item.current }
|
|
288
288
|
return if total.zero?
|
|
289
289
|
|
|
290
290
|
ratio = progress.sum(&:current).fdiv(total)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# A request to stop starting new work, which any thread may make or
|
|
7
|
+
# check. The multi widgets take one as `stop:`: once it is set, jobs
|
|
8
|
+
# already running finish, and jobs not yet started are skipped.
|
|
9
|
+
#
|
|
10
|
+
# {#trap} sets it on Ctrl-C, so a command can end early and still report
|
|
11
|
+
# what it did. A second Ctrl-C interrupts as usual.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# ui.stoppable do |stop|
|
|
15
|
+
# files = ui.multi_spinner("Downloading", concurrent: 10, stop: stop) { |m| ... }
|
|
16
|
+
# ui.info "Stopped after #{files.compact.size} files" if stop.stopped?
|
|
17
|
+
# end
|
|
18
|
+
class Stop
|
|
19
|
+
# A plain flag rather than an atomic one, which takes a lock, and a
|
|
20
|
+
# trap handler may not. Setting it once, to true, is safe to race.
|
|
21
|
+
def initialize
|
|
22
|
+
@stopped = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether a stop was asked for
|
|
26
|
+
def stopped? = @stopped
|
|
27
|
+
|
|
28
|
+
# Asks for a stop.
|
|
29
|
+
#
|
|
30
|
+
# @return [self]
|
|
31
|
+
def stop!
|
|
32
|
+
@stopped = true
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Runs the block with the signal trapped: the first one asks for a
|
|
37
|
+
# stop, and the next raises `Interrupt`. The previous handler is put
|
|
38
|
+
# back when the block ends.
|
|
39
|
+
#
|
|
40
|
+
# @param signal [String]
|
|
41
|
+
# @yieldparam stop [Stop] this stop
|
|
42
|
+
# @return [Object] whatever the block returns
|
|
43
|
+
def trap(signal = "INT")
|
|
44
|
+
previous = Signal.trap(signal) { stopped? ? raise(Interrupt) : stop! }
|
|
45
|
+
yield self
|
|
46
|
+
ensure
|
|
47
|
+
Signal.trap(signal, previous || "DEFAULT")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/dry/cli/ui/version.rb
CHANGED
|
@@ -16,8 +16,13 @@ module Dry
|
|
|
16
16
|
# limit, is drawn before the first job starts.
|
|
17
17
|
#
|
|
18
18
|
# On an animated terminal the rows are drawn once and redrawn in place.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# When there are more rows than the screen has, only the jobs running
|
|
20
|
+
# are shown under the headline, as many as fit. Without animation it
|
|
21
|
+
# prints `Title...`, then each job's outcome as it ends, then the headline's.
|
|
22
|
+
#
|
|
23
|
+
# Given a {Stop} that is set, jobs already running finish, jobs not yet
|
|
24
|
+
# started are marked skipped, and so is the headline; while the running
|
|
25
|
+
# jobs finish, the headline says `stopping`.
|
|
21
26
|
#
|
|
22
27
|
# When a job raises, jobs already running finish, jobs not yet started
|
|
23
28
|
# are marked skipped, the headline is marked failed, and the first error
|
|
@@ -72,6 +77,7 @@ module Dry
|
|
|
72
77
|
@live = nil
|
|
73
78
|
@lock = Mutex.new
|
|
74
79
|
@frame = 0
|
|
80
|
+
@drawn = 0
|
|
75
81
|
end
|
|
76
82
|
|
|
77
83
|
# Declares the jobs with the block, then runs them.
|
|
@@ -79,19 +85,21 @@ module Dry
|
|
|
79
85
|
# @param title [String] the headline above the jobs
|
|
80
86
|
# @param concurrent [Boolean, Integer] all at once, one at a time, or
|
|
81
87
|
# at most this many at once
|
|
88
|
+
# @param stop [Stop, nil] once set, no more jobs start
|
|
82
89
|
# @yieldparam builder [Object] declares the jobs
|
|
83
90
|
# @return [Array<Object>] what each job returned, in declaration
|
|
84
91
|
# order; nil for a job that never ran
|
|
85
92
|
# @raise [ArgumentError] with an invalid concurrent
|
|
86
93
|
# @raise [Exception] the first error a job raised
|
|
87
|
-
def run(title, concurrent: true)
|
|
94
|
+
def run(title, concurrent: true, stop: nil)
|
|
88
95
|
Pool.concurrency(concurrent)
|
|
89
96
|
yield builder
|
|
90
97
|
@title = title
|
|
98
|
+
@stop = stop
|
|
91
99
|
@started = clock.call
|
|
92
100
|
ticker = start
|
|
93
101
|
begin
|
|
94
|
-
Pool.run(jobs, concurrent) { |job| execute(job) }
|
|
102
|
+
Pool.run(jobs, concurrent, stop: stop) { |job| execute(job) }
|
|
95
103
|
ensure
|
|
96
104
|
ticker&.shutdown
|
|
97
105
|
ticker&.wait_for_termination(1)
|
|
@@ -117,6 +125,9 @@ module Dry
|
|
|
117
125
|
# @return [String]
|
|
118
126
|
attr_reader :title
|
|
119
127
|
|
|
128
|
+
# @return [Stop, nil]
|
|
129
|
+
attr_reader :stop
|
|
130
|
+
|
|
120
131
|
# @return [Float] when the headline started, by the clock
|
|
121
132
|
attr_reader :started
|
|
122
133
|
|
|
@@ -182,7 +193,9 @@ module Dry
|
|
|
182
193
|
return
|
|
183
194
|
end
|
|
184
195
|
|
|
185
|
-
|
|
196
|
+
drawn = rows
|
|
197
|
+
drawn.each { |row| terminal.puts(row) }
|
|
198
|
+
@drawn = drawn.size
|
|
186
199
|
Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }.tap(&:execute)
|
|
187
200
|
end
|
|
188
201
|
|
|
@@ -206,12 +219,21 @@ module Dry
|
|
|
206
219
|
def finish
|
|
207
220
|
jobs.each { |job| change(job, :skipped) if job.state == :pending }
|
|
208
221
|
lock.synchronize do
|
|
209
|
-
self.state =
|
|
222
|
+
self.state = outcome
|
|
210
223
|
@seconds = clock.call - started
|
|
211
224
|
live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds))
|
|
212
225
|
end
|
|
213
226
|
end
|
|
214
227
|
|
|
228
|
+
# @return [Symbol] the headline's state once every job has ended:
|
|
229
|
+
# failed when any job failed, skipped when any was skipped
|
|
230
|
+
def outcome
|
|
231
|
+
states = jobs.map(&:state)
|
|
232
|
+
return :failed if states.include?(:failed)
|
|
233
|
+
|
|
234
|
+
states.include?(:skipped) ? :skipped : :done
|
|
235
|
+
end
|
|
236
|
+
|
|
215
237
|
# @param job [Job]
|
|
216
238
|
# @param state [Symbol]
|
|
217
239
|
# @param seconds [Float, nil]
|
|
@@ -228,15 +250,23 @@ module Dry
|
|
|
228
250
|
end
|
|
229
251
|
end
|
|
230
252
|
|
|
231
|
-
# Whether to redraw in place
|
|
232
|
-
# the screen, since the cursor cannot move above the top row.
|
|
253
|
+
# Whether to redraw in place, which needs cursor movement.
|
|
233
254
|
#
|
|
234
255
|
# @return [Boolean]
|
|
235
256
|
def live?
|
|
236
|
-
@live = terminal.animated?
|
|
257
|
+
@live = terminal.animated? if @live.nil?
|
|
237
258
|
@live
|
|
238
259
|
end
|
|
239
260
|
|
|
261
|
+
# Whether some rows must be left out, since the cursor cannot move
|
|
262
|
+
# above the top row of the screen.
|
|
263
|
+
#
|
|
264
|
+
# @return [Boolean]
|
|
265
|
+
def crowded?
|
|
266
|
+
@crowded = jobs.size + 1 >= terminal.height if @crowded.nil?
|
|
267
|
+
@crowded
|
|
268
|
+
end
|
|
269
|
+
|
|
240
270
|
# @return [void]
|
|
241
271
|
def tick
|
|
242
272
|
lock.synchronize do
|
|
@@ -245,18 +275,34 @@ module Dry
|
|
|
245
275
|
end
|
|
246
276
|
end
|
|
247
277
|
|
|
278
|
+
# Draws the rows over the ones drawn last, clearing the screen below
|
|
279
|
+
# first, since there may be fewer rows than before.
|
|
280
|
+
#
|
|
248
281
|
# @return [void]
|
|
249
282
|
def redraw
|
|
250
|
-
|
|
283
|
+
drawn = rows
|
|
284
|
+
terminal.print(terminal.cursor.up(@drawn) + terminal.cursor.clear_screen_down + drawn.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
|
|
285
|
+
@drawn = drawn.size
|
|
251
286
|
end
|
|
252
287
|
|
|
253
|
-
# The headline, then one row per job with its tree branch.
|
|
288
|
+
# The headline, then one row per job shown, with its tree branch.
|
|
254
289
|
#
|
|
255
290
|
# @return [Array<String>]
|
|
256
291
|
def rows
|
|
257
292
|
width = label_width
|
|
258
|
-
|
|
259
|
-
|
|
293
|
+
shown = visible
|
|
294
|
+
branches = shown.each_index.map { |index| index == shown.size - 1 ? "└─ " : "├─ " }
|
|
295
|
+
[headline(width), *shown.zip(branches).map { |job, branch| terminal.pastel.bright_black(branch) + row(job, width - branch.length) }]
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Every job; when crowded, only the running ones, as many as leave
|
|
299
|
+
# the bottom row free.
|
|
300
|
+
#
|
|
301
|
+
# @return [Array<Job>]
|
|
302
|
+
def visible
|
|
303
|
+
return jobs unless crowded?
|
|
304
|
+
|
|
305
|
+
jobs.select { |job| job.state == :running }.first([terminal.height - 2, 0].max)
|
|
260
306
|
end
|
|
261
307
|
|
|
262
308
|
# The columns every label is padded to, so what follows lines up.
|
|
@@ -269,12 +315,15 @@ module Dry
|
|
|
269
315
|
# @param width [Integer]
|
|
270
316
|
# @return [String]
|
|
271
317
|
def headline(width)
|
|
272
|
-
return "#{glyph(state)} #{running_headline(width)}" if state == :running
|
|
318
|
+
return "#{glyph(state)} #{running_headline(width)}#{stopping}" if state == :running
|
|
273
319
|
|
|
274
320
|
elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(@seconds)})")}"
|
|
275
321
|
"#{glyph(state)} #{headline_summary}#{elapsed}"
|
|
276
322
|
end
|
|
277
323
|
|
|
324
|
+
# @return [String] ` stopping` once a stop is asked for, or nothing
|
|
325
|
+
def stopping = stop&.stopped? ? " #{terminal.pastel.yellow('stopping')}" : ""
|
|
326
|
+
|
|
278
327
|
# @param job [Job]
|
|
279
328
|
# @param width [Integer] the columns its label is padded to
|
|
280
329
|
# @return [String]
|
|
@@ -14,7 +14,7 @@ module Dry
|
|
|
14
14
|
# @example
|
|
15
15
|
# ui.multi_progress("Downloading") do |m|
|
|
16
16
|
# files.each do |file|
|
|
17
|
-
# m.progress(file.name, total: file.size) do |bar|
|
|
17
|
+
# m.progress(file.name, total: file.size, color: file.large? ? :yellow : nil) do |bar|
|
|
18
18
|
# download(file) { |bytes| bar.advance(bytes) }
|
|
19
19
|
# end
|
|
20
20
|
# end
|
|
@@ -32,19 +32,49 @@ module Dry
|
|
|
32
32
|
# Declares a job with a progress bar of its own.
|
|
33
33
|
#
|
|
34
34
|
# @param label [String]
|
|
35
|
-
# @param total [Integer] units of work
|
|
35
|
+
# @param total [Integer, nil] units of work; nil when the job finds
|
|
36
|
+
# out as it runs, and sets `total=` on its handle
|
|
37
|
+
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
38
|
+
# {Configuration#bar_color}
|
|
36
39
|
# @yieldparam progress [Progress::Handle] call `advance` as units complete
|
|
37
40
|
# @return [self]
|
|
38
|
-
# @raise [ArgumentError] without a block,
|
|
39
|
-
|
|
41
|
+
# @raise [ArgumentError] without a block, when total is neither nil
|
|
42
|
+
# nor a non-negative Integer, or when color is not a Pastel style
|
|
43
|
+
def progress(label, total:, color: nil, &work)
|
|
40
44
|
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
45
|
|
|
43
|
-
|
|
46
|
+
Progress.total(total) unless total.nil?
|
|
47
|
+
|
|
48
|
+
@jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
|
|
44
49
|
self
|
|
45
50
|
end
|
|
46
51
|
end
|
|
47
52
|
|
|
53
|
+
# What the headline bar can count.
|
|
54
|
+
COUNTS = %i[units jobs].freeze
|
|
55
|
+
|
|
56
|
+
# Declares the jobs with the block, then runs them.
|
|
57
|
+
#
|
|
58
|
+
# @param title [String] the headline above the jobs
|
|
59
|
+
# @param concurrent [Boolean, Integer] see {Multi#run}
|
|
60
|
+
# @param count [Symbol] what the headline bar counts: `:units`, the
|
|
61
|
+
# sum of every bar, or `:jobs`, how many jobs have ended
|
|
62
|
+
# @param total [Integer, nil] the headline bar's total; nil for the
|
|
63
|
+
# sum of every bar's, or the number of jobs when counting jobs
|
|
64
|
+
# @param stop [Stop, nil] see {Multi#run}
|
|
65
|
+
# @yieldparam builder [Builder] declares the jobs
|
|
66
|
+
# @return [Array<Object>] what each job returned, in declaration order
|
|
67
|
+
# @raise [ArgumentError] with an invalid concurrent, count or total
|
|
68
|
+
# @raise [Exception] the first error a job raised
|
|
69
|
+
def run(title, concurrent: true, count: :units, total: nil, stop: nil, &)
|
|
70
|
+
raise ArgumentError, "count must be one of #{COUNTS.inspect}, got #{count.inspect}" unless COUNTS.include?(count)
|
|
71
|
+
|
|
72
|
+
Progress.total(total) unless total.nil?
|
|
73
|
+
@count = count
|
|
74
|
+
@total = total
|
|
75
|
+
super(title, concurrent: concurrent, stop: stop, &)
|
|
76
|
+
end
|
|
77
|
+
|
|
48
78
|
private
|
|
49
79
|
|
|
50
80
|
# @param job [Job]
|
|
@@ -55,12 +85,12 @@ module Dry
|
|
|
55
85
|
# @param width [Integer]
|
|
56
86
|
# @return [String]
|
|
57
87
|
def running(job, width)
|
|
58
|
-
"#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started)}"
|
|
88
|
+
"#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started, job.handle.color)}"
|
|
59
89
|
end
|
|
60
90
|
|
|
61
91
|
# @param job [Job]
|
|
62
92
|
# @return [String]
|
|
63
|
-
def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total}"
|
|
93
|
+
def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total || '?'}"
|
|
64
94
|
|
|
65
95
|
# @param width [Integer]
|
|
66
96
|
# @return [String]
|
|
@@ -71,24 +101,40 @@ module Dry
|
|
|
71
101
|
# @return [String]
|
|
72
102
|
def headline_summary = "#{title} #{current}/#{total}"
|
|
73
103
|
|
|
74
|
-
# @return [Integer] units completed across every job
|
|
75
|
-
def current
|
|
104
|
+
# @return [Integer] units completed across every job, or jobs ended
|
|
105
|
+
def current
|
|
106
|
+
return jobs.count(&:seconds) if @count == :jobs
|
|
76
107
|
|
|
77
|
-
|
|
78
|
-
|
|
108
|
+
jobs.sum { |job| job.handle.current }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Integer] the total given; or units across every job,
|
|
112
|
+
# counting what a job without a total has done so far; or every job
|
|
113
|
+
def total
|
|
114
|
+
return @total if @total
|
|
115
|
+
return jobs.size if @count == :jobs
|
|
116
|
+
|
|
117
|
+
jobs.sum { |job| job.handle.total || job.handle.current }
|
|
118
|
+
end
|
|
79
119
|
|
|
80
120
|
# `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
|
|
81
121
|
# the widest any row can show, so every count ends in one column.
|
|
82
122
|
#
|
|
123
|
+
# A bar whose total is not known yet is drawn empty, counting `12/?`.
|
|
124
|
+
#
|
|
83
125
|
# @param done [Integer]
|
|
84
|
-
# @param all [Integer]
|
|
126
|
+
# @param all [Integer, nil]
|
|
85
127
|
# @param since [Float, nil] when the work started, by the clock
|
|
128
|
+
# @param color [Symbol, nil] the bar's own colour, if any
|
|
86
129
|
# @return [String]
|
|
87
|
-
def meter(done, all, since)
|
|
88
|
-
ratio = all.
|
|
89
|
-
|
|
130
|
+
def meter(done, all, since, color = nil)
|
|
131
|
+
ratio = if all.nil? then 0.0
|
|
132
|
+
elsif all.zero? then 1.0
|
|
133
|
+
else done.fdiv(all)
|
|
134
|
+
end
|
|
135
|
+
bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
|
|
90
136
|
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))
|
|
137
|
+
bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all || '?'}".rjust(count_width), eta: eta(done, all, since))
|
|
92
138
|
end
|
|
93
139
|
|
|
94
140
|
# The widest count any row shows: the headline's, once every job is done.
|
|
@@ -104,11 +150,12 @@ module Dry
|
|
|
104
150
|
end
|
|
105
151
|
|
|
106
152
|
# @param done [Integer]
|
|
107
|
-
# @param all [Integer]
|
|
153
|
+
# @param all [Integer, nil]
|
|
108
154
|
# @param since [Float, nil]
|
|
109
|
-
# @return [String] the time left at the rate so far, or `--` before
|
|
155
|
+
# @return [String] the time left at the rate so far, or `--` before
|
|
156
|
+
# any progress or while the total is not known
|
|
110
157
|
def eta(done, all, since)
|
|
111
|
-
return "--" if since.nil? || done.zero?
|
|
158
|
+
return "--" if since.nil? || done.zero? || all.nil?
|
|
112
159
|
|
|
113
160
|
Duration.format((clock.call - since) / done * (all - done))
|
|
114
161
|
end
|
|
@@ -11,7 +11,8 @@ module Dry
|
|
|
11
11
|
#
|
|
12
12
|
# When one raises, items already running finish, items not yet
|
|
13
13
|
# started are never started, and the first error is re-raised once
|
|
14
|
-
# everything running has stopped.
|
|
14
|
+
# everything running has stopped. Once a {Stop} is set, items not yet
|
|
15
|
+
# started are never started either.
|
|
15
16
|
module Pool
|
|
16
17
|
# Checks a `concurrent:` setting.
|
|
17
18
|
#
|
|
@@ -29,13 +30,14 @@ module Dry
|
|
|
29
30
|
#
|
|
30
31
|
# @param items [Array]
|
|
31
32
|
# @param concurrent [Boolean, Integer] see {.concurrency}
|
|
33
|
+
# @param stop [Stop, nil] checked before each item starts
|
|
32
34
|
# @yieldparam item [Object] one of the items
|
|
33
35
|
# @return [void]
|
|
34
36
|
# @raise [Exception] the first error any block raised
|
|
35
|
-
def self.run(items, concurrent, &)
|
|
36
|
-
return items.each(
|
|
37
|
+
def self.run(items, concurrent, stop: nil, &each)
|
|
38
|
+
return items.each { |item| stop&.stopped? ? break : each.call(item) } unless concurrent
|
|
37
39
|
|
|
38
|
-
futures = concurrent == true ? all_at_once(items, &) : at_most(concurrent, items, &)
|
|
40
|
+
futures = concurrent == true ? all_at_once(items, &each) : at_most(concurrent, items, stop, &each)
|
|
39
41
|
futures.each(&:wait)
|
|
40
42
|
failed = futures.find(&:rejected?)
|
|
41
43
|
raise failed.reason if failed
|
|
@@ -52,26 +54,28 @@ module Dry
|
|
|
52
54
|
#
|
|
53
55
|
# @param limit [Integer]
|
|
54
56
|
# @param items [Array]
|
|
57
|
+
# @param stop [Stop, nil]
|
|
55
58
|
# @return [Array<Concurrent::Promises::Future>] one per worker
|
|
56
|
-
def self.at_most(limit, items, &)
|
|
59
|
+
def self.at_most(limit, items, stop, &)
|
|
57
60
|
queue = Queue.new
|
|
58
61
|
items.each { |item| queue << item }
|
|
59
62
|
queue.close
|
|
60
|
-
|
|
61
|
-
Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, stop, &) } }
|
|
63
|
+
failed = Concurrent::AtomicBoolean.new
|
|
64
|
+
Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, failed, stop, &) } }
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
# @param queue [Queue] closed, so `pop` returns nil once it is empty
|
|
65
|
-
# @param
|
|
68
|
+
# @param failed [Concurrent::AtomicBoolean] set once any worker raises
|
|
69
|
+
# @param stop [Stop, nil]
|
|
66
70
|
# @return [void]
|
|
67
|
-
def self.work(queue, stop, &each)
|
|
71
|
+
def self.work(queue, failed, stop, &each)
|
|
68
72
|
ok = false
|
|
69
|
-
while (item = queue.pop) &&
|
|
73
|
+
while !stop&.stopped? && (item = queue.pop) && failed.false?
|
|
70
74
|
each.call(item)
|
|
71
75
|
end
|
|
72
76
|
ok = true
|
|
73
77
|
ensure
|
|
74
|
-
|
|
78
|
+
failed.make_true unless ok
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
private_class_method :all_at_once, :at_most, :work
|