dry-cli-ui 0.4.0 → 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.
data/examples/README.md CHANGED
@@ -1,15 +1,13 @@
1
1
  # Examples
2
2
 
3
- These examples offers two commands that are are indicative of the added behavior.
3
+ A small `dry-cli` app, `bin/mycli`, with two commands that show off the UI widgets.
4
4
 
5
- But first,
5
+ First:
6
6
 
7
7
  ```bash
8
8
  bundle install
9
9
  ```
10
10
 
11
- Then:
12
-
13
11
  ## Help
14
12
 
15
13
  ```bash
@@ -19,9 +17,8 @@ USAGE
19
17
 
20
18
  COMMANDS
21
19
  version, v Print version
22
- urls_progress Fetch URLs with a progress bar each, and write them all to urls.txt
23
- urls_spinner Fetch URLs with a spinner each, and write each to its own file
24
- primes Compute closest prime to a given number in the array
20
+ download-urls Download URLs, each to its own file
21
+ find-hosts Find hosts on the local network that answer on TCP ports
25
22
 
26
23
  OPTIONS
27
24
  -h, --help Show help
@@ -30,6 +27,23 @@ OPTIONS
30
27
 
31
28
  ## Commands to Try
32
29
 
33
- - primes
34
- - urls_progress url1 url2 ...
35
- - urls_spinner url1 url2 ...
30
+ - `download-urls URL1 URL2 ...` downloads each URL to its own file in the current folder, under a spinner per URL.
31
+ - `download-urls -u urls.txt` reads the URLs from `urls.txt`, one per line, skipping blank lines and `#` comments. URLs given as arguments are downloaded too. Invalid URLs, from either place, are skipped and listed in a warning at the end.
32
+ - `download-urls --progress --save-to=downloads URL1 URL2 ...` shows a progress bar per URL instead, and saves the files in `downloads/`, creating it when missing.
33
+ - `find-hosts` probes every address on the local /24 subnet on the common TCP ports (22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000), 10 addresses at a time, with a spinner per address. It ends with a box listing each host that answered and its open ports.
34
+ - `find-hosts --progress --output=hosts.txt` shows two bars instead, one counting the addresses that answered and one those that did not, and also writes the result to `hosts.txt`.
35
+ - `find-hosts --port=22` probes only port 22, with the two bars.
36
+
37
+ `download-urls` downloads at most 10 URLs at a time. `--progress` and `--spinner` are mutually exclusive. `download-urls` defaults to a spinner. `find-hosts` defaults to spinners, or to progress bars with `--port`.
38
+
39
+ ## File names
40
+
41
+ `download-urls` names each file after the URL's host, path and query, with anything unsafe replaced by `_`:
42
+
43
+ | URL | File |
44
+ | ------------------------------------------------------- | ----------------------------------------------- |
45
+ | `https://www.ruby-lang.org/images/header-ruby-logo.png` | `www.ruby-lang.org_images_header-ruby-logo.png` |
46
+ | `https://example.com` | `example.com.html` |
47
+ | `https://httpbin.org/json` | `httpbin.org_json.json` |
48
+
49
+ A file keeps the extension its URL path has. Without one, the extension comes from the response's `Content-Type`, and is `.txt` for a type the command does not know.
data/examples/bin/mycli CHANGED
@@ -12,7 +12,8 @@ require "dry/cli/help"
12
12
  require "dry/cli/autocomplete/command"
13
13
  require "dry/cli/ui"
14
14
  require "net/http"
15
- require "prime"
15
+ require "concurrent"
16
+ require "socket"
16
17
 
17
18
  module Foo
18
19
  module CLI
@@ -27,43 +28,61 @@ module Foo
27
28
  end
28
29
  end
29
30
 
30
- # Shared by the two URL commands: follows redirects, and asks for the
31
- # body as it is stored, so the bytes counted match Content-Length.
31
+ # Used by download-urls: follows up to REDIRECTS redirects on every
32
+ # request, and asks for the body as it is stored, so the bytes counted
33
+ # match Content-Length.
32
34
  module HTTP
33
35
  HEADERS = { "Accept-Encoding" => "identity" }.freeze
