dry-cli-ui 0.6.1 → 0.7.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.
@@ -171,6 +171,17 @@ module Dry
171
171
  # @return [String]
172
172
  def summary(job) = job.label
173
173
 
174
+ # What a job's row says after its elapsed time once it has ended.
175
+ #
176
+ # @param job [Job]
177
+ # @return [String, nil]
178
+ def note(_job) = nil
179
+
180
+ # What the headline says after its elapsed time once every job has ended.
181
+ #
182
+ # @return [String, nil]
183
+ def headline_note = nil
184
+
174
185
  # What follows the headline's glyph while jobs run.
175
186
  #
176
187
  # @param width [Integer] the columns the title is padded to
@@ -221,7 +232,7 @@ module Dry
221
232
  lock.synchronize do
222
233
  self.state = outcome
223
234
  @seconds = clock.call - started
224
- live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds))
235
+ live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds, note: headline_note))
225
236
  end
226
237
  end
227
238
 
@@ -318,7 +329,7 @@ module Dry
318
329
  return "#{glyph(state)} #{running_headline(width)}#{stopping}" if state == :running
319
330
 
320
331
  elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(@seconds)})")}"
321
- "#{glyph(state)} #{headline_summary}#{elapsed}"
332
+ "#{glyph(state)} #{headline_summary}#{elapsed}#{noted(headline_note)}"
322
333
  end
323
334
 
324
335
  # @return [String] ` stopping` once a stop is asked for, or nothing
@@ -331,9 +342,15 @@ module Dry
331
342
  return "#{glyph(:running)} #{running(job, width)}" if job.state == :running
332
343
 
333
344
  elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(job.seconds)})")}" if job.seconds
334
- "#{glyph(job.state)} #{job.state == :pending ? job.label : summary(job)}#{elapsed}"
345
+ return "#{glyph(job.state)} #{job.label}" if job.state == :pending
346
+
347
+ "#{glyph(job.state)} #{summary(job)}#{elapsed}#{noted(note(job))}"
335
348
  end
336
349
 
350
+ # @param text [String, nil]
351
+ # @return [String] the note, two spaces after what it follows; or nothing
352
+ def noted(text) = text ? " #{text}" : ""
353
+
337
354
  # A state's marker, `[✓]`; a turning spinner, `[⠏]`, for a running row, live.
338
355
  #
339
356
  # @param state [Symbol]
@@ -58,7 +58,7 @@ module Dry
58
58
 
59
59
  Progress.total(total) unless total.nil?
60
60
 
61
- @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
61
+ @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, color: Progress.color(color)))
62
62
  self
63
63
  end
64
64
 
@@ -112,9 +112,11 @@ module Dry
112
112
  # @return [Object]
113
113
  def call(job) = spinner?(job) ? Line.call(job.work, job.handle) : super
114
114
 
115
+ # A bar whose total is known and zero ends failed, as {Progress::Handle#finish} says.
116
+ #
115
117
  # @param job [Job]
116
118
  # @return [Boolean] whether its line or its bar was told to fail
117
- def reported_failure?(job) = job.handle.failed?
119
+ def reported_failure?(job) = spinner?(job) ? job.handle.failed? : job.handle.finish.failed?
118
120
 
119
121
  # @param job [Job]
120
122
  # @return [Progress::Handle, nil] nil for a spinner, which has no progress
@@ -126,22 +128,42 @@ module Dry
126
128
  def running(job, width)
127
129
  return [job.label, job.handle.detail].reject(&:empty?).join(" ") if spinner?(job)
128
130
 
129
- "#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started, job.handle.color)}"
131
+ handle = job.handle
132
+ "#{job.label.ljust(width)} #{meter(handle.current, handle.total, job.started, handle.color, handle.counts)}"
130
133
  end
131
134
 
132
135
  # @param job [Job]
133
136
  # @return [String]
134
137
  def summary(job) = job.handle.summary(job.label)
135
138
 
139
+ # @param job [Job]
140
+ # @return [String, nil] a bar's failed and auxiliary counts, if any
141
+ def note(job) = spinner?(job) ? nil : Progress.breakdown(terminal.pastel, config, job.handle.counts)
142
+
136
143
  # @param width [Integer]
