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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 555aba61a68d2f480ae76f61d6851786e83e46afd0855c276e5eba01fe0d8dc8
4
- data.tar.gz: 584e4a3268d121ecdb3e85f3ead9afef643e5c1a086b0c4702b418f550c1b8d7
3
+ metadata.gz: 71d205edc6e78bba4bffbeebe70fc24ba7c992cd08a4f9856f1d9c1e4fd20ae4
4
+ data.tar.gz: 4070740b8964f52e87b33f2094cc5638d4323eeaad6882723c3593db5d1b77f3
5
5
  SHA512:
6
- metadata.gz: 86e23d86e7eca50b4a7ed51bf7dc6d4a784c4711f7ddfc5c10fbc90ebc53d44647da0eaea7ea00296e7228ba73c08920a1dd08ff6c435cbd4d10f2b3266d4c37
7
- data.tar.gz: 3b73f7e064fd7dfd35540a034ef1e721e4aed3e368d4d474ffa8ccc283fd4c06d4913bba72343b3760e8ecaf9ec8a64645c4d97f6c160bfb26cf7c7cb5021f30
6
+ metadata.gz: e053b8d527886759bbc2c72a8825d3e99ff0b5abe54211acc798ceab0b15238d0a0bd2442717d851d3c713e96117290100b8bd7e77a78a3f333cc3216b2d3dda
7
+ data.tar.gz: feeafcdddf3d3f620acf6e45b2fb77ef0cbc00b9bf1028b515f0d7d8a6c56a72a34a7c86e1e928320cb5755056ad72e3edafeaa96780b5dcdae023bb98d10b8b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - `ui.multi_spinner(title, concurrent: true)` runs several jobs at once, each under a spinner of its own, beneath a headline spinner. `ui.multi_progress(title, concurrent: true)` does the same with a progress bar per job and a headline bar that counts them all. The block declares the jobs with `m.spinner(label)` or `m.progress(label, total:)`; they run once it returns, all at once or at most `concurrent:` at a time, and the call returns what each job returned. Waiting jobs show `[ ]`, a job that raises is marked `[𝘅]`, jobs not yet started are skipped, and the first error is re-raised.
4
+ - Task trees and the multi widgets mark every row in brackets: `[ ]` while it waits and a turning `[⠏]` while it runs, both bold yellow, then a green `[✓]`, a red `[𝘅]`, or a yellow `[—]` for work that was skipped. Outcome lines use the same glyphs, so a failed spinner now ends `𝘅 Loading (0.5s)`.
5
+ - `Dry::CLI::UI.configure` sets `spinner_format` (a TTY::Spinner format name, or `{ interval:, frames: }`), `bar_format` (a TTY::ProgressBar bar format name, or `{ complete:, incomplete: }`), `bar_color` and `bar_background` (Pastel styles, or nil) for every spinner and bar. Spinners default to `:dots`. Bars now draw a green `◼` for each finished part on a gray track, between brackets, rather than `███░░░`. `Console.new` takes `config:` for a configuration of its own.
6
+ - `ui.status_bar(title, hints:)` keeps a status line at the bottom of the screen while its block runs: what started last, how many things are running, done and failed, overall progress, elapsed time and hints. Every widget inside the block reports to it. It sets no scroll region, so scrollback stays intact, and does nothing when `err` is not animated.
7
+ - `ui.multi_progress` right-aligns every count, so `8/503` and `1/8` end in the same column.
8
+
3
9
  ## [0.2.0]
4
10
 
5
11
  - `ui.spinner` and every `ui.tasks` task give their block a `Dry::CLI::UI::Line`. `line.detail = "..."` shows text after the label while the work runs, redrawn in place on an animated terminal and never printed otherwise. `line.fail("reason")` ends the work as `✗ label: reason` without raising; the spinner still returns the block's value, and a task tree runs on past a task that fails this way.
data/README.md CHANGED
@@ -51,7 +51,7 @@ When the output is piped, and the import fails part way:
51
51
  Loading tax rules...
52
52
  ✓ Loading tax rules (0.3s)
53
53
  Importing rules...
54
- Importing rules 1482/1900 (4.1s)
54
+ 𝘅 Importing rules 1482/1900 (4.1s)
55
55
  ┌─ Error ──────────────────────────────────────────────────┐
56
56
  │ │
57
57
  │ Import failed │
@@ -62,7 +62,7 @@ Importing rules...
62
62
  └──────────────────────────────────────────────────────────┘