34
36
  REDIRECTS = 5
35
37
 
36
38
  module_function
37
39
 
40
+ # Statuses a server answers HEAD with when it only serves GET.
41
+ NO_HEAD = %w[403 405 501].freeze
42
+
38
43
  # @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
39
44
  # @raise [RuntimeError] for a response that is neither a redirect nor a success
40
45
  def resolve(url, hops = REDIRECTS)
41
46
  uri = URI(url)
42
47
  response = start(uri) { |http| http.head(uri.request_uri, HEADERS) }
43
- if response.is_a?(Net::HTTPRedirection) && hops.positive?
44
- return resolve(URI.join(uri, response["location"]).to_s, hops - 1)
45
- end
48
+ return resolve(redirect(uri, response, hops), hops - 1) if response.is_a?(Net::HTTPRedirection)
49
+ return [uri, nil] if NO_HEAD.include?(response.code) # the GET finds out
46
50
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
47
51
 
48
52
  [uri, response.content_length]
49
53
  end
50
54
 
51
55
  # @yieldparam bytes [Integer] the size of each chunk as it arrives
52
- # @return [String] the body
56
+ # @return [Array(String, String, nil)] the body, and its media type when the server says
53
57
  # @raise [RuntimeError] for a response that is not a success
54
- def download(uri)
58
+ def download(uri, hops = REDIRECTS, &)
55
59
  body = +""
60
+ type = nil
61
+ location = nil
56
62
  start(uri) do |http|
57
63
  http.request_get(uri.request_uri, HEADERS) do |response|
64
+ next location = redirect(uri, response, hops) if response.is_a?(Net::HTTPRedirection)
58
65
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
59
66
 
67
+ type = response.content_type
60
68
  response.read_body do |chunk|
61
69
  body << chunk
62
70
  yield chunk.bytesize if block_given?
63
71
  end
64
72
  end
65
73
  end
66
- body
74
+ return download(URI(location), hops - 1, &) if location
75
+
76
+ [body, type]
77
+ end
78
+
79
+ # @return [String] the absolute URL a redirect points to
80
+ # @raise [RuntimeError] once there are no hops left, or without a Location
81
+ def redirect(uri, response, hops)
82
+ raise "Too many redirects" unless hops.positive?
83
+ raise "HTTP #{response.code} without a Location" unless response["location"]
84
+
85
+ URI.join(uri, response["location"]).to_s
67
86
  end
68
87
 
69
88
  def start(uri, &)
@@ -71,59 +90,93 @@ module Foo
71
90
  end
72
91
  end
73
92
 
74
- # One progress bar per URL, filling as bytes arrive, under a headline
75
- # bar that counts every byte.
76
- class FetchUrlsProgress < Dry::CLI::Command
93
+ # Downloads URLs, each to its own file, under a spinner or a progress bar.
94
+ class DownloadUrls < Dry::CLI::Command
77
95
  include Dry::CLI::UI
78
96
 
