dry-cli-ui 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 71d205edc6e78bba4bffbeebe70fc24ba7c992cd08a4f9856f1d9c1e4fd20ae4
4
- data.tar.gz: 4070740b8964f52e87b33f2094cc5638d4323eeaad6882723c3593db5d1b77f3
3
+ metadata.gz: be9415991dfb76bcec0253194fd2964b2f3781ca06cfc36be0653a9dd70bbba4
4
+ data.tar.gz: c3cd053b4e9a8e49cb6b33ebf2b41df1f3ead240e0f4b74184695914c85850ec
5
5
  SHA512:
6
- metadata.gz: e053b8d527886759bbc2c72a8825d3e99ff0b5abe54211acc798ceab0b15238d0a0bd2442717d851d3c713e96117290100b8bd7e77a78a3f333cc3216b2d3dda
7
- data.tar.gz: feeafcdddf3d3f620acf6e45b2fb77ef0cbc00b9bf1028b515f0d7d8a6c56a72a34a7c86e1e928320cb5755056ad72e3edafeaa96780b5dcdae023bb98d10b8b
6
+ metadata.gz: c55ab258c17e0fc192e16995374190cafd81d0f493057af61e3b186bac96e12b7f5736f28f4ff8f19d551298de830637c7001d6f49f240bb1a9db5ace54e401d
7
+ data.tar.gz: 01ab36292c51f456c16c313a7fe45607b50ddc63906f0aa356eb98b4dd31b522ef1df064c147a6a57fdf6df3e95278abd9366e09762e5cfc5d6fab773e209dcc
data/CHANGELOG.md CHANGED
@@ -1,4 +1,11 @@
1
- ## [Unreleased]
1
+ ## [0.5.0]
2
+
3
+ - The README documents every public method, option and error, with the plain output each widget prints when piped, and a section on testing a command with `StringIO`.
4
+ - `ui.progress` and `m.progress` inside `ui.multi_progress` take `color:`, a Pastel style for that bar's finished part in place of the configured `bar_color`, so bars side by side can differ. An unknown style raises `ArgumentError`.
5
+ - `bar_background` defaults to `nil`, so bars no longer sit on a gray track; the brackets mark where each bar begins and ends.
6
+ - `SPECIFICATION.md` moves to `docs/SPECIFICATION.md`, marked as the specification for version 0.1.0, and is no longer part of the YARD documentation.
7
+
8
+ ## [0.4.0]
2
9
 
3
10
  - `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
11
  - 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)`.
@@ -6,7 +13,11 @@
6
13
  - `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
14
  - `ui.multi_progress` right-aligns every count, so `8/503` and `1/8` end in the same column.
8
15
 
9
- ## [0.2.0]
16
+ ## [0.3.1]
17
+
18
+ - Adds `examples/`, a small dry-cli application (`bin/mycli`) that shows spinners and progress bars fetching URLs and computing primes.
19
+
20
+ ## [0.3.0]
10
21
 
11
22
  - `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.