63
63
  ```
64
64
 
65
- On a terminal the spinner turns and the bar fills in place, with percent, count and ETA, and each is replaced by the same `✓` or `✗` line when its block ends.
65
+ On a terminal the spinner turns and the bar fills in place, with percent, count and ETA, and each is replaced by the same `✓` or `𝘅` line when its block ends.
66
66
 
67
67
  Include the module once in a base class and every command has `ui`. Including it loads nothing: the TTY gems load the first time `ui` is used.
68
68
 
@@ -107,7 +107,7 @@ ui.status "Disk nearly full", level: :warn # ⚠ Disk nearly full
107
107
  rules = ui.spinner("Loading tax rules") { load_rules }
108
108
  ```
109
109
 
110
- Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or `✗` and the re-raised error when the block fails.
110
+ Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or `𝘅` and the re-raised error when the block fails.
111
111
 
112
112
  The block is given a `Dry::CLI::UI::Line`, for work that has more to say while it runs, or that can fail without raising:
113
113
 
@@ -118,7 +118,7 @@ ui.spinner("Importing rules") do |line|
118
118
  end
119
119
  ```
120
120
 
121
- `line.detail = "..."` shows text after the label, redrawn in place as it changes. Piped, the detail is kept and never printed, since it can change many times a second. `line.fail(reason)` ends the spinner as `✗ Importing rules: 3 rules skipped (4.1s)` without raising, and the block's value is still returned. `line.failed?` and `line.reason` read it back. Every `Line` method is safe to call from any thread.
121
+ `line.detail = "..."` shows text after the label, redrawn in place as it changes. Piped, the detail is kept and never printed, since it can change many times a second. `line.fail(reason)` ends the spinner as `𝘅 Importing rules: 3 rules skipped (4.1s)` without raising, and the block's value is still returned. `line.failed?` and `line.reason` read it back. Every `Line` method is safe to call from any thread.
122
122
 
123
123
  ### Progress bars
124
124
 
@@ -131,7 +131,126 @@ ui.progress("Importing rules", total: rules.size) do |bar|
131
131
  end
132
132
  ```
133
133
 
