dry-cli-ui 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +37 -4
- data/SPECIFICATION.md +44 -15
- data/lib/dry/cli/ui/console.rb +34 -4
- data/lib/dry/cli/ui/line.rb +90 -0
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/box.rb +40 -1
- data/lib/dry/cli/ui/widgets/spinner.rb +14 -6
- data/lib/dry/cli/ui/widgets/tasks.rb +97 -15
- data/lib/dry/cli/ui.rb +1 -0
- data/sig/dry/cli/ui.rbs +14 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 412754e986acfa5b60ce22a1d447cd470098ffe42434ec453636cf563e65a8d3
|
|
4
|
+
data.tar.gz: 23fcbd8f9cbd0cdda55e6f88883b36092b07703e3895f47f8348a11743f4f3a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 46f47189bba69663ef98407036a101983ff870f8daa56e02fcbd788bf4a82f83d9a8546730c2cfc61056548a26ccc84102fe2f1a64d70ff1a063d5517c8179fe
|
|
7
|
+
data.tar.gz: b83ec6dd332065f8fdc8cb346abe35773e5ddb3cc14a01cec09dd7eabcba04529194e003d837336c1891d21f140b2e344d8ef1f0fedab942b21facd4c1634b96
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0]
|
|
4
|
+
|
|
5
|
+
- `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.
|
|
6
|
+
- `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`.
|
|
7
|
+
- `ui.popup(*paragraphs, title:, width:)` draws a box on `err` over whatever is on the screen: sized to its text, centred, with the cursor left where it was. Without animation it is the same box `ui.box` draws.
|
|
8
|
+
|
|
3
9
|
## [0.1.0]
|
|
4
10
|
|
|
5
11
|
- Initial release of `dry-cli-ui`, which replaces `dry-cli-autocomplete` in this repository.
|
data/README.md
CHANGED
|
@@ -86,6 +86,14 @@ ui.info "Short and narrow", width: 40
|
|
|
86
86
|
ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile" # untitled without title:
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
### Popups
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
ui.popup "h help", "q quit", title: "Keys"
|
|
93
|
+
```
|
|
94
|
+
|
|
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`.
|
|
96
|
+
|
|
89
97
|
### Status lines
|
|
90
98
|
|
|
91
99
|
```ruby
|
|
@@ -101,6 +109,17 @@ rules = ui.spinner("Loading tax rules") { load_rules }
|
|
|
101
109
|
|
|
102
110
|
Returns the block's value. Leaves `✓ Loading tax rules (0.3s)` behind, or `✗` and the re-raised error when the block fails.
|
|
103
111
|
|
|
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
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
ui.spinner("Importing rules") do |line|
|
|
116
|
+
rules.each { |rule| line.detail = rule.name; import(rule) }
|
|
117
|
+
line.fail("#{skipped.size} rules skipped") if skipped.any?
|
|
118
|
+
end
|
|
119
|
+
```
|
|
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.
|
|
122
|
+
|
|
104
123
|
### Progress bars
|
|
105
124
|
|
|
106
125
|
```ruby
|
|
@@ -147,7 +166,21 @@ Deploy
|
|
|
147
166
|
└─ ✓ Restart (0.3s)
|
|
148
167
|
```
|
|
149
168
|
|
|
150
|
-
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. When a task
|
|
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.
|
|
170
|
+
|
|
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:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
ui.tasks("Fetching", concurrent: 4) do |t|
|
|
175
|
+
assets.each do |asset|
|
|
176
|
+
t.task(asset.name) do |line|
|
|
177
|
+
fetch(asset) { |percent| line.detail = "#{percent}%" }
|
|
178
|
+
rescue Timeout::Error
|
|
179
|
+
line.fail("timed out")
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
```
|
|
151
184
|
|
|
152
185
|
### Tables
|
|
153
186
|
|
|
@@ -185,9 +218,9 @@ When the input runs out, a prompt returns its default, or raises `Dry::CLI::UI::
|
|
|
185
218
|
|
|
186
219
|
## Where output goes
|
|
187
220
|
|
|
188
|
-
| To `out` (results) | To `err` (everything else)
|
|
189
|
-
| ----------------------------------------------------------- |
|
|
190
|
-
| `info`, `success`, `box`, `table`, `status` at those levels | `debug`, `warn`, `error`, `fatal`, spinners, progress bars, task trees, prompts |
|
|
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 |
|
|
191
224
|
|
|
192
225
|
`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.
|
|
193
226
|
|
data/SPECIFICATION.md
CHANGED
|
@@ -93,6 +93,7 @@ ui.progress(...)
|
|
|
93
93
|
ui.status(...)
|
|
94
94
|
|
|
95
95
|
ui.box(...)
|
|
96
|
+
ui.popup(...)
|
|
96
97
|
ui.table(...)
|
|
97
98
|
ui.tasks(...)
|
|
98
99
|
|
|
@@ -225,10 +226,10 @@ Only files under `widgets/` and `terminal.rb` touch a TTY class. A future render
|
|
|
225
226
|
|
|
226
227
|
Results go to `out`; everything about the command's own progress goes to `err`. Piping a command therefore captures its results and nothing else.
|
|
227
228
|
|
|
228
|
-
| `out` | `err`
|
|
229
|
-
| --------------------------------- |
|
|
230
|
-
| `info`, `success`, `box`, `table` | `debug`, `warn`, `error`, `fatal`, `spinner`, `progress`, `tasks`, prompts |
|
|
231
|
-
| `status` at `info` or `success` | `status` at `debug`, `warn`, `error` or `fatal`
|
|
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
233
|
|
|
233
234
|
`#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.
|
|
234
235
|
|
|
@@ -245,18 +246,33 @@ Each stream is judged on its own:
|
|
|
245
246
|
|
|
246
247
|
`Console.new(color:, animate:, width:)` overrides detection. Without animation:
|
|
247
248
|
|
|
248
|
-
| Widget | Plain output
|
|
249
|
-
| --------------------- |
|
|
250
|
-
| spinner | `Label...` before the block, `✓ Label (1.2s)` or `✗ Label (1.2s)` after it
|
|
251
|
-
| progress | `Label...` before, `✓ Label 1900/1900 (4.2s)` after, with the count reached
|
|
252
|
-
| tasks | each line printed once final: a group when it starts, a task when it ends, skipped ones at the end
|
|
253
|
-
| prompts | the question on `err`, one line read from input
|
|
254
|
-
| boxes, tables, status | unchanged apart from colour
|
|
249
|
+
| Widget | Plain output |
|
|
250
|
+
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
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)` |
|
|
252
|
+
| progress | `Label...` before, `✓ Label 1900/1900 (4.2s)` after, with the count reached |
|
|
253
|
+
| tasks | each line printed once final: a group when it starts, a task when it ends, skipped ones at the end |
|
|
254
|
+
| prompts | the question on `err`, one line read from input |
|
|
255
|
+
| boxes, tables, status | unchanged apart from colour |
|
|
256
|
+
| popup | the same box `ui.box` draws, on `err`, where the output scrolls rather than over it |
|
|
255
257
|
|
|
256
258
|
### Spinners and progress bars
|
|
257
259
|
|
|
258
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.
|
|
259
261
|
|
|
262
|
+
`ui.spinner` yields a `Dry::CLI::UI::Line`, the same handle every task in a tree is given:
|
|
263
|
+
|
|
264
|
+
| Method | Effect |
|
|
265
|
+
| --------------- | -------------------------------------------------------------------------------------------------------- |
|
|
266
|
+
| `detail = text` | Text after the label while the work runs, redrawn in place when animated; kept, never printed, otherwise |
|
|
267
|
+
| `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 |
|
|
269
|
+
| `failed?` | Whether `fail` was called |
|
|
270
|
+
| `reason` | What `fail` was given |
|
|
271
|
+
|
|
272
|
+
Every method may be called from any thread, which is how work that reports from a reader thread (a child process's output, say) updates its line. A block that ignores the line works as before, and so does a lambda that takes no arguments. The detail is never printed without animation because it can change many times a second, and a log of every change is not what a pipe asked for.
|
|
273
|
+
|
|
274
|
+
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
|
+
|
|
260
276
|
### Task trees
|
|
261
277
|
|
|
262
278
|
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.
|
|
@@ -276,12 +292,23 @@ ui.tasks("Deploy") do |t|
|
|
|
276
292
|
end
|
|
277
293
|
```
|
|
278
294
|
|
|
279
|
-
- 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.
|
|
295
|
+
- 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.
|
|
280
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.
|
|
281
|
-
- 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.
|
|
298
|
+
- 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.
|
|
282
299
|
- 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.
|
|
283
300
|
- Task blocks should not write to the terminal while a live tree is drawn; the next redraw overwrites their output.
|
|
284
301
|
|
|
302
|
+
### Popups
|
|
303
|
+
|
|
304
|
+
`ui.popup(*paragraphs, title:, width:)` draws a box on `err` over whatever the terminal is showing, such as a key reference over a running spinner. On an animated terminal it is:
|
|
305
|
+
|
|
306
|
+
- as wide as its widest line or its title needs, never narrower than 20 columns and never wider than the box width (`width:`, then the console's `box_width`, then the terminal less the margin);
|
|
307
|
+
- centred on the screen by absolute cursor positioning;
|
|
308
|
+
- wrapped in a cursor save and restore, with no trailing newline, so it neither moves the cursor nor scrolls the screen.
|
|
309
|
+
|
|
310
|
+
Whatever redraws that part of the screen next draws over it, which is all the dismissal a popup needs. Without animation the output cannot be drawn over, so it is the box `ui.box` draws, on `err`.
|
|
311
|
+
|
|
285
312
|
### Tables
|
|
286
313
|
|
|
287
314
|
`ui.table(rows, header:)` renders with box-drawing borders and a bold header. Tables are data, so they are never narrowed, truncated or rotated to fit the screen. TTY::Table otherwise measures the screen, prints a warning on STDERR, and turns a wide table on its side.
|
|
@@ -308,7 +335,9 @@ With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped
|
|
|
308
335
|
- [x] `#ui` writes to the streams dry-cli was called with.
|
|
309
336
|
- [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.
|
|
310
337
|
- [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.
|
|
311
|
-
- [x] Task trees nest, run groups concurrently when asked, and mark failed and skipped tasks.
|
|
338
|
+
- [x] Task trees nest, run groups concurrently when asked, at most as many at once as asked, and mark failed and skipped tasks.
|
|
339
|
+
- [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
|
+
- [x] `popup` draws a content-sized, centred box that leaves the cursor where it was, and a plain box without animation.
|
|
312
341
|
- [x] Tables render rows and a header without truncation.
|
|
313
342
|
- [x] Prompts work interactively and from piped input, and never block on an exhausted input.
|
|
314
343
|
- [x] Output that is not a TTY, or runs under `TERM=dumb`, contains no escape sequences; `NO_COLOR` removes colour.
|
|
@@ -317,6 +346,6 @@ With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped
|
|
|
317
346
|
|
|
318
347
|
## Out of scope
|
|
319
348
|
|
|
320
|
-
- Full-screen applications, alternate screen buffers, and a public cursor-positioning API. TTY::Cursor and TTY::Screen are used internally only.
|
|
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.
|
|
321
350
|
- Keyboard input beyond prompts.
|
|
322
351
|
- Renderers other than the TTY toolkit. The widget boundary allows one later.
|
data/lib/dry/cli/ui/console.rb
CHANGED
|
@@ -93,6 +93,23 @@ module Dry
|
|
|
93
93
|
nil
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# A box drawn over whatever is on the screen, on `err`. On an animated
|
|
97
|
+
# terminal it is as wide as its text needs, up to the box width, and
|
|
98
|
+
# centred, and the cursor is left where it was. Otherwise it is the
|
|
99
|
+
# same box {#box} draws.
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# ui.popup("h help", "q quit", title: "Keys")
|
|
103
|
+
#
|
|
104
|
+
# @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
|
|
105
|
+
# @param title [String, nil]
|
|
106
|
+
# @param width [Integer, nil] the widest it may be, overriding the console's box width
|
|
107
|
+
# @return [nil]
|
|
108
|
+
def popup(*paragraphs, title: nil, width: nil)
|
|
109
|
+
err.print(Widgets::Box.new(err, width: width || box_width).popup(paragraphs, title: title))
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
96
113
|
# One line with a coloured glyph, going to the level's stream.
|
|
97
114
|
#
|
|
98
115
|
# @example
|
|
@@ -109,10 +126,17 @@ module Dry
|
|
|
109
126
|
end
|
|
110
127
|
|
|
111
128
|
# Runs a block under a spinner and leaves `✓ label (1.2s)` behind, or
|
|
112
|
-
# `✗ label` when the block raises.
|
|
129
|
+
# `✗ label` when the block raises. The block is given a {Line}: its
|
|
130
|
+
# detail is drawn after the label while the spinner turns, and
|
|
131
|
+
# {Line#fail} leaves `✗ label: reason` without raising.
|
|
132
|
+
#
|
|
133
|
+
# @example
|
|
134
|
+
# ui.spinner("Importing") do |line|
|
|
135
|
+
# rules.each { |rule| line.detail = rule.name }
|
|
136
|
+
# end
|
|
113
137
|
#
|
|
114
138
|
# @param label [String]
|
|
115
|
-
# @
|
|
139
|
+
# @yieldparam line [Line] reports on the work while it runs
|
|
116
140
|
# @return [Object] whatever the block returns
|
|
117
141
|
# @raise [ArgumentError] without a block
|
|
118
142
|
def spinner(label, &)
|
|
@@ -143,11 +167,17 @@ module Dry
|
|
|
143
167
|
# t.task("images") { fetch(:images) }
|
|
144
168
|
# end
|
|
145
169
|
#
|
|
170
|
+
# @example At most two at a time, each saying what it is doing
|
|
171
|
+
# ui.tasks("Fetching", concurrent: 2) do |t|
|
|
172
|
+
# assets.each { |asset| t.task(asset.name) { |line| fetch(asset) { |pct| line.detail = "#{pct}%" } } }
|
|
173
|
+
# end
|
|
174
|
+
#
|
|
146
175
|
# @param title [String, nil]
|
|
147
|
-
# @param concurrent [Boolean] run the top-level tasks at the
|
|
176
|
+
# @param concurrent [Boolean, Integer] run the top-level tasks at the
|
|
177
|
+
# same time: all of them, or at most this many
|
|
148
178
|
# @yieldparam tasks [Widgets::Tasks::Builder] declares `task`s and `group`s
|
|
149
179
|
# @return [nil]
|
|
150
|
-
# @raise [ArgumentError] without a block
|
|
180
|
+
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
151
181
|
def tasks(title = nil, concurrent: false, &)
|
|
152
182
|
raise ArgumentError, "tasks needs a block" unless block_given?
|
|
153
183
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# What a spinner or a task gives its block, so the work can report on
|
|
7
|
+
# itself while it runs.
|
|
8
|
+
#
|
|
9
|
+
# `detail` is shown after the label while the work runs, and redrawn in
|
|
10
|
+
# place on an animated terminal. Without animation it is kept and never
|
|
11
|
+
# printed, since it may change many times a second.
|
|
12
|
+
#
|
|
13
|
+
# `fail` ends the work as a failure without raising: the outcome line
|
|
14
|
+
# reads `✗ label: reason (1.2s)`, and the block's value is still
|
|
15
|
+
# returned. Every method may be called from any thread.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# ui.spinner("Importing") do |line|
|
|
19
|
+
# rules.each { |rule| line.detail = rule.name }
|
|
20
|
+
# line.fail("3 rules skipped") if skipped.any?
|
|
21
|
+
# end
|
|
22
|
+
class Line
|
|
23
|
+
# @yieldparam detail [String] called whenever the detail changes, by
|
|
24
|
+
# the widget that draws the line
|
|
25
|
+
def initialize(&on_change)
|
|
26
|
+
@on_change = on_change
|
|
27
|
+
@lock = Mutex.new
|
|
28
|
+
@detail = ""
|
|
29
|
+
@failed = false
|
|
30
|
+
@reason = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Calls a block with a line, or without one when the block is a
|
|
34
|
+
# lambda that takes no arguments and would reject it.
|
|
35
|
+
#
|
|
36
|
+
# @param job [Proc]
|
|
37
|
+
# @param line [Line]
|
|
38
|
+
# @return [Object] whatever the block returns
|
|
39
|
+
def self.call(job, line)
|
|
40
|
+
job.lambda? && job.arity.zero? ? job.call : job.call(line)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [String] the text shown after the label, empty for none
|
|
44
|
+
def detail = lock.synchronize { @detail }
|
|
45
|
+
|
|
46
|
+
# @param text [#to_s, nil] the text to show after the label; nil for none
|
|
47
|
+
def detail=(text)
|
|
48
|
+
text = text.to_s
|
|
49
|
+
lock.synchronize { @detail = text }
|
|
50
|
+
on_change&.call(text)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Ends the work as a failure when the block returns, without raising.
|
|
54
|
+
#
|
|
55
|
+
# @param reason [#to_s, nil] shown after the label
|
|
56
|
+
# @return [self]
|
|
57
|
+
def fail(reason = nil)
|
|
58
|
+
lock.synchronize do
|
|
59
|
+
@failed = true
|
|
60
|
+
@reason = reason&.to_s
|
|
61
|
+
end
|
|
62
|
+
self
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] whether {#fail} was called
|
|
66
|
+
def failed? = lock.synchronize { @failed }
|
|
67
|
+
|
|
68
|
+
# @return [String, nil] what {#fail} was given
|
|
69
|
+
def reason = lock.synchronize { @reason }
|
|
70
|
+
|
|
71
|
+
# The label with the failure's reason after it, when there is one.
|
|
72
|
+
#
|
|
73
|
+
# @param label [String]
|
|
74
|
+
# @return [String]
|
|
75
|
+
def summary(label)
|
|
76
|
+
text = reason
|
|
77
|
+
failed? && !text.to_s.empty? ? "#{label}: #{text}" : label
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# @return [Proc, nil]
|
|
83
|
+
attr_reader :on_change
|
|
84
|
+
|
|
85
|
+
# @return [Mutex]
|
|
86
|
+
attr_reader :lock
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/dry/cli/ui/version.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Dry
|
|
|
12
12
|
#
|
|
13
13
|
# The box is as wide as it is told to be: a fixed number of columns,
|
|
14
14
|
# or the whole terminal less {MARGIN}. It never grows wider than the
|
|
15
|
-
# terminal.
|
|
15
|
+
# terminal. A {#popup} is only as wide as its text, up to that width.
|
|
16
16
|
class Box
|
|
17
17
|
# Columns left free on the right when the box fills the terminal.
|
|
18
18
|
MARGIN = 2
|
|
@@ -49,6 +49,33 @@ module Dry
|
|
|
49
49
|
)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Renders a box over whatever is on the screen: as wide as its text
|
|
53
|
+
# and title need, no wider than {#render}'s box, and centred. The
|
|
54
|
+
# cursor is saved before and restored after, so drawing it neither
|
|
55
|
+
# moves the cursor nor scrolls. Without animation it is the same box
|
|
56
|
+
# {#render} draws.
|
|
57
|
+
#
|
|
58
|
+
# @param paragraphs [Array<#to_s>]
|
|
59
|
+
# @param title [String, nil]
|
|
60
|
+
# @return [String]
|
|
61
|
+
def popup(paragraphs, title: nil)
|
|
62
|
+
return render(paragraphs, title: title) unless terminal.animated?
|
|
63
|
+
|
|
64
|
+
text = wrap(paragraphs)
|
|
65
|
+
width = popup_width(text, title)
|
|
66
|
+
height = text.lines.size + 2 + (PADDING[0] * 2)
|
|
67
|
+
frame = TTY::Box.frame(
|
|
68
|
+
text,
|
|
69
|
+
top: [(terminal.height - height) / 2, 0].max,
|
|
70
|
+
left: [(terminal.width - width) / 2, 0].max,
|
|
71
|
+
width: width, height: height, padding: PADDING, border: :light,
|
|
72
|
+
title: title ? { top_left: heading(title, nil) } : {},
|
|
73
|
+
style: { border: { fg: :white } },
|
|
74
|
+
enable_color: terminal.color?
|
|
75
|
+
)
|
|
76
|
+
"#{terminal.cursor.save}#{frame}#{terminal.cursor.restore}"
|
|
77
|
+
end
|
|
78
|
+
|
|
52
79
|
private
|
|
53
80
|
|
|
54
81
|
# @return [Terminal]
|
|
@@ -68,6 +95,18 @@ module Dry
|
|
|
68
95
|
paragraphs.map { |p| Strings::Wrap.wrap(p.to_s, text_width).gsub(/[ \t]+$/, "") }.join("\n\n")
|
|
69
96
|
end
|
|
70
97
|
|
|
98
|
+
# Columns a popup needs: its widest line or its title, whichever is
|
|
99
|
+
# wider, with the border and padding around it.
|
|
100
|
+
#
|
|
101
|
+
# @param text [String] already wrapped
|
|
102
|
+
# @param title [String, nil]
|
|
103
|
+
# @return [Integer]
|
|
104
|
+
def popup_width(text, title)
|
|
105
|
+
content = text.lines.map { |line| Unicode::DisplayWidth.of(Strings::ANSI.sanitize(line.chomp)) }
|
|
106
|
+
content << (Unicode::DisplayWidth.of(title) + 3) if title
|
|
107
|
+
(content.max.to_i + 2 + (PADDING[1] * 2)).clamp(MIN_WIDTH, box_width)
|
|
108
|
+
end
|
|
109
|
+
|
|
71
110
|
# @return [Integer]
|
|
72
111
|
def box_width
|
|
73
112
|
available = terminal.width - MARGIN
|
|
@@ -11,6 +11,10 @@ module Dry
|
|
|
11
11
|
# On an animated terminal the spinner turns until the block returns
|
|
12
12
|
# and is replaced by the outcome line. Anywhere else it prints
|
|
13
13
|
# `Label...` before the block and the outcome line after it.
|
|
14
|
+
#
|
|
15
|
+
# The block is given a {Line}. Its detail is drawn after the label
|
|
16
|
+
# while the spinner turns, and {Line#fail} ends it as a failure
|
|
17
|
+
# without raising.
|
|
14
18
|
class Spinner
|
|
15
19
|
# @param terminal [Terminal]
|
|
16
20
|
# @param clock [#call] returns monotonic seconds
|
|
@@ -22,19 +26,21 @@ module Dry
|
|
|
22
26
|
# Runs the block under a spinner.
|
|
23
27
|
#
|
|
24
28
|
# @param label [String]
|
|
25
|
-
# @
|
|
29
|
+
# @yieldparam line [Line] reports on the work while it runs
|
|
26
30
|
# @return [Object] whatever the block returns
|
|
27
31
|
# @raise [Exception] whatever the block raises, after marking the spinner failed
|
|
28
|
-
def run(label)
|
|
32
|
+
def run(label, &job)
|
|
33
|
+
spinner = nil
|
|
34
|
+
line = Line.new { |text| spinner&.update(detail: text.empty? ? "" : " #{text}") }
|
|
29
35
|
started = clock.call
|
|
30
36
|
spinner = start(label)
|
|
31
37
|
ok = false
|
|
32
|
-
result =
|
|
33
|
-
ok =
|
|
38
|
+
result = Line.call(job, line)
|
|
39
|
+
ok = !line.failed?
|
|
34
40
|
result
|
|
35
41
|
ensure
|
|
36
42
|
spinner&.stop
|
|
37
|
-
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, label, clock.call - started))
|
|
43
|
+
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, line.summary(label), clock.call - started))
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
private
|
|
@@ -53,7 +59,9 @@ module Dry
|
|
|
53
59
|
return
|
|
54
60
|
end
|
|
55
61
|
|
|
56
|
-
TTY::Spinner.new(":spinner #{label}", output: terminal.io, format: :dots, hide_cursor: true,
|
|
62
|
+
TTY::Spinner.new(":spinner #{label}:detail", output: terminal.io, format: :dots, hide_cursor: true,
|
|
63
|
+
clear: true)
|
|
64
|
+
.tap { |spinner| spinner.update(detail: "") }
|
|
57
65
|
.tap(&:auto_spin)
|
|
58
66
|
end
|
|
59
67
|
end
|
|
@@ -19,6 +19,11 @@ module Dry
|
|
|
19
19
|
# tree is taller than the screen, each line is printed once it has
|
|
20
20
|
# something final to say.
|
|
21
21
|
#
|
|
22
|
+
# Every task is given a {Line}. Its detail is drawn after the task's
|
|
23
|
+
# name while it runs, on an animated terminal. A task that calls
|
|
24
|
+
# {Line#fail} is marked failed with its reason, as is every group above
|
|
25
|
+
# it, and the rest of the tree runs on.
|
|
26
|
+
#
|
|
22
27
|
# When a task raises, it and every group above it are marked failed,
|
|
23
28
|
# tasks already running beside it finish, everything that had not
|
|
24
29
|
# started is marked skipped, and the error is re-raised.
|
|
@@ -26,8 +31,8 @@ module Dry
|
|
|
26
31
|
# @example
|
|
27
32
|
# ui.tasks("Deploy") do |t|
|
|
28
33
|
# t.task("Build assets") { build }
|
|
29
|
-
# t.group("Migrate", concurrent:
|
|
30
|
-
# g.task("users") { migrate(:users) }
|
|
34
|
+
# t.group("Migrate", concurrent: 2) do |g|
|
|
35
|
+
# g.task("users") { |line| migrate(:users) { |table| line.detail = table } }
|
|
31
36
|
# g.task("orders") { migrate(:orders) }
|
|
32
37
|
# end
|
|
33
38
|
# end
|
|
@@ -38,11 +43,24 @@ module Dry
|
|
|
38
43
|
# Seconds between spinner frames.
|
|
39
44
|
INTERVAL = 0.1
|
|
40
45
|
|
|
46
|
+
# Checks a `concurrent:` setting.
|
|
47
|
+
#
|
|
48
|
+
# @param value [Boolean, Integer] true for all at once, false for one
|
|
49
|
+
# at a time, or the most that may run at once
|
|
50
|
+
# @return [Boolean, Integer] the value
|
|
51
|
+
# @raise [ArgumentError] for anything else
|
|
52
|
+
def self.concurrency(value)
|
|
53
|
+
return value if [true, false].include?(value) || (value.is_a?(Integer) && value.positive?)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "concurrent must be true, false or a positive Integer, got #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
41
58
|
# One task, or a group of them.
|
|
42
59
|
class Node
|
|
43
60
|
# @param name [String]
|
|
44
61
|
# @param job [Proc, nil] the work; nil for a group
|
|
45
|
-
# @param concurrent [Boolean] whether
|
|
62
|
+
# @param concurrent [Boolean, Integer] whether, or how many of, a
|
|
63
|
+
# group's tasks run at once
|
|
46
64
|
def initialize(name, job = nil, concurrent: false)
|
|
47
65
|
@name = name
|
|
48
66
|
@job = job
|
|
@@ -50,6 +68,7 @@ module Dry
|
|
|
50
68
|
@children = []
|
|
51
69
|
@state = :pending
|
|
52
70
|
@seconds = nil
|
|
71
|
+
@line = Line.new
|
|
53
72
|
end
|
|
54
73
|
|
|
55
74
|
# @return [String]
|
|
@@ -58,9 +77,11 @@ module Dry
|
|
|
58
77
|
# @return [Proc, nil]
|
|
59
78
|
attr_reader :job
|
|
60
79
|
|
|
61
|
-
# @return [Boolean]
|
|
80
|
+
# @return [Boolean, Integer]
|
|
62
81
|
attr_reader :concurrent
|
|
63
|
-
|
|
82
|
+
|
|
83
|
+
# @return [Line] what the task's work reports through
|
|
84
|
+
attr_reader :line
|
|
64
85
|
|
|
65
86
|
# @return [Array<Node>]
|
|
66
87
|
attr_reader :children
|
|
@@ -73,6 +94,17 @@ module Dry
|
|
|
73
94
|
|
|
74
95
|
# @return [Boolean] whether this node holds tasks rather than work
|
|
75
96
|
def group? = job.nil?
|
|
97
|
+
|
|
98
|
+
# Whether the work reported a failure without raising: through its
|
|
99
|
+
# line, or for a group, through any of its children.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def failed?
|
|
103
|
+
group? ? children.any? { |child| child.state == :failed } : line.failed?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [String] the name, with the reason once the work has failed
|
|
107
|
+
def label = line.summary(name)
|
|
76
108
|
end
|
|
77
109
|
|
|
78
110
|
# What the declaration block is given.
|
|
@@ -85,7 +117,7 @@ module Dry
|
|
|
85
117
|
# Declares a task.
|
|
86
118
|
#
|
|
87
119
|
# @param name [String]
|
|
88
|
-
# @
|
|
120
|
+
# @yieldparam line [Line] reports on the work while it runs
|
|
89
121
|
# @return [self]
|
|
90
122
|
# @raise [ArgumentError] without a block
|
|
91
123
|
def task(name, &job)
|
|
@@ -98,14 +130,15 @@ module Dry
|
|
|
98
130
|
# Declares a group of tasks.
|
|
99
131
|
#
|
|
100
132
|
# @param name [String]
|
|
101
|
-
# @param concurrent [Boolean] run the group's tasks at the
|
|
133
|
+
# @param concurrent [Boolean, Integer] run the group's tasks at the
|
|
134
|
+
# same time: all of them, or at most this many
|
|
102
135
|
# @yieldparam group [Builder] declares the tasks inside the group
|
|
103
136
|
# @return [self]
|
|
104
|
-
# @raise [ArgumentError] without a block
|
|
137
|
+
# @raise [ArgumentError] without a block, or with an invalid concurrent
|
|
105
138
|
def group(name, concurrent: false)
|
|
106
139
|
raise ArgumentError, "group #{name.inspect} needs a block" unless block_given?
|
|
107
140
|
|
|
108
|
-
node = Node.new(name, concurrent: concurrent)
|
|
141
|
+
node = Node.new(name, concurrent: Tasks.concurrency(concurrent))
|
|
109
142
|
nodes << node
|
|
110
143
|
yield Builder.new(node.children)
|
|
111
144
|
self
|
|
@@ -131,10 +164,13 @@ module Dry
|
|
|
131
164
|
# Declares the tree with the block, then runs it.
|
|
132
165
|
#
|
|
133
166
|
# @param title [String, nil] a heading printed above the tree
|
|
134
|
-
# @param concurrent [Boolean] run the top-level tasks at the
|
|
167
|
+
# @param concurrent [Boolean, Integer] run the top-level tasks at the
|
|
168
|
+
# same time: all of them, or at most this many
|
|
135
169
|
# @yieldparam tasks [Builder]
|
|
136
170
|
# @return [nil]
|
|
171
|
+
# @raise [ArgumentError] with an invalid concurrent
|
|
137
172
|
def run(title = nil, concurrent: false)
|
|
173
|
+
Tasks.concurrency(concurrent)
|
|
138
174
|
yield Builder.new(roots)
|
|
139
175
|
terminal.puts(terminal.pastel.bold(title)) if title
|
|
140
176
|
ticker = start_ticker
|
|
@@ -166,25 +202,59 @@ module Dry
|
|
|
166
202
|
attr_accessor :frame
|
|
167
203
|
|
|
168
204
|
# @param nodes [Array<Node>]
|
|
169
|
-
# @param concurrent [Boolean]
|
|
205
|
+
# @param concurrent [Boolean, Integer]
|
|
170
206
|
# @return [void]
|
|
171
207
|
def run_all(nodes, concurrent)
|
|
172
208
|
return nodes.each { |node| execute(node) } unless concurrent
|
|
173
209
|
|
|
174
|
-
futures =
|
|
210
|
+
futures = concurrent == true ? all_at_once(nodes) : at_most(concurrent, nodes)
|
|
175
211
|
futures.each(&:wait)
|
|
176
212
|
failed = futures.find(&:rejected?)
|
|
177
213
|
raise failed.reason if failed
|
|
178
214
|
end
|
|
179
215
|
|
|
216
|
+
# @param nodes [Array<Node>]
|
|
217
|
+
# @return [Array<Concurrent::Promises::Future>] one per node
|
|
218
|
+
def all_at_once(nodes)
|
|
219
|
+
nodes.map { |node| Concurrent::Promises.future(node) { |each| execute(each) } }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Workers that take nodes off a queue until it is empty, or until
|
|
223
|
+
# one of them raises. A node no worker took stays pending, and is
|
|
224
|
+
# marked skipped once the tree ends.
|
|
225
|
+
#
|
|
226
|
+
# @param limit [Integer]
|
|
227
|
+
# @param nodes [Array<Node>]
|
|
228
|
+
# @return [Array<Concurrent::Promises::Future>] one per worker
|
|
229
|
+
def at_most(limit, nodes)
|
|
230
|
+
queue = Queue.new
|
|
231
|
+
nodes.each { |node| queue << node }
|
|
232
|
+
queue.close
|
|
233
|
+
stop = Concurrent::AtomicBoolean.new
|
|
234
|
+
Array.new([limit, nodes.size].min) { Concurrent::Promises.future { work(queue, stop) } }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @param queue [Queue] closed, so `pop` returns nil once it is empty
|
|
238
|
+
# @param stop [Concurrent::AtomicBoolean] set once any worker raises
|
|
239
|
+
# @return [void]
|
|
240
|
+
def work(queue, stop)
|
|
241
|
+
ok = false
|
|
242
|
+
while (node = queue.pop) && stop.false?
|
|
243
|
+
execute(node)
|
|
244
|
+
end
|
|
245
|
+
ok = true
|
|
246
|
+
ensure
|
|
247
|
+
stop.make_true unless ok
|
|
248
|
+
end
|
|
249
|
+
|
|
180
250
|
# @param node [Node]
|
|
181
251
|
# @return [void]
|
|
182
252
|
def execute(node)
|
|
183
253
|
started = clock.call
|
|
184
254
|
change(node, :running)
|
|
185
255
|
ok = false
|
|
186
|
-
node.group? ? run_all(node.children, node.concurrent
|
|
187
|
-
ok =
|
|
256
|
+
node.group? ? run_all(node.children, node.concurrent) : Line.call(node.job, node.line)
|
|
257
|
+
ok = !node.failed?
|
|
188
258
|
ensure
|
|
189
259
|
change(node, ok ? :done : :failed, seconds: clock.call - started)
|
|
190
260
|
end
|
|
@@ -262,7 +332,19 @@ module Dry
|
|
|
262
332
|
glyph, color = Theme::STATES.fetch(node.state)
|
|
263
333
|
glyph = FRAMES[frame % FRAMES.size] if node.state == :running && live?
|
|
264
334
|
elapsed = " #{pastel.bright_black("(#{Duration.format(node.seconds)})")}" if node.seconds
|
|
265
|
-
"#{pastel.bright_black(rows.fetch(node))}#{pastel.decorate(glyph, color)} #{node
|
|
335
|
+
"#{pastel.bright_black(rows.fetch(node))}#{pastel.decorate(glyph, color)} #{text(node)}#{elapsed}"
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# What follows the glyph: the name, then the detail while the task
|
|
339
|
+
# runs, or the reason once it has failed.
|
|
340
|
+
#
|
|
341
|
+
# @param node [Node]
|
|
342
|
+
# @return [String]
|
|
343
|
+
def text(node)
|
|
344
|
+
detail = node.line.detail
|
|
345
|
+
return node.label unless node.state == :running && !detail.empty?
|
|
346
|
+
|
|
347
|
+
"#{node.name} #{detail}"
|
|
266
348
|
end
|
|
267
349
|
|
|
268
350
|
# Every node in display order, with the tree branch drawn before it.
|
data/lib/dry/cli/ui.rb
CHANGED
|
@@ -33,6 +33,7 @@ module Dry
|
|
|
33
33
|
|
|
34
34
|
autoload :Console, File.expand_path("ui/console", __dir__)
|
|
35
35
|
autoload :Duration, File.expand_path("ui/duration", __dir__)
|
|
36
|
+
autoload :Line, File.expand_path("ui/line", __dir__)
|
|
36
37
|
autoload :Terminal, File.expand_path("ui/terminal", __dir__)
|
|
37
38
|
autoload :Theme, File.expand_path("ui/theme", __dir__)
|
|
38
39
|
autoload :Widgets, File.expand_path("ui/widgets", __dir__)
|
data/sig/dry/cli/ui.rbs
CHANGED
|
@@ -11,6 +11,17 @@ module Dry
|
|
|
11
11
|
|
|
12
12
|
def ui: () -> Console
|
|
13
13
|
|
|
14
|
+
class Line
|
|
15
|
+
def self.call: [T] (^(Line) -> T job, Line line) -> T
|
|
16
|
+
def initialize: () ?{ (String detail) -> void } -> void
|
|
17
|
+
def detail: () -> String
|
|
18
|
+
def detail=: (_ToS? text) -> void
|
|
19
|
+
def fail: (?_ToS? reason) -> self
|
|
20
|
+
def failed?: () -> bool
|
|
21
|
+
def reason: () -> String?
|
|
22
|
+
def summary: (String label) -> String
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
class Console
|
|
15
26
|
def debug: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
16
27
|
def info: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
@@ -20,9 +31,10 @@ module Dry
|
|
|
20
31
|
def fatal: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
21
32
|
def box: (*_ToS paragraphs, ?title: String?, ?level: Symbol?, ?width: Integer?) -> nil
|
|
22
33
|
def status: (*_ToS words, ?level: Symbol) -> nil
|
|
23
|
-
def
|
|
34
|
+
def popup: (*_ToS paragraphs, ?title: String?, ?width: Integer?) -> nil
|
|
35
|
+
def spinner: [T] (String label) { (Line line) -> T } -> T
|
|
24
36
|
def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
|
|
25
|
-
def tasks: (?String? title, ?concurrent: bool) { (untyped tasks) -> void } -> nil
|
|
37
|
+
def tasks: (?String? title, ?concurrent: (bool | Integer)) { (untyped tasks) -> void } -> nil
|
|
26
38
|
def table: (Array[Array[_ToS]] rows, ?header: Array[_ToS]?) -> nil
|
|
27
39
|
def prompt: (String question, ?default: untyped, ?choices: (Array[String] | Hash[String, untyped])?) -> untyped
|
|
28
40
|
def confirm: (String question, ?default: bool) -> bool
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dry-cli-ui
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -181,6 +181,7 @@ files:
|
|
|
181
181
|
- lib/dry/cli/ui.rb
|
|
182
182
|
- lib/dry/cli/ui/console.rb
|
|
183
183
|
- lib/dry/cli/ui/duration.rb
|
|
184
|
+
- lib/dry/cli/ui/line.rb
|
|
184
185
|
- lib/dry/cli/ui/terminal.rb
|
|
185
186
|
- lib/dry/cli/ui/theme.rb
|
|
186
187
|
- lib/dry/cli/ui/version.rb
|