dry-cli-ui 0.3.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +219 -18
- data/SPECIFICATION.md +71 -11
- data/examples/Gemfile +14 -0
- data/examples/Gemfile.lock +109 -0
- data/examples/README.md +35 -0
- data/examples/bin/mycli +206 -0
- data/lib/dry/cli/ui/configuration.rb +160 -0
- data/lib/dry/cli/ui/console.rb +76 -4
- data/lib/dry/cli/ui/status_bar.rb +314 -0
- data/lib/dry/cli/ui/terminal.rb +38 -2
- data/lib/dry/cli/ui/theme.rb +21 -6
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +300 -0
- data/lib/dry/cli/ui/widgets/multi_progress.rb +119 -0
- data/lib/dry/cli/ui/widgets/multi_spinner.rb +69 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +1 -1
- data/lib/dry/cli/ui/widgets/pool.rb +82 -0
- data/lib/dry/cli/ui/widgets/progress.rb +41 -6
- data/lib/dry/cli/ui/widgets/spinner.rb +11 -3
- data/lib/dry/cli/ui/widgets/tasks.rb +17 -61
- data/lib/dry/cli/ui/widgets.rb +4 -0
- data/lib/dry/cli/ui.rb +32 -0
- data/sig/dry/cli/ui.rbs +25 -0
- metadata +11 -1
data/examples/bin/mycli
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Load the gems examples/Gemfile names, even when run as bin/mycli without
|
|
5
|
+
# bundle exec; otherwise Ruby picks the newest installed dry-cli-ui instead.
|
|
6
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
|
7
|
+
require "bundler/setup"
|
|
8
|
+
|
|
9
|
+
require "fileutils"
|
|
10
|
+
require "dry/cli"
|
|
11
|
+
require "dry/cli/help"
|
|
12
|
+
require "dry/cli/autocomplete/command"
|
|
13
|
+
require "dry/cli/ui"
|
|
14
|
+
require "net/http"
|
|
15
|
+
require "prime"
|
|
16
|
+
|
|
17
|
+
module Foo
|
|
18
|
+
module CLI
|
|
19
|
+
module Commands
|
|
20
|
+
extend Dry::CLI::Registry
|
|
21
|
+
|
|
22
|
+
class Version < Dry::CLI::Command
|
|
23
|
+
desc "Print version"
|
|
24
|
+
|
|
25
|
+
def call(*)
|
|
26
|
+
puts "1.0.0"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
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.
|
|
32
|
+
module HTTP
|
|
33
|
+
HEADERS = { "Accept-Encoding" => "identity" }.freeze
|
|
34
|
+
REDIRECTS = 5
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @return [Array(URI, Integer, nil)] where the URL ends up, and its size when the server says
|
|
39
|
+
# @raise [RuntimeError] for a response that is neither a redirect nor a success
|
|
40
|
+
def resolve(url, hops = REDIRECTS)
|
|
41
|
+
uri = URI(url)
|
|
42
|
+
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
|
|
46
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
47
|
+
|
|
48
|
+
[uri, response.content_length]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @yieldparam bytes [Integer] the size of each chunk as it arrives
|
|
52
|
+
# @return [String] the body
|
|
53
|
+
# @raise [RuntimeError] for a response that is not a success
|
|
54
|
+
def download(uri)
|
|
55
|
+
body = +""
|
|
56
|
+
start(uri) do |http|
|
|
57
|
+
http.request_get(uri.request_uri, HEADERS) do |response|
|
|
58
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
59
|
+
|
|
60
|
+
response.read_body do |chunk|
|
|
61
|
+
body << chunk
|
|
62
|
+
yield chunk.bytesize if block_given?
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def start(uri, &)
|
|
70
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 10, &)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
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
|
|
77
|
+
include Dry::CLI::UI
|
|
78
|
+
|
|
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
|
|
90
|
+
end
|
|
91
|
+
return ui.error("None of the #{urls.size} URLs could be fetched") if found.empty?
|
|
92
|
+
|
|
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
|
|
103
|
+
end
|
|
104
|
+
|
|
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
|
|
111
|
+
|
|
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
|
|
115
|
+
|
|
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"
|
|
118
|
+
|
|
119
|
+
def call(urls:, **)
|
|
120
|
+
bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
|
|
121
|
+
urls.each do |url|
|
|
122
|
+
m.spinner(url) do |line|
|
|
123
|
+
line.detail = "resolving"
|
|
124
|
+
uri, = HTTP.resolve(url)
|
|
125
|
+
line.detail = "downloading #{uri.host}"
|
|
126
|
+
sleep rand(1..4) # slow enough to watch
|
|
127
|
+
HTTP.download(uri)
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
line.fail(e.message)
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
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) }
|
|
139
|
+
end
|
|
140
|
+
ui.success "Wrote #{files.size} of #{urls.size} URLs", files.join("\n")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def friendly_filename(url)
|
|
146
|
+
url.sub(%r{\Ahttps?://}, "").gsub(/[^\w.-]+/, "_").delete_suffix("_")
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class ComputePrimes < Dry::CLI::Command
|
|
151
|
+
include Dry::CLI::UI
|
|
152
|
+
|
|
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
|
|
162
|
+
|
|
163
|
+
primes = []
|
|
164
|
+
primes << lower if lower > 1 && ::Prime.prime?(lower)
|
|
165
|
+
primes << upper if ::Prime.prime?(upper)
|
|
166
|
+
|
|
167
|
+
# Return the prime(s) found at the shortest distance
|
|
168
|
+
return primes.size == 1 ? primes.first : primes if primes.any?
|
|
169
|
+
|
|
170
|
+
distance += 1
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
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
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
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)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
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
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
Dry::CLI.new(Foo::CLI::Commands).call
|
|
205
|
+
|
|
206
|
+
FileUtils.rm_f(Dir.glob("#{File.expand_path('../', __dir__)}/*.txt"))
|
|
@@ -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, over a gray track the whole bar's width.
|
|
28
|
+
DEFAULTS = {
|
|
29
|
+
spinner_format: :dots,
|
|
30
|
+
bar_format: { complete: "◼", incomplete: " " }.freeze,
|
|
31
|
+
bar_color: :green,
|
|
32
|
+
bar_background: :on_bright_black
|
|
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_bright_black; 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
|
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -64,13 +64,15 @@ 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
78
|
# A framed panel. Given a level, it takes that level's title, colour
|
|
@@ -142,7 +144,27 @@ 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.
|
|
@@ -155,7 +177,30 @@ module Dry
|
|
|
155
177
|
def progress(label, total:, &)
|
|
156
178
|
raise ArgumentError, "progress needs a block" unless block_given?
|
|
157
179
|
|
|
158
|
-
Widgets::Progress.new(err, clock: clock).run(label, total: total, &)
|
|
180
|
+
Widgets::Progress.new(err, clock: clock, config: config).run(label, total: total, &)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Runs several jobs at once, each with a progress bar of its own,
|
|
184
|
+
# beneath a headline bar that counts them all. Each job is given a
|
|
185
|
+
# {Widgets::Progress::Handle}. See {Widgets::MultiProgress}.
|
|
186
|
+
#
|
|
187
|
+
# @example
|
|
188
|
+
# ui.multi_progress("Downloading") do |m|
|
|
189
|
+
# files.each do |file|
|
|
190
|
+
# m.progress(file.name, total: file.size) { |bar| download(file) { |n| bar.advance(n) } }
|
|
191
|
+
# end
|
|
192
|
+
# end
|
|
193
|
+
#
|
|
194
|
+
# @param title [String] the headline
|
|
195
|
+
# @param concurrent [Boolean, Integer] all at once (the default), one at
|
|
196
|
+
# a time, or at most this many at once
|
|
197
|
+
# @yieldparam bars [Widgets::MultiProgress::Builder] declares each `progress`
|
|
198
|
+
# @return [Array<Object>] what each job returned, in declaration order
|
|
199
|
+
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
200
|
+
def multi_progress(title, concurrent: true, &)
|
|
201
|
+
raise ArgumentError, "multi_progress needs a block" unless block_given?
|
|
202
|
+
|
|
203
|
+
Widgets::MultiProgress.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
159
204
|
end
|
|
160
205
|
|
|
161
206
|
# Declares a tree of tasks, then runs it, showing each task's state
|
|
@@ -181,7 +226,31 @@ module Dry
|
|
|
181
226
|
def tasks(title = nil, concurrent: false, &)
|
|
182
227
|
raise ArgumentError, "tasks needs a block" unless block_given?
|
|
183
228
|
|
|
184
|
-
Widgets::Tasks.new(err, clock: clock).run(title, concurrent: concurrent, &)
|
|
229
|
+
Widgets::Tasks.new(err, clock: clock, config: config).run(title, concurrent: concurrent, &)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Keeps a status line at the bottom of the screen while the block runs,
|
|
233
|
+
# saying how the command is doing overall. Every spinner, progress
|
|
234
|
+
# bar, multi widget and task tree started inside the block reports to
|
|
235
|
+
# it. Without an animated `err` it does nothing but run the block, and
|
|
236
|
+
# inside another status bar it does the same. See {StatusBar}.
|
|
237
|
+
#
|
|
238
|
+
# @example
|
|
239
|
+
# ui.status_bar("deploy", hints: ["^C cancel"]) do
|
|
240
|
+
# ui.multi_progress("Uploading") { |m| ... }
|
|
241
|
+
# ui.tasks("Migrate") { |t| ... }
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# @param title [String, nil] shown first, in bold
|
|
245
|
+
# @param hints [Array<String>] shown at the right edge
|
|
246
|
+
# @return [Object] whatever the block returns
|
|
247
|
+
# @raise [ArgumentError] without a block
|
|
248
|
+
def status_bar(title = nil, hints: [], &)
|
|
249
|
+
raise ArgumentError, "status_bar needs a block" unless block_given?
|
|
250
|
+
return yield if !err.animated? || err.reporter
|
|
251
|
+
|
|
252
|
+
others = out.animated? ? [out] : []
|
|
253
|
+
StatusBar.new(err, others: others, title: title, hints: Array(hints), clock: clock, config: config).run(&)
|
|
185
254
|
end
|
|
186
255
|
|
|
187
256
|
# Prints a table to `out`.
|
|
@@ -237,6 +306,9 @@ module Dry
|
|
|
237
306
|
# @return [#call]
|
|
238
307
|
attr_reader :clock
|
|
239
308
|
|
|
309
|
+
# @return [Configuration]
|
|
310
|
+
attr_reader :config
|
|
311
|
+
|
|
240
312
|
# @param theme [Theme::Level]
|
|
241
313
|
# @return [Terminal]
|
|
242
314
|
def stream(theme)
|