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.
@@ -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, or when total is not a non-negative Integer
177
- def progress(label, total:, &)
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(&:total)
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
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.4.0"
14
+ VERSION = "0.5.1"
15
15
  end
16
16
  end
17
17
  end
@@ -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
- # 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.
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
- rows.each { |row| terminal.puts(row) }
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 = jobs.all? { |job| job.state == :done } ? :done : :failed
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: needs cursor movement, and every row on
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? && jobs.size + 1 < terminal.height if @live.nil?
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
- terminal.print(terminal.cursor.up(jobs.size + 1) + rows.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
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
- 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) }]
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, or when total is not a non-negative Integer
39
- def progress(label, total:, &work)
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
- @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil))
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 = jobs.sum { |job| job.handle.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
- # @return [Integer] units across every job
78
- def total = jobs.sum { |job| job.handle.total }
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.zero? ? 1.0 : done.fdiv(all)
89
- bar = Progress.bar(terminal.pastel, config, ratio, bar_columns)
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 any progress
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(&) unless concurrent
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
- stop = Concurrent::AtomicBoolean.new
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 stop [Concurrent::AtomicBoolean] set once any worker raises
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) && stop.false?
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
- stop.make_true unless ok
78
+ failed.make_true unless ok
75
79
  end
76
80
 
77
81
  private_class_method :all_at_once, :at_most, :work