dry-cli-ui 0.3.1 → 0.5.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,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty/spinner/formats"
5
+ require "tty/progressbar/formats"
6
+
7
+ module Dry
8
+ class CLI
9
+ module UI
10
+ # Process-wide settings, made once through {UI.configure}:
11
+ #
12
+ # Dry::CLI::UI.configure do
13
+ # spinner_format :dots # any TTY::Spinner format name
14
+ # bar_format :box # any TTY::ProgressBar bar format name
15
+ # bar_color :cyan # any Pastel style, or nil
16
+ # bar_background nil # any Pastel style, or nil
17
+ # end
18
+ #
19
+ # Dry::CLI::UI.configure do |config|
20
+ # config.spinner_format = { interval: 8, frames: %w[◐ ◓ ◑ ◒] }
21
+ # config.bar_format = { complete: "#", incomplete: "." }
22
+ # end
23
+ #
24
+ # Anything not set reads from {DEFAULTS}.
25
+ class Configuration
26
+ # What a setting reads before it is set: a green `◼` for each finished
27
+ # part of a bar, with no background behind it.
28
+ DEFAULTS = {
29
+ spinner_format: :dots,
30
+ bar_format: { complete: "◼", incomplete: " " }.freeze,
31
+ bar_color: :green,
32
+ bar_background: nil
33
+ }.freeze
34
+
35
+ # Every style name Pastel knows, for checking colour settings.
36
+ STYLES = Pastel.new(enabled: true).styles.keys.freeze
37
+
38
+ # Marks a DSL call made without a value, which reads instead of writes.
39
+ UNSET = Object.new.freeze
40
+ private_constant :UNSET
41
+
42
+ def initialize
43
+ @values = {}
44
+ end
45
+
46
+ # @!method spinner_format(value = UNSET)
47
+ # Reads the spinner format, or sets it when given a value.
48
+ # @param value [Symbol, Hash] a key of `TTY::Formats::FORMATS`, or
49
+ # `{ interval:, frames: }`: frames per second, and the frames
50
+ # @return [Symbol, Hash]
51
+ # @!method bar_format(value = UNSET)
52
+ # Reads the bar format, or sets it when given a value.
53
+ # @param value [Symbol, Hash] a key of `TTY::ProgressBar::Formats::FORMATS`,
54
+ # or `{ complete:, incomplete: }`
55
+ # @return [Symbol, Hash]
56
+ # @!method bar_color(value = UNSET)
57
+ # Reads the colour a bar's finished part is drawn in, or sets it.
58
+ # @param value [Symbol, nil] a Pastel style, such as :green; nil for none
59
+ # @return [Symbol, nil]
60
+ # @!method bar_background(value = UNSET)
61
+ # Reads the background the whole bar is drawn on, or sets it.
62
+ # @param value [Symbol, nil] a Pastel style, such as :on_blue; nil for none
63
+ # @return [Symbol, nil]
64
+ DEFAULTS.each_key do |name|
65
+ define_method(name) do |value = UNSET|
66
+ return @values.fetch(name) { DEFAULTS.fetch(name) } if UNSET.equal?(value)
67
+
68
+ public_send(:"#{name}=", value)
69
+ end
70
+ end
71
+
72
+ # @param value [Symbol, Hash] see {#spinner_format}
73
+ # @raise [ArgumentError] for an unknown name or a malformed Hash
74
+ def spinner_format=(value)
75
+ @values[:spinner_format] = spinner_definition(value) && value
76
+ end
77
+
78
+ # @param value [Symbol, Hash] see {#bar_format}
79
+ # @raise [ArgumentError] for an unknown name or a malformed Hash
80
+ def bar_format=(value)
81
+ @values[:bar_format] = bar_definition(value) && value
82
+ end
83
+
84
+ # @param value [Symbol, nil] see {#bar_color}
85
+ # @raise [ArgumentError] for a style Pastel does not know
86
+ def bar_color=(value)
87
+ @values[:bar_color] = style(:bar_color, value)
88
+ end
89
+
90
+ # @param value [Symbol, nil] see {#bar_background}
91
+ # @raise [ArgumentError] for a style Pastel does not know
92
+ def bar_background=(value)
93
+ @values[:bar_background] = style(:bar_background, value)
94
+ end
95
+
96
+ # @return [Array<String>] the frames a spinner cycles through
97
+ def spinner_frames
98
+ frames = spinner_definition(spinner_format).fetch(:frames)
99
+ frames.is_a?(String) ? frames.chars : frames
100
+ end
101
+
102
+ # @return [Float] seconds between two spinner frames
103
+ def spinner_frame_seconds
104
+ 1.0 / spinner_definition(spinner_format).fetch(:interval)
105
+ end
106
+
107
+ # @return [String] what a finished part of a bar is drawn with
108
+ def bar_complete
109
+ bar_definition(bar_format).fetch(:complete)
110
+ end
111
+
112
+ # @return [String] what an unfinished part of a bar is drawn with
113
+ def bar_incomplete
114
+ bar_definition(bar_format).fetch(:incomplete)
115
+ end
116
+
117
+ private
118
+
119
+ # @param value [Symbol, Hash]
120
+ # @return [Hash{Symbol => Object}] with :interval and :frames
121
+ def spinner_definition(value)
122
+ return TTY::Formats::FORMATS.fetch(value) { unknown(:spinner_format, value) } if value.is_a?(Symbol)
123
+
124
+ valid = value.is_a?(Hash) && value[:interval].is_a?(Numeric) && value[:interval].positive? &&
125
+ (value[:frames].is_a?(String) || value[:frames].is_a?(Array)) && !value[:frames].empty?
126
+ valid ? value : malformed(:spinner_format, value, "{ interval: Numeric, frames: Array }")
127
+ end
128
+
129
+ # @param value [Symbol, Hash]
130
+ # @return [Hash{Symbol => String}] with :complete and :incomplete
131
+ def bar_definition(value)
132
+ return TTY::ProgressBar::Formats::FORMATS.fetch(value) { unknown(:bar_format, value) } if value.is_a?(Symbol)
133
+
134
+ valid = value.is_a?(Hash) && value[:complete].is_a?(String) && value[:incomplete].is_a?(String)
135
+ valid ? value : malformed(:bar_format, value, "{ complete: String, incomplete: String }")
136
+ end
137
+
138
+ # @param setting [Symbol]
139
+ # @param value [Symbol, nil]
140
+ # @return [Symbol, nil] the value
141
+ # @raise [ArgumentError] for anything but nil or a style Pastel knows
142
+ def style(setting, value)
143
+ return value if value.nil? || STYLES.include?(value)
144
+
145
+ raise ArgumentError, "#{setting} must be a Pastel style or nil, got #{value.inspect}"
146
+ end
147
+
148
+ # @raise [ArgumentError]
149
+ def unknown(setting, value)
150
+ raise ArgumentError, "#{setting} #{value.inspect} is not a known format"
151
+ end
152
+
153
+ # @raise [ArgumentError]
154
+ def malformed(setting, value, shape)
155
+ raise ArgumentError, "#{setting} must be a format name or #{shape}, got #{value.inspect}"
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -64,16 +64,18 @@ module Dry
64
64
  # @param width [Integer, nil] force the terminal width; nil asks the terminal