79
- desc "Fetch URLs with a progress bar each, and write them all to urls.txt"
80
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
81
-
82
- def call(urls:, **)
83
- # A bar's total is fixed when it is declared, so ask each URL for its
84
- # size first, with a HEAD request. A URL that fails here is left out.
85
- found = urls.filter_map do |url|
86
- [url, *HTTP.resolve(url)]
87
- rescue StandardError => e
88
- ui.status "#{url}: #{e.message}", level: :warn
89
- nil
97
+ # File extensions for the media types a URL's own name may not reveal.
98
+ EXTENSIONS = {
99
+ "application/gzip" => ".gz",
100
+ "application/javascript" => ".js",
101
+ "application/json" => ".json",
102
+ "application/pdf" => ".pdf",
103
+ "application/xml" => ".xml",
104
+ "application/zip" => ".zip",
105
+ "image/gif" => ".gif",
106
+ "image/jpeg" => ".jpg",
107
+ "image/png" => ".png",
108
+ "image/svg+xml" => ".svg",
109
+ "image/webp" => ".webp",
110
+ "text/css" => ".css",
111
+ "text/csv" => ".csv",
112
+ "text/html" => ".html",
113
+ "text/javascript" => ".js",
114
+ "text/markdown" => ".md",
115
+ "text/plain" => ".txt",
116
+ "text/xml" => ".xml"
117
+ }.freeze
118
+
119
+ # The most URLs downloaded at once.
120
+ CONCURRENCY = 10
121
+
122
+ desc "Download URLs, each to its own file"
123
+ argument :urls, type: :array, required: false, desc: "URLs to download, separated by spaces"
124
+ option :'urls-file', aliases: ["-u"], desc: "A file with one URL per line; blank lines and # comments are skipped"
125
+ option :'save-to', default: ".", desc: "Directory to save the files in, created when missing"
126
+ option :progress, type: :flag, default: false, desc: "Show a progress bar per URL"
127
+ option :spinner, type: :flag, default: false, desc: "Show a spinner per URL; used when neither flag is given"
128
+
129
+ def call(urls: [], progress: false, spinner: false, **options)
130
+ return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
131
+
132
+ urls_file = options[:'urls-file']
133
+ save_to = options.fetch(:'save-to', ".")
134
+
135
+ begin
136
+ urls += read_urls(urls_file) if urls_file
137
+ rescue SystemCallError => e
138
+ return ui.error("Cannot read #{urls_file}", e.message)
90
139
  end
91
- return ui.error("None of the #{urls.size} URLs could be fetched") if found.empty?
140
+ urls, invalid = urls.partition { |url| valid?(url) }
141
+ download(urls, save_to, progress)
142
+ ui.warn "Skipped #{invalid.size} invalid URLs", invalid.join("\n") if invalid.any?
143
+ end
92
144
 
93
- bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
94
- found.each do |url, uri, size|
95
- m.progress(url, total: size || 1) do |bar|
96
- sleep rand(1..4)
97
- # Move the bar one byte at a time, as each chunk arrives.
98
- body = HTTP.download(uri) { |bytes| bytes.times { bar.advance } if size }
99
- bar.advance unless size # no Content-Length: done in one step
100
- body
101
- end
102
- end
145
+ private
146
+
147
+ def download(urls, save_to, progress)
148
+ return ui.error("No URLs to download", "Pass them as arguments, or in a file with --urls-file") if urls.empty?
149
+
150
+ begin
151
+ FileUtils.mkdir_p(save_to)
152
+ rescue SystemCallError => e
153
+ return ui.error("Cannot create #{save_to}", e.message)
103
154
  end
104
155
 
105
- File.write("urls.txt", bodies.join("\n"))
106
- ui.success "Wrote #{bodies.sum(&:bytesize)} bytes from #{found.size} of #{urls.size} URLs to urls.txt"
107
- rescue StandardError => e
108
- ui.error("Fetching failed", e.message)
109
- end
110
- end
156
+ downloads = progress ? with_progress(urls) : with_spinner(urls)
157
+ files = urls.zip(downloads).filter_map do |url, (body, type)|
158
+ next unless body
111
159
 
112
- # One spinner per URL, each saying what it is doing, and one file per URL.
113
- class FetchUrls < Dry::CLI::Command
114
- include Dry::CLI::UI
160
+ File.join(save_to, filename(url, type)).tap { |file| File.write(file, body) }
161
+ end
162
+ ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
163
+ end
115
164
 
116
- desc "Fetch URLs with a spinner each, and write each to its own file"
117
- argument :urls, type: :array, required: true, desc: "URLs to fetch, separated by spaces"
165
+ # An http or https URL with a host.
166
+ def valid?(url)
167
+ uri = URI.parse(url)
168
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
169
+ rescue URI::InvalidURIError
170
+ false
171
+ end
118
172
 
119
- def call(urls:, **)
120
- bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
173
+ def with_spinner(urls)
174
+ ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: CONCURRENCY) do |m|
121
175
  urls.each do |url|
122
176
  m.spinner(url) do |line|
123
177
  line.detail = "resolving"
124
178
  uri, = HTTP.resolve(url)
125
179
  line.detail = "downloading #{uri.host}"
126
- sleep rand(1..4) # slow enough to watch
127
180
  HTTP.download(uri)
128
181
  rescue StandardError => e