134
- The bar shows percent, `current/total` and ETA, and ends with `✓ Importing rules 1900/1900 (4.2s)`.
134
+ The bar shows percent, `current/total` and ETA, `Importing rules [◼◼◼◼◼◼ ] 61% 1159/1900 ETA 2.7s`, and ends with `✓ Importing rules 1900/1900 (4.2s)`. On a terminal the `◼`s are green and the whole bar sits on a gray background; see [Configuration](#configuration) to change either.
135
+
136
+ ### Several spinners at once
137
+
138
+ ```ruby
139
+ ui.multi_spinner("Fetching", concurrent: 2) do |m|
140
+ m.spinner("fonts") { fetch(:fonts) }
141
+ m.spinner("images") do |line|
142
+ fetch(:images) { |done, all| line.detail = "#{done} of #{all}" }
143
+ end
144
+ m.spinner("video") { fetch(:video) }
145
+ end
146
+ ```
147
+
148
+ The block declares the jobs; they run once it returns, all at once by default, or at most `concurrent: 2` at a time. It returns what each job returned, in declaration order. While they run, every job has a row of its own under a headline spinner, and a job still waiting shows `[ ]`:
149
+
150
+ ```text
151
+ [⠹] Fetching
152
+ ├─ [⠹] fonts
153
+ ├─ [⠹] images 12 of 40
154
+ └─ [ ] video
155
+ ```
156
+
157
+ Once they finish, the headline ends `✓`, or `𝘅` when any job failed:
158
+
159
+ ```text
160
+ [𝘅] Fetching (0.5s)
161
+ ├─ [✓] fonts (0.3s)
162
+ ├─ [𝘅] images: 2 timed out (0.3s)
163
+ └─ [✓] video (0.2s)
164
+ ```
165
+
166
+ Each job is given a `Line`, as a single spinner's block is. When a job raises, jobs already running finish, jobs not yet started are marked skipped (`[—]`), and the error is re-raised. Piped, it prints `Fetching...`, then each job's outcome as it ends, then the headline's.
167
+
168
+ ### Several progress bars at once
169
+
170
+ ```ruby
171
+ ui.multi_progress("Downloading") do |m|
172
+ files.each do |file|
173
+ m.progress(file.name, total: file.size) do |bar|
174
+ download(file) { |bytes| bar.advance(bytes) }
175
+ end
176
+ end
177
+ end
178
+ ```
179
+
180
+ The same shape as `multi_spinner`, with a bar per job and a headline bar that counts them all. Bars start and end in the same columns, and counts are right-aligned:
181
+
182
+ ```text
183
+ [⠋] Downloading [◼◼◼◼ ] 27% 66/240 ETA 2.1s
184
+ ├─ [⠋] fonts.zip [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 95% 38/40 ETA 0.1s
185
+ ├─ [⠋] images.tar.gz [◼◼◼ ] 23% 28/120 ETA 2.4s
186
+ └─ [ ] video.mp4
187
+ ```
188
+
189
+ Each job is given the same handle as `ui.progress`, with `advance(step = 1)`, `current` and `total`. A finished job's row reads `[✓] fonts.zip 40/40 (0.1s)`, and the headline's `[✓] Downloading 240/240 (0.3s)`.
190
+
191
+ ### Example: fetching many URLs
192
+
193
+ With `multi_spinner`, each URL gets a spinner, and the call returns every page in the same order as `urls`. Nothing writes to a shared file from several threads:
194
+
195
+ ```ruby
196
+ bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
197
+ urls.each { |url| m.spinner(url) { fetch(url) } }
198
+ end
199
+
200
+ File.write("urls.txt", bodies.join("\n"))
201
+ ```
202
+
203
+ With `multi_progress`, each URL gets a bar that fills one byte at a time as the body arrives. A bar's `total` is fixed when it is declared, so a `HEAD` request asks each URL for its size first:
204
+
205
+ ```ruby
206
+ found = urls.filter_map do |url|
207
+ [url, content_length(url)]
208
+ rescue StandardError => e
209
+ ui.status "#{url}: #{e.message}", level: :warn
210
+ nil
211
+ end
212
+
213
+ bodies = ui.multi_progress("Fetching #{found.size} URLs", concurrent: 8) do |m|
214
+ found.each do |url, size|
215
+ m.progress(url, total: size || 1) do |bar|
216
+ body = download(url) { |bytes| bytes.times { bar.advance } if size }
217
+ bar.advance unless size # no Content-Length: done in one step
218
+ body
219
+ end
220
+ end
221
+ end
222
+
223
+ File.write("urls.txt", bodies.join("\n"))
224
+ ```
225
+
226
+ The two helpers, with `Net::HTTP`:
227
+
228
+ ```ruby
229
+ require "net/http"
230
+
231
+ def content_length(url)
232
+ uri = URI(url)
233
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
234
+ http.head(uri.request_uri).content_length # nil when the server does not say
235
+ end
236
+ end
237
+
238
+ def download(url)
239
+ uri = URI(url)
240
+ body = +""
241
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
242
+ http.request_get(uri.request_uri) do |response|
243
+ response.read_body do |chunk|
244
+ body << chunk
245
+ yield chunk.bytesize
246
+ end
247
+ end
248
+ end
249
+ body
250
+ end
251
+ ```
252
+
253
+ In both, at most eight requests run at once and the rest wait as `[ ]` rows. The headline counts every URL, and in `multi_progress` every byte. A server that sends no `Content-Length` gets a bar of one unit, which sits at 0% and fills when its download ends. These helpers are kept short: a real one follows redirects, and sends `Accept-Encoding: identity` so the bytes counted match `Content-Length`. With more URLs than the screen has rows, each finished URL prints one line instead.
135
254
 
136
255
  ### Task trees
137
256
 
@@ -156,19 +275,19 @@ On a terminal, once it finishes:
156
275
 
157
276
  ```text
158
277
  Deploy
159
- ├─ ✓ Build assets (0.4s)
160
- ├─ ✓ Migrate (0.3s)
161
- │ ├─ ✓ users (0.1s)
162
- │ └─ ✓ orders (0.2s)
163
- ├─ ✓ Warm caches (0.5s)
164
- │ ├─ ✓ fonts (0.5s)
165
- │ └─ ✓ images (0.3s)
166
- └─ ✓ Restart (0.3s)
278
+ ├─ [] Build assets (0.4s)
279
+ ├─ [] Migrate (0.3s)
280
+ │ ├─ [] users (0.1s)
281
+ │ └─ [] orders (0.2s)
282
+ ├─ [] Warm caches (0.5s)
283
+ │ ├─ [] fonts (0.5s)
284
+ │ └─ [] images (0.3s)
285
+ └─ [] Restart (0.3s)
167
286
  ```
168
287
 
169
- While it runs, the tree redraws in place and every running task has its own spinner. Piped, each line is printed once it is final, and a group's line appears as `▸` when it starts. `concurrent: true` runs a group's tasks at the same time, on a group or on `ui.tasks` itself, and `concurrent: 3` runs at most three at once. When a task raises, it is marked `✗`, tasks already running finish, the rest are marked skipped (`–`), and the error is re-raised.
288
+ While it runs, the tree redraws in place and every running task has its own spinner. Every row is marked in brackets, in bold yellow while it waits, `[ ]`, and while it runs, a turning `[⠏]`; then a green `[✓]` when it is done, a red `[𝘅]` when it failed, or a yellow `[—]` when it was skipped. Piped, each line is printed once it is final, and a group's line appears as `[▸]` when it starts. `concurrent: true` runs a group's tasks at the same time, on a group or on `ui.tasks` itself, and `concurrent: 3` runs at most three at once. When a task raises, it is marked `[𝘅]`, tasks already running finish, the rest are marked skipped (`[—]`), and the error is re-raised.
170
289
 
171
- Each task is given a `Line`, as a spinner's block is. Its detail is drawn after the task's name while it runs, and `line.fail(reason)` marks the task `✗ name: reason` and its groups `✗`, while the rest of the tree runs on:
290
+ Each task is given a `Line`, as a spinner's block is. Its detail is drawn after the task's name while it runs, and `line.fail(reason)` marks the task `𝘅 name: reason` and its groups `𝘅`, while the rest of the tree runs on:
172
291
 
173
292
  ```ruby
174
293
  ui.tasks("Fetching", concurrent: 4) do |t|
@@ -182,6 +301,66 @@ ui.tasks("Fetching", concurrent: 4) do |t|
182
301
  end
183
302
  ```
184
303
 
304
+ ### Status bar
305
+
306
+ ```ruby
307
+ ui.status_bar("deploy", hints: ["^C cancel"]) do
308
+ ui.spinner("Building assets") { build }
309
+ ui.multi_progress("Uploading", concurrent: 2) { |m| ... }
310
+ ui.tasks("Migrate") { |t| ... }
311
+ end
312
+ ```
313
+
314
+ While the block runs, the bottom of the screen shows how the whole command is doing, under a rule: what started last, how many things are running, done and failed, a bar over every progress bar so far, the elapsed time, and your hints at the right edge. Everything the widgets print scrolls above it, and it disappears when the block ends:
315
+
316
+ ```text
317
+ ✓ Building assets (0.2s)
318
+ [⠙] Uploading [◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 37% 49/132 ETA 0.3s
319
+ ├─ [✓] app.js 40/40 (0.2s)
320
+ ├─ [⠙] app.css [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 75% 9/12 ETA 0.1s
321
+ └─ [⠙] fonts.zip [ ] 0% 0/80 ETA --
322
+ ──────────────────────────────────────────────────────────────────────────────────────────
323
+ ⠸ deploy · fonts.zip · 2 running · 2 done · [◼◼◼ ] 37% · 0.4s ^C cancel
324
+ ```
325
+
326
+ Nothing reports to it by hand: every `spinner`, `progress`, `multi_spinner`, `multi_progress` and task started inside the block does so on its own. Hints that do not fit are left out, and a status that does not fit is cut short with `…`.
327
+
328
+ It sets no scroll region, so the scrollback keeps everything, and an interrupted command leaves nothing behind. Piped, or without animation, it just runs the block. Write through `ui` while it runs: a bare `puts` lands where the bar is, until the next `ui` call draws the bar again.
329
+
330
+ ### Putting it together
331
+
332
+ ```ruby
333
+ class Deploy < Dry::CLI::Command
334
+ include Dry::CLI::UI
335
+
336
+ def call(**)
337
+ ui.status_bar("deploy", hints: ["^C cancel"]) do
338
+ assets = ui.spinner("Building assets") { build_assets }
339
+
340
+ ui.multi_progress("Uploading", concurrent: 3) do |m|
341
+ assets.each do |asset|
342
+ m.progress(asset.name, total: asset.bytesize) do |bar|
343
+ upload(asset) { |sent| bar.advance(sent) }
344
+ end
345
+ end
346
+ end
347
+
348
+ ui.multi_spinner("Warming caches") do |m|
349
+ regions.each do |region|
350
+ m.spinner(region) do |line|
351
+ warm(region) { |host| line.detail = host }
352
+ end
353
+ end
354
+ end
355
+ end
356
+
357
+ ui.success "Deployed #{assets.size} assets"
358
+ rescue => e
359
+ ui.error("Deploy failed", e.message)
360
+ end
361
+ end
362
+ ```
363
+
185
364
  ### Tables
186
365
 
187
366
  ```ruby
@@ -218,9 +397,9 @@ When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::
218
397
 
219
398
  ## Where output goes
220
399
 
221
- | To `out` (results) | To `err` (everything else) |
222
- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
223
- | `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, `popup`, spinners, progress bars, task trees, prompts |
400
+ | To `out` (results) | To `err` (everything else) |
401
+ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
402
+ | `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, `popup`, spinners, progress bars, their multi forms, task trees, the status bar, prompts |
224
403
 
225
404
  `mycli export > rules.csv` therefore writes only the command's results to the file, while its progress stays on the screen. `ui` writes to the streams dry-cli was called with, so `Dry::CLI.new(registry).call(out: io, err: io)` captures everything.
226
405
 
@@ -228,6 +407,28 @@ A stream that is not a terminal, or runs under `TERM=dumb`, gets no animation, n
228
407
 
229
408
  ## Configuration
230
409
 
410
+ Spinners and bars look the same everywhere, and are set once for the whole process:
411
+
412
+ ```ruby
413
+ Dry::CLI::UI.configure do
414
+ spinner_format :dots # any TTY::Spinner format name
415
+ bar_format(complete: "◼", incomplete: " ") # or any TTY::ProgressBar bar format name, such as :box
416
+ bar_color :green # the finished part: any Pastel style, or nil
417
+ bar_background :on_bright_black # the whole bar: any Pastel style, or nil
418
+ end
419
+ ```
420
+
421
+ Those are the defaults: spinners turn through `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏` ten times a second, and bars draw a green `◼` for each finished part on a gray track. Without colour the track is blank, and the brackets still show where the bar ends. Every spinner reads the same format, including `multi_spinner`, task trees and the status bar, and every bar reads the same characters and colours. The formats also take a definition of your own:
422
+
423
+ ```ruby
424
+ Dry::CLI::UI.configure do |config|
425
+ config.spinner_format = { interval: 8, frames: %w[◐ ◓ ◑ ◒] } # frames per second, and the frames
426
+ config.bar_format = { complete: "#", incomplete: "." }
427
+ end
428
+ ```
429
+
430
+ An unknown name or a malformed definition raises `ArgumentError` when it is set.
431
+
231
432
  Override `ui` to configure the console:
232
433
 
233
434
  ```ruby
data/SPECIFICATION.md CHANGED
@@ -63,7 +63,7 @@ Example output, piped, when the import fails part way:
63
63
  Loading tax rules...
64
64
  ✓ Loading tax rules (0.3s)
65
65
  Importing rules...
66
- Importing rules 1482/1900 (4.1s)
66
+ 𝘅 Importing rules 1482/1900 (4.1s)
67
67
  ┌─ Error ──────────────────────────────────────────────────┐
68
68
  │ │
69
69
  │ Import failed │
@@ -89,8 +89,11 @@ ui.error(...)
89
89
  ui.fatal(...)
90
90
 
91
91
  ui.spinner(...)
92
+ ui.multi_spinner(...)
92
93
  ui.progress(...)
94
+ ui.multi_progress(...)
93
95
  ui.status(...)
96
+ ui.status_bar(...)
94
97
 
95
98
  ui.box(...)
96
99
  ui.popup(...)
@@ -226,10 +229,10 @@ Only files under `widgets/` and `terminal.rb` touch a TTY class. A future render
226
229
 
227
230
  Results go to `out`; everything about the command's own progress goes to `err`. Piping a command therefore captures its results and nothing else.
228
231
 
229
- | `out` | `err` |
230
- | --------------------------------- | ----------------------------------------------------------------------------------- |
231
- | `info`, `success`, `box`, `table` | `debug`, `warn`, `error`, `fatal`, `popup`, `spinner`, `progress`, `tasks`, prompts |
232
- | `status` at `info` or `success` | `status` at `debug`, `warn`, `error` or `fatal` |
232
+ | `out` | `err` |
233
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
234
+ | `info`, `success`, `box`, `table` | `debug`, `warn`, `error`, `fatal`, `popup`, `spinner`, `multi_spinner`, `progress`, `multi_progress`, `tasks`, `status_bar`, prompts |
235
+ | `status` at `info` or `success` | `status` at `debug`, `warn`, `error` or `fatal` |
233
236
 
234
237
  `#ui` uses the command's own `out` and `err` when dry-cli has set them (`Dry::CLI#call(out:, err:)`), and `$stdout` and `$stderr` otherwise. Every write flushes, so the two streams stay in order when both are piped to the same place.
235
238
 
@@ -248,8 +251,10 @@ Each stream is judged on its own:
248
251
 
249
252
  | Widget | Plain output |
250
253
  | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
251
- | spinner | `Label...` before the block, `✓ Label (1.2s)` or `✗ Label (1.2s)` after it; a `Line`'s detail is never printed, a `Line#fail` reason follows the label: `✗ Label: reason (1.2s)` |
254
+ | spinner | `Label...` before the block, `✓ Label (1.2s)` or `𝘅 Label (1.2s)` after it; a `Line`'s detail is never printed, a `Line#fail` reason follows the label: `𝘅 Label: reason (1.2s)` |
252
255
  | progress | `Label...` before, `✓ Label 1900/1900 (4.2s)` after, with the count reached |
256
+ | multi_spinner | `Title...` before, each job's outcome indented as it ends, skipped ones at the end, then the headline's outcome |
257
+ | multi_progress | as multi_spinner, each outcome with its count: `✓ a.zip 40/40 (0.1s)`, and the headline with the total count |
253
258
  | tasks | each line printed once final: a group when it starts, a task when it ends, skipped ones at the end |
254
259
  | prompts | the question on `err`, one line read from input |
255
260
  | boxes, tables, status | unchanged apart from colour |
@@ -257,7 +262,7 @@ Each stream is judged on its own:
257
262
 
258
263
  ### Spinners and progress bars
259
264
 
260
- Both run a block, return what it returns, and re-raise what it raises after marking the outcome `✗`. The elapsed time comes from a monotonic clock. `ui.progress` yields a handle with `advance(step = 1)`, `current` and `total`; progress is clamped to `0..total`, and `total: 0` is allowed.
265
+ Both run a block, return what it returns, and re-raise what it raises after marking the outcome `𝘅`. The elapsed time comes from a monotonic clock. `ui.progress` yields a handle with `advance(step = 1)`, `current` and `total`; progress is clamped to `0..total`, and `total: 0` is allowed.
261
266
 
262
267
  `ui.spinner` yields a `Dry::CLI::UI::Line`, the same handle every task in a tree is given:
263
268
 
@@ -265,7 +270,7 @@ Both run a block, return what it returns, and re-raise what it raises after mark
265
270
  | --------------- | -------------------------------------------------------------------------------------------------------- |
266
271
  | `detail = text` | Text after the label while the work runs, redrawn in place when animated; kept, never printed, otherwise |
267
272
  | `detail` | The current text, `""` for none |
268
- | `fail(reason)` | Ends the work as `✗ label: reason` when the block returns, without raising; the reason is optional |
273
+ | `fail(reason)` | Ends the work as `𝘅 label: reason` when the block returns, without raising; the reason is optional |
269
274
  | `failed?` | Whether `fail` was called |
270
275
  | `reason` | What `fail` was given |
271
276
 
@@ -273,6 +278,58 @@ Every method may be called from any thread, which is how work that reports from
273
278
 
274
279
  A spinner whose block calls `fail` still returns the block's value. That is the difference from raising: the work finished and has a result, and the result is that it did not succeed.
275
280
 
281
+ ### Several at once: `multi_spinner` and `multi_progress`
282
+
283
+ ```ruby
284
+ ui.multi_spinner("Fetching", concurrent: 2) do |m|
285
+ m.spinner("fonts") { fetch(:fonts) }
286
+ m.spinner("images") { |line| fetch(:images) { |n| line.detail = "#{n} of 40" } }
287
+ end
288
+
289
+ ui.multi_progress("Downloading") do |m|
290
+ files.each { |f| m.progress(f.name, total: f.size) { |bar| download(f) { |n| bar.advance(n) } } }
291
+ end
292
+ ```
293
+
294
+ - The block declares the jobs; nothing runs until it returns, so every row, including those of jobs waiting under a concurrency limit, is drawn before the first job starts. TTY::Spinner::Multi and TTY::ProgressBar::Multi give a row only to a job that has started and move the cursor relative to the last row drawn, which is why these widgets draw their rows themselves, as task trees do.
295
+ - Jobs run all at once by default. `concurrent:` takes the same values as on `ui.tasks`, with the same meaning.
296
+ - The call returns what each job returned, in declaration order, and `nil` for a job that never ran.
297
+ - The headline turns while any job runs and ends `✓` when every job succeeded, `𝘅` otherwise. Every row, the headline's included, is marked in brackets as task rows are: `[ ]` while waiting, a turning `[⠏]` while running, then `[✓]`, `[𝘅]` or `[—]` with its elapsed time.
298
+ - `multi_spinner` gives each job a `Line`; its detail follows the label while the job runs, and `fail` marks the job `𝘅 label: reason` without raising. `multi_progress` gives each job a `Widgets::Progress::Handle`; its row shows a bar, a percentage, a count and an ETA, and the headline's bar counts every job. Labels are padded so every bar starts and ends in the same columns.
299
+ - When a job raises, jobs already running finish, jobs not yet started are marked skipped, and the first error is re-raised.
300
+ - As with task trees, rows are redrawn in place only when they all fit on the screen; otherwise they print as without animation.
301
+
302
+ ### Configuration
303
+
304
+ `Dry::CLI::UI.configure` sets how every spinner and bar in the process looks. A `Console` reads `Dry::CLI::UI.config` unless given `config:`.
305
+
306
+ | Setting | Takes | Default | Draws |
307
+ | ---------------- | ---------------------------------------------------------------------------- | ------------------------------------ | ------------------------------------ |
308
+ | `spinner_format` | A `TTY::Formats::FORMATS` name, or `{ interval:, frames: }` | `:dots` | `⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏`, 10 a second |
309
+ | `bar_format` | A `TTY::ProgressBar::Formats::FORMATS` name, or `{ complete:, incomplete: }` | `{ complete: "◼", incomplete: " " }` | `[◼◼◼ ]` |
310
+ | `bar_color` | A Pastel style, or nil | `:green` | The finished part of every bar |
311
+ | `bar_background` | A Pastel style, or nil | `:on_bright_black` | The whole of every bar, a gray track |
312
+
313
+ An unknown name, a malformed definition or a style Pastel does not know raises `ArgumentError` when it is set, not when a spinner first turns. Loading the configuration loads only the two format tables and Pastel, never a TTY widget. Colours follow the stream, as everything else does: without colour a bar is its characters alone, and the brackets show its extent.
314
+
315
+ Every count in a `multi_progress` is right-aligned to the widest any row can show, the headline's total, so `8/503` and `1/8` end in the same column.
316
+
317
+ ### Status bar
318
+
319
+ `ui.status_bar(title = nil, hints: [])` keeps two rows at the bottom of the screen while its block runs: a rule, then
320
+
321
+ ```text
322
+ ⠸ deploy · fonts.zip · 2 running · 2 done · [◼◼◼ ] 37% · 0.4s ^C cancel
323
+ ```
324
+
325
+ - The glyph turns while anything runs. Then come the title in bold, the label of the work started most recently, the counts of what is running, done and failed, a bar over every progress bar reported so far, finished or not, and the elapsed time. Hints are right-aligned when they fit and left out when they do not; a status too wide for the screen is truncated with `…`.
326
+ - Every `spinner`, `progress`, `multi_spinner` and `multi_progress` job, and every task (not group) in a tree, reports its start and end to the bar through its `Terminal`. Commands supply only the title and the hints.
327
+ - It is drawn at the bottom rather than pinned to the top. Pinning a top row needs a scroll region, terminals such as iTerm2 and tmux drop lines scrolled out of a region from the scrollback, and a crash that skips cleanup leaves the region set. A bottom line needs neither.
328
+ - While it runs, the console's terminals write through a `StatusBar::Output`. Each write clears from the cursor to the end of the screen, writes, draws the rule and status line below, and moves the cursor back to where the write left it, in one write to the stream. The column is followed through the text, carriage returns, `CSI n G/C/D` and cursor save and restore, so a spinner redrawing its own line keeps working. The status line alone is redrawn ten times a second.
329
+ - `Terminal#height` is two rows short while it runs, so live widgets fall back to plain output two rows sooner.
330
+ - `out` writes through it too when `out` is animated, since both streams share the screen. Writes that bypass the console land where the bar is until the next write draws over them.
331
+ - Without an animated `err`, and inside another status bar, it only runs the block.
332
+
276
333
  ### Task trees
277
334
 
278
335
  The block declares the tree; nothing runs until it returns. Knowing the whole shape first is what lets the tree draw `├─` and `└─` correctly before the first task starts.
@@ -293,8 +350,8 @@ end
293
350
  ```
294
351
 
295
352
  - Tasks run in order. A group declared `concurrent: true`, or `ui.tasks(concurrent: true)` at the top level, runs its tasks at the same time on `concurrent-ruby` futures. `concurrent: 3` runs at most three at once, taking tasks in declaration order as each finishes. Anything but `true`, `false` or a positive Integer raises `ArgumentError`.
296
- - Each task is given a `Line`. Its detail is drawn after the task's name while it runs, on a live tree only. A task that calls `fail` is marked `✗ name: reason`, every group above it ends `✗`, and the rest of the tree runs on: nothing is skipped and nothing is raised.
297
- - States are pending `○`, running `▸`, done `✓`, failed `✗` and skipped `–`. On an animated terminal a running task shows a turning spinner instead of `▸`, so a concurrent group is a multi-spinner.
353
+ - Each task is given a `Line`. Its detail is drawn after the task's name while it runs, on a live tree only. A task that calls `fail` is marked `𝘅 name: reason`, every group above it ends `𝘅`, and the rest of the tree runs on: nothing is skipped and nothing is raised.
354
+ - Each row is marked with its state in brackets: pending `[ ]` and running `[▸]` in bold yellow, done `[✓]` in green, failed `[𝘅]` in red and skipped `[—]` in yellow. On an animated terminal a running task shows a turning spinner instead, `[⠏]`, drawn from the configured spinner format, so a concurrent group is a multi-spinner.
298
355
  - When a task raises, it and its enclosing groups are marked failed, tasks already running beside it finish, tasks not yet started are marked skipped, and the first error is re-raised. Under a concurrency limit, no further task is started once one has raised.
299
356
  - The live tree is redrawn in place with cursor movement, which cannot reach above the top of the screen. A tree with as many rows as the screen, or more, is printed line by line instead.
300
357
  - Task blocks should not write to the terminal while a live tree is drawn; the next redraw overwrites their output.
@@ -336,6 +393,9 @@ With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped
336
393
  - [x] `debug`, `info`, `success`, `warn`, `error` and `fatal` draw white single-line boxes titled by level, wrapped, as wide as configured or the terminal less a margin, and never lose text.
337
394
  - [x] `spinner`, `progress` and `tasks` return their block's value, re-raise its error, and leave an outcome line with the elapsed time; progress shows percent, count and ETA.
338
395
  - [x] Task trees nest, run groups concurrently when asked, at most as many at once as asked, and mark failed and skipped tasks.
396
+ - [x] `multi_spinner` and `multi_progress` run declared jobs at once, at most as many as asked, draw every row including waiting ones, return each job's value, and mark failed and skipped jobs.
397
+ - [x] `Dry::CLI::UI.configure` sets the spinner frames, bar characters and bar colours every widget draws with, and rejects unknown formats and styles when set.
398
+ - [x] `status_bar` keeps an automatically fed status line below everything the command prints, restores the cursor after every write, leaves the scrollback intact, and removes itself when the block ends or raises.
339
399
  - [x] Spinner and task blocks get a thread-safe `Line` whose detail is drawn while they run, and which can fail them without raising.
340
400
  - [x] `popup` draws a content-sized, centred box that leaves the cursor where it was, and a plain box without animation.
341
401
  - [x] Tables render rows and a header without truncation.
@@ -346,6 +406,6 @@ With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped
346
406
 
347
407
  ## Out of scope
348
408
 
349
- - Full-screen applications, alternate screen buffers, and a public cursor-positioning API. TTY::Cursor and TTY::Screen are used internally only; `popup` positions itself, and takes no coordinates.
409
+ - Full-screen applications, alternate screen buffers, scroll regions, and a public cursor-positioning API. TTY::Cursor and TTY::Screen are used internally only; `popup` positions itself, and takes no coordinates.
350
410
  - Keyboard input beyond prompts.
351
411
  - Renderers other than the TTY toolkit. The widget boundary allows one later.
@@ -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