12
23
  - `concurrent:` on `ui.tasks` and `group` also takes a positive Integer, the most tasks that run at once. Anything other than `true`, `false` or a positive Integer raises `ArgumentError`.
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  Runtime terminal UI for [dry-cli](https://github.com/dry-rb/dry-cli) commands: spinners, progress bars, boxes, status lines, task trees, tables and prompts.
6
6
 
7
7
  > [!NOTE]
8
- > The design, and the reasons behind it, are in [SPECIFICATION](SPECIFICATION.md).
8
+ > The design, and the reasons behind it, are in [SPECIFICATION](docs/SPECIFICATION.md).
9
9
 
10
10
  A long-running command has more to say than `puts` can show well: what it is doing now, how far along it is, what went wrong. Include one module and the command gets a `ui` that says it, in colour and in place on a terminal, and as plain lines when the output is piped to a file or a CI log.
11
11
 
@@ -15,7 +15,14 @@ A long-running command has more to say than `puts` can show well: what it is doi
15
15
  gem "dry-cli-ui"
16
16
  ```
17
17
 
18
- Requires Ruby 4.0 or later.
18
+ Requires Ruby 4.0 or later and `dry-cli` 1.0 or later. The rendering comes from the [TTY toolkit](https://ttytoolkit.org) (`tty-box`, `tty-spinner`, `tty-progressbar`, `tty-table`, `tty-prompt`, `tty-cursor`, `tty-screen`), `pastel`, `strings` and `concurrent-ruby`, which Bundler installs with the gem.
19
+
20
+ Either require works, and both load the same file:
21
+
22
+ ```ruby
23
+ require "dry-cli-ui" # matches the gem name
24
+ require "dry/cli/ui" # matches the constant Dry::CLI::UI
25
+ ```
19
26
 
20
27
  ## Usage
21
28
 
@@ -52,20 +59,47 @@ Loading tax rules...
52
59
  āœ“ Loading tax rules (0.3s)
53
60
  Importing rules...
54
61
  š˜… Importing rules 1482/1900 (4.1s)
55
- ā”Œā”€ Error ──────────────────────────────────────────────────┐
56
- │ │
57
- │ Import failed │
58
- │ │
59
- │ Could not validate rule US.2026.IRC.199A: missing │
60
- │ dependency taxable_income │
61
- │ │
62
- ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
62
+
63
+ ā”Œā”€ Error ────────────────────────────────────────────────────────────────────┐
64
+ │ │
65
+ │ Import failed │
66
+ │ │
67
+ │ Could not validate rule US.2026.IRC.199A: missing dependency │
68
+ │ taxable_income │
69
+ │ │
70
+ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
63
71
  ```
64
72
 
73
+ A stream that is not a terminal is taken to be 80 columns wide, so a piped box is 78.
74
+
65
75
  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
76
 
67
77
  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
78
 
79
+ ```ruby
80
+ class ApplicationCommand < Dry::CLI::Command
81
+ include Dry::CLI::UI
82
+ end
83
+
84
+ class Import < ApplicationCommand
85
+ def call(**) = ui.success("Nothing to import")
86
+ end
87
+ ```
88
+
89
+ `ui` writes to the command's `out` and `err` when dry-cli has set them, and to `$stdout` and `$stderr` otherwise.
90
+
91
+ ### Without dry-cli
92
+
93
+ `Dry::CLI::UI::Console` needs nothing from dry-cli, so a Rake task or a plain script can use it directly:
94
+
95
+ ```ruby
96
+ require "dry-cli-ui"
97
+
98
+ ui = Dry::CLI::UI::Console.new
99
+ ui.spinner("Compacting the database") { compact! }
100
+ ui.success "Done"
101
+ ```
102
+
69
103
  ## API
70
104
 
71
105
  ### Messages
@@ -79,28 +113,95 @@ ui.error "Import failed", e.message
79
113
  ui.fatal "Database unreachable"
80
114
  ```
81
115
 
82
- Each draws a box with a single white border and the level's name as a coloured title. Every argument is a paragraph, wrapped to fit. The box fills the terminal less a two-column margin, or takes a fixed width:
116
+ Each prints a blank line, then a box with a single white border and the level's name as a coloured title, and returns `nil`. Every argument is a paragraph, wrapped to fit, with a blank line between paragraphs. The box fills the terminal less a two-column margin, or takes a fixed width:
83
117
 
84
118
  ```ruby
85
119
  ui.info "Short and narrow", width: 40
86
- ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile" # untitled without title:
87
120
  ```
88
121
 
122
+ ```text
123
+
124
+ ā”Œā”€ Info ───────────────────────────────┐
125
+ │ │
126
+ │ Short and narrow │
127
+ │ │
128
+ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
129
+ ```
130
+
131
+ A `width:` is never wider than the terminal less its margin, and never narrower than 20 columns.
132
+
133
+ | Method | Title | Colour | Stream |
134
+ | --------- | ------- | ------- | ------ |
135
+ | `debug` | Debug | gray | `err` |
136
+ | `info` | Info | cyan | `out` |
137
+ | `success` | Success | green | `out` |
138
+ | `warn` | Warning | yellow | `err` |
139
+ | `error` | Error | red | `err` |
140
+ | `fatal` | Fatal | magenta | `err` |
141
+
142
+ `ui.box` is the general form. Without `level:` it is untitled unless given a `title:`, uncoloured, and goes to `out`:
143
+
144
+ ```ruby
145
+ ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile", width: 40
146
+ ```
147
+
148
+ ```text
149
+
150
+ ā”Œā”€ Profile ────────────────────────────┐
151
+ │ │
152
+ │ Name: Alan Turing │
153
+ │ │
154
+ │ Role: Cryptanalyst │
155
+ │ │
156
+ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
157
+ ```
158
+
159
+ With `level:` it takes that level's colour and stream, and its title unless `title:` replaces it:
160
+
161
+ ```ruby
162
+ ui.box "Disk is 91% full", level: :warn, title: "Disk" # a yellow "Disk" box on err
163
+ ui.box "Plain and untitled" # no title, on out
164
+ ```
165
+
166
+ An unknown level raises `ArgumentError`.
167
+
89
168
  ### Popups
90
169
 
91
170
  ```ruby
92
171
  ui.popup "h help", "q quit", title: "Keys"
93
172
  ```
94
173
 
95
- On a terminal, a box drawn over whatever is on the screen: only as wide as its text, centred, and leaving the cursor where it was, so a spinner or a redrawn screen carries on underneath. Piped, it is the same box `ui.box` draws, on `err`.
174
+ On a terminal, a box drawn over whatever is on the screen: only as wide as its text (at least 20 columns, at most `width:` or the box width), centred, and leaving the cursor where it was, so a spinner or a redrawn screen carries on underneath. Piped, it is the same box `ui.box` draws, on `err`:
175
+
176
+ ```text
177
+ ā”Œā”€ Keys ─────────────────────┐
178
+ │ │
179
+ │ h help │
180
+ │ │
181
+ │ q quit │
182
+ │ │
183
+ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜
184
+ ```
96
185
 
97
186
  ### Status lines
98
187
 
188
+ One line with a coloured glyph, on the level's stream. The level defaults to `:info`, and every argument is joined with a space:
189
+
99
190
  ```ruby
100
191
  ui.status "Connected to the database", level: :success # āœ“ Connected to the database
101
192
  ui.status "Disk nearly full", level: :warn # ⚠ Disk nearly full
193
+ ui.status "Loaded", rules.size, "rules" # ℹ Loaded 1900 rules
102
194
  ```
103
195
 
196
+ | Level | Glyph | Stream |
197
+ | ---------- | ----- | ------ |
198
+ | `:debug` | `Ā·` | `err` |
199
+ | `:info` | `ℹ` | `out` |
200
+ | `:success` | `āœ“` | `out` |
201
+ | `:warn` | `⚠` | `err` |
202
+ | `:error` | `āœ—` | `err` |
203
+ | `:fatal` | `āœ–` | `err` |
204
+
104
205
  ### Spinners
105
206
 
106
207
  ```ruby
@@ -118,7 +219,27 @@ ui.spinner("Importing rules") do |line|
118
219
  end
119
220
  ```
120
221
 
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.
222
+ `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?`, `line.reason` and `line.detail` read it back. Every `Line` method is safe to call from any thread.
223
+
224
+ Piped, each of these prints its label with `...` when it starts, then its outcome:
225
+
226
+ ```text
227
+ Loading tax rules...
228
+ āœ“ Loading tax rules (0.3s)
229
+ Importing rules...
230
+ š˜… Importing rules: 3 rules skipped (4.1s)
231
+ ```
232
+
233
+ A block that raises leaves `š˜… Loading tax rules (0.3s)` and the error propagates, so `rescue` it around the call. A lambda that takes no arguments is called without a `Line`, so a method object or a stored lambda can be passed as it is:
234
+
235
+ ```ruby
236
+ load = -> { YAML.load_file("rules.yml") }
237
+ rules = ui.spinner("Loading tax rules", &load)
238
+ ```
239
+
240
+ Without a block, `spinner` raises `ArgumentError`. The same is true of `progress`, `multi_spinner`, `multi_progress`, `tasks` and `status_bar`.
241
+
242
+ Elapsed times read `0.4s` under a minute, `1m 02s` under an hour, and `1h 02m` after that.
122
243
 
123
244
  ### Progress bars
124
245
 
@@ -131,7 +252,27 @@ ui.progress("Importing rules", total: rules.size) do |bar|
131
252
  end
132
253
  ```
133
254
 
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.
255
+ 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, between brackets, on no background; see [Configuration](#configuration) to change either.
256
+
257
+ The block is given a `Dry::CLI::UI::Widgets::Progress::Handle`, never the underlying `TTY::ProgressBar`:
258
+
259
+ ```ruby
260
+ ui.progress("Copying", total: files.sum(&:size)) do |bar|
261
+ files.each do |file|
262
+ copy(file) { |bytes| bar.advance(bytes) }
263
+ ui.status "#{bar.current} of #{bar.total} bytes" if bar.current == bar.total
264
+ end
265
+ :copied # progress returns the block's value
266
+ end
267
+ ```
268
+
269
+ - `advance(step = 1)` adds to `current` and returns the handle; `current` never passes `total`.
270
+ - `total:` must be a non-negative Integer, or `progress` raises `ArgumentError`.
271
+ - `total: 0` draws no bar, and ends `āœ“ Copying 0/0`.
272
+ - `color:` paints this bar's finished part in any Pastel style, such as `color: :red`, instead of the configured `bar_color`. A style Pastel does not know raises `ArgumentError` before the block runs.
273
+ - The outcome is `āœ“` whenever the block returns, even short of the total (`āœ“ Copying 12/20`), and `š˜…` when it raises.
274
+
275
+ Piped, it prints `Importing rules...` when it starts and the outcome line when it ends, with no bar in between.
135
276
 
136
277
  ### Several spinners at once
137
278
 
@@ -163,7 +304,31 @@ Once they finish, the headline ends `āœ“`, or `š˜…` when any job failed:
163
304
  └─ [āœ“] video (0.2s)
164
305
  ```
165
306
 
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.
307
+ 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 first error is re-raised. A job that never ran has `nil` in the returned array, which matters only when you rescue the error.
308
+
309
+ `concurrent:` takes `true` (all at once, the default), `false` (one at a time, in order), or a positive Integer. Anything else, `0` included, raises `ArgumentError` before any job runs.
310
+
311
+ Piped, it prints `Fetching...`, then each job's outcome as it ends, then the headline's:
312
+
313
+ ```text
314
+ Fetching...
315
+ [āœ“] fonts (0.3s)
316
+ [š˜…] images: 2 timed out (0.3s)
317
+ [āœ“] video (0.2s)
318
+ š˜… Fetching (0.5s)
319
+ ```
320
+
321
+ When a job raises under `concurrent: 1`:
322
+
323
+ ```text
324
+ Fetching...
325
+ [š˜…] fonts (0.1s)
326
+ [—] images
327
+ [—] video
328
+ š˜… Fetching (0.1s)
329
+ ```
330
+
331
+ The rows are also printed one by one on a terminal that has fewer rows than the widget needs, since the cursor cannot move above the top of the screen.
167
332
 
168
333
  ### Several progress bars at once
169
334
 
@@ -186,7 +351,26 @@ The same shape as `multi_spinner`, with a bar per job and a headline bar that co
186
351
  └─ [ ] video.mp4
187
352
  ```
188
353
 
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)`.
354
+ Each job is given the same handle as `ui.progress`, with `advance(step = 1)`, `current` and `total`. `m.progress` takes `color:` as `ui.progress` does, so bars side by side can differ:
355
+
356
+ ```ruby
357
+ ui.multi_progress("Probing #{hosts.size} hosts") do |m|
358
+ m.progress("Answered", total: hosts.size, color: :green) { |bar| ... }
359
+ m.progress("No answer", total: hosts.size, color: :red) { |bar| ... }
360
+ end
361
+ ```
362
+
363
+ The headline bar keeps the configured colour. A finished job's row reads `[āœ“] fonts.zip 40/40 (0.1s)`, and the headline's `[āœ“] Downloading 240/240 (0.3s)`. It returns what each job returned, in declaration order, and takes `concurrent:` as `multi_spinner` does. Every `m.progress` needs a block and a non-negative Integer `total:`, or raises `ArgumentError`.
364
+
365
+ Piped:
366
+
367
+ ```text
368
+ Downloading...
369
+ [āœ“] fonts.zip 40/40 (0.1s)
370
+ [āœ“] images.tar.gz 120/120 (0.2s)
371
+ [āœ“] video.mp4 80/80 (0.3s)
372
+ āœ“ Downloading 240/240 (0.3s)
373
+ ```
190
374
 
191
375
  ### Example: fetching many URLs
192
376
 
@@ -285,7 +469,37 @@ Deploy
285
469
  └─ [āœ“] Restart (0.3s)
286
470
  ```
287
471
 
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.
472
+ 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. Without it, tasks run one after another. When a task raises, it is marked `[š˜…]`, tasks already running finish, the rest are marked skipped (`[—]`), and the error is re-raised. `ui.tasks` returns `nil`, and its title is optional: `ui.tasks { |t| ... }` draws the tree without a heading.
473
+
474
+ Piped, the same tree as the example above, with one migration failing:
475
+
476
+ ```text
477
+ Deploy
478
+ ā”œā”€ [āœ“] Build assets (0.4s)
479
+ ā”œā”€ [ā–ø] Migrate
480
+ │ ā”œā”€ [āœ“] users (0.1s)
481
+ │ └─ [š˜…] orders: table locked (0.2s)
482
+ ā”œā”€ [ā–ø] Warm caches
483
+ │ ā”œā”€ [āœ“] fonts (0.5s)
484
+ │ └─ [āœ“] images (0.3s)
485
+ └─ [āœ“] Restart (0.3s)
486
+ ```
487
+
488
+ And when `Build assets` raises instead:
489
+
490
+ ```text
491
+ Deploy
492
+ ā”œā”€ [š˜…] Build assets (0.4s)
493
+ ā”œā”€ [—] Migrate
494
+ │ ā”œā”€ [—] users
495
+ │ └─ [—] orders
496
+ ā”œā”€ [—] Warm caches
497
+ │ ā”œā”€ [—] fonts
498
+ │ └─ [—] images
499
+ └─ [—] Restart
500
+ ```
501
+
502
+ `task` and `group` each need a block, and `concurrent:` takes the same values as on the multi widgets; either mistake raises `ArgumentError` while the tree is being declared, before anything runs.
289
503
 
290
504
  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:
291
505
 
@@ -325,7 +539,15 @@ While the block runs, the bottom of the screen shows how the whole command is do
325
539
 
326
540
  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
541
 
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.
542
+ 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, and a `status_bar` inside another one does the same. Either way it returns the block's value. Write through `ui` while it runs: a bare `puts` lands where the bar is, until the next `ui` call draws the bar again.
543
+
544
+ Both arguments are optional, and `hints:` takes one string or several:
545
+
546
+ ```ruby
547
+ report = ui.status_bar { build_report } # no title, no hints
548
+ ui.status_bar("sync", hints: "q quit") { sync }
549
+ ui.status_bar("sync", hints: ["^C cancel", "? help"]) { sync }
550
+ ```
329
551
 
330
552
  ### Putting it together
331
553
 
@@ -376,7 +598,11 @@ ui.table([["Alan Turing", 41], ["Ada Lovelace", 36]], header: %w[Name Age])
376
598
  ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”˜
377
599
  ```
378
600
 
379
- Tables are never truncated or rotated to fit the screen.
601
+ Tables go to `out` and return `nil`. Cells are converted with `to_s`, the header is bold on a colour terminal, `header:` is optional, and an empty `rows` prints nothing. Tables are never truncated or rotated to fit the screen: a table wider than the terminal wraps like any long line.
602
+
603
+ ```ruby
604
+ ui.table(User.limit(10).pluck(:email, :created_at)) # no header
605
+ ```
380
606
 
381
607
  ### Prompts
382
608
 
@@ -387,13 +613,52 @@ tier = ui.prompt("Tier?", choices: { "Free" => :free, "Pro" => :pro })
387
613
  ui.confirm("Deploy to #{env}?", default: false)
388
614
  ```
389
615
 
390
- On a terminal these use arrow-key menus and line editing. Otherwise they read lines from standard input, so answers can be piped:
616
+ - `prompt` returns the answer as a String, or the default for an empty answer.
617
+ - With an Array of `choices:`, it returns the chosen name; with a Hash, the value the chosen name maps to (`:pro` above). `default:` is the name of a choice.
618
+ - `confirm` returns `true` or `false`, and `default:` is `false` unless given.
619
+
620
+ When both standard input and `err` are terminals, these use arrow-key menus and line editing (TTY::Prompt). Otherwise they print the question to `err` and read lines from standard input, so answers can be piped:
391
621
 
392
622
  ```bash
393
623
  printf 'production\ny\n' | mycli deploy
394
624
  ```
395
625
 
396
- When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::NonInteractiveError` if it has none.
626
+ In that mode a list of choices is numbered, and an answer may be the number or the name:
627
+
628
+ ```text
629
+ Name? [Alan Turing]
630
+ Environment?
631
+ 1) staging
632
+ 2) production
633
+ Choose 1-2 [staging]: 2
634
+ Tier?
635
+ 1) Free
636
+ 2) Pro
637
+ Choose 1-2: Pro
638
+ Deploy to production? (y/N) maybe
639
+ Please answer y or n.
640
+ Deploy to production? (y/N) yes
641
+ ```
642
+
643
+ An answer that matches no choice asks again, and `confirm` accepts `y`, `yes`, `n` and `no` in any case. When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::NonInteractiveError` if it has none:
644
+
645
+ ```ruby
646
+ token = begin
647
+ ui.prompt("API token?")
648
+ rescue Dry::CLI::UI::NonInteractiveError
649
+ ENV.fetch("API_TOKEN")
650
+ end
651
+ ```
652
+
653
+ ## Errors
654
+
655
+ | Error | Raised when |
656
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
657
+ | `Dry::CLI::UI::NonInteractiveError` | a prompt has no answer left to read and no default |
658
+ | `Dry::CLI::UI::Error` | never directly: the base class of the gem's own errors, for `rescue Dry::CLI::UI::Error` |
659
+ | `ArgumentError` | a widget has no block, `total:` is not a non-negative Integer, `concurrent:` is invalid, a level or configuration is unknown |
660
+
661
+ Errors raised inside a block are never swallowed: the widget marks itself failed and re-raises them.
397
662
 
