dry-cli-ui 0.3.1 → 0.4.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.
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Runs several named jobs, by default all at once, under one headline,
10
+ # and shows the state of each on a row of its own. {MultiSpinner} and
11
+ # {MultiProgress} decide what a row says; this class runs the jobs and
12
+ # draws the rows.
13
+ #
14
+ # The block only declares the jobs; nothing runs until it returns, so
15
+ # every row, including those of jobs still waiting under a concurrency
16
+ # limit, is drawn before the first job starts.
17
+ #
18
+ # On an animated terminal the rows are drawn once and redrawn in place.
19
+ # Otherwise, or when there are more rows than the screen has, it prints
20
+ # `Title...`, then each job's outcome as it ends, then the headline's.
21
+ #
22
+ # When a job raises, jobs already running finish, jobs not yet started
23
+ # are marked skipped, the headline is marked failed, and the first error
24
+ # is re-raised.
25
+ class Multi
26
+ # What a job's row is prefixed with when rows are printed one by one.
27
+ PLAIN_INDENT = " "
28
+
29
+ # One declared job.
30
+ class Job
31
+ # @param label [String]
32
+ # @param work [Proc]
33
+ # @param handle [Object] what the work is given to report through
34
+ def initialize(label, work, handle)
35
+ @label = label
36
+ @work = work
37
+ @handle = handle
38
+ @state = :pending
39
+ end
40
+
41
+ # @return [String]
42
+ attr_reader :label
43
+
44
+ # @return [Proc]
45
+ attr_reader :work
46
+
47
+ # @return [Object] a {Line} or a {Progress::Handle}
48
+ attr_reader :handle
49
+
50
+ # @return [Symbol] one of the keys of {Theme::STATES}
51
+ attr_accessor :state
52
+
53
+ # @return [Float, nil] when the job started, by the widget's clock
54
+ attr_accessor :started
55
+
56
+ # @return [Float, nil] how long the job ran, once it has ended
57
+ attr_accessor :seconds
58
+
59
+ # @return [Object] what the work returned
60
+ attr_accessor :value
61
+ end
62
+
63
+ # @param terminal [Terminal]
64
+ # @param clock [#call] returns monotonic seconds
65
+ # @param config [Configuration] where the spinner frames come from
66
+ def initialize(terminal, clock:, config: UI.config)
67
+ @terminal = terminal
68
+ @clock = clock
69
+ @config = config
70
+ @jobs = []
71
+ @state = :pending
72
+ @live = nil
73
+ @lock = Mutex.new
74
+ @frame = 0
75
+ end
76
+
77
+ # Declares the jobs with the block, then runs them.
78
+ #
79
+ # @param title [String] the headline above the jobs
80
+ # @param concurrent [Boolean, Integer] all at once, one at a time, or
81
+ # at most this many at once
82
+ # @yieldparam builder [Object] declares the jobs
83
+ # @return [Array<Object>] what each job returned, in declaration
84
+ # order; nil for a job that never ran
85
+ # @raise [ArgumentError] with an invalid concurrent
86
+ # @raise [Exception] the first error a job raised
87
+ def run(title, concurrent: true)
88
+ Pool.concurrency(concurrent)
89
+ yield builder
90
+ @title = title
91
+ @started = clock.call
92
+ ticker = start
93
+ begin
94
+ Pool.run(jobs, concurrent) { |job| execute(job) }
95
+ ensure
96
+ ticker&.shutdown
97
+ ticker&.wait_for_termination(1)
98
+ finish
99
+ end
100
+ jobs.map(&:value)
101
+ end
102
+
103
+ private
104
+
105
+ # @return [Terminal]
106
+ attr_reader :terminal
107
+
108
+ # @return [#call]
109
+ attr_reader :clock
110
+
111
+ # @return [Configuration]
112
+ attr_reader :config
113
+
114
+ # @return [Array<Job>]
115
+ attr_reader :jobs
116
+
117
+ # @return [String]
118
+ attr_reader :title
119
+
120
+ # @return [Float] when the headline started, by the clock
121
+ attr_reader :started
122
+
123
+ # @return [Mutex] held while a row changes or the rows are drawn
124
+ attr_reader :lock
125
+
126
+ # @return [Symbol] the headline's state
127
+ attr_accessor :state
128
+
129
+ # @return [Integer] the spinner frame to draw next
130
+ attr_accessor :frame
131
+
132
+ # @return [Object] what the declaration block is given: the
133
+ # subclass's Builder, appending to {#jobs}
134
+ def builder = self.class::Builder.new(jobs)
135
+
136
+ # Runs a job's work with its handle.
137
+ #
138
+ # @param job [Job]
139
+ # @return [Object] what the work returns
140
+ def call(job) = job.work.call(job.handle)
141
+
142
+ # @param job [Job]
143
+ # @return [#current, #total, nil] what a status bar reads the job's progress from
144
+ def progress_of(_job) = nil
145
+
146
+ # @param job [Job]
147
+ # @return [Boolean] whether the work reported a failure without raising
148
+ def reported_failure?(_job) = false
149
+
150
+ # What follows a running job's glyph.
151
+ #
152
+ # @param job [Job]
153
+ # @param width [Integer] the columns its label is padded to
154
+ # @return [String]
155
+ def running(job, _width) = job.label
156
+
157
+ # What follows a job's glyph once it has ended.
158
+ #
159
+ # @param job [Job]
160
+ # @return [String]
161
+ def summary(job) = job.label
162
+
163
+ # What follows the headline's glyph while jobs run.
164
+ #
165
+ # @param width [Integer] the columns the title is padded to
166
+ # @return [String]
167
+ def running_headline(_width) = title
168
+
169
+ # What follows the headline's glyph once every job has ended.
170
+ #
171
+ # @return [String]
172
+ def headline_summary = title
173
+
174
+ # Draws every row and starts the spinners turning when live, or
175
+ # prints the title otherwise.
176
+ #
177
+ # @return [Concurrent::TimerTask, nil]
178
+ def start
179
+ self.state = :running
180
+ unless live?
181
+ terminal.puts("#{title}...")
182
+ return
183
+ end
184
+
185
+ rows.each { |row| terminal.puts(row) }
186
+ Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }.tap(&:execute)
187
+ end
188
+
189
+ # @param job [Job]
190
+ # @return [void]
191
+ def execute(job)
192
+ job.started = clock.call
193
+ terminal.started(job, job.label, progress: progress_of(job))
194
+ change(job, :running)
195
+ ok = false
196
+ job.value = call(job)
197
+ ok = !reported_failure?(job)
198
+ ensure
199
+ terminal.finished(job, ok)
200
+ change(job, ok ? :done : :failed, seconds: clock.call - job.started)
201
+ end
202
+
203
+ # Marks jobs that never started as skipped, and ends the headline.
204
+ #
205
+ # @return [void]
206
+ def finish
207
+ jobs.each { |job| change(job, :skipped) if job.state == :pending }
208
+ lock.synchronize do
209
+ self.state = jobs.all? { |job| job.state == :done } ? :done : :failed
210
+ @seconds = clock.call - started
211
+ live? ? redraw : terminal.puts(Outcome.line(terminal, state, headline_summary, @seconds))
212
+ end
213
+ end
214
+
215
+ # @param job [Job]
216
+ # @param state [Symbol]
217
+ # @param seconds [Float, nil]
218
+ # @return [void]
219
+ def change(job, state, seconds: nil)
220
+ lock.synchronize do
221
+ job.state = state
222
+ job.seconds = seconds
223
+ if live?
224
+ redraw
225
+ elsif state != :running
226
+ terminal.puts("#{PLAIN_INDENT}#{row(job, 0)}")
227
+ end
228
+ end
229
+ end
230
+
231
+ # Whether to redraw in place: needs cursor movement, and every row on
232
+ # the screen, since the cursor cannot move above the top row.
233
+ #
234
+ # @return [Boolean]
235
+ def live?
236
+ @live = terminal.animated? && jobs.size + 1 < terminal.height if @live.nil?
237
+ @live
238
+ end
239
+
240
+ # @return [void]
241
+ def tick
242
+ lock.synchronize do
243
+ self.frame += 1
244
+ redraw
245
+ end
246
+ end
247
+
248
+ # @return [void]
249
+ def redraw
250
+ terminal.print(terminal.cursor.up(jobs.size + 1) + rows.map { |row| "#{terminal.cursor.clear_line}#{row}\n" }.join)
251
+ end
252
+
253
+ # The headline, then one row per job with its tree branch.
254
+ #
255
+ # @return [Array<String>]
256
+ def rows
257
+ width = label_width
258
+ branches = jobs.each_with_index.map { |_, index| index == jobs.size - 1 ? "└─ " : "├─ " }
259
+ [headline(width), *jobs.zip(branches).map { |job, branch| terminal.pastel.bright_black(branch) + row(job, width - branch.length) }]
260
+ end
261
+
262
+ # The columns every label is padded to, so what follows lines up.
263
+ #
264
+ # @return [Integer]
265
+ def label_width
266
+ [title.length, *jobs.map { |job| job.label.length + 3 }].max
267
+ end
268
+
269
+ # @param width [Integer]
270
+ # @return [String]
271
+ def headline(width)
272
+ return "#{glyph(state)} #{running_headline(width)}" if state == :running
273
+
274
+ elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(@seconds)})")}"
275
+ "#{glyph(state)} #{headline_summary}#{elapsed}"
276
+ end
277
+
278
+ # @param job [Job]
279
+ # @param width [Integer] the columns its label is padded to
280
+ # @return [String]
281
+ def row(job, width)
282
+ return "#{glyph(:running)} #{running(job, width)}" if job.state == :running
283
+
284
+ elapsed = " #{terminal.pastel.bright_black("(#{Duration.format(job.seconds)})")}" if job.seconds
285
+ "#{glyph(job.state)} #{job.state == :pending ? job.label : summary(job)}#{elapsed}"
286
+ end
287
+
288
+ # A state's marker, `[✓]`; a turning spinner, `[⠏]`, for a running row, live.
289
+ #
290
+ # @param state [Symbol]
291
+ # @return [String]
292
+ def glyph(state)
293
+ frames = config.spinner_frames
294
+ Theme.marker(terminal.pastel, state, (frames[frame % frames.size] if state == :running && live?))
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ module Widgets
7
+ # Several progress bars under one headline bar that counts them all,
8
+ # like TTY::ProgressBar::Multi. Each job is given a {Progress::Handle}.
9
+ #
10
+ # Every row shows a bar, a percentage, a count and an ETA while its job
11
+ # runs, and is replaced by `✓ label 120/120 (1.1s)` when it ends. The
12
+ # bar's characters come from {Configuration#bar_format}.
13
+ #
14
+ # @example
15
+ # ui.multi_progress("Downloading") do |m|
16
+ # files.each do |file|
17
+ # m.progress(file.name, total: file.size) do |bar|
18
+ # download(file) { |bytes| bar.advance(bytes) }
19
+ # end
20
+ # end
21
+ # end
22
+ #
23
+ # See {Multi} for how the jobs run and how the rows are drawn.
24
+ class MultiProgress < Multi
25
+ # What the declaration block is given.
26
+ class Builder
27
+ # @param jobs [Array<Multi::Job>] the list new jobs are appended to
28
+ def initialize(jobs)
29
+ @jobs = jobs
30
+ end
31
+
32
+ # Declares a job with a progress bar of its own.
33
+ #
34
+ # @param label [String]
35
+ # @param total [Integer] units of work
36
+ # @yieldparam progress [Progress::Handle] call `advance` as units complete
37
+ # @return [self]
38
+ # @raise [ArgumentError] without a block, or when total is not a non-negative Integer
39
+ def progress(label, total:, &work)
40
+ raise ArgumentError, "progress #{label.inspect} needs a block" unless work
41
+ raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
42
+
43
+ @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil))
44
+ self
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # @param job [Job]
51
+ # @return [Progress::Handle]
52
+ def progress_of(job) = job.handle
53
+
54
+ # @param job [Job]
55
+ # @param width [Integer]
56
+ # @return [String]
57
+ def running(job, width)
58
+ "#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started)}"
59
+ end
60
+
61
+ # @param job [Job]
62
+ # @return [String]
63
+ def summary(job) = "#{job.label} #{job.handle.current}/#{job.handle.total}"
64
+
65
+ # @param width [Integer]
66
+ # @return [String]
67
+ def running_headline(width)
68
+ "#{title.ljust(width)} #{meter(current, total, started)}"
69
+ end
70
+
71
+ # @return [String]
72
+ def headline_summary = "#{title} #{current}/#{total}"
73
+
74
+ # @return [Integer] units completed across every job
75
+ def current = jobs.sum { |job| job.handle.current }
76
+
77
+ # @return [Integer] units across every job
78
+ def total = jobs.sum { |job| job.handle.total }
79
+
80
+ # `[◼◼◼ ] 48% 96/200 ETA 3.1s`, with the count right-aligned to
81
+ # the widest any row can show, so every count ends in one column.
82
+ #
83
+ # @param done [Integer]
84
+ # @param all [Integer]
85
+ # @param since [Float, nil] when the work started, by the clock
86
+ # @return [String]
87
+ def meter(done, all, since)
88
+ ratio = all.zero? ? 1.0 : done.fdiv(all)
89
+ bar = Progress.bar(terminal.pastel, config, ratio, bar_columns)
90
+ format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
91
+ bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all}".rjust(count_width), eta: eta(done, all, since))
92
+ end
93
+
94
+ # The widest count any row shows: the headline's, once every job is done.
95
+ #
96
+ # @return [Integer]
97
+ def count_width = "#{total}/#{total}".length
98
+
99
+ # One width for every bar, so the headline's lines up with the jobs'.
100
+ #
101
+ # @return [Integer]
102
+ def bar_columns
103
+ [terminal.width - label_width - 3 - Progress::CHROME, Progress::MIN_BAR].max
104
+ end
105
+
106
+ # @param done [Integer]
107
+ # @param all [Integer]
108
+ # @param since [Float, nil]
109
+ # @return [String] the time left at the rate so far, or `--` before any progress
110
+ def eta(done, all, since)
111
+ return "--" if since.nil? || done.zero?
112
+
113
+ Duration.format((clock.call - since) / done * (all - done))
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ module Widgets
7
+ # Several spinners under one headline, one per job, all turning at once.
8
+ #
9
+ # Each job is given a {Line}: its detail is drawn after the label while
10
+ # the job runs, on an animated terminal, and {Line#fail} ends it as
11
+ # `✗ label: reason` without raising. The headline ends `✓` when every
12
+ # job succeeded, and `✗` otherwise.
13
+ #
14
+ # @example
15
+ # ui.multi_spinner("Fetching", concurrent: 2) do |m|
16
+ # m.spinner("fonts") { fetch(:fonts) }
17
+ # m.spinner("images") { |line| fetch(:images) { |name| line.detail = name } }
18
+ # end
19
+ #
20
+ # See {Multi} for how the jobs run and how the rows are drawn.
21
+ class MultiSpinner < Multi
22
+ # What the declaration block is given.
23
+ class Builder
24
+ # @param jobs [Array<Multi::Job>] the list new jobs are appended to
25
+ def initialize(jobs)
26
+ @jobs = jobs
27
+ end
28
+
29
+ # Declares a job with a spinner of its own.
30
+ #
31
+ # @param label [String]
32
+ # @yieldparam line [Line] reports on the work while it runs
33
+ # @return [self]
34
+ # @raise [ArgumentError] without a block
35
+ def spinner(label, &work)
36
+ raise ArgumentError, "spinner #{label.inspect} needs a block" unless work
37
+
38
+ @jobs << Multi::Job.new(label, work, Line.new)
39
+ self
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # @param job [Job]
46
+ # @return [Object]
47
+ def call(job) = Line.call(job.work, job.handle)
48
+
49
+ # @param job [Job]
50
+ # @return [Boolean]
51
+ def reported_failure?(job) = job.handle.failed?
52
+
53
+ # The label, then the detail when the job has one.
54
+ #
55
+ # @param job [Job]
56
+ # @return [String]
57
+ def running(job, _width)
58
+ detail = job.handle.detail
59
+ detail.empty? ? job.label : "#{job.label} #{detail}"
60
+ end
61
+
62
+ # @param job [Job]
63
+ # @return [String] the label, with the reason once the job has failed
64
+ def summary(job) = job.handle.summary(job.label)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -16,7 +16,7 @@ module Dry
16
16
  def self.line(terminal, state, label, seconds)