137
144
  # @return [String]
138
145
  def running_headline(width)
139
- "#{title.ljust(width)} #{meter(current, total, started)}"
146
+ "#{title.ljust(width)} #{meter(current, total, started, nil, counts)}"
140
147
  end
141
148
 
142
149
  # @return [String]
143
150
  def headline_summary = "#{title} #{current}/#{total}"
144
151
 
152
+ # @return [String, nil] every bar's failed and auxiliary counts, if
153
+ # any, when the headline counts units
154
+ def headline_note
155
+ tally = counts
156
+ tally && Progress.breakdown(terminal.pastel, config, tally)
157
+ end
158
+
159
+ # @return [Hash{Symbol => Integer}, nil] units by outcome across every
160
+ # bar; nil when counting jobs
161
+ def counts
162
+ return if @count == :jobs
163
+
164
+ Progress::Handle::OUTCOMES.to_h { |outcome| [outcome, bars.sum { it.handle.counts[outcome] }] }
165
+ end
166
+
145
167
  # @return [Integer] units completed across every job, or jobs ended
146
168
  def current
147
169
  return jobs.count(&:seconds) if @count == :jobs
@@ -163,22 +185,17 @@ module Dry
163
185
 
164
186
  # `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
165
187
  # the widest any row can show, so every count ends in one column.
166
- #
167
- # A bar whose total is not known yet is drawn empty, counting `12/?`.
188
+ # See {Progress.meter}.
168
189
  #
169
190
  # @param done [Integer]
170
191
  # @param all [Integer, nil]
171
- # @param since [Float, nil] when the work started, by the clock
192
+ # @param since [Float] when the work started, by the clock
172
193
  # @param color [Symbol, nil] the bar's own colour, if any
194
+ # @param tally [Hash{Symbol => Integer}, nil] units by outcome
173
195
  # @return [String]
174
- def meter(done, all, since, color = nil)
175
- ratio = if all.nil? then 0.0
176
- elsif all.zero? then 1.0
177
- else done.fdiv(all)
178
- end
179
- bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
180
- format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
181
- bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all || '?'}".rjust(count_width), eta: eta(done, all, since))
196
+ def meter(done, all, since, color, tally)
197
+ Progress.meter(terminal.pastel, config, done: done, all: all, columns: bar_columns, eta: eta(done, all, since),
198
+ color: color, counts: tally, count_width: count_width)
182
199
  end
183
200
 
184
201
  # The widest count any row shows: the headline's, once every job is done.
@@ -195,14 +212,9 @@ module Dry
195
212
 
196
213
  # @param done [Integer]
197
214
  # @param all [Integer, nil]
198
- # @param since [Float, nil]
199
- # @return [String] the time left at the rate so far, or `--` before
200
- # any progress or while the total is not known
201
- def eta(done, all, since)
202
- return "--" if since.nil? || done.zero? || all.nil?
203
-
204
- Duration.format((clock.call - since) / done * (all - done))
205
- end
215
+ # @param since [Float] when the work started, by the clock
216
+ # @return [String] see {Progress.eta}
217
+ def eta(done, all, since) = Progress.eta(done, all) { clock.call - since }
206
218
  end
207
219
  end
208
220
  end
@@ -12,11 +12,13 @@ module Dry
12
12
  # @param state [Symbol] `:done` or `:failed`
13
13
  # @param label [String] what the operation was
14
14
  # @param seconds [Numeric] how long it took
15
+ # @param note [String, nil] said after the time, such as a progress
16
+ # bar's breakdown `20 failed, 5 auxiliary`
15
17
  # @return [String] the line, without a newline
16
- def self.line(terminal, state, label, seconds)
18
+ def self.line(terminal, state, label, seconds, note: nil)
17
19
  glyph, color = Theme::STATES.fetch(state)
18
20
  pastel = terminal.pastel
19
- "#{pastel.decorate(glyph, *color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
21
+ "#{pastel.decorate(glyph, *color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}#{" #{note}" if note}"
20
22
  end