65
65
  # @param box_width [Integer, nil] box width in columns; nil fills the terminal
66
66
  # @param clock [#call] returns monotonic seconds
67
+ # @param config [Configuration] spinner and bar formats; {UI.config} by default
67
68
  def initialize(out: $stdout, err: $stderr, input: $stdin, env: ENV, color: nil, animate: nil,
68
- width: nil, box_width: nil, clock: Duration::CLOCK)
69
+ width: nil, box_width: nil, clock: Duration::CLOCK, config: UI.config)
69
70
  @out = Terminal.new(out, env: env, color: color, animate: animate, width: width)
70
71
  @err = Terminal.new(err, env: env, color: color, animate: animate, width: width)
71
72
  @input = input
72
73
  @box_width = box_width
73
74
  @clock = clock
75
+ @config = config
74
76
  end
75
77
 
76
- # A framed panel. Given a level, it takes that level's title, colour
78
+ # A framed panel, preceded by a blank line. Given a level, it takes that level's title, colour
77
79
  # and stream; without one it is untitled unless given a title, and goes
78
80
  # to `out`.
79
81
  #
@@ -89,7 +91,7 @@ module Dry
89
91
  theme = level && Theme.level(level)
90
92
  terminal = theme ? stream(theme) : out
91
93
  widget = Widgets::Box.new(terminal, width: width || box_width)