17
17
  glyph, color = Theme::STATES.fetch(state)
18
18
  pastel = terminal.pastel
19
- "#{pastel.decorate(glyph, color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
19
+ "#{pastel.decorate(glyph, *color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
20
20
  end
21
21
  end
22
22
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
8
+ module Widgets
9
+ # Runs a list of items one at a time, all at once, or at most so many
10
+ # at once, for every widget that runs several pieces of work.
11
+ #
12
+ # When one raises, items already running finish, items not yet
13
+ # started are never started, and the first error is re-raised once
14
+ # everything running has stopped.
15
+ module Pool
16
+ # Checks a `concurrent:` setting.
17
+ #
18
+ # @param value [Boolean, Integer] true for all at once, false for one
19
+ # at a time, or the most that may run at once
20
+ # @return [Boolean, Integer] the value
21
+ # @raise [ArgumentError] for anything else
22
+ def self.concurrency(value)
23
+ return value if [true, false].include?(value) || (value.is_a?(Integer) && value.positive?)
24
+
25
+ raise ArgumentError, "concurrent must be true, false or a positive Integer, got #{value.inspect}"
26
+ end
27
+
28
+ # Runs the block for each item.
29
+ #
30
+ # @param items [Array]
31
+ # @param concurrent [Boolean, Integer] see {.concurrency}
32
+ # @yieldparam item [Object] one of the items
33
+ # @return [void]
34
+ # @raise [Exception] the first error any block raised
35
+ def self.run(items, concurrent, &)
36
+ return items.each(&) unless concurrent
37
+
38
+ futures = concurrent == true ? all_at_once(items, &) : at_most(concurrent, items, &)
39
+ futures.each(&:wait)
40
+ failed = futures.find(&:rejected?)
41
+ raise failed.reason if failed
42
+ end
43
+
44
+ # @param items [Array]
45
+ # @return [Array<Concurrent::Promises::Future>] one per item
46
+ def self.all_at_once(items, &)
47
+ items.map { |item| Concurrent::Promises.future(item, &) }
48
+ end
49
+
50
+ # Workers that take items off a queue until it is empty, or until
51
+ # one of them raises.
52
+ #
53
+ # @param limit [Integer]
54
+ # @param items [Array]
55
+ # @return [Array<Concurrent::Promises::Future>] one per worker
56
+ def self.at_most(limit, items, &)
57
+ queue = Queue.new
58
+ items.each { |item| queue << item }
59
+ queue.close
60
+ stop = Concurrent::AtomicBoolean.new
61
+ Array.new([limit, items.size].min) { Concurrent::Promises.future { work(queue, stop, &) } }
62
+ end
63
+
64
+ # @param queue [Queue] closed, so `pop` returns nil once it is empty
65
+ # @param stop [Concurrent::AtomicBoolean] set once any worker raises
66
+ # @return [void]
67
+ def self.work(queue, stop, &each)
68
+ ok = false
69
+ while (item = queue.pop) && stop.false?
70
+ each.call(item)
71
+ end
72
+ ok = true
73
+ ensure
74
+ stop.make_true unless ok
75
+ end
76
+
77
+ private_class_method :all_at_once, :at_most, :work
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -46,17 +46,47 @@ module Dry
46
46
  attr_writer :current