21
23
  end
22
24
  end
@@ -1,27 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-progressbar"
3
+ require "concurrent"
4
4
 
5
5
  module Dry
6
6
  class CLI
7
7
  module UI
8
8
  module Widgets
9
- # A progress bar with a percentage, a count and an ETA, followed by
10
- # the outcome line once the block ends.
9
+ # A progress bar with a turning spinner, a percentage, a count and an
10
+ # ETA, followed by the outcome line once the block ends.
11
+ #
12
+ # Each unit can be counted as a success, as auxiliary, or as a failure
13
+ # (see {Handle#advance}). The bar's finished part is drawn left to
14
+ # right in {Configuration#bar_failed_color}, {Configuration#bar_aux_color}
15
+ # and the bar's own colour, each part as wide as its share, and the
16
+ # outcome line ends with the breakdown: `✓ Placing 100/100 (2.1s) 20 failed, 5 auxiliary`.
11
17
  #
12
18
  # Without an animated terminal it prints `Label...` before the block
13
19
  # and the outcome line, with the final count, after it.
14
20
  class Progress
15
21
  # What the block is given to report progress through.
16
22
  class Handle
23
+ # What {#advance} can count a unit as: a success, something
24
+ # relevant but auxiliary, or a failure.
25
+ OUTCOMES = %i[ok aux failed].freeze
26
+
17
27
  # @param total [Integer, nil] nil until the work finds out
18
- # @param bar [TTY::ProgressBar, nil]
28
+ # @param on_change [#call, nil] called with no arguments whenever
29
+ # the count or the total changes, by the widget that draws the bar
19
30
  # @param color [Symbol, nil] see {Progress.color}
20
- def initialize(total, bar, color: nil)
31
+ def initialize(total, on_change = nil, color: nil)
21
32
  @total = total
22
- @bar = bar
33
+ @on_change = on_change
23
34
  @color = color
24
35
  @current = 0
36
+ @tally = OUTCOMES.to_h { [it, 0] }
25
37
  end
26
38
 
27
39
  # @return [Integer, nil] the number of units the operation has; nil
@@ -35,24 +47,38 @@ module Dry
35
47
  # @return [Integer] the number of units completed so far
36
48
  attr_reader :current
37
49
 
50
+ # @return [Hash{Symbol => Integer}] the units completed so far by
51
+ # outcome, `{ ok:, aux:, failed: }`, adding up to {#current}; frozen
52
+ def counts = @tally.dup.freeze
53
+
38
54
  # Sets the number of units once the work finds out, such as a download
39
- # learning its size. {#current} is lowered to fit.
55
+ # learning its size. {#current} is lowered to fit, taking the units
56
+ # off {#counts} from `:ok` first.
40
57
  #
41
58
  # @param value [Integer]
42
59
  # @raise [ArgumentError] when value is not a non-negative Integer
43
60
  def total=(value)
44
61
  @total = Progress.total(value)
45
- self.current = current.clamp(0, value)
46
- bar&.update(total: value)
62
+ settle(current.clamp(0, value), :ok)
63
+ changed
47
64
  end
48
65
 
49
- # Marks units as complete. Progress never passes {#total} once it is known.
66
+ # Marks units as complete, each counted as the outcome given.
67
+ # Progress never passes {#total} once it is known.
68
+ #
69
+ # @example
70
+ # bar.advance # one unit that succeeded
71
+ # bar.advance(as: :failed) # one that failed
72
+ # bar.advance(3, as: :aux) # three relevant but auxiliary ones
50
73
  #
51
74
  # @param step [Integer]
75
+ # @param as [Symbol] one of {OUTCOMES}
52
76
  # @return [self]
53
- def advance(step = 1)
54
- self.current = (current + step).clamp(0, total)
55
- bar&.advance(step)
77
+ # @raise [ArgumentError] when as is not one of {OUTCOMES}
78
+ def advance(step = 1, as: :ok)
79
+ Progress.outcome(as)
80
+ settle((current + step).clamp(0, total), as)
81
+ changed
56
82
  self
57
83
  end
58
84
 