92
- terminal.print(widget.render(paragraphs, title: title || theme&.title, color: theme&.color))
94
+ terminal.print("\n#{widget.render(paragraphs, title: title || theme&.title, color: theme&.color)}")
93
95
  nil
94
96
  end
95
97
 
@@ -142,20 +144,66 @@ module Dry
142
144
  def spinner(label, &)
143
145
  raise ArgumentError, "spinner needs a block" unless block_given?
144
146
 
145
- Widgets::Spinner.new(err, clock: clock).run(label, &)
147
+ Widgets::Spinner.new(err, clock: clock, config: config).run(label, &)
148
+ end
149
+
150
+ # Runs several jobs at once, each under a spinner of its own, beneath a
151
+ # headline spinner. Each job is given a {Line}. See {Widgets::MultiSpinner}.
152
+ #
153
+ # @example
154
+ # ui.multi_spinner("Fetching", concurrent: 3) do |m|
155
+ # assets.each { |asset| m.spinner(asset.name) { fetch(asset) } }
156
+ # end
157
+ #
158
+ # @param title [String] the headline
159
+ # @param concurrent [Boolean, Integer] all at once (the default), one at
160
+ # a time, or at most this many at once
161
+ # @yieldparam spinners [Widgets::MultiSpinner::Builder] declares each `spinner`
162
+ # @return [Array<Object>] what each job returned, in declaration order
163
+ # @raise [ArgumentError] without a block, or with an invalid concurrent
164
+ def multi_spinner(title, concurrent: true, &)
165
+ raise ArgumentError, "multi_spinner needs a block" unless block_given?
166
+
167
+ Widgets::MultiSpinner.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
146
168
  end
147
169
 
148
170
  # Runs a block with a progress bar showing percent, count and ETA.
149
171
  #
150
172
  # @param label [String]
151
173
  # @param total [Integer] units of work
174
+ # @param color [Symbol, nil] the finished part's Pastel style; nil for
175
+ # the configured `bar_color`
152
176
  # @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
153
177
  # @return [Object] whatever the block returns
154
- # @raise [ArgumentError] without a block, or when total is not a non-negative Integer
155
- def progress(label, total:, &)
178
+ # @raise [ArgumentError] without a block, when total is not a
179
+ # non-negative Integer, or when color is not a Pastel style
180
+ def progress(label, total:, color: nil, &)
156
181
  raise ArgumentError, "progress needs a block" unless block_given?
157
182
 
158
- Widgets::Progress.new(err, clock: clock).run(label, total: total, &)
183
+ Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, color: color, &)
184
+ end
185
+
186
+ # Runs several jobs at once, each with a progress bar of its own,
187
+ # beneath a headline bar that counts them all. Each job is given a
188
+ # {Widgets::Progress::Handle}. See {Widgets::MultiProgress}.
189
+ #
190
+ # @example
191
+ # ui.multi_progress("Downloading") do |m|
192
+ # files.each do |file|
193
+ # m.progress(file.name, total: file.size) { |bar| download(file) { |n| bar.advance(n) } }
194
+ # end
195
+ # end
196
+ #
197
+ # @param title [String] the headline
198
+ # @param concurrent [Boolean, Integer] all at once (the default), one at
199
+ # a time, or at most this many at once
200
+ # @yieldparam bars [Widgets::MultiProgress::Builder] declares each `progress`
201
+ # @return [Array<Object>] what each job returned, in declaration order
202
+ # @raise [ArgumentError] without a block, or with an invalid concurrent
203
+ def multi_progress(title, concurrent: true, &)
204
+ raise ArgumentError, "multi_progress needs a block" unless block_given?
205
+
206
+ Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
159
207
  end
