dry-cli-ui 0.6.0 → 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
- # @return [Boolean]
117
- def reported_failure?(job) = spinner?(job) && job.handle.failed?
118
+ # @return [Boolean] whether its line or its bar was told to fail
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,26 +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
- def summary(job)
135
- return job.handle.summary(job.label) if spinner?(job)
137
+ def summary(job) = job.handle.summary(job.label)
136
138
 
137
- "#{job.label} #{job.handle.current}/#{job.handle.total || '?'}"
138
- end
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)
139
142
 
140
143
  # @param width [Integer]
141
144
  # @return [String]
142
145
  def running_headline(width)
143
- "#{title.ljust(width)} #{meter(current, total, started)}"
146
+ "#{title.ljust(width)} #{meter(current, total, started, nil, counts)}"
144
147
  end
145
148
 
146
149
  # @return [String]
147
150
  def headline_summary = "#{title} #{current}/#{total}"
148
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
+
149
167
  # @return [Integer] units completed across every job, or jobs ended
150
168
  def current
151
169
  return jobs.count(&:seconds) if @count == :jobs
@@ -167,22 +185,17 @@ module Dry
167
185
 
168
186
  # `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
169
187
  # the widest any row can show, so every count ends in one column.
170
- #
171
- # A bar whose total is not known yet is drawn empty, counting `12/?`.
188
+ # See {Progress.meter}.
172
189
  #
173
190
  # @param done [Integer]
174
191
  # @param all [Integer, nil]
175
- # @param since [Float, nil] when the work started, by the clock
192
+ # @param since [Float] when the work started, by the clock
176
193
  # @param color [Symbol, nil] the bar's own colour, if any
194
+ # @param tally [Hash{Symbol => Integer}, nil] units by outcome
177
195
  # @return [String]
178
- def meter(done, all, since, color = nil)
179
- ratio = if all.nil? then 0.0
180
- elsif all.zero? then 1.0
181
- else done.fdiv(all)
182
- end
183
- bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
184
- format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
185
- 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)
186
199
  end
187
200
 
188
201
  # The widest count any row shows: the headline's, once every job is done.
@@ -199,14 +212,9 @@ module Dry
199
212
 
200
213
  # @param done [Integer]
201
214
  # @param all [Integer, nil]
202
- # @param since [Float, nil]
203
- # @return [String] the time left at the rate so far, or `--` before
204
- # any progress or while the total is not known
205
- def eta(done, all, since)
206
- return "--" if since.nil? || done.zero? || all.nil?
207
-
208
- Duration.format((clock.call - since) / done * (all - done))
209
- 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 }
210
218
  end
211
219
  end
212
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,41 +47,116 @@ 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
 
85
+ # Ends the work as a failure when the block returns, without
86
+ # raising: for work that went on after some of its units failed.
87
+ #
88
+ # @param reason [#to_s, nil] said after the count, such as "2 failed"
89
+ # @return [self]
90
+ def fail(reason = nil)
91
+ @failed = true
92
+ @reason = reason&.to_s
93
+ self
94
+ end
95
+
96
+ # @return [Boolean] whether {#fail} was called
97
+ def failed? = @failed == true
98
+
99
+ # @return [String, nil] what {#fail} was given
100
+ attr_reader :reason
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
+
113
+ # The label and the count, with the failure's reason after it when
114
+ # there is one: `Extracting 1002/1002: 2 failed`.
115
+ #
116
+ # @param label [String]
117
+ # @return [String]
118
+ def summary(label)
119
+ count = "#{label} #{current}/#{total || '?'}"
120
+ failed? && !reason.to_s.empty? ? "#{count}: #{reason}" : count
121
+ end
122
+
59
123
  private
60
124
 
61
- # @return [TTY::ProgressBar, nil]
62
- 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
63
142
 
64
- attr_writer :current
143
+ # @return [void]
144
+ def changed = @on_change&.call
65
145
  end