@@ -73,6 +99,17 @@ module Dry
73
99
  # @return [String, nil] what {#fail} was given
74
100
  attr_reader :reason
75
101
 
102
+ # Called by the widget when the block returns. A total that is known
103
+ # and zero ends the work as a failure, with {Progress::NOTHING} as
104
+ # the reason, unless {#fail} was already called.
105
+ #
106
+ # @return [self]
107
+ def finish
108
+ fail(NOTHING) if total&.zero? && !failed?
109
+
110
+ self
111
+ end
112
+
76
113
  # The label and the count, with the failure's reason after it when
77
114
  # there is one: `Extracting 1002/1002: 2 failed`.
78
115
  #
@@ -85,18 +122,41 @@ module Dry
85
122
 
86
123
  private
87
124
 
88
- # @return [TTY::ProgressBar, nil]
89
- attr_reader :bar
125
+ # Moves {#current} to a new value, adding the units gained to an
126
+ # outcome, or taking the units lost off that outcome first.
127
+ #
128
+ # @param value [Integer]
129
+ # @param outcome [Symbol]
130
+ # @return [void]
131
+ def settle(value, outcome)
132
+ change = value - current
133
+ @current = value
134
+ return @tally[outcome] += change unless change.negative?
135
+
136
+ [outcome, *(OUTCOMES - [outcome])].reduce(-change) do |left, key|
137
+ taken = [left, @tally[key]].min
138
+ @tally[key] -= taken
139
+ left - taken
140
+ end
141
+ end
90
142
 
91
- attr_writer :current
143
+ # @return [void]
144
+ def changed = @on_change&.call
92
145
  end
93
146
 
94
- # Columns kept for the brackets, percentage, count and ETA around the bar.
147
+ # The reason a bar fails with when it ends with nothing to do.
148
+ NOTHING = "nothing to process"
149
+
150
+ # Columns kept for the spinner, brackets, percentage, count and ETA around the bar.
95
151
  CHROME = 36
96
152
 
97
153
  # Narrowest bar drawn.
98
154
  MIN_BAR = 10
99
155
 
156
+ # The words the outcome line's breakdown counts with, and the setting
157
+ # each one is painted with.
158
+ BREAKDOWN = { failed: ["failed", :bar_failed_color], aux: ["auxiliary", :bar_aux_color] }.freeze
159
+
100
160
  # Checks a bar's total.
101
161
  #
102
162
  # @param value [Integer]
@@ -119,19 +179,121 @@ module Dry
119
179
  raise ArgumentError, "color must be a Pastel style or nil, got #{value.inspect}"
120
180
  end
121
181
 
182
+ # Checks what a unit is counted as.
183
+ #
184
+ # @param value [Symbol] one of {Handle::OUTCOMES}
185
+ # @return [Symbol] the value
186
+ # @raise [ArgumentError] for anything else
187
+ def self.outcome(value)
188
+ return value if Handle::OUTCOMES.include?(value)
189
+
190
+ raise ArgumentError, "as must be one of #{Handle::OUTCOMES.inspect}, got #{value.inspect}"
191
+ end
192
+
122
193
  # A bar between brackets, painted as the configuration says: the
123
194
  # finished part in {Configuration#bar_color}, or in the bar's own
124
195
  # colour when it has one, and all of it on {Configuration#bar_background}.
125
196
  #
197
+ # Given counts, the finished part starts with the failed units in
198
+ # {Configuration#bar_failed_color}, then the auxiliary ones in
199
+ # {Configuration#bar_aux_color}, each as wide as its share (see {.segments}).
200
+ #
126
201
  # @param pastel [Pastel::Delegator] a no-op when colour is off
127
202
  # @param config [Configuration]
128
203
  # @param ratio [Float] how much is finished, from 0 to 1
129
204
  # @param columns [Integer] the bar's width inside the brackets
130
205
  # @param color [Symbol, nil] the finished part's style; nil for {Configuration#bar_color}
206
+ # @param counts [Hash{Symbol => Integer}, nil] units by outcome, as {Handle#counts}
131
207
  # @return [String]
