dry-cli-ui 0.3.1 → 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/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 +7 -1
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)
|
|
@@ -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
|