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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1cbc8893268017d5d41b730292d9f6a640230ff87aa2282afd4cfc0a0084487
4
- data.tar.gz: 3781eb7e227b6466555153687b6cf1783a7f4acbc6dffc1b5cd86c5b209dad91
3
+ metadata.gz: 412754e986acfa5b60ce22a1d447cd470098ffe42434ec453636cf563e65a8d3
4
+ data.tar.gz: 23fcbd8f9cbd0cdda55e6f88883b36092b07703e3895f47f8348a11743f4f3a8
5
5
  SHA512:
6
- metadata.gz: c7a31f33372b1432c461f11319daad6503755f6c173a5bba9aee8508034de826c77ef662579471c9ce18b802098f1c426942f71e46e0d6a18c0b492fbd2a3fd9
7
- data.tar.gz: 96c0528c1e32b927abf1d5f042a4879bb67e5d3cc9fa408a4d655ff020e9a01d17483a99da83aaa573d632486f8e2532a7f6c760105eb46c7b94b97083357491
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 fails, it is marked `✗`, tasks already running finish, the rest are marked skipped (`–`), and the error is re-raised.
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.
@@ -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
- # @yield the work
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 same time
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
@@ -11,7 +11,7 @@ module Dry
11
11
  # Presentation helpers for Dry::CLI commands.
12
12
  module UI
13
13
  # The gem version.
14
- VERSION = "0.1.0"
14
+ VERSION = "0.3.0"
15
15
  end
16
16
  end
17
17
  end
@@ -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
- # @yield the work to show progress for
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 = yield
33
- ok = true
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, clear: 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: true) do |g|
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 a group runs its tasks at once
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
- alias concurrent? concurrent
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
- # @yield the work
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 same time
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 same time
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 = nodes.map { |node| Concurrent::Promises.future(node) { |each| execute(each) } }
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?) : node.job.call
187
- ok = true
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.name}#{elapsed}"
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 spinner: [T] (String label) { () -> T } -> T
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.1.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