132
- def self.bar(pastel, config, ratio, columns, color: nil)
208
+ def self.bar(pastel, config, ratio, columns, color: nil, counts: nil)
133
209
  filled = (ratio * columns).floor
134
- "[#{complete(pastel, config, color) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
210
+ failed, aux, ok = counts ? segments(filled, counts) : [0, 0, filled]
211
+ [
212
+ "[",
213
+ paint(pastel, config, config.bar_failed_color) * failed,
214
+ paint(pastel, config, config.bar_aux_color) * aux,
215
+ complete(pastel, config, color) * ok,
216
+ incomplete(pastel, config) * (columns - filled),
217
+ "]"
218
+ ].join
219
+ end
220
+
221
+ # Splits a bar's finished cells between the failed, auxiliary and
222
+ # successful units, in proportion to their counts. The cells always
223
+ # add up to the finished width, and each outcome that has units gets
224
+ # at least one cell while the width allows.
225
+ #
226
+ # @param filled [Integer] the finished cells
227
+ # @param counts [Hash{Symbol => Integer}] units by outcome, as {Handle#counts}
228
+ # @return [Array(Integer, Integer, Integer)] failed, auxiliary and successful cells
229
+ def self.segments(filled, counts)
230
+ values = counts.values_at(:failed, :aux, :ok)
231
+ sum = values.sum
232
+ return [0, 0, filled] if sum.zero?
233
+
234
+ shares = values.map { it * filled / sum.to_f }
235
+ cells = shares.map(&:floor)
236
+ shares.each_index.sort_by { |index| [cells[index] - shares[index], index] }
237
+ .first(filled - cells.sum).each { cells[it] += 1 }
238
+ values.each_index do |index|
239
+ next unless values[index].positive? && cells[index].zero?
240
+
241
+ donor = cells.index(cells.max)
242
+ next unless cells[donor] > 1
243
+
244
+ cells[donor] -= 1
245
+ cells[index] += 1
246
+ end
247
+ cells
248
+ end
249
+
250
+ # `[◼◼◼ ] 48% 96/200 ETA 3.1s`. A bar whose total is not known
251
+ # yet is drawn empty, counting `12/?`; an empty one is drawn full.
252
+ #
253
+ # @param pastel [Pastel::Delegator]
254
+ # @param config [Configuration]
255
+ # @param done [Integer]
256
+ # @param all [Integer, nil]
257
+ # @param columns [Integer] the bar's width inside the brackets
258
+ # @param eta [String] the time left, as {.eta} says it
259
+ # @param color [Symbol, nil] the bar's own colour, if any
260
+ # @param counts [Hash{Symbol => Integer}, nil] units by outcome
261
+ # @param count_width [Integer] the columns the count is right-aligned to
262
+ # @return [String]
263
+ def self.meter(pastel, config, done:, all:, columns:, eta:, color: nil, counts: nil, count_width: 0)
264
+ ratio = if all.nil? then 0.0
265
+ elsif all.zero? then 1.0
266
+ else done.fdiv(all)
267
+ end
268
+ format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
269
+ bar: bar(pastel, config, ratio, columns, color: color, counts: counts),
270
+ percent: (ratio * 100).floor, count: "#{done}/#{all || '?'}".rjust(count_width), eta: eta)
271
+ end
272
+
273
+ # The time left at the rate so far.
274
+ #
275
+ # @param done [Integer]
276
+ # @param all [Integer, nil]
277
+ # @yieldreturn [Numeric] the seconds spent so far; asked only when needed
278
+ # @return [String] `--` before any progress or while the total is not known
279
+ def self.eta(done, all)
280
+ return "--" if done.zero? || all.nil?
281
+
282
+ Duration.format(yield / done * (all - done))
283
+ end
284
+
285
+ # The failed and auxiliary counts, each in its configured colour:
286
+ # `20 failed, 5 auxiliary`.
287
+ #
288
+ # @param pastel [Pastel::Delegator]
289
+ # @param config [Configuration]
290
+ # @param counts [Hash{Symbol => Integer}] units by outcome, as {Handle#counts}
291
+ # @return [String, nil] nil when no unit failed or was auxiliary
292
+ def self.breakdown(pastel, config, counts)
293
+ parts = BREAKDOWN.filter_map do |key, (word, setting)|
294
+ pastel.decorate("#{counts[key]} #{word}", *[config.public_send(setting)].compact) if counts[key].positive?
295
+ end
296
+ parts.join(", ") unless parts.empty?
135
297
  end