398
663
  ## Where output goes
399
664
 
@@ -414,11 +679,11 @@ Dry::CLI::UI.configure do
414
679
  spinner_format :dots # any TTY::Spinner format name
415
680
  bar_format(complete: "ā—¼", incomplete: " ") # or any TTY::ProgressBar bar format name, such as :box
416
681
  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
682
+ bar_background nil # the whole bar: any Pastel style, or nil
418
683
  end
419
684
  ```
420
685
 
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:
686
+ Those are the defaults: spinners turn through `ā ‹ ā ™ ā ¹ ā ø ā ¼ ā “ ā ¦ ā § ā ‡ ā ` ten times a second, and bars draw a green `ā—¼` for each finished part, with nothing behind them, and brackets show where the bar begins and 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, except a bar given its own `color:`. The formats also take a definition of your own:
422
687
 
423
688
  ```ruby
424
689
  Dry::CLI::UI.configure do |config|
@@ -427,7 +692,34 @@ Dry::CLI::UI.configure do |config|
427
692
  end
428
693
  ```
429
694
 
430
- An unknown name or a malformed definition raises `ArgumentError` when it is set.
695
+ An unknown name or a malformed definition raises `ArgumentError` when it is set, as does a colour Pastel does not know:
696
+
697
+ ```ruby
698
+ Dry::CLI::UI.configure { bar_color :nope }
699
+ # => ArgumentError: bar_color must be a Pastel style or nil, got :nope
700
+ ```
701
+
702
+ Called without a value, each setting reads it back, and `Dry::CLI::UI.config` returns the configuration itself. `Dry::CLI::UI.reset!` restores every default, which is useful between specs:
703
+
704
+ ```ruby
705
+ Dry::CLI::UI.config.bar_color # => :green
706
+ Dry::CLI::UI.config.spinner_frames # => ["ā ‹", "ā ™", "ā ¹", ...]
707
+
708
+ RSpec.configure { |c| c.after { Dry::CLI::UI.reset! } }
709
+ ```
710
+
711
+ Some more looks, all from names the TTY gems already know:
712
+
713
+ ```ruby
714
+ Dry::CLI::UI.configure do
715
+ spinner_format :classic # | / - \
716
+ bar_format :block # ā–ˆ and ā–‘
717
+ bar_color :cyan
718
+ bar_background :on_blue # a blue track the whole bar's width
719
+ end
720
+ ```
721
+
722
+ ### Console options
431
723
 
432
724
  Override `ui` to configure the console:
433
725
 
@@ -447,6 +739,47 @@ class ApplicationCommand < Dry::CLI::Command
447
739
  end
448
740
  ```