160
208
 
161
209
  # Declares a tree of tasks, then runs it, showing each task's state
@@ -181,7 +229,31 @@ module Dry
181
229
  def tasks(title = nil, concurrent: false, &)
182
230
  raise ArgumentError, "tasks needs a block" unless block_given?
183
231
 
184
- Widgets::Tasks.new(err, clock: clock).run(title, concurrent: concurrent, &)
232
+ Widgets::Tasks.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
233
+ end
234
+
235
+ # Keeps a status line at the bottom of the screen while the block runs,
236
+ # saying how the command is doing overall. Every spinner, progress
237
+ # bar, multi widget and task tree started inside the block reports to
238
+ # it. Without an animated `err` it does nothing but run the block, and
239
+ # inside another status bar it does the same. See {StatusBar}.
240
+ #
241
+ # @example
242
+ # ui.status_bar("deploy", hints: ["^C cancel"]) do
243
+ # ui.multi_progress("Uploading") { |m| ... }
244
+ # ui.tasks("Migrate") { |t| ... }
245
+ # end
246
+ #
247
+ # @param title [String, nil] shown first, in bold
248
+ # @param hints [Array<String>] shown at the right edge
249
+ # @return [Object] whatever the block returns
250
+ # @raise [ArgumentError] without a block
251
+ def status_bar(title = nil, hints: [], &)
252
+ raise ArgumentError, "status_bar needs a block" unless block_given?
253
+ return yield if !err.animated? || err.reporter
254
+
255
+ others = out.animated? ? [out] : []
256
+ StatusBar.new(err, others: others, title: title, hints: Array(hints), clock: clock, config: config).run(&)
185
257
  end
186
258
 
187
259
  # Prints a table to `out`.
@@ -237,6 +309,9 @@ module Dry
237
309
  # @return [#call]
238
310
  attr_reader :clock
239
311
 
312
+ # @return [Configuration]
313
+ attr_reader :config
314
+
240
315
  # @param theme [Theme::Level]
241
316
  # @return [Terminal]