47
47
  end
48
48
 
49
- # Columns kept for the label, percentage, count and ETA around the bar.
50
- CHROME = 34
49
+ # Columns kept for the brackets, percentage, count and ETA around the bar.
50
+ CHROME = 36
51
51
 
52
52
  # Narrowest bar drawn.
53
53
  MIN_BAR = 10
54
54
 
55
+ # A bar between brackets, painted as the configuration says: the
56
+ # finished part in {Configuration#bar_color}, and all of it on
57
+ # {Configuration#bar_background}.
58
+ #
59
+ # @param pastel [Pastel::Delegator] a no-op when colour is off
60
+ # @param config [Configuration]
61
+ # @param ratio [Float] how much is finished, from 0 to 1
62
+ # @param columns [Integer] the bar's width inside the brackets
63
+ # @return [String]
64
+ def self.bar(pastel, config, ratio, columns)
65
+ filled = (ratio * columns).floor
66
+ "[#{complete(pastel, config) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
67
+ end
68
+
69
+ # @param pastel [Pastel::Delegator]
70
+ # @param config [Configuration]
71
+ # @return [String] one finished cell, painted
72
+ def self.complete(pastel, config)
73
+ pastel.decorate(config.bar_complete, *[config.bar_color, config.bar_background].compact)
74
+ end
75
+
76
+ # @param pastel [Pastel::Delegator]
77
+ # @param config [Configuration]
78
+ # @return [String] one unfinished cell, painted
79
+ def self.incomplete(pastel, config)
80
+ pastel.decorate(config.bar_incomplete, *[config.bar_background].compact)
81
+ end
82
+
55
83
  # @param terminal [Terminal]