136
298
 
137
299
  # @param pastel [Pastel::Delegator]
@@ -139,7 +301,15 @@ module Dry
139
301
  # @param color [Symbol, nil] the style; nil for {Configuration#bar_color}
140
302
  # @return [String] one finished cell, painted
141
303
  def self.complete(pastel, config, color = nil)
142
- pastel.decorate(config.bar_complete, *[color || config.bar_color, config.bar_background].compact)
304
+ paint(pastel, config, color || config.bar_color)
305
+ end
306
+
307
+ # @param pastel [Pastel::Delegator]
308
+ # @param config [Configuration]
309
+ # @param style [Symbol, nil] the cell's style; nil for none
310
+ # @return [String] one finished cell in the style, on {Configuration#bar_background}
311
+ def self.paint(pastel, config, style)
312
+ pastel.decorate(config.bar_complete, *[style, config.bar_background].compact)
143
313
  end
144
314
 
145
315
  # @param pastel [Pastel::Delegator]
@@ -156,6 +326,9 @@ module Dry
156
326
  @terminal = terminal
157
327
  @clock = clock
158
328
  @config = config
329
+ @lock = Mutex.new
330
+ @live = false
331
+ @frame = 0
159
332
  end
160
333
 
161
334
  # Runs the block with a progress bar.
@@ -171,19 +344,21 @@ module Dry
171
344
  def run(label, total:, color: nil)
172
345
  Progress.total(total)
173
346
  Progress.color(color)
174
- started = clock.call
175
- bar = start(label, total, color)
176
- handle = Handle.new(total, bar, color: color)
347
+ @label = label
348
+ @started = clock.call
349
+ handle = @handle = Handle.new(total, method(:refresh), color: color)
177
350
  terminal.started(handle, label, progress: handle)
351
+ ticker = start
178
352
  ok = false
179
353
  result = yield handle
180
- ok = !handle.failed?
354
+ ok = !handle.finish.failed?
181
355
  result
182
356
  ensure
183
357
  if handle
184
- bar&.stop
358
+ stop(ticker)
185
359
  terminal.finished(handle, ok)
186
- terminal.puts(Outcome.line(terminal, ok ? :done : :failed, handle.summary(label), clock.call - started))
360
+ note = Progress.breakdown(terminal.pastel, config, handle.counts)
361
+ terminal.puts(Outcome.line(terminal, ok ? :done : :failed, handle.summary(label), clock.call - started, note: note))
187
362
  end
188
363
  end
189
364
 
@@ -198,26 +373,73 @@ module Dry
198
373
  # @return [Configuration]
199
374
  attr_reader :config
200
375
 
201
- # @param label [String]
202
- # @param total [Integer]
203
- # @param color [Symbol, nil]
204
- # @return [TTY::ProgressBar, nil]
205
- def start(label, total, color)
206
- unless terminal.animated? && total.positive?
376
+ # @return [Mutex] held while the bar is drawn
377
+ attr_reader :lock
378
+
379
+ # @return [String]
380
+ attr_reader :label
381
+
382
+ # @return [Handle]
383
+ attr_reader :handle
384
+
385
+ # @return [Float] when the block started, by the clock
386
+ attr_reader :started
387
+
388
+ # Draws the bar and starts its spinner turning when live, or prints
389
+ # the label otherwise.
390
+ #
391
+ # @return [Concurrent::TimerTask, nil]
392
+ def start
393
+ unless terminal.animated? && handle.total.positive?
207
394
  terminal.puts("#{label}...")
208
395
  return
209
396
  end
210
397
 
