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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -2
- data/README.md +595 -35
- data/examples/.envrc +1 -0
- data/examples/.gitignore +1 -0
- data/examples/Gemfile +1 -1
- data/examples/Gemfile.lock +5 -5
- data/examples/README.md +24 -10
- data/examples/bin/mycli +270 -94
- data/lib/dry/cli/ui/configuration.rb +160 -0
- data/lib/dry/cli/ui/console.rb +83 -8
- 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 +123 -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 +71 -12
- 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 +26 -1
- metadata +9 -2
- data/SPECIFICATION.md +0 -351
|
@@ -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
|
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -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,
|
|
155
|
-
|
|
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
|
data/lib/dry/cli/ui/terminal.rb
CHANGED
|
@@ -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
|