66
146
 
67
- # 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.
68
151
  CHROME = 36
69
152
 
70
153
  # Narrowest bar drawn.
71
154
  MIN_BAR = 10
72
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
+
73
160
  # Checks a bar's total.
74
161
  #
75
162
  # @param value [Integer]
@@ -92,19 +179,121 @@ module Dry
92
179
  raise ArgumentError, "color must be a Pastel style or nil, got #{value.inspect}"
93
180
  end
94
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
+
95
193
  # A bar between brackets, painted as the configuration says: the
96
194
  # finished part in {Configuration#bar_color}, or in the bar's own
97
195
  # colour when it has one, and all of it on {Configuration#bar_background}.
98
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
+ #
99
201
  # @param pastel [Pastel::Delegator] a no-op when colour is off
100
202
  # @param config [Configuration]
101
203
  # @param ratio [Float] how much is finished, from 0 to 1
102
204
  # @param columns [Integer] the bar's width inside the brackets
103
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}
104
207
  # @return [String]
105
- def self.bar(pastel, config, ratio, columns, color: nil)
208
+ def self.bar(pastel, config, ratio, columns, color: nil, counts: nil)
106
209
  filled = (ratio * columns).floor
107
- "[#{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?
108
297
  end
109
298
 
110
299
  # @param pastel [Pastel::Delegator]
@@ -112,7 +301,15 @@ module Dry
112
301
  # @param color [Symbol, nil] the style; nil for {Configuration#bar_color}
113
302
  # @return [String] one finished cell, painted
114
303
  def self.complete(pastel, config, color = nil)
115
- 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)
116
313
  end
117
314
 
118
315
  # @param pastel [Pastel::Delegator]
@@ -129,6 +326,9 @@ module Dry
129
326
  @terminal = terminal
130
327
  @clock = clock
131
328
  @config = config
329
+ @lock = Mutex.new
330
+ @live = false
331
+ @frame = 0
132
332
  end
133
333
 
134
334
  # Runs the block with a progress bar.
@@ -144,20 +344,21 @@ module Dry
144
344
  def run(label, total:, color: nil)
145
345
  Progress.total(total)
146
346
  Progress.color(color)
147
- started = clock.call
148
- bar = start(label, total, color)
149
- handle = Handle.new(total, bar, color: color)
347
+ @label = label
348
+ @started = clock.call
349
+ handle = @handle = Handle.new(total, method(:refresh), color: color)
150
350
  terminal.started(handle, label, progress: handle)
351
+ ticker = start
151
352
  ok = false
152
353
  result = yield handle
153
- ok = true
354
+ ok = !handle.finish.failed?
154
355
  result
155
356
  ensure
156
357
  if handle
157
- bar&.stop
358
+ stop(ticker)
158
359
  terminal.finished(handle, ok)
159
- summary = "#{label} #{handle.current}/#{handle.total}"
160
- terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, 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))
161
362
  end
162
363
  end
163
364
 
@@ -172,26 +373,73 @@ module Dry
172
373
  # @return [Configuration]
173
374
  attr_reader :config
174
375
 
175
- # @param label [String]
176
- # @param total [Integer]
177
- # @param color [Symbol, nil]
178
- # @return [TTY::ProgressBar, nil]
179
- def start(label, total, color)
180
- 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?
181
394
  terminal.puts("#{label}...")
182
395
  return
183
396
  end
184
397
 
185
- TTY::ProgressBar.new(
186
- "#{label} [:bar] :percent :current/:total ETA :eta",
187
- total: total,
188
- width: [terminal.width - label.length - CHROME, MIN_BAR].max,
189
- output: terminal.io,
190
- complete: Progress.complete(terminal.pastel, config, color),
191
- incomplete: Progress.incomplete(terminal.pastel, config),
192
- clear: true,
193
- hide_cursor: true
194
- )
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}"
195
443
  end
196
444
  end
197
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__)