56
84
  # @param clock [#call] returns monotonic seconds
57
- def initialize(terminal, clock:)
85
+ # @param config [Configuration] where the bar's characters come from
86
+ def initialize(terminal, clock:, config: UI.config)
58
87
  @terminal = terminal
59
88
  @clock = clock
89
+ @config = config
60
90
  end
61
91
 
62
92
  # Runs the block with a progress bar.
@@ -72,6 +102,7 @@ module Dry
72
102
  started = clock.call
73
103
  bar = start(label, total)
74
104
  handle = Handle.new(total, bar)
105
+ terminal.started(handle, label, progress: handle)
75
106
  ok = false
76
107
  result = yield handle
77
108
  ok = true
@@ -79,6 +110,7 @@ module Dry
79
110
  ensure
80
111
  if handle
81
112
  bar&.stop
113
+ terminal.finished(handle, ok)
82
114
  summary = "#{label} #{handle.current}/#{total}"
83
115
  terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
84
116
  end
@@ -92,6 +124,9 @@ module Dry
92
124
  # @return [#call]
93
125
  attr_reader :clock
94
126
 
127
+ # @return [Configuration]
128
+ attr_reader :config
129
+
95
130
  # @param label [String]
96
131
  # @param total [Integer]
97
132
  # @return [TTY::ProgressBar, nil]
@@ -102,12 +137,12 @@ module Dry
102
137
  end
103
138
 
104
139
  TTY::ProgressBar.new(
105
- "#{label} :bar :percent :current/:total ETA :eta",
140
+ "#{label} [:bar] :percent :current/:total ETA :eta",
106
141
  total: total,
107
142
  width: [terminal.width - label.length - CHROME, MIN_BAR].max,
108
143
  output: terminal.io,
109
- complete: "█",
110
- incomplete: "░",
144
+ complete: Progress.complete(terminal.pastel, config),
145
+ incomplete: Progress.incomplete(terminal.pastel, config),
111
146
  clear: true,
112
147
  hide_cursor: true
113
148
  )