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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -7
- data/README.md +207 -9
- data/examples/Gemfile.lock +20 -7
- data/lib/dry/cli/ui/configuration.rb +27 -2
- data/lib/dry/cli/ui/console.rb +31 -4
- data/lib/dry/cli/ui/flags.rb +143 -0
- data/lib/dry/cli/ui/invocation.rb +100 -0
- data/lib/dry/cli/ui/logging.rb +192 -0
- data/lib/dry/cli/ui/report.rb +16 -0
- data/lib/dry/cli/ui/reporting.rb +134 -0
- data/lib/dry/cli/ui/terminal.rb +19 -2
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/legend.rb +48 -0
- data/lib/dry/cli/ui/widgets/multi.rb +20 -3
- data/lib/dry/cli/ui/widgets/multi_progress.rb +35 -23
- data/lib/dry/cli/ui/widgets/outcome.rb +4 -2
- data/lib/dry/cli/ui/widgets/progress.rb +264 -42
- data/lib/dry/cli/ui/widgets/prompt.rb +6 -2
- data/lib/dry/cli/ui/widgets.rb +1 -0
- data/lib/dry/cli/ui.rb +29 -5
- data/sig/dry/cli/ui.rbs +86 -2
- metadata +49 -1
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
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,
|
|
199
|
-
# @return [String]
|
|
200
|
-
|
|
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 "
|
|
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
|
|
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
|
|
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,
|
|
31
|
+
def initialize(total, on_change = nil, color: nil)
|
|
21
32
|
@total = total
|
|
22
|
-
@
|
|
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
|
-
|
|
46
|
-
|
|
62
|
+
settle(current.clamp(0, value), :ok)
|
|
63
|
+
changed
|
|
47
64
|
end
|
|
48
65
|
|
|
49
|
-
# Marks units as complete
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
#
|
|
89
|
-
|
|
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
|
-
|
|
143
|
+
# @return [void]
|
|
144
|
+
def changed = @on_change&.call
|
|
92
145
|
end
|
|
93
146
|
|
|
94
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
handle = Handle.new(total,
|
|
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
|
-
|
|
358
|
+
stop(ticker)
|
|
185
359
|
terminal.finished(handle, ok)
|
|
186
|
-
|
|
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
|
-
# @
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
# @return [
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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)'} ")
|
data/lib/dry/cli/ui/widgets.rb
CHANGED
|
@@ -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__)
|