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.
@@ -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
@@ -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
@@ -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: ["", :bright_black],
36
- running: ["▸", :cyan],
37
- done: ["✓", :green],
38
- failed: ["", :red],
39
- skipped: ["", :bright_black]
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
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.3.1"
14
+ VERSION = "0.4.0"
15
15
  end
16
16
  end
17
17
  end