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
|
@@ -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
|
data/lib/dry/cli/ui/theme.rb
CHANGED
|
@@ -30,15 +30,30 @@ module Dry
|
|
|
30
30
|
fatal: Level.new(name: :fatal, title: "Fatal", glyph: "✖", color: :magenta, stream: :err)
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
|
-
# Glyphs for the states an operation passes through
|
|
33
|
+
# Glyphs and colours for the states an operation passes through: bold
|
|
34
|
+
# yellow until it ends, then a green check, a red cross, or a yellow
|
|
35
|
+
# dash for work that was skipped.
|
|
34
36
|
STATES = {
|
|
35
|
-
pending: ["
|
|
36
|
-
running: ["▸",
|
|
37
|
-
done: ["✓",
|
|
38
|
-
failed: ["
|
|
39
|
-
skipped: ["
|
|
37
|
+
pending: [" ", %i[bold yellow]],
|
|
38
|
+
running: ["▸", %i[bold yellow]],
|
|
39
|
+
done: ["✓", %i[green]],
|
|
40
|
+
failed: ["𝘅", %i[red]],
|
|
41
|
+
skipped: ["—", %i[yellow]]
|
|
40
42
|
}.freeze
|
|
41
43
|
|
|
44
|
+
# A state's glyph between brackets, the glyph in the state's colour:
|
|
45
|
+
# `[✓]`, `[✗]`, or `[ ]` while pending. Task trees and the multi
|
|
46
|
+
# widgets mark every row with one.
|
|
47
|
+
#
|
|
48
|
+
# @param pastel [Pastel::Delegator]
|
|
49
|
+
# @param state [Symbol] one of the keys of {STATES}
|
|
50
|
+
# @param glyph [String, nil] drawn instead of the state's own, such as a spinner frame
|
|
51
|
+
# @return [String]
|
|
52
|
+
def self.marker(pastel, state, glyph = nil)
|
|
53
|
+
default, color = STATES.fetch(state)
|
|
54
|
+
"[#{pastel.decorate(glyph || default, *color)}]"
|
|
55
|
+
end
|
|
56
|
+
|
|
42
57
|
# Looks up a level by name.
|
|
43
58
|
#
|
|
44
59
|
# @param name [Symbol] one of {LEVELS}' keys
|