449
741
 
742
+ Every option `Console.new` takes:
743
+
744
+ | Option | Default | What it does |
745
+ | ------------ | --------------------- | -------------------------------------------------------------------- |
746
+ | `out:` | `$stdout` | where results go |
747
+ | `err:` | `$stderr` | where diagnostics, progress and prompts go |
748
+ | `input:` | `$stdin` | where prompt answers are read from |
749
+ | `env:` | `ENV` | read for `NO_COLOR` and `TERM` |
750
+ | `color:` | `nil` | `true` or `false` forces colour on both streams; `nil` detects it |
751
+ | `animate:` | `nil` | `true` or `false` forces animation on both streams; `nil` detects it |
752
+ | `width:` | `nil` | forces the terminal width in columns; `nil` asks the terminal, or 80 |
753
+ | `box_width:` | `nil` | the width of every box; `nil` fills the terminal less two columns |
754
+ | `clock:` | monotonic clock | any object whose `call` returns seconds, for elapsed times |
755
+ | `config:` | `Dry::CLI::UI.config` | a `Dry::CLI::UI::Configuration` for this console alone |
756
+
757
+ `config:` gives one console a look of its own without changing the process-wide one:
758
+
759
+ ```ruby
760
+ classic = Dry::CLI::UI::Configuration.new.tap { |c| c.spinner_format = :classic }
761
+ ui = Dry::CLI::UI::Console.new(config: classic)
762
+ ```
763
+
764
+ ### Testing a command
765
+
766
+ Pass `StringIO`s and assert on what was written. A `StringIO` is not a terminal, so the output is the plain form shown throughout this README, with no escape codes:
767
+
768
+ ```ruby
769
+ out = StringIO.new
770
+ err = StringIO.new
771
+ ui = Dry::CLI::UI::Console.new(out: out, err: err, input: StringIO.new("y\n"))
772
+
773
+ ui.spinner("Loading") { :ok }
774
+ ui.success "Imported"
775
+ ui.confirm("Continue?") # => true
776
+
777
+ err.string # => "Loading...\nāœ“ Loading (0.0s)\nContinue? (y/N) "
778
+ out.string # => the Success box
779
+ ```
780
+
781
+ Through dry-cli, `Dry::CLI.new(registry).call(arguments: %w[import], out: out, err: err)` gives every command's `ui` those streams.
782
+
450
783
  ## Relationship to dry-cli-help
