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 +4 -4
- data/CHANGELOG.md +13 -2
- data/README.md +385 -26
- data/examples/.envrc +1 -0
- data/examples/.gitignore +1 -0
- data/examples/Gemfile +1 -1
- data/examples/Gemfile.lock +5 -5
- data/examples/README.md +24 -10
- data/examples/bin/mycli +270 -94
- data/lib/dry/cli/ui/configuration.rb +3 -3
- data/lib/dry/cli/ui/console.rb +8 -5
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi_progress.rb +11 -7
- data/lib/dry/cli/ui/widgets/progress.rb +37 -13
- data/sig/dry/cli/ui.rbs +1 -1
- metadata +3 -2
- data/SPECIFICATION.md +0 -411
|
@@ -16,15 +16,21 @@ module Dry
|
|
|
16
16
|
class Handle
|
|
17
17
|
# @param total [Integer]
|
|
18
18
|
# @param bar [TTY::ProgressBar, nil]
|
|
19
|
-
|
|
19
|
+
# @param color [Symbol, nil] see {Progress.color}
|
|
20
|
+
def initialize(total, bar, color: nil)
|
|
20
21
|
@total = total
|
|
21
22
|
@bar = bar
|
|
23
|
+
@color = color
|
|
22
24
|
@current = 0
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
# @return [Integer] the number of units the operation has
|
|
26
28
|
attr_reader :total
|
|
27
29
|
|
|
30
|
+
# @return [Symbol, nil] the Pastel style of the bar's finished part;
|
|
31
|
+
# nil for {Configuration#bar_color}
|
|
32
|
+
attr_reader :color
|
|
33
|
+
|
|
28
34
|
# @return [Integer] the number of units completed so far
|
|
29
35
|
attr_reader :current
|
|
30
36
|
|
|
@@ -52,25 +58,38 @@ module Dry
|
|
|
52
58
|
# Narrowest bar drawn.
|
|
53
59
|
MIN_BAR = 10
|
|
54
60
|
|
|
61
|
+
# Checks a bar's own colour.
|
|
62
|
+
#
|
|
63
|
+
# @param value [Symbol, nil] any Pastel style, or nil for {Configuration#bar_color}
|
|
64
|
+
# @return [Symbol, nil] the value
|
|
65
|
+
# @raise [ArgumentError] for anything else
|
|
66
|
+
def self.color(value)
|
|
67
|
+
return value if value.nil? || Configuration::STYLES.include?(value)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "color must be a Pastel style or nil, got #{value.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
55
72
|
# A bar between brackets, painted as the configuration says: the
|
|
56
|
-
# finished part in {Configuration#bar_color},
|
|
57
|
-
# {Configuration#bar_background}.
|
|
73
|
+
# finished part in {Configuration#bar_color}, or in the bar's own
|
|
74
|
+
# colour when it has one, and all of it on {Configuration#bar_background}.
|
|
58
75
|
#
|
|
59
76
|
# @param pastel [Pastel::Delegator] a no-op when colour is off
|
|
60
77
|
# @param config [Configuration]
|
|
61
78
|
# @param ratio [Float] how much is finished, from 0 to 1
|
|
62
79
|
# @param columns [Integer] the bar's width inside the brackets
|
|
80
|
+
# @param color [Symbol, nil] the finished part's style; nil for {Configuration#bar_color}
|
|
63
81
|
# @return [String]
|
|
64
|
-
def self.bar(pastel, config, ratio, columns)
|
|
82
|
+
def self.bar(pastel, config, ratio, columns, color: nil)
|
|
65
83
|
filled = (ratio * columns).floor
|
|
66
|
-
"[#{complete(pastel, config) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
|
|
84
|
+
"[#{complete(pastel, config, color) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
# @param pastel [Pastel::Delegator]
|
|
70
88
|
# @param config [Configuration]
|
|
89
|
+
# @param color [Symbol, nil] the style; nil for {Configuration#bar_color}
|
|
71
90
|
# @return [String] one finished cell, painted
|
|
72
|
-
def self.complete(pastel, config)
|
|
73
|
-
pastel.decorate(config.bar_complete, *[config.bar_color, config.bar_background].compact)
|
|
91
|
+
def self.complete(pastel, config, color = nil)
|
|
92
|
+
pastel.decorate(config.bar_complete, *[color || config.bar_color, config.bar_background].compact)
|
|
74
93
|
end
|
|
75
94
|
|
|
76
95
|
# @param pastel [Pastel::Delegator]
|
|
@@ -93,15 +112,19 @@ module Dry
|
|
|
93
112
|
#
|
|
94
113
|
# @param label [String]
|
|
95
114
|
# @param total [Integer] the number of units of work
|
|
115
|
+
# @param color [Symbol, nil] the finished part's Pastel style; nil for
|
|
116
|
+
# {Configuration#bar_color}
|
|
96
117
|
# @yieldparam progress [Handle]
|
|
97
118
|
# @return [Object] whatever the block returns
|
|
98
|
-
# @raise [ArgumentError] when total is not a non-negative Integer
|
|
99
|
-
|
|
119
|
+
# @raise [ArgumentError] when total is not a non-negative Integer, or
|
|
120
|
+
# color is not a Pastel style
|
|
121
|
+
def run(label, total:, color: nil)
|
|
100
122
|
raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
|
|
101
123
|
|
|
124
|
+
Progress.color(color)
|
|
102
125
|
started = clock.call
|
|
103
|
-
bar = start(label, total)
|
|
104
|
-
handle = Handle.new(total, bar)
|
|
126
|
+
bar = start(label, total, color)
|
|
127
|
+
handle = Handle.new(total, bar, color: color)
|
|
105
128
|
terminal.started(handle, label, progress: handle)
|
|
106
129
|
ok = false
|
|
107
130
|
result = yield handle
|
|
@@ -129,8 +152,9 @@ module Dry
|
|
|
129
152
|
|
|
130
153
|
# @param label [String]
|
|
131
154
|
# @param total [Integer]
|
|
155
|
+
# @param color [Symbol, nil]
|
|
132
156
|
# @return [TTY::ProgressBar, nil]
|
|
133
|
-
def start(label, total)
|
|
157
|
+
def start(label, total, color)
|
|
134
158
|
unless terminal.animated? && total.positive?
|
|
135
159
|
terminal.puts("#{label}...")
|
|
136
160
|
return
|
|
@@ -141,7 +165,7 @@ module Dry
|
|
|
141
165
|
total: total,
|
|
142
166
|
width: [terminal.width - label.length - CHROME, MIN_BAR].max,
|
|
143
167
|
output: terminal.io,
|
|
144
|
-
complete: Progress.complete(terminal.pastel, config),
|
|
168
|
+
complete: Progress.complete(terminal.pastel, config, color),
|
|
145
169
|
incomplete: Progress.incomplete(terminal.pastel, config),
|
|
146
170
|
clear: true,
|
|
147
171
|
hide_cursor: true
|
data/sig/dry/cli/ui.rbs
CHANGED
|
@@ -56,7 +56,7 @@ module Dry
|
|
|
56
56
|
def popup: (*_ToS paragraphs, ?title: String?, ?width: Integer?) -> nil
|
|
57
57
|
def spinner: [T] (String label) { (Line line) -> T } -> T
|
|
58
58
|
def multi_spinner: (String title, ?concurrent: (bool | Integer)) { (untyped spinners) -> void } -> Array[untyped]
|
|
59
|
-
def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
|
|
59
|
+
def progress: [T] (String label, total: Integer, ?color: Symbol?) { (untyped progress) -> T } -> T
|
|
60
60
|
def multi_progress: (String title, ?concurrent: (bool | Integer)) { (untyped bars) -> void } -> Array[untyped]
|
|
61
61
|
def status_bar: [T] (?String? title, ?hints: Array[String] | String) { () -> T } -> T
|
|
62
62
|
def tasks: (?String? title, ?concurrent: (bool | Integer)) { (untyped tasks) -> void } -> nil
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -176,7 +176,8 @@ files:
|
|
|
176
176
|
- CHANGELOG.md
|
|
177
177
|
- LICENSE.txt
|
|
178
178
|
- README.md
|
|
179
|
-
-
|
|
179
|
+
- examples/.envrc
|
|
180
|
+
- examples/.gitignore
|
|
180
181
|
- examples/Gemfile
|
|
181
182
|
- examples/Gemfile.lock
|
|
182
183
|
- examples/README.md
|
data/SPECIFICATION.md
DELETED
|
@@ -1,411 +0,0 @@
|
|
|
1
|
-
# `dry-cli-ui`
|
|
2
|
-
|
|
3
|
-
Rich runtime terminal UI for [`dry-cli`](https://github.com/dry-rb/dry-cli) commands.
|
|
4
|
-
|
|
5
|
-
## Purpose
|
|
6
|
-
|
|
7
|
-
`dry-cli-ui` gives ordinary `dry-cli` commands a high-level API for presenting their **runtime state**.
|
|
8
|
-
|
|
9
|
-
It is particularly useful for long-running commands where plain `puts` output does not adequately communicate progress, activity, warnings, failures, or completion.
|
|
10
|
-
|
|
11
|
-
It is not intended primarily as a framework for building full-screen terminal applications.
|
|
12
|
-
|
|
13
|
-
Instead, it adds rich terminal UI to normal CLI commands while preserving the familiar command-line experience and terminal scrollback.
|
|
14
|
-
|
|
15
|
-
## Responsibilities
|
|
16
|
-
|
|
17
|
-
- Spinners
|
|
18
|
-
- Progress bars
|
|
19
|
-
- Status messages
|
|
20
|
-
- Success messages
|
|
21
|
-
- Warning messages
|
|
22
|
-
- Error messages
|
|
23
|
-
- Styled boxes and panels
|
|
24
|
-
- Tables
|
|
25
|
-
- Task trees
|
|
26
|
-
- Nested operations
|
|
27
|
-
- Several operations running at once
|
|
28
|
-
- Elapsed time and ETA
|
|
29
|
-
- Interactive prompts
|
|
30
|
-
- Terminal-aware rendering
|
|
31
|
-
- Graceful fallback when ANSI/interactive output is unavailable
|
|
32
|
-
|
|
33
|
-
## Example
|
|
34
|
-
|
|
35
|
-
```ruby
|
|
36
|
-
class Import < Dry::CLI::Command
|
|
37
|
-
include Dry::CLI::UI
|
|
38
|
-
|
|
39
|
-
def call(**)
|
|
40
|
-
ui.info "Importing tax rules..."
|
|
41
|
-
|
|
42
|
-
ui.spinner("Loading tax rules") do
|
|
43
|
-
load_rules
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
ui.progress("Importing rules", total: rules.size) do |bar|
|
|
47
|
-
rules.each do |rule|
|
|
48
|
-
import(rule)
|
|
49
|
-
bar.advance
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
ui.success "Imported #{rules.size} rules"
|
|
54
|
-
rescue => e
|
|
55
|
-
ui.error("Import failed", e.message)
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
Example output, piped, when the import fails part way:
|
|
61
|
-
|
|
62
|
-
```text
|
|
63
|
-
Loading tax rules...
|
|
64
|
-
✓ Loading tax rules (0.3s)
|
|
65
|
-
Importing rules...
|
|
66
|
-
𝘅 Importing rules 1482/1900 (4.1s)
|
|
67
|
-
┌─ Error ──────────────────────────────────────────────────┐
|
|
68
|
-
│ │
|
|
69
|
-
│ Import failed │
|
|
70
|
-
│ │
|
|
71
|
-
│ Could not validate rule US.2026.IRC.199A: missing │
|
|
72
|
-
│ dependency taxable_income │
|
|
73
|
-
│ │
|
|
74
|
-
└──────────────────────────────────────────────────────────┘
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
On a terminal the spinner turns and the bar fills in place (`Importing rules ███████░░░ 78% 1482/1900 ETA 4s`), and each is replaced by the same outcome line when its block ends.
|
|
78
|
-
|
|
79
|
-
## API
|
|
80
|
-
|
|
81
|
-
Commands depend on a small semantic API rather than directly manipulating terminal primitives:
|
|
82
|
-
|
|
83
|
-
```ruby
|
|
84
|
-
ui.debug(...)
|
|
85
|
-
ui.info(...)
|
|
86
|
-
ui.success(...)
|
|
87
|
-
ui.warn(...)
|
|
88
|
-
ui.error(...)
|
|
89
|
-
ui.fatal(...)
|
|
90
|
-
|
|
91
|
-
ui.spinner(...)
|
|
92
|
-
ui.multi_spinner(...)
|
|
93
|
-
ui.progress(...)
|
|
94
|
-
ui.multi_progress(...)
|
|
95
|
-
ui.status(...)
|
|
96
|
-
ui.status_bar(...)
|
|
97
|
-
|
|
98
|
-
ui.box(...)
|
|
99
|
-
ui.popup(...)
|
|
100
|
-
ui.table(...)
|
|
101
|
-
ui.tasks(...)
|
|
102
|
-
|
|
103
|
-
ui.prompt(...)
|
|
104
|
-
ui.confirm(...)
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
This separates **what the command wants to communicate** from **how the terminal renders it**.
|
|
108
|
-
|
|
109
|
-
## Rendering
|
|
110
|
-
|
|
111
|
-
The implementation builds on existing Ruby terminal libraries rather than reimplementing terminal mechanics: `tty-box`, `tty-spinner`, `tty-progressbar`, `tty-table`, `tty-prompt`, `tty-cursor`, `tty-screen`, `pastel` and `strings`.
|
|
112
|
-
|
|
113
|
-
The public API does not expose these dependencies. No method returns or yields a TTY object, and no argument takes one.
|
|
114
|
-
|
|
115
|
-
That leaves open the possibility of introducing other renderers later, including richer inline TUI implementations, without changing application command code.
|
|
116
|
-
|
|
117
|
-
## Design Principle
|
|
118
|
-
|
|
119
|
-
`dry-cli-ui` owns what the user sees **while a command runs and when it finishes**.
|
|
120
|
-
|
|
121
|
-
```text
|
|
122
|
-
dry-cli
|
|
123
|
-
│
|
|
124
|
-
└── dry-cli-ui
|
|
125
|
-
│
|
|
126
|
-
├── spinner
|
|
127
|
-
├── progress
|
|
128
|
-
├── status
|
|
129
|
-
├── debug/info/success/warn/error/fatal
|
|
130
|
-
├── boxes
|
|
131
|
-
├── tables
|
|
132
|
-
├── task trees
|
|
133
|
-
└── prompts
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Relationship to dry-cli-help
|
|
137
|
-
|
|
138
|
-
The two gems deliberately have separate responsibilities:
|
|
139
|
-
|
|
140
|
-
```text
|
|
141
|
-
dry-cli
|
|
142
|
-
│
|
|
143
|
-
├── dry-cli-help
|
|
144
|
-
│ Static presentation
|
|
145
|
-
│
|
|
146
|
-
│ "What does this command do?"
|
|
147
|
-
│
|
|
148
|
-
└── dry-cli-ui
|
|
149
|
-
Runtime presentation
|
|
150
|
-
|
|
151
|
-
"What is this command doing?"
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
A CLI application can use either gem independently or combine them:
|
|
155
|
-
|
|
156
|
-
```ruby
|
|
157
|
-
gem "dry-cli"
|
|
158
|
-
gem "dry-cli-help"
|
|
159
|
-
gem "dry-cli-ui"
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Together they provide richer presentation without turning `dry-cli` itself into a large terminal UI framework.
|
|
163
|
-
|
|
164
|
-
## Boxes
|
|
165
|
-
|
|
166
|
-
`debug`, `info`, `success`, `warn`, `error` and `fatal` each draw a box:
|
|
167
|
-
|
|
168
|
-
- a single-line white border,
|
|
169
|
-
- the level's name as a bold, coloured title in the top border (`┌─ Error ───`),
|
|
170
|
-
- one blank row above and below the text and two columns either side,
|
|
171
|
-
- each argument as its own paragraph, wrapped to fit, separated by a blank line.
|
|
172
|
-
|
|
173
|
-
The width is one of:
|
|
174
|
-
|
|
175
|
-
1. a fixed number of columns, per console (`Console.new(box_width: 72)`) or per call (`ui.info("...", width: 72)`), never wider than the terminal;
|
|
176
|
-
1. the whole terminal less a two-column margin, which is the default.
|
|
177
|
-
|
|
178
|
-
A box is never narrower than 20 columns. `ui.box(*paragraphs, title:, level:)` draws the same frame without a level, or with a level's styling and a different title.
|
|
179
|
-
|
|
180
|
-
| Level | Title | Glyph | Colour | Stream |
|
|
181
|
-
| --------- | ------- | ----- | ------- | ------ |
|
|
182
|
-
| `debug` | Debug | `·` | grey | err |
|
|
183
|
-
| `info` | Info | `ℹ` | cyan | out |
|
|
184
|
-
| `success` | Success | `✓` | green | out |
|
|
185
|
-
| `warn` | Warning | `⚠` | yellow | err |
|
|
186
|
-
| `error` | Error | `✗` | red | err |
|
|
187
|
-
| `fatal` | Fatal | `✖` | magenta | err |
|
|
188
|
-
|
|
189
|
-
`success` and `fatal` were added to the original five (`debug`, `info`, `warn`, `error`, `fatal`) because the example above uses `success`.
|
|
190
|
-
|
|
191
|
-
## Design decisions
|
|
192
|
-
|
|
193
|
-
### Architecture
|
|
194
|
-
|
|
195
|
-
```mermaid
|
|
196
|
-
flowchart LR
|
|
197
|
-
Command["Dry::CLI::Command<br/>include Dry::CLI::UI"] -->|"#ui"| Console
|
|
198
|
-
Console --> OutTerm["Terminal (out)"]
|
|
199
|
-
Console --> ErrTerm["Terminal (err)"]
|
|
200
|
-
Console --> Widgets
|
|
201
|
-
subgraph Widgets
|
|
202
|
-
Box
|
|
203
|
-
Status
|
|
204
|
-
Spinner
|
|
205
|
-
Progress
|
|
206
|
-
Tasks
|
|
207
|
-
Table
|
|
208
|
-
Prompt
|
|
209
|
-
end
|
|
210
|
-
Widgets --> TTY["TTY toolkit, Pastel, Strings"]
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
| File | Role |
|
|
214
|
-
| ----------------------------- | -------------------------------------------------------------------------- |
|
|
215
|
-
| `lib/dry/cli/ui.rb` | The mixin. Defines `#ui` and autoloads everything else. |
|
|
216
|
-
| `lib/dry/cli/ui/console.rb` | The public API. Routes each call to a widget and a stream. |
|
|
217
|
-
| `lib/dry/cli/ui/terminal.rb` | One stream and what it can do: TTY, animation, colour, width, height. |
|
|
218
|
-
| `lib/dry/cli/ui/theme.rb` | Levels (title, glyph, colour, stream) and operation states. |
|
|
219
|
-
| `lib/dry/cli/ui/duration.rb` | The monotonic clock and `0.4s` / `1m 02s` / `1h 02m` formatting. |
|
|
220
|
-
| `lib/dry/cli/ui/widgets/*.rb` | One renderer per widget, each owning its rich form and its plain fallback. |
|
|
221
|
-
|
|
222
|
-
Only files under `widgets/` and `terminal.rb` touch a TTY class. A future renderer replaces widgets, not `Console`.
|
|
223
|
-
|
|
224
|
-
### Including costs nothing at boot
|
|
225
|
-
|
|
226
|
-
`include Dry::CLI::UI` loads the mixin and nothing else. `Console`, the widgets and every TTY gem are autoloaded on first use, so a command that never calls `ui` never loads them. A spec pins this by checking `$LOADED_FEATURES` in a fresh process.
|
|
227
|
-
|
|
228
|
-
### Streams
|
|
229
|
-
|
|
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.
|
|
231
|
-
|
|
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` |
|
|
236
|
-
|
|
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.
|
|
238
|
-
|
|
239
|
-
### Terminal detection and fallback
|
|
240
|
-
|
|
241
|
-
Each stream is judged on its own:
|
|
242
|
-
|
|
243
|
-
| Condition | Animation and cursor movement | Colour |
|
|
244
|
-
| ---------------------------------- | ----------------------------- | ------ |
|
|
245
|
-
| TTY | yes | yes |
|
|
246
|
-
| TTY with `NO_COLOR` set, non-empty | yes | no |
|
|
247
|
-
| TTY with `TERM=dumb` | no | no |
|
|
248
|
-
| not a TTY | no | no |
|
|
249
|
-
|
|
250
|
-
`Console.new(color:, animate:, width:)` overrides detection. Without animation:
|
|
251
|
-
|
|
252
|
-
| Widget | Plain output |
|
|
253
|
-
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
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)` |
|
|
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 |
|
|
258
|
-
| tasks | each line printed once final: a group when it starts, a task when it ends, skipped ones at the end |
|
|
259
|
-
| prompts | the question on `err`, one line read from input |
|
|
260
|
-
| boxes, tables, status | unchanged apart from colour |
|
|
261
|
-
| popup | the same box `ui.box` draws, on `err`, where the output scrolls rather than over it |
|
|
262
|
-
|
|
263
|
-
### Spinners and progress bars
|
|
264
|
-
|
|
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.
|
|
266
|
-
|
|
267
|
-
`ui.spinner` yields a `Dry::CLI::UI::Line`, the same handle every task in a tree is given:
|
|
268
|
-
|
|
269
|
-
| Method | Effect |
|
|
270
|
-
| --------------- | -------------------------------------------------------------------------------------------------------- |
|
|
271
|
-
| `detail = text` | Text after the label while the work runs, redrawn in place when animated; kept, never printed, otherwise |
|
|
272
|
-
| `detail` | The current text, `""` for none |
|
|
273
|
-
| `fail(reason)` | Ends the work as `𝘅 label: reason` when the block returns, without raising; the reason is optional |
|
|
274
|
-
| `failed?` | Whether `fail` was called |
|
|
275
|
-
| `reason` | What `fail` was given |
|
|
276
|
-
|
|
277
|
-
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.
|
|
278
|
-
|
|
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.
|
|
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
|
-
|
|
333
|
-
### Task trees
|
|
334
|
-
|
|
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.
|
|
336
|
-
|
|
337
|
-
```ruby
|
|
338
|
-
ui.tasks("Deploy") do |t|
|
|
339
|
-
t.task("Build assets") { build }
|
|
340
|
-
t.group("Migrate") do |g|
|
|
341
|
-
g.task("users") { migrate(:users) }
|
|
342
|
-
g.task("orders") { migrate(:orders) }
|
|
343
|
-
end
|
|
344
|
-
t.group("Warm caches", concurrent: true) do |g|
|
|
345
|
-
g.task("fonts") { warm(:fonts) }
|
|
346
|
-
g.task("images") { warm(:images) }
|
|
347
|
-
end
|
|
348
|
-
t.task("Restart") { restart }
|
|
349
|
-
end
|
|
350
|
-
```
|
|
351
|
-
|
|
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`.
|
|
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.
|
|
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.
|
|
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.
|
|
357
|
-
- Task blocks should not write to the terminal while a live tree is drawn; the next redraw overwrites their output.
|
|
358
|
-
|
|
359
|
-
### Popups
|
|
360
|
-
|
|
361
|
-
`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:
|
|
362
|
-
|
|
363
|
-
- 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);
|
|
364
|
-
- centred on the screen by absolute cursor positioning;
|
|
365
|
-
- wrapped in a cursor save and restore, with no trailing newline, so it neither moves the cursor nor scrolls the screen.
|
|
366
|
-
|
|
367
|
-
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`.
|
|
368
|
-
|
|
369
|
-
### Tables
|
|
370
|
-
|
|
371
|
-
`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.
|
|
372
|
-
|
|
373
|
-
### Prompts
|
|
374
|
-
|
|
375
|
-
`ui.prompt(question, default:, choices:)` asks for a line of text, or for one of `choices` (an Array of names, or a Hash of names to the values returned). `ui.confirm(question, default: false)` asks yes or no.
|
|
376
|
-
|
|
377
|
-
With an interactive input and output they use `tty-prompt`'s line editing and arrow-key menus. Otherwise they read lines, so answers can be piped in:
|
|
378
|
-
|
|
379
|
-
```bash
|
|
380
|
-
printf 'production\ny\n' | mycli deploy
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
An empty answer takes the default. An exhausted input takes the default too, and a question with no default raises `Dry::CLI::UI::NonInteractiveError` rather than inventing an answer. An answer that is not a valid choice, or not yes or no, asks again.
|
|
384
|
-
|
|
385
|
-
### A TTY::Box defect worked around
|
|
386
|
-
|
|
387
|
-
With a fixed width, TTY::Box 0.7 wraps text but sizes the box from the unwrapped lines, so everything past the first rows is silently dropped. `Widgets::Box` wraps the text with `Strings::Wrap` first, leaving TTY::Box nothing to wrap.
|
|
388
|
-
|
|
389
|
-
## Acceptance criteria
|
|
390
|
-
|
|
391
|
-
- [x] `include Dry::CLI::UI` gives a command `#ui`; including it loads no TTY gem.
|
|
392
|
-
- [x] `#ui` writes to the streams dry-cli was called with.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
400
|
-
- [x] `popup` draws a content-sized, centred box that leaves the cursor where it was, and a plain box without animation.
|
|
401
|
-
- [x] Tables render rows and a header without truncation.
|
|
402
|
-
- [x] Prompts work interactively and from piped input, and never block on an exhausted input.
|
|
403
|
-
- [x] Output that is not a TTY, or runs under `TERM=dumb`, contains no escape sequences; `NO_COLOR` removes colour.
|
|
404
|
-
- [x] The public API exposes no TTY object.
|
|
405
|
-
- [x] 100% line and branch coverage, enforced by the suite.
|
|
406
|
-
|
|
407
|
-
## Out of scope
|
|
408
|
-
|
|
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.
|
|
410
|
-
- Keyboard input beyond prompts.
|
|
411
|
-
- Renderers other than the TTY toolkit. The widget boundary allows one later.
|