129
182
  line.fail(e.message)
@@ -131,76 +184,199 @@ module Foo
131
184
  end
132
185
  end
133
186
  end
187
+ end
134
188
 
135
- files = urls.zip(bodies).filter_map do |url, body|
136
- next unless body
137
-
138
- "#{friendly_filename(url)}.txt".tap { |file| File.write(file, body) }
189
+ # A bar's total is fixed when it is declared, so ask each URL for its
190
+ # size first, with a HEAD request. A URL that fails here is left out.
191
+ def with_progress(urls)
192
+ found = urls.to_h do |url|
193
+ [url, HTTP.resolve(url)]
194
+ rescue StandardError => e
195
+ ui.status "#{url}: #{e.message}", level: :warn
196
+ [url, nil]
139
197
  end
140
- ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
198
+ ready = found.select { |_, (uri, _)| uri }
199
+ return urls.map { nil } if ready.empty?
200
+
201
+ # A bar cannot be marked failed, so a download that fails is
202
+ # reported once every bar has finished.
203
+ errors = Concurrent::Array.new
204
+ bodies = ui.multi_progress("Downloading #{ready.size} URLs", concurrent: CONCURRENCY) do |m|
205
+ ready.each do |url, (uri, size)|
206
+ m.progress(url, total: size || 1) do |bar|
207
+ download = HTTP.download(uri) { |bytes| bar.advance(bytes) if size }
208
+ bar.advance unless size # no Content-Length: done in one step
209
+ download
210
+ rescue StandardError => e
211
+ errors << "#{url}: #{e.message}"
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ errors.each { |error| ui.status error, level: :warn }
217
+ downloads = ready.keys.zip(bodies).to_h
218
+ urls.map { |url| downloads[url] }
141
219
  end
142
220
 
