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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +246 -18
- 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 +36 -28
- data/lib/dry/cli/ui/widgets/outcome.rb +4 -2
- data/lib/dry/cli/ui/widgets/progress.rb +291 -43
- 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 +76 -10
- data/sig/dry/cli/ui.rbs +91 -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
|
-
# @return [Boolean]
|
|
117
|
-
def reported_failure?(job) = spinner?(job)
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
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,
|
|
203
|
-
# @return [String]
|
|
204
|
-
|
|
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 "
|
|
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,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
|
-
|
|
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
|
|
|
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
|
-
#
|
|
62
|
-
|
|
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
|
-
|
|
143
|
+
# @return [void]
|
|
144
|
+
def changed = @on_change&.call
|
|
65
145
|
end
|
|
66
146
|
|
|
67
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
handle = Handle.new(total,
|
|
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 =
|
|
354
|
+
ok = !handle.finish.failed?
|
|
154
355
|
result
|
|
155
356
|
ensure
|
|
156
357
|
if handle
|
|
157
|
-
|
|
358
|
+
stop(ticker)
|
|
158
359
|
terminal.finished(handle, ok)
|
|
159
|
-
|
|
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
|
-
# @
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# @return [
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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__)
|