242
317
  def stream(theme)
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "monitor"
5
+ require "strings"
6
+
7
+ module Dry
8
+ class CLI
9
+ module UI
10
+ # A line kept at the bottom of the screen while a block runs, under a
11
+ # rule, saying how the command is doing overall:
12
+ #
13
+ # ⠋ deploy · Migrating users · 2 running · 5 done · 1 failed · [◼◼◼◼ ] 42% · 12.3s ^C cancel
14
+ #
15
+ # Every spinner, progress bar, multi widget and task tree started inside
16
+ # the block reports to it, so a command only supplies the title and the
17
+ # hints. Everything else the console writes prints above it, and the
18
+ # scrollback stays intact: no scroll region is set.
19
+ #
20
+ # It works by standing between the console's terminals and their
21
+ # streams. Each write first clears the two rows below the cursor, then
22
+ # writes, then draws them again, and puts the cursor back where the
23
+ # write left it. Writes that bypass the console, such as a bare `puts`,
24
+ # land where the bar is and are drawn over by the next write.
25
+ class StatusBar
26
+ # Rows the bar takes: a rule, then the status line.
27
+ ROWS = 2
28
+
29
+ # Columns of the overall progress bar.
30
+ BAR = 10
31
+
32
+ # Clears from the cursor to the end of the screen.
33
+ CLEAR_BELOW = "\e[J"
34
+
35
+ # The last column a stream's cursor is in, followed through what is
36
+ # written to it, so the cursor can be put back after the bar is drawn.
37
+ class Column
38
+ # One control sequence, a line break, or a run of text.
39
+ TOKEN = /\e\[[\d;?]*[A-Za-z]|\e[78]|\r|\n|[^\e\r\n]+|\e/
40
+
41
+ def initialize
42
+ @column = 0
43
+ @saved = 0
44
+ end
45
+
46
+ # @return [Integer] zero-based
47
+ attr_reader :column
48
+
49
+ # @param text [String] what was just written
50
+ # @return [Integer] the column after it
51
+ def follow(text)
52
+ text.scan(TOKEN) { |token| step(token) }
53
+ column
54
+ end
55
+
56
+ private
57
+
58
+ # @param token [String]
59
+ # @return [void]
60
+ def step(token)
61
+ case token
62
+ when "\n", "\r" then @column = 0
63
+ when "\e7", "\e[s" then @saved = @column
64
+ when "\e8", "\e[u" then @column = @saved
65
+ when /\A\e\[(\d*)([GCD])\z/ then move(Regexp.last_match(1), Regexp.last_match(2))
66
+ when /\A\e/ then nil
67
+ else @column += Strings::ANSI.sanitize(token).then { |plain| Unicode::DisplayWidth.of(plain) }
68
+ end
69
+ end
70
+
71
+ # @param count [String] the sequence's number, empty for its default
72
+ # @param kind [String] G, C or D
73
+ # @return [void]
74
+ def move(count, kind)
75
+ n = count.empty? ? 1 : count.to_i
76
+ @column = case kind
77
+ when "G" then n - 1
78
+ when "C" then @column + n
79
+ else [@column - n, 0].max
80
+ end
81
+ end
82
+ end
83
+
84
+ # Stands in for a terminal's stream while the bar runs.
85
+ class Output
86
+ # @param io [IO] the real stream
87
+ # @param bar [StatusBar]
88
+ def initialize(io, bar)
89
+ @io = io
90
+ @bar = bar
91
+ end
92
+
93
+ # @param text [#to_s]
94
+ # @return [Integer] bytes written
95
+ def write(*text)
96
+ text = text.join
97
+ @bar.around_write(@io, text)
98
+ text.bytesize
99
+ end
100
+
101
+ # @param text [Array<#to_s>]
102
+ # @return [nil]
103
+ def print(*text)
104
+ write(*text)
105
+ nil
106
+ end
107
+
108
+ # @param text [#to_s]
109
+ # @return [self]
110
+ def <<(text)
111
+ write(text)
112
+ self
113
+ end
114
+
115
+ # @return [Boolean] always true: a bar is only drawn on a terminal
116
+ def tty? = true
117
+
118
+ # @return [void]
119
+ def flush
120
+ @io.flush if @io.respond_to?(:flush)
121
+ end
122
+
123
+ # @return [Boolean]
124
+ def respond_to_missing?(name, include_private = false) = @io.respond_to?(name, include_private) || super
125
+
126
+ # Anything else the stream answers, such as `winsize`.
127
+ def method_missing(name, ...)
128
+ @io.respond_to?(name) ? @io.public_send(name, ...) : super
129
+ end
130
+ end
131
+
132
+ # @param terminal [Terminal] where the bar is drawn
133
+ # @param others [Array<Terminal>] other terminals on the same screen,
134
+ # whose writes must also go above the bar
135
+ # @param title [String, nil]
136
+ # @param hints [Array<String>] shown at the right, such as "^C cancel"
137
+ # @param clock [#call] returns monotonic seconds
138
+ # @param config [Configuration] where the spinner frames and bar characters come from
139
+ def initialize(terminal, others: [], title: nil, hints: [], clock: Duration::CLOCK, config: UI.config)
140
+ @terminal = terminal
141
+ @terminals = [terminal, *others]
142
+ @title = title
143
+ @hints = hints
144
+ @clock = clock
145
+ @config = config
146
+ @monitor = Monitor.new
147
+ @column = Column.new
148
+ @running = {}
149
+ @finished = []
150
+ @done = 0
151
+ @failed = 0
152
+ @frame = 0
153
+ end
154
+
155
+ # Draws the bar, runs the block, and takes the bar away again.
156
+ #
157
+ # @return [Object] whatever the block returns
158
+ def run
159
+ @started = clock.call
160
+ ticker = Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }
161
+ streams = @terminals.to_h { |terminal| [terminal, terminal.io] }
162
+ streams.each { |terminal, io| terminal.redirect(Output.new(io, self), reporter: self, reserved: ROWS) }
163
+ begin
164
+ terminal.print("")
165
+ ticker.execute
166
+ yield
167
+ ensure
168
+ ticker.shutdown
169
+ ticker.wait_for_termination(1)
170
+ streams.each { |terminal, io| terminal.redirect(io) }
171
+ synchronize { terminal.io.print(CLEAR_BELOW) }
172
+ end
173
+ end
174
+
175
+ # Records that work has started. Widgets report through {Terminal#started}.
176
+ #
177
+ # @param key [Object] identifies the work until it finishes
178
+ # @param label [String]
179
+ # @param progress [#current, #total, nil] read whenever the bar is drawn
180
+ # @return [void]
181
+ def started(key, label, progress: nil)
182
+ synchronize { @running[key] = [label, progress] }
183
+ end
184
+
185
+ # Records that work has ended. Widgets report through {Terminal#finished}.
186
+ #
187
+ # @param key [Object] as given to {#started}
188
+ # @param succeeded [Boolean]
189
+ # @return [void]
190
+ def finished(key, succeeded)
191
+ synchronize do
192
+ _, progress = @running.delete(key)
193
+ @finished << progress if progress
194
+ succeeded ? @done += 1 : @failed += 1
195
+ end
196
+ end
197
+
198
+ # Writes text to a stream above the bar. For {Output}.
199
+ #
200
+ # @param io [IO]
201
+ # @param text [String]
202
+ # @return [void]
203
+ def around_write(io, text)
204
+ synchronize do
205
+ @column.follow(text)
206
+ io.print("#{CLEAR_BELOW}#{text}#{footer}")
207
+ end
208
+ end
209
+
210
+ # The status line, as wide as the terminal at most.
211
+ #
212
+ # @return [String]
213
+ def line
214
+ synchronize do
215
+ pastel = terminal.pastel
216
+ fields = [title && pastel.bold(title), current, *counts, meter, Duration.format(clock.call - @started)]
217
+ fit(" #{glyph} #{fields.compact.join(pastel.bright_black(' · '))}", pastel.bright_black(hints.join(" ")))
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ # @return [Terminal]
224
+ attr_reader :terminal
225
+
226
+ # @return [String, nil]
227
+ attr_reader :title
228
+
229
+ # @return [Array<String>]
230
+ attr_reader :hints
231
+
232
+ # @return [#call]
233
+ attr_reader :clock
234
+
235
+ # @return [Configuration]
236
+ attr_reader :config
237
+
238
+ # @return [void]
239
+ def synchronize(&) = @monitor.synchronize(&)
240
+
241
+ # @return [void]
242
+ def tick
243
+ synchronize do
244
+ @frame += 1
245
+ terminal.io.print("")
246
+ end
247
+ end
248
+
249
+ # The rule and the status line below the cursor, and the sequence that
250
+ # puts the cursor back. From the start of a row, the bar starts on that
251
+ # row; from anywhere else, on the next.
252
+ #
253
+ # @return [String]
254
+ def footer
255
+ rule = terminal.pastel.bright_black("─" * terminal.width)
256
+ rows = "\e[2K#{rule}\n\e[2K#{line}"
257
+ col = @column.column
258
+ return "#{rows}\e[#{ROWS - 1}A\r" if col.zero?
259
+
260
+ "\n#{rows}\e[#{ROWS}A\e[#{col + 1}G"
261
+ end
262
+
263
+ # @return [String] a turning spinner while anything runs, a dot otherwise
264
+ def glyph
265
+ return terminal.pastel.bright_black("·") if @running.empty?
266
+
267
+ frames = config.spinner_frames
268
+ terminal.pastel.cyan(frames[@frame % frames.size])
269
+ end
270
+
271
+ # @return [String, nil] the label of the work started most recently
272
+ def current = @running.values.last&.first
273
+
274
+ # @return [Array<String>]
275
+ def counts
276
+ pastel = terminal.pastel
277
+ [
278
+ (pastel.cyan("#{@running.size} running") if @running.any?),
279
+ (pastel.green("#{@done} done") if @done.positive?),
280
+ (pastel.red("#{@failed} failed") if @failed.positive?)
281
+ ].compact
282
+ end
283
+
284
+ # @return [String, nil] a bar over every progress reported so far
285
+ def meter
286
+ progress = @finished + @running.values.filter_map(&:last)
287
+ total = progress.sum(&:total)
288
+ return if total.zero?
289
+
290
+ ratio = progress.sum(&:current).fdiv(total)
291
+ "#{Widgets::Progress.bar(terminal.pastel, config, ratio, BAR)} #{(ratio * 100).floor}%"
292
+ end
293
+
294
+ # The left part, with the hints right-aligned after it when they fit,
295
+ # truncated to the terminal's width.
296
+ #
297
+ # @param left [String]
298
+ # @param right [String]
299
+ # @return [String]
300
+ def fit(left, right)
301
+ width = terminal.width
302
+ gap = width - display(left) - display(right) - 1
303
+ return "#{left}#{' ' * gap}#{right}" if gap >= 2 && !hints.empty?
304
+
305
+ Strings::Truncate.truncate(left, width - 1)
306
+ end
307
+
308
+ # @param text [String]
309
+ # @return [Integer] columns, not counting escape codes
310
+ def display(text) = Unicode::DisplayWidth.of(Strings::ANSI.sanitize(text))
311
+ end
312
+ end
313
+ end
314
+ end
@@ -37,6 +37,42 @@ module Dry
37
37
  # @return [IO] the stream this terminal writes to
38
38
  attr_reader :io
39
39
 
40
+ # @return [StatusBar, nil] what widgets report their work to, while a status bar runs
41
+ attr_reader :reporter
42
+
43
+ # Sends what this terminal writes, and what its widgets report, to a
44
+ # {StatusBar}, and keeps rows free for it at the bottom of the screen.
45
+ # Called again with the original stream to undo it.
46
+ #
47
+ # @param io [IO]
48
+ # @param reporter [StatusBar, nil]
49
+ # @param reserved [Integer] rows at the bottom widgets must not use
50
+ # @return [void]
51
+ def redirect(io, reporter: nil, reserved: 0)
52
+ @io = io
53
+ @reporter = reporter
54
+ @reserved = reserved
55
+ end
56
+
57
+ # Reports that a widget started some work.
58
+ #
59
+ # @param key [Object] identifies the work until it finishes
60
+ # @param label [String]
61
+ # @param progress [#current, #total, nil]
62
+ # @return [void]
63
+ def started(key, label, progress: nil)
64
+ reporter&.started(key, label, progress: progress)
65
+ end
66
+
67
+ # Reports that a widget's work ended.
68
+ #
69
+ # @param key [Object] as given to {#started}
70
+ # @param succeeded [Boolean]
71
+ # @return [void]
72
+ def finished(key, succeeded)
73
+ reporter&.finished(key, succeeded)
74
+ end
75
+
40
76
  # Whether the stream is an interactive terminal.
41
77
  #
42
78
  # @return [Boolean]
@@ -67,9 +103,9 @@ module Dry
67
103
  @width || (tty? ? TTY::Screen.width : DEFAULT_WIDTH)
68
104
  end
69
105
 
70
- # @return [Integer] rows available
106
+ # @return [Integer] rows available, less any a status bar keeps
71
107
  def height
72
- tty? ? TTY::Screen.height : DEFAULT_HEIGHT
108
+ (tty? ? TTY::Screen.height : DEFAULT_HEIGHT) - @reserved.to_i
73
109
  end
74
110
 
75
111
  # @return [Pastel::Delegator] a colouriser that is a no-op when colour is off