143
- private
221
+ def read_urls(file)
222
+ File.readlines(file, chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
223
+ end
144
224
 
145
- def friendly_filename(url)
146
- url.sub(%r{\Ahttps?://}, "").gsub(/[^\w.-]+/, "_").delete_suffix("_")
225
+ # The URL's host, path and query as one safe name, keeping the path's
226
+ # extension. Without one, the extension comes from the media type,
227
+ # and is .txt when that is unknown too.
228
+ def filename(url, type)
229
+ uri = URI(url)
230
+ extension = File.extname(uri.path)
231
+ stem = "#{uri.host}#{uri.path.delete_suffix(extension)}#{"_#{uri.query}" if uri.query}"
232
+ extension = EXTENSIONS.fetch(type, ".txt") if extension.empty?
233
+ "#{stem.gsub(/[^\w.-]+/, '_').delete_suffix('_')}#{extension}"
147
234
  end
148
235
  end
149
236
 
150
- class ComputePrimes < Dry::CLI::Command
237
+ # Scans the local /24 subnet for hosts that answer on TCP ports, with a
238
+ # spinner or a progress bar for every address.
239
+ class FindHosts < Dry::CLI::Command
151
240
  include Dry::CLI::UI
152
241
 
153
- def self.closest_prime(n)
154
- # Primes must be greater than 1
155
- return 2 if n <= 2
156
- return n if ::Prime.prime?(n)
157
-
158
- distance = 1
159
- loop do
160
- lower = n - distance
161
- upper = n + distance
242
+ # Well-known TCP ports: SSH, DNS, HTTP, NTP, NetBIOS, HTTPS, SMB, IPP,
243
+ # RDP, and the two AirPlay and Synology use.
244
+ COMMON_PORTS = [22, 53, 80, 123, 139, 443, 445, 631, 3389, 5000, 7000].freeze
245
+
246
+ # How many addresses are probed at once. Each probes its ports all at
247
+ # once, so this keeps open sockets well under the default limit of 256.
248
+ CONCURRENCY = 10
249
+
250
+ desc "Find hosts on the local network that answer on TCP ports"
251
+ option :port, type: :integer, desc: "Probe only this TCP port, instead of #{COMMON_PORTS.join(', ')}"
252
+ option :timeout, type: :float, default: 0.5, aliases: ["-t"], desc: "Seconds to wait for each port"
253
+ option :output, aliases: ["-o"], desc: "Also write the hosts to this file"
254
+ option :progress, type: :flag, default: false, aliases: ["-p"], desc: "Show progress bars; the default with --port"
255
+ option :spinner, type: :flag, default: false, aliases: ["-s"], desc: "Show spinners; the default without --port"
256
+
257
+ def call(timeout:, port: nil, output: nil, progress: false, spinner: false, **)
258
+ return ui.error("Pick one of --progress and --spinner, not both") if progress && spinner
259
+
260
+ ip = local_ip or return ui.error("No local IPv4 address found")
261
+ subnet = ip.split(".").first(3).join(".")
262
+ ips = (1..254).map { |i| "#{subnet}.#{i}" }
263
+ ports = port ? [port.to_i] : COMMON_PORTS
264
+ ui.info "Local IP: #{ip}, scanning #{subnet}.1-254 on #{ports.size == 1 ? 'port' : 'ports'} #{ports.join(', ')}"
265
+
266
+ progress ||= port && !spinner
267
+ results = progress ? with_progress(ips, ports, timeout.to_f) : with_spinner(ips, ports, timeout.to_f)
268
+ hosts = ips.zip(results).select { |_, open| open }
269
+ return ui.warn("No hosts answered on #{ports.join(', ')}") if hosts.empty?
270
+
271
+ lines = hosts.map { |host, open| "#{host}: #{open.empty? ? 'no open ports' : open.join(', ')}" }
272
+ File.write(output, lines.join("\n") << "\n") if output
273
+ ui.success "Found #{hosts.size} hosts", lines.join("\n"), *("Written to #{output}" if output)
274
+ end
162
275
 
163
- primes = []
164
- primes << lower if lower > 1 && ::Prime.prime?(lower)
165
- primes << upper if ::Prime.prime?(upper)
276
+ private
166
277
 
167
- # Return the prime(s) found at the shortest distance
168
- return primes.size == 1 ? primes.first : primes if primes.any?
278
+ def local_ip
279
+ Socket.getifaddrs
280
+ .map(&:addr)
281
+ .find { |addr| addr&.ipv4? && !addr.ipv4_loopback? }
282
+ &.ip_address
283
+ end
169
284
 
170
- distance += 1
285
+ # A spinner per address, counting the ports answered so far. An
286
+ # address where nothing answered is marked failed.
287
+ def with_spinner(ips, ports, timeout)
288
+ ui.multi_spinner("Probing #{ips.size} addresses", concurrent: CONCURRENCY) do |m|
289
+ ips.each do |ip|
290
+ m.spinner(ip) do |line|
291
+ answered = Concurrent::AtomicFixnum.new
292
+ open = scan(ip, ports, timeout) { line.detail = "#{answered.increment} of #{ports.size} ports" }
293
+ line.fail("no answer") unless open
294
+ open
295
+ end
296
+ end
171
297
  end
172
298
  end
173
299
 
174
- desc 'Compute closest prime to a given number in the array'
175
- option :max, default: 100_000, desc: "Maximum integer to compute closest primes for", type: :integer
176
-
177
- def call(max:)
178
- ui.info "Computing closest primes..."
179
- File.open('primes.txt', 'w') do |f|
180
- integers = (1..max.to_i)
181
- ui.progress("Computing Primes", total: integers.size) do |bar|
182
- integers.each do |i|
183
- p = ComputePrimes.closest_prime(i)
184
- f.puts "closest prime to #{i} is #{p}"
185
- bar.advance
300
+ # A progress bar per address, advancing as each port answers or times out.
301
+ # Two bars, one counting the addresses that answered and one those
302
+ # that did not. Each bar's block is given only its own handle, so the
303
+ # "No answer" job hands its bar to the "Answered" job, which runs the
304
+ # scan, and then waits for the scan to end.
305
+ def with_progress(ips, ports, timeout)
306
+ silent_bar = Concurrent::Promises.resolvable_future
307
+ scanned = Concurrent::Promises.resolvable_future
308
+ results = nil
309
+ ui.multi_progress("Probing #{ips.size} addresses") do |m|
310
+ m.progress("Answered", total: ips.size, color: :green) do |answered|
311
+ silent = silent_bar.value!
312
+ results = in_parallel(ips) do |ip|
313
+ scan(ip, ports, timeout) { nil }.tap { |open| (open ? answered : silent).advance }
186
314
  end
315
+ ensure
316
+ scanned.fulfill(true)
317
+ end
318
+ m.progress("No answer", total: ips.size, color: :red) do |silent|
319
+ silent_bar.fulfill(silent)
320
+ scanned.wait
187
321
  end
188
322
  end
189
- ui.success("Computed #{max} primes, they are in the 'primes.txt' files.")
190
- rescue StandardError => e
191
- ui.error("Import failed", e.message)
323
+ results
324
+ end
325
+
326
+ # Runs the block for each item, at most CONCURRENCY at once.
327
+ #
328
+ # @return [Array] what the block returned for each item, in order
329
+ def in_parallel(items)
330
+ queue = Queue.new
331
+ items.each_with_index { |item, index| queue << [item, index] }
332
+ queue.close
333
+ results = Array.new(items.size)
334
+ Array.new(CONCURRENCY) do
335
+ Thread.new do
336
+ while (pair = queue.pop)
337
+ item, index = pair
338
+ results[index] = yield item
339
+ end
340
+ end
341
+ end.each(&:join)
342
+ results
343
+ end
344
+
345
+ # Probes every port on an address at once.
346
+ #
347
+ # @yield once for each port, as its probe ends
348
+ # @return [Array<Integer>, nil] the open ports, empty when every port
349
+ # refused; nil when nothing answered at all
350
+ def scan(ip, ports, timeout, &)
351
+ states = ports.map do |port|
352
+ Thread.new do
353
+ probe(ip, port, timeout).tap(&)
354
+ end
355
+ end.map(&:value)
356
+ return if states.all?(:silent)
357
+
358
+ ports.zip(states).filter_map { |port, state| port if state == :open }
359
+ end
360
+
361
+ # A refused connection still means something is at that address.
362
+ #
363
+ # @return [Symbol] :open, :closed, or :silent when nothing answered
364
+ def probe(ip, port, timeout)
365
+ Socket.tcp(ip, port, connect_timeout: timeout).close
366
+ :open
367
+ rescue Errno::ECONNREFUSED
368
+ :closed
369
+ rescue SystemCallError, IOError, SocketError
370
+ :silent
192
371
  end
193
372
  end
194
373
 
195
- register "version", Version, aliases: ["v", "-v", "--version"]
196
- register "urls_progress", FetchUrlsProgress
197
- register "urls_spinner", FetchUrls
198
- register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
199
- register "primes", ComputePrimes
374
+ register "version", Version, aliases: ["v", "-v", "--version"]
375
+ register "download-urls", DownloadUrls
376
+ register "find-hosts", FindHosts
377
+ register "completion", ::Dry::CLI::Autocomplete::Command[::Foo::CLI::Commands], hidden: true
200
378
  end
201
379
  end
202
380
  end
203
381
 
204
382
  Dry::CLI.new(Foo::CLI::Commands).call
205
-
206
- FileUtils.rm_f(Dir.glob("#{File.expand_path('../', __dir__)}/*.txt"))
@@ -24,12 +24,12 @@ module Dry
24
24
  # Anything not set reads from {DEFAULTS}.
25
25
  class Configuration
26
26
  # What a setting reads before it is set: a green `◼` for each finished
27
- # part of a bar, over a gray track the whole bar's width.
27
+ # part of a bar, with no background behind it.
28
28
  DEFAULTS = {
29
29
  spinner_format: :dots,
30
30
  bar_format: { complete: "◼", incomplete: " " }.freeze,
31
31
  bar_color: :green,
32
- bar_background: :on_bright_black
32
+ bar_background: nil
33
33
  }.freeze
34
34
 
35
35
  # Every style name Pastel knows, for checking colour settings.
@@ -59,7 +59,7 @@ module Dry
59
59
  # @return [Symbol, nil]
60
60
  # @!method bar_background(value = UNSET)
61
61
  # Reads the background the whole bar is drawn on, or sets it.
62
- # @param value [Symbol, nil] a Pastel style, such as :on_bright_black; nil for none
62
+ # @param value [Symbol, nil] a Pastel style, such as :on_blue; nil for none
63
63
  # @return [Symbol, nil]
64
64
  DEFAULTS.each_key do |name|
65
65
  define_method(name) do |value = UNSET|
@@ -75,7 +75,7 @@ module Dry
75
75
  @config = config
76
76
  end
77
77
 
78
- # 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
79
79
  # and stream; without one it is untitled unless given a title, and goes
80
80
  # to `out`.
81
81
  #
@@ -91,7 +91,7 @@ module Dry
91
91
  theme = level && Theme.level(level)
92
92
  terminal = theme ? stream(theme) : out
93
93
  widget = Widgets::Box.new(terminal, width: width || box_width)
94
- 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)}")
95
95
  nil
96
96
  end
97
97
 
@@ -171,13 +171,16 @@ module Dry
171
171
  #
172
172
  # @param label [String]
173
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`
174
176
  # @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
175
177
  # @return [Object] whatever the block returns
176
- # @raise [ArgumentError] without a block, or when total is not a non-negative Integer
177
- 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, &)
178
181
  raise ArgumentError, "progress needs a block" unless block_given?
179
182
 
180
- Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, &)
183
+ Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, color: color, &)
181
184
  end
182
185
 
183
186
  # Runs several jobs at once, each with a progress bar of its own,
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.4.0"
14
+ VERSION = "0.5.0"
15
15
  end
16
16
  end
17
17
  end
@@ -14,7 +14,7 @@ module Dry
14
14
  # @example
15
15
  # ui.multi_progress("Downloading") do |m|
16
16
  # files.each do |file|
17
- # m.progress(file.name, total: file.size) do |bar|
17
+ # m.progress(file.name, total: file.size, color: file.large? ? :yellow : nil) do |bar|
18
18
  # download(file) { |bytes| bar.advance(bytes) }
19
19
  # end
20
20
  # end
@@ -33,14 +33,17 @@ module Dry
33
33
  #
34
34
  # @param label [String]
35
35
  # @param total [Integer] units of work
36
+ # @param color [Symbol, nil] the finished part's Pastel style; nil for
37
+ # {Configuration#bar_color}
36
38
  # @yieldparam progress [Progress::Handle] call `advance` as units complete
37
39
  # @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] without a block, when total is not a
41
+ # non-negative Integer, or when color is not a Pastel style
42
+ def progress(label, total:, color: nil, &work)
40
43
  raise ArgumentError, "progress #{label.inspect} needs a block" unless work
41
44
  raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
42
45
 
43
- @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil))
46
+ @jobs << Multi::Job.new(label, work, Progress::Handle.new(total, nil, color: Progress.color(color)))
44
47
  self
45
48
  end
46
49
  end
@@ -55,7 +58,7 @@ module Dry
55
58
  # @param width [Integer]
56
59
  # @return [String]
57
60
  def running(job, width)
58
- "#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started)}"
61
+ "#{job.label.ljust(width)} #{meter(job.handle.current, job.handle.total, job.started, job.handle.color)}"
59
62
  end
60
63
 
61
64
  # @param job [Job]
@@ -83,10 +86,11 @@ module Dry
83
86
  # @param done [Integer]
84
87
  # @param all [Integer]
85
88
  # @param since [Float, nil] when the work started, by the clock
89
+ # @param color [Symbol, nil] the bar's own colour, if any
86
90
  # @return [String]
87
- def meter(done, all, since)
91
+ def meter(done, all, since, color = nil)
88
92
  ratio = all.zero? ? 1.0 : done.fdiv(all)
89
- bar = Progress.bar(terminal.pastel, config, ratio, bar_columns)
93
+ bar = Progress.bar(terminal.pastel, config, ratio, bar_columns, color: color)
90
94
  format("%<bar>s %<percent>3d%% %<count>s ETA %<eta>s",
91
95
  bar: bar, percent: (ratio * 100).floor, count: "#{done}/#{all}".rjust(count_width), eta: eta(done, all, since))
92
96
  end