451
784
 
452
785
  `dry-cli-help` is static presentation: what does this command do? `dry-cli-ui` is runtime presentation: what is this command doing? Use either, or both.
@@ -457,6 +790,19 @@ gem "dry-cli-help"
457
790
  gem "dry-cli-ui"
458
791
  ```
459
792
 
793
+ ## Examples
794
+
795
+ [`examples/`](examples/README.md) holds a small dry-cli application that uses the gem:
796
+
797
+ ```bash
798
+ cd examples
799
+ bundle install
800
+ bundle exec bin/mycli primes --max 200000
801
+ bundle exec bin/mycli urls_spinner https://www.ruby-lang.org https://dry-rb.org
802
+ bundle exec bin/mycli urls_progress https://www.ruby-lang.org https://dry-rb.org
803
+ bundle exec bin/mycli urls_progress https://www.ruby-lang.org | cat # the plain form
804
+ ```
805
+
460
806
  ## Development
461
807
 
462
808
  ```bash
@@ -465,10 +811,11 @@ just test # the suite, with 100% line and branch coverage enforced
465
811
  just lint # rubocop
466
812
  just ci # both
467
813
  just format # rubocop -a, then mdformat
814
+ just doc # YARD documentation
468
815
  bin/console # IRB with the gem loaded
