dry-cli-ui 0.1.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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ # The object {UI#ui} returns: a small semantic API for what a command
7
+ # wants to say, independent of how the terminal renders it.
8
+ #
9
+ # Messages about the command itself (`debug`, `warn`, `error`, `fatal`),
10
+ # spinners, progress bars, task trees and prompts go to `err`. The
11
+ # command's results (`info`, `success`, tables, plain boxes) go to
12
+ # `out`. Piping a command's output therefore captures its results and
13
+ # nothing else.
14
+ #
15
+ # @example
16
+ # ui = Dry::CLI::UI::Console.new
17
+ # ui.info "Importing tax rules..."
18
+ # rules = ui.spinner("Loading YAML") { load_rules }
19
+ # ui.progress("Importing", total: rules.size) do |bar|
20
+ # rules.each do |rule|
21
+ # import(rule)
22
+ # bar.advance
23
+ # end
24
+ # end
25
+ # ui.success "Imported #{rules.size} rules"
26
+ class Console
27
+ # @!method debug(*paragraphs, width: nil)
28
+ # A grey "Debug" box on `err`.
29
+ # @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
30
+ # @param width [Integer, nil] columns, overriding the console's box width
31
+ # @return [nil]
32
+ # @!method info(*paragraphs, width: nil)
33
+ # A cyan "Info" box on `out`.
34
+ # @param (see #debug)
35
+ # @return [nil]
36
+ # @!method success(*paragraphs, width: nil)
37
+ # A green "Success" box on `out`.
38
+ # @param (see #debug)
39
+ # @return [nil]
40
+ # @!method warn(*paragraphs, width: nil)
41
+ # A yellow "Warning" box on `err`.
42
+ # @param (see #debug)
43
+ # @return [nil]
44
+ # @!method error(*paragraphs, width: nil)
45
+ # A red "Error" box on `err`.
46
+ # @param (see #debug)
47
+ # @return [nil]
48
+ # @!method fatal(*paragraphs, width: nil)
49
+ # A magenta "Fatal" box on `err`.
50
+ # @param (see #debug)
51
+ # @return [nil]
52
+ Theme::LEVELS.each_key do |level|
53
+ define_method(level) do |*paragraphs, width: nil|
54
+ box(*paragraphs, level: level, width: width)
55
+ end
56
+ end
57
+
58
+ # @param out [IO] where results go
59
+ # @param err [IO] where diagnostics, progress and prompts go
60
+ # @param input [IO] where prompt answers come from
61
+ # @param env [Hash{String => String}] read for NO_COLOR and TERM
62
+ # @param color [Boolean, nil] force colour on or off; nil decides per stream
63
+ # @param animate [Boolean, nil] force animation on or off; nil decides per stream
64
+ # @param width [Integer, nil] force the terminal width; nil asks the terminal
65
+ # @param box_width [Integer, nil] box width in columns; nil fills the terminal
66
+ # @param clock [#call] returns monotonic seconds
67
+ def initialize(out: $stdout, err: $stderr, input: $stdin, env: ENV, color: nil, animate: nil,
68
+ width: nil, box_width: nil, clock: Duration::CLOCK)
69
+ @out = Terminal.new(out, env: env, color: color, animate: animate, width: width)
70
+ @err = Terminal.new(err, env: env, color: color, animate: animate, width: width)
71
+ @input = input
72
+ @box_width = box_width
73
+ @clock = clock
74
+ end
75
+
76
+ # A framed panel. Given a level, it takes that level's title, colour
77
+ # and stream; without one it is untitled unless given a title, and goes
78
+ # to `out`.
79
+ #
80
+ # @example
81
+ # ui.box("Name: Alan Turing", "Role: Cryptanalyst", title: "Profile")
82
+ #
83
+ # @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
84
+ # @param title [String, nil] overrides the level's title
85
+ # @param level [Symbol, nil] one of {Theme::LEVELS}
86
+ # @param width [Integer, nil] columns, overriding the console's box width
87
+ # @return [nil]
88
+ def box(*paragraphs, title: nil, level: nil, width: nil)
89
+ theme = level && Theme.level(level)
90
+ terminal = theme ? stream(theme) : out
91
+ widget = Widgets::Box.new(terminal, width: width || box_width)
92
+ terminal.print(widget.render(paragraphs, title: title || theme&.title, color: theme&.color))
93
+ nil
94
+ end
95
+
96
+ # One line with a coloured glyph, going to the level's stream.
97
+ #
98
+ # @example
99
+ # ui.status "Connected to the database", level: :success # ✓ Connected to the database
100
+ #
101
+ # @param words [Array<#to_s>] joined with spaces
102
+ # @param level [Symbol] one of {Theme::LEVELS}
103
+ # @return [nil]
104
+ def status(*words, level: :info)
105
+ theme = Theme.level(level)
106
+ terminal = stream(theme)
107
+ terminal.puts(Widgets::Status.line(terminal, theme, words.join(" ")))
108
+ nil
109
+ end
110
+
111
+ # Runs a block under a spinner and leaves `✓ label (1.2s)` behind, or
112
+ # `✗ label` when the block raises.
113
+ #
114
+ # @param label [String]
115
+ # @yield the work
116
+ # @return [Object] whatever the block returns
117
+ # @raise [ArgumentError] without a block
118
+ def spinner(label, &)
119
+ raise ArgumentError, "spinner needs a block" unless block_given?
120
+
121
+ Widgets::Spinner.new(err, clock: clock).run(label, &)
122
+ end
123
+
124
+ # Runs a block with a progress bar showing percent, count and ETA.
125
+ #
126
+ # @param label [String]
127
+ # @param total [Integer] units of work
128
+ # @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
129
+ # @return [Object] whatever the block returns
130
+ # @raise [ArgumentError] without a block, or when total is not a non-negative Integer
131
+ def progress(label, total:, &)
132
+ raise ArgumentError, "progress needs a block" unless block_given?
133
+
134
+ Widgets::Progress.new(err, clock: clock).run(label, total: total, &)
135
+ end
136
+
137
+ # Declares a tree of tasks, then runs it, showing each task's state
138
+ # and elapsed time. See {Widgets::Tasks}.
139
+ #
140
+ # @example Several operations at once
141
+ # ui.tasks("Fetching", concurrent: true) do |t|
142
+ # t.task("fonts") { fetch(:fonts) }
143
+ # t.task("images") { fetch(:images) }
144
+ # end
145
+ #
146
+ # @param title [String, nil]
147
+ # @param concurrent [Boolean] run the top-level tasks at the same time
148
+ # @yieldparam tasks [Widgets::Tasks::Builder] declares `task`s and `group`s
149
+ # @return [nil]
150
+ # @raise [ArgumentError] without a block
151
+ def tasks(title = nil, concurrent: false, &)
152
+ raise ArgumentError, "tasks needs a block" unless block_given?
153
+
154
+ Widgets::Tasks.new(err, clock: clock).run(title, concurrent: concurrent, &)
155
+ end
156
+
157
+ # Prints a table to `out`.
158
+ #
159
+ # @example
160
+ # ui.table([["Alan Turing", 41]], header: ["Name", "Age"])
161
+ #
162
+ # @param rows [Array<Array<#to_s>>]
163
+ # @param header [Array<#to_s>, nil]
164
+ # @return [nil]
165
+ def table(rows, header: nil)
166
+ out.print(Widgets::Table.new(out).render(rows, header: header))
167
+ nil
168
+ end
169
+
170
+ # Asks a question. See {Widgets::Prompt#ask}.
171
+ #
172
+ # @example
173
+ # ui.prompt("Environment?", choices: %w[staging production], default: "staging")
174
+ #
175
+ # @param question [String]
176
+ # @param default [Object, nil]
177
+ # @param choices [Array<String>, Hash{String => Object}, nil]
178
+ # @return [Object, nil]
179
+ # @raise [NonInteractiveError] when the input is exhausted and there is no default
180
+ def prompt(question, default: nil, choices: nil)
181
+ prompter.ask(question, default: default, choices: choices)
182
+ end
183
+
184
+ # Asks a yes/no question.
185
+ #
186
+ # @param question [String]
187
+ # @param default [Boolean]
188
+ # @return [Boolean]
189
+ def confirm(question, default: false)
190
+ prompter.confirm(question, default: default)
191
+ end
192
+
193
+ private
194
+
195
+ # @return [Terminal]
196
+ attr_reader :out
197
+
198
+ # @return [Terminal]
199
+ attr_reader :err
200
+
201
+ # @return [IO]
202
+ attr_reader :input
203
+
204
+ # @return [Integer, nil]
205
+ attr_reader :box_width
206
+
207
+ # @return [#call]
208
+ attr_reader :clock
209
+
210
+ # @param theme [Theme::Level]
211
+ # @return [Terminal]
212
+ def stream(theme)
213
+ theme.stream == :err ? err : out
214
+ end
215
+
216
+ # @return [Widgets::Prompt]
217
+ def prompter
218
+ @prompter ||= Widgets::Prompt.new(input: input, terminal: err)
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ # Measures and formats elapsed time.
7
+ module Duration
8
+ # The default clock: monotonic seconds, immune to wall-clock changes.
9
+ CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
10
+
11
+ # Formats a number of seconds for a human.
12
+ #
13
+ # @example
14
+ # Duration.format(0.42) # => "0.4s"
15
+ # Duration.format(62) # => "1m 02s"
16
+ # Duration.format(3720) # => "1h 02m"
17
+ #
18
+ # @param seconds [Numeric]
19
+ # @return [String]
20
+ def self.format(seconds)
21
+ case seconds
22
+ in ...60 then Kernel.format("%<s>.1fs", s: seconds)
23
+ in ...3600 then Kernel.format("%<m>dm %<s>02ds", m: seconds / 60, s: seconds % 60)
24
+ else Kernel.format("%<h>dh %<m>02dm", h: seconds / 3600, m: (seconds % 3600) / 60)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty-cursor"
5
+ require "tty-screen"
6
+
7
+ module Dry
8
+ class CLI
9
+ module UI
10
+ # One output stream, and what it can do.
11
+ #
12
+ # A stream that is not a TTY, or runs under `TERM=dumb`, gets no
13
+ # animation and no cursor movement. `NO_COLOR` (https://no-color.org)
14
+ # additionally switches colour off. Widgets ask these questions rather
15
+ # than inspecting the IO themselves, which is what keeps the fallback in
16
+ # one place.
17
+ class Terminal
18
+ # Width assumed for a stream that is not a TTY.
19
+ DEFAULT_WIDTH = 80
20
+
21
+ # Height assumed for a stream that is not a TTY.
22
+ DEFAULT_HEIGHT = 24
23
+
24
+ # @param io [IO] the stream to write to
25
+ # @param env [Hash{String => String}] the environment to read NO_COLOR and TERM from
26
+ # @param color [Boolean, nil] force colour on or off; nil decides from the stream
27
+ # @param animate [Boolean, nil] force animation on or off; nil decides from the stream
28
+ # @param width [Integer, nil] force the width; nil asks the terminal
29
+ def initialize(io, env: ENV, color: nil, animate: nil, width: nil)
30
+ @io = io
31
+ @env = env
32
+ @color = color
33
+ @animate = animate
34
+ @width = width
35
+ end
36
+
37
+ # @return [IO] the stream this terminal writes to
38
+ attr_reader :io
39
+
40
+ # Whether the stream is an interactive terminal.
41
+ #
42
+ # @return [Boolean]
43
+ def tty?
44
+ io.respond_to?(:tty?) && io.tty?
45
+ end
46
+
47
+ # Whether spinners may animate and the cursor may move.
48
+ #
49
+ # @return [Boolean]
50
+ def animated?
51
+ return animate unless animate.nil?
52
+
53
+ tty? && env["TERM"] != "dumb"
54
+ end
55
+
56
+ # Whether to emit ANSI colour codes.
57
+ #
58
+ # @return [Boolean]
59
+ def color?
60
+ return color unless color.nil?
61
+
62
+ animated? && env["NO_COLOR"].to_s.empty?
63
+ end
64
+
65
+ # @return [Integer] columns available
66
+ def width
67
+ @width || (tty? ? TTY::Screen.width : DEFAULT_WIDTH)
68
+ end
69
+
70
+ # @return [Integer] rows available
71
+ def height
72
+ tty? ? TTY::Screen.height : DEFAULT_HEIGHT
73
+ end
74
+
75
+ # @return [Pastel::Delegator] a colouriser that is a no-op when colour is off
76
+ def pastel
77
+ @pastel ||= Pastel.new(enabled: color?)
78
+ end
79
+
80
+ # @return [Module] TTY::Cursor, for widgets that redraw in place
81
+ def cursor = TTY::Cursor
82
+
83
+ # Writes a string without a newline. Flushes, so that output on `out`
84
+ # and `err` stays in the order it was written even when piped.
85
+ #
86
+ # @param text [String]
87
+ # @return [void]
88
+ def print(text)
89
+ io.print(text)
90
+ io.flush if io.respond_to?(:flush)
91
+ end
92
+
93
+ # Writes a line, flushing as {#print} does.
94
+ #
95
+ # @param text [String]
96
+ # @return [void]
97
+ def puts(text = "")
98
+ print("#{text}\n")
99
+ end
100
+
101
+ private
102
+
103
+ # @return [Hash{String => String}]
104
+ attr_reader :env
105
+
106
+ # @return [Boolean, nil]
107
+ attr_reader :color
108
+
109
+ # @return [Boolean, nil]
110
+ attr_reader :animate
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ # The six message levels and how each one looks.
7
+ module Theme
8
+ # How one message level is presented.
9
+ #
10
+ # @!attribute [r] name
11
+ # @return [Symbol] the level, e.g. `:error`
12
+ # @!attribute [r] title
13
+ # @return [String] the box title, e.g. "Error"
14
+ # @!attribute [r] glyph
15
+ # @return [String] the status-line marker, e.g. "✗"
16
+ # @!attribute [r] color
17
+ # @return [Symbol] a Pastel colour for the title and glyph
18
+ # @!attribute [r] stream
19
+ # @return [Symbol] `:out` or `:err`, where messages at this level go
20
+ Level = ::Data.define(:name, :title, :glyph, :color, :stream)
21
+
22
+ # Every level, keyed by name. Diagnostics go to STDERR so that a
23
+ # command's real output can still be piped.
24
+ LEVELS = {
25
+ debug: Level.new(name: :debug, title: "Debug", glyph: "·", color: :bright_black, stream: :err),
26
+ info: Level.new(name: :info, title: "Info", glyph: "ℹ", color: :cyan, stream: :out),
27
+ success: Level.new(name: :success, title: "Success", glyph: "✓", color: :green, stream: :out),
28
+ warn: Level.new(name: :warn, title: "Warning", glyph: "⚠", color: :yellow, stream: :err),
29
+ error: Level.new(name: :error, title: "Error", glyph: "✗", color: :red, stream: :err),
30
+ fatal: Level.new(name: :fatal, title: "Fatal", glyph: "✖", color: :magenta, stream: :err)
31
+ }.freeze
32
+
33
+ # Glyphs for the states an operation passes through.
34
+ STATES = {
35
+ pending: ["○", :bright_black],
36
+ running: ["▸", :cyan],
37
+ done: ["✓", :green],
38
+ failed: ["✗", :red],
39
+ skipped: ["–", :bright_black]
40
+ }.freeze
41
+
42
+ # Looks up a level by name.
43
+ #
44
+ # @param name [Symbol] one of {LEVELS}' keys
45
+ # @return [Level]
46
+ # @raise [ArgumentError] when the level does not exist
47
+ def self.level(name)
48
+ LEVELS.fetch(name) { raise ArgumentError, "unknown level #{name.inspect}, expected one of #{LEVELS.keys}" }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ # Reopened rather than defined: dry-cli declares `class CLI`, not a module,
5
+ # and getting that wrong raises TypeError the moment both are loaded.
6
+ #
7
+ # Declared here without requiring dry-cli, because the gemspec loads this
8
+ # file at build time when the dependency may not be installed. dry-cli's CLI
9
+ # inherits from Object, so an empty reopening is compatible either way.
10
+ class CLI
11
+ # Presentation helpers for Dry::CLI commands.
12
+ module UI
13
+ # The gem version.
14
+ VERSION = "0.1.0"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strings"
4
+ require "tty-box"
5
+
6
+ module Dry
7
+ class CLI
8
+ module UI
9
+ module Widgets
10
+ # A framed panel with a single white border and an optional coloured
11
+ # title.
12
+ #
13
+ # The box is as wide as it is told to be: a fixed number of columns,
14
+ # or the whole terminal less {MARGIN}. It never grows wider than the
15
+ # terminal.
16
+ class Box
17
+ # Columns left free on the right when the box fills the terminal.
18
+ MARGIN = 2
19
+
20
+ # Narrowest box drawn, however small the terminal.
21
+ MIN_WIDTH = 20
22
+
23
+ # Blank rows above and below the text, and columns either side of it.
24
+ PADDING = [1, 2].freeze
25
+
26
+ # @param terminal [Terminal]
27
+ # @param width [Integer, nil] columns; nil fills the terminal
28
+ def initialize(terminal, width: nil)
29
+ @terminal = terminal
30
+ @width = width
31
+ end
32
+
33
+ # Renders a box. Each paragraph is wrapped on its own and separated
34
+ # from the next by a blank line.
35
+ #
36
+ # @param paragraphs [Array<#to_s>]
37
+ # @param title [String, nil]
38
+ # @param color [Symbol, nil] Pastel colour for the title
39
+ # @return [String] the box, ending in a newline
40
+ def render(paragraphs, title: nil, color: nil)
41
+ TTY::Box.frame(
42
+ wrap(paragraphs),
43
+ width: box_width,
44
+ padding: PADDING,
45
+ border: :light,
46
+ title: title ? { top_left: heading(title, color) } : {},
47
+ style: { border: { fg: :white } },
48
+ enable_color: terminal.color?
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ # @return [Terminal]
55
+ attr_reader :terminal
56
+
57
+ # @return [Integer, nil]
58
+ attr_reader :width
59
+
60
+ # TTY::Box wraps text itself but sizes the box from the unwrapped
61
+ # lines, which silently drops everything past the first few rows.
62
+ # Wrapping here first means its own wrap has nothing left to do.
63
+ #
64
+ # @param paragraphs [Array<#to_s>]
65
+ # @return [String]
66
+ def wrap(paragraphs)
67
+ text_width = box_width - 2 - (PADDING[1] * 2)
68
+ paragraphs.map { |p| Strings::Wrap.wrap(p.to_s, text_width).gsub(/[ \t]+$/, "") }.join("\n\n")
69
+ end
70
+
71
+ # @return [Integer]
72
+ def box_width
73
+ available = terminal.width - MARGIN
74
+ [width ? [width, available].min : available, MIN_WIDTH].max
75
+ end
76
+
77
+ # @param title [String]
78
+ # @param color [Symbol, nil]
79
+ # @return [String]
80
+ def heading(title, color)
81
+ pastel = terminal.pastel
82
+ styled = color ? pastel.decorate(title, color, :bold) : pastel.bold(title)
83
+ "─ #{styled} "
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dry
4
+ class CLI
5
+ module UI
6
+ module Widgets
7
+ # The line an operation leaves behind when it ends: `✓ Loading (1.2s)`.
8
+ # Spinners and progress bars share it, so every operation finishes the
9
+ # same way whether or not the terminal could animate it.
10
+ module Outcome
11
+ # @param terminal [Terminal]
12
+ # @param state [Symbol] `:done` or `:failed`
13
+ # @param label [String] what the operation was
14
+ # @param seconds [Numeric] how long it took
15
+ # @return [String] the line, without a newline
16
+ def self.line(terminal, state, label, seconds)
17
+ glyph, color = Theme::STATES.fetch(state)
18
+ pastel = terminal.pastel
19
+ "#{pastel.decorate(glyph, color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-progressbar"
4
+
5
+ module Dry
6
+ class CLI
7
+ module UI
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.
11
+ #
12
+ # Without an animated terminal it prints `Label...` before the block
13
+ # and the outcome line, with the final count, after it.
14
+ class Progress
15
+ # What the block is given to report progress through.
16
+ class Handle
17
+ # @param total [Integer]
18
+ # @param bar [TTY::ProgressBar, nil]
19
+ def initialize(total, bar)
20
+ @total = total
21
+ @bar = bar
22
+ @current = 0
23
+ end
24
+
25
+ # @return [Integer] the number of units the operation has
26
+ attr_reader :total
27
+
28
+ # @return [Integer] the number of units completed so far
29
+ attr_reader :current
30
+
31
+ # Marks units as complete. Progress never passes {#total}.
32
+ #
33
+ # @param step [Integer]
34
+ # @return [self]
35
+ def advance(step = 1)
36
+ self.current = (current + step).clamp(0, total)
37
+ bar&.advance(step)
38
+ self
39
+ end
40
+
41
+ private
42
+
43
+ # @return [TTY::ProgressBar, nil]
44
+ attr_reader :bar
45
+
46
+ attr_writer :current
47
+ end
48
+
49
+ # Columns kept for the label, percentage, count and ETA around the bar.
50
+ CHROME = 34
51
+
52
+ # Narrowest bar drawn.
53
+ MIN_BAR = 10
54
+
55
+ # @param terminal [Terminal]
56
+ # @param clock [#call] returns monotonic seconds
57
+ def initialize(terminal, clock:)
58
+ @terminal = terminal
59
+ @clock = clock
60
+ end
61
+
62
+ # Runs the block with a progress bar.
63
+ #
64
+ # @param label [String]
65
+ # @param total [Integer] the number of units of work
66
+ # @yieldparam progress [Handle]
67
+ # @return [Object] whatever the block returns
68
+ # @raise [ArgumentError] when total is not a non-negative Integer
69
+ def run(label, total:)
70
+ raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
71
+
72
+ started = clock.call
73
+ bar = start(label, total)
74
+ handle = Handle.new(total, bar)
75
+ ok = false
76
+ result = yield handle
77
+ ok = true
78
+ result
79
+ ensure
80
+ if handle
81
+ bar&.stop
82
+ summary = "#{label} #{handle.current}/#{total}"
83
+ terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ # @return [Terminal]
90
+ attr_reader :terminal
91
+
92
+ # @return [#call]
93
+ attr_reader :clock
94
+
95
+ # @param label [String]
96
+ # @param total [Integer]
97
+ # @return [TTY::ProgressBar, nil]
98
+ def start(label, total)
99
+ unless terminal.animated? && total.positive?
100
+ terminal.puts("#{label}...")
101
+ return
102
+ end
103
+
104
+ TTY::ProgressBar.new(
105
+ "#{label} :bar :percent :current/:total ETA :eta",
106
+ total: total,
107
+ width: [terminal.width - label.length - CHROME, MIN_BAR].max,
108
+ output: terminal.io,
109
+ complete: "█",
110
+ incomplete: "░",
111
+ clear: true,
112
+ hide_cursor: true
113
+ )
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end