211
- TTY::ProgressBar.new(
212
- "#{label} [:bar] :percent :current/:total ETA :eta",
213
- total: total,
214
- width: [terminal.width - label.length - CHROME, MIN_BAR].max,
215
- output: terminal.io,
216
- complete: Progress.complete(terminal.pastel, config, color),
217
- incomplete: Progress.incomplete(terminal.pastel, config),
218
- clear: true,
219
- hide_cursor: true
220
- )
398
+ terminal.print(terminal.cursor.hide)
399
+ @live = true
400
+ refresh
401
+ Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { refresh(1) }.tap(&:execute)
402
+ end
403
+
404
+ # Stops the spinner and clears the bar.
405
+ #
406
+ # @param ticker [Concurrent::TimerTask, nil]
407
+ # @return [void]
408
+ def stop(ticker)
409
+ ticker&.shutdown
410
+ ticker&.wait_for_termination(1)
411
+ lock.synchronize do
412
+ next unless @live
413
+
414
+ @live = false
415
+ terminal.print("#{terminal.cursor.clear_line}#{terminal.cursor.show}")
416
+ end
417
+ end
418
+
419
+ # Draws the bar over itself, turning the spinner by some frames.
420
+ #
421
+ # @param turn [Integer] frames to move the spinner on
422
+ # @return [void]
423
+ def refresh(turn = 0)
424
+ lock.synchronize do
425
+ next unless @live
426
+
427
+ @frame += turn
428
+ terminal.print("#{terminal.cursor.clear_line}#{row}")
429
+ end
430
+ end
431
+
432
+ # `⠋ Importing [◼◼◼ ] 48% 96/200 ETA 3.1s`
433
+ #
434
+ # @return [String]
435
+ def row
436
+ frames = config.spinner_frames
437
+ done = handle.current
438
+ all = handle.total
439
+ meter = Progress.meter(terminal.pastel, config, done: done, all: all, color: handle.color, counts: handle.counts,
440
+ columns: [terminal.width - label.length - CHROME, MIN_BAR].max,
441
+ eta: Progress.eta(done, all) { clock.call - started })
442
+ "#{frames[@frame % frames.size]} #{label} #{meter}"
221
443
  end
222
444
  end
223
445
  end
@@ -17,6 +17,9 @@ module Dry
17
17
  # An exhausted input returns the default. A question without a
18
18
  # default raises {NonInteractiveError} rather than inventing an answer.
19
19
  class Prompt
20
+ # The choices {#confirm} offers on an interactive terminal.
21
+ CONFIRM_CHOICES = { "YES" => true, "NO" => false }.freeze
22
+
20
23
  # @param input [IO] where answers come from
21
24
  # @param terminal [Terminal] where questions go
22
25
  # @param backend [TTY::Prompt, nil] the interactive implementation; built on first use when nil
@@ -43,13 +46,14 @@ module Dry
43
46
  end
44
47
  end
45
48
 
46
- # Asks a yes/no question.
49
+ # Asks a yes/no question. On an interactive terminal it is a list to pick YES or NO from,
50
+ # starting on the default; otherwise it reads `y`, `yes`, `n` or `no`.
47
51
  #
48
52
  # @param question [String]
49
53
  # @param default [Boolean] returned for an empty answer or an exhausted input
50
54
  # @return [Boolean]
51
55
  def confirm(question, default: false)
52
- return backend.yes?(question, default: default) if interactive?
56
+ return backend.select(question, CONFIRM_CHOICES, default: default ? 1 : 2) if interactive?
53
57
 
54
58
  loop do
55
59
  terminal.print("#{question} #{default ? '(Y/n)' : '(y/N)'} ")
@@ -8,6 +8,7 @@ module Dry
8
8
  # namespace touches a TTY toolkit class.
9
9
  module Widgets
10
10
  autoload :Box, File.expand_path("widgets/box", __dir__)
11
+ autoload :Legend, File.expand_path("widgets/legend", __dir__)
11
12
  autoload :Multi, File.expand_path("widgets/multi", __dir__)
12
13
  autoload :MultiProgress, File.expand_path("widgets/multi_progress", __dir__)
13
14
  autoload :MultiSpinner, File.expand_path("widgets/multi_spinner", __dir__)