469
816
  ```
470
817
 
471
- Specs render into a `StringIO`. The animated code paths run against `FakeTTY`, a `StringIO` that answers `tty?` with true, and elapsed times come from a fake clock.
818
+ Specs render into a `StringIO`. The animated code paths run against `FakeTTY`, a `StringIO` that answers `tty?` with true, and elapsed times come from a fake clock. RBS signatures for the public API are in [`sig/dry/cli/ui.rbs`](sig/dry/cli/ui.rbs).
472
819
 
473
820
  ## Contributing
474
821
 
@@ -477,6 +824,18 @@ Bug reports and pull requests are welcome at <https://github.com/kigster/dry-cli
477
824
  > [!WARNING]
478
825
  > The `dry-` prefix and the `Dry::CLI::UI` namespace do not imply endorsement by `dry-rb`. This is an independent gem that extends theirs.
479
826
 
827
+ ## Note to Dry-Rb Maintainers
828
+
829
+ First — hats off to all of you who tirelessly built out one of the most valuable collections of libraries in the Ruby ecosystem.
830
+
831
+ While I admire and would be willing to contribute any or all of the extension gem's code to the original gem, I feel that creating plugins and extensions allows the author to fully express their needs and wants, and then, if the authors of `dry-cli` become interested in any of them, I would be honored to submit a PR to `dry-cli` itself.
832
+
833
+ This method offered a very open road to extensibility and experimentation. If the code quality or design is not up to the level required for direct contributions to `dry-rb`, then let it be known that:
834
+
835
+ 1. We would be very happy to receive any feedback and improve, refactor, and update the gem assuming it improves it
836
+ 1. Roll any part of the codebase as a PR to the `dry-cli` core.
837
+ 1. We hold the authors of `dry-rb` in high regard, and generally would love to collaborate, as long as the feedback loop/cycle is not so long that the context of the changes gets lost in time, as with so many contributions made to other gems in the past.
838
+
480
839
  ## License
481
840
 
482
841
  MIT. See [LICENSE.txt](LICENSE.txt).
data/examples/.envrc ADDED
@@ -0,0 +1 @@
1
+ PATH_add bin
@@ -0,0 +1 @@
1
+ *.txt
data/examples/Gemfile CHANGED
@@ -10,5 +10,5 @@ group :development, :test do
10
10
  # multi_spinner, multi_progress and status_bar live on the
11
11
  # kig/add-multi-spinner-progress branch until it is merged; switch this
12
12
  # back to path: "../" then.
13
- gem 'dry-cli-ui', path: File.expand_path("~/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress")
13
+ gem 'dry-cli-ui', path: '../'
14
14
  end
@@ -1,7 +1,7 @@
1
1
  PATH
2
- remote: /Users/kig/.agents.worktrees/dry-cli-ui-add-multi-spinner-progress
2
+ remote: ..
3
3
  specs:
4
- dry-cli-ui (0.3.0)
4
+ dry-cli-ui (0.5.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  dry-cli (>= 1.0)
7
7
  pastel (~> 0.8)
@@ -22,7 +22,7 @@ GEM
22
22
  dry-cli-autocomplete (0.1.3)
23
23
  dry-cli (>= 1.0)
24
24
  dry-inflector (>= 1.0)
25
- dry-cli-help (0.2.0)
25
+ dry-cli-help (0.2.1)
26
26
  dry-cli (>= 1.1.1, < 2)
27
27
  pastel (~> 0.8)
28
28
  dry-inflector (1.3.1)
@@ -83,8 +83,8 @@ CHECKSUMS
83
83
  concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
84
84
  dry-cli (1.4.1) sha256=b8015bb76c708aa8705a36faf694973e75eeeffca39b89c8e172dc6f66a7d874
85
85
  dry-cli-autocomplete (0.1.3) sha256=14f76447bda0000c71739b585c827dc0a0a763e30a68a8b4b2dff91f640768e4
86
- dry-cli-help (0.2.0)
87
- dry-cli-ui (0.3.0)
86
+ dry-cli-help (0.2.1) sha256=eaaaf6edb787ffe810425b4e4bb1a675d89397ccc759102d70b5b26e7bc692da
87
+ dry-cli-ui (0.5.0)
88
88
  dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
89
89
  forwardable (1.4.0) sha256=f1cd40cc9812937980e1c76f1aa053660990a7c9b6a98fc37d945468afcce838
90
90
  pastel (0.8.0) sha256=481da9fb7d2f6e6b1a08faf11fa10363172dc40fd47848f096ae21209f805a75