dry-cli-ui 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +219 -18
- data/SPECIFICATION.md +71 -11
- data/lib/dry/cli/ui/configuration.rb +160 -0
- data/lib/dry/cli/ui/console.rb +76 -4
- data/lib/dry/cli/ui/status_bar.rb +314 -0
- data/lib/dry/cli/ui/terminal.rb +38 -2
- data/lib/dry/cli/ui/theme.rb +21 -6
- data/lib/dry/cli/ui/version.rb +1 -1
- data/lib/dry/cli/ui/widgets/multi.rb +300 -0
- data/lib/dry/cli/ui/widgets/multi_progress.rb +119 -0
- data/lib/dry/cli/ui/widgets/multi_spinner.rb +69 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +1 -1
- data/lib/dry/cli/ui/widgets/pool.rb +82 -0
- data/lib/dry/cli/ui/widgets/progress.rb +41 -6
- data/lib/dry/cli/ui/widgets/spinner.rb +11 -3
- data/lib/dry/cli/ui/widgets/tasks.rb +17 -61
- data/lib/dry/cli/ui/widgets.rb +4 -0
- data/lib/dry/cli/ui.rb +32 -0
- data/sig/dry/cli/ui.rbs +25 -0
- metadata +7 -1
|
@@ -18,9 +18,11 @@ module Dry
|
|
|
18
18
|
class Spinner
|
|
19
19
|
# @param terminal [Terminal]
|
|
20
20
|
# @param clock [#call] returns monotonic seconds
|
|
21
|
-
|
|
21
|
+
# @param config [Configuration] where the frames come from
|
|
22
|
+
def initialize(terminal, clock:, config: UI.config)
|
|
22
23
|
@terminal = terminal
|
|
23
24
|
@clock = clock
|
|
25
|
+
@config = config
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
# Runs the block under a spinner.
|
|
@@ -33,6 +35,7 @@ module Dry
|
|
|
33
35
|
spinner = nil
|
|
34
36
|
line = Line.new { |text| spinner&.update(detail: text.empty? ? "" : " #{text}") }
|
|
35
37
|
started = clock.call
|
|
38
|
+
terminal.started(line, label)
|
|
36
39
|
spinner = start(label)
|
|
37
40
|
ok = false
|
|
38
41
|
result = Line.call(job, line)
|
|
@@ -40,6 +43,7 @@ module Dry
|
|
|
40
43
|
result
|
|
41
44
|
ensure
|
|
42
45
|
spinner&.stop
|
|
46
|
+
terminal.finished(line, ok)
|
|
43
47
|
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, line.summary(label), clock.call - started))
|
|
44
48
|
end
|
|
45
49
|
|
|
@@ -51,6 +55,9 @@ module Dry
|
|
|
51
55
|
# @return [#call]
|
|
52
56
|
attr_reader :clock
|
|
53
57
|
|
|
58
|
+
# @return [Configuration]
|
|
59
|
+
attr_reader :config
|
|
60
|
+
|
|
54
61
|
# @param label [String]
|
|
55
62
|
# @return [TTY::Spinner, nil] the running spinner, or nil when not animating
|
|
56
63
|
def start(label)
|
|
@@ -59,8 +66,9 @@ module Dry
|
|
|
59
66
|
return
|
|
60
67
|
end
|
|
61
68
|
|
|
62
|
-
TTY::Spinner.new(":spinner #{label}:detail", output: terminal.io,
|
|
63
|
-
|
|
69
|
+
TTY::Spinner.new(":spinner #{label}:detail", output: terminal.io, frames: config.spinner_frames,
|
|
70
|
+
interval: 1.0 / config.spinner_frame_seconds,
|
|
71
|
+
hide_cursor: true, clear: true)
|
|
64
72
|
.tap { |spinner| spinner.update(detail: "") }
|
|
65
73
|
.tap(&:auto_spin)
|
|
66
74
|
end
|
|
@@ -37,23 +37,12 @@ module Dry
|
|
|
37
37
|
# end
|
|
38
38
|
# end
|
|
39
39
|
class Tasks
|
|
40
|
-
#
|
|
41
|
-
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
42
|
-
|
|
43
|
-
# Seconds between spinner frames.
|
|
44
|
-
INTERVAL = 0.1
|
|
45
|
-
|
|
46
|
-
# Checks a `concurrent:` setting.
|
|
40
|
+
# Checks a `concurrent:` setting. See {Pool.concurrency}.
|
|
47
41
|
#
|
|
48
|
-
# @param value [Boolean, Integer]
|
|
49
|
-
# at a time, or the most that may run at once
|
|
42
|
+
# @param value [Boolean, Integer]
|
|
50
43
|
# @return [Boolean, Integer] the value
|
|
51
44
|
# @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
|
|
45
|
+
def self.concurrency(value) = Pool.concurrency(value)
|
|
57
46
|
|
|
58
47
|
# One task, or a group of them.
|
|
59
48
|
class Node
|
|
@@ -152,9 +141,11 @@ module Dry
|
|
|
152
141
|
|
|
153
142
|
# @param terminal [Terminal]
|
|
154
143
|
# @param clock [#call] returns monotonic seconds
|
|
155
|
-
|
|
144
|
+
# @param config [Configuration] where the spinner frames come from
|
|
145
|
+
def initialize(terminal, clock:, config: UI.config)
|
|
156
146
|
@terminal = terminal
|
|
157
147
|
@clock = clock
|
|
148
|
+
@config = config
|
|
158
149
|
@roots = []
|
|
159
150
|
@live = nil
|
|
160
151
|
@lock = Mutex.new
|
|
@@ -192,6 +183,9 @@ module Dry
|
|
|
192
183
|
# @return [#call]
|
|
193
184
|
attr_reader :clock
|
|
194
185
|
|
|
186
|
+
# @return [Configuration]
|
|
187
|
+
attr_reader :config
|
|
188
|
+
|
|
195
189
|
# @return [Array<Node>]
|
|
196
190
|
attr_reader :roots
|
|
197
191
|
|
|
@@ -205,57 +199,20 @@ module Dry
|
|
|
205
199
|
# @param concurrent [Boolean, Integer]
|
|
206
200
|
# @return [void]
|
|
207
201
|
def run_all(nodes, concurrent)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
futures = concurrent == true ? all_at_once(nodes) : at_most(concurrent, nodes)
|
|
211
|
-
futures.each(&:wait)
|
|
212
|
-
failed = futures.find(&:rejected?)
|
|
213
|
-
raise failed.reason if failed
|
|
214
|
-
end
|
|
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
|
|
202
|
+
Pool.run(nodes, concurrent) { |node| execute(node) }
|
|
248
203
|
end
|
|
249
204
|
|
|
250
205
|
# @param node [Node]
|
|
251
206
|
# @return [void]
|
|
252
207
|
def execute(node)
|
|
253
208
|
started = clock.call
|
|
209
|
+
terminal.started(node, node.name) unless node.group?
|
|
254
210
|
change(node, :running)
|
|
255
211
|
ok = false
|
|
256
212
|
node.group? ? run_all(node.children, node.concurrent) : Line.call(node.job, node.line)
|
|
257
213
|
ok = !node.failed?
|
|
258
214
|
ensure
|
|
215
|
+
terminal.finished(node, ok) unless node.group?
|
|
259
216
|
change(node, ok ? :done : :failed, seconds: clock.call - started)
|
|
260
217
|
end
|
|
261
218
|
|
|
@@ -308,7 +265,7 @@ module Dry
|
|
|
308
265
|
return unless live?
|
|
309
266
|
|
|
310
267
|
rows.each_key { |node| terminal.puts(line(node)) }
|
|
311
|
-
Concurrent::TimerTask.new(execution_interval:
|
|
268
|
+
Concurrent::TimerTask.new(execution_interval: config.spinner_frame_seconds) { tick }.tap(&:execute)
|
|
312
269
|
end
|
|
313
270
|
|
|
314
271
|
# @return [void]
|
|
@@ -321,18 +278,17 @@ module Dry
|
|
|
321
278
|
|
|
322
279
|
# @return [void]
|
|
323
280
|
def redraw
|
|
324
|
-
terminal.print(terminal.cursor.up(rows.size))
|
|
325
|
-
rows.each_key { |node| terminal.print("#{terminal.cursor.clear_line}#{line(node)}\n") }
|
|
281
|
+
terminal.print(terminal.cursor.up(rows.size) + rows.each_key.map { |node| "#{terminal.cursor.clear_line}#{line(node)}\n" }.join)
|
|
326
282
|
end
|
|
327
283
|
|
|
328
284
|
# @param node [Node]
|
|
329
285
|
# @return [String]
|
|
330
286
|
def line(node)
|
|
331
287
|
pastel = terminal.pastel
|
|
332
|
-
|
|
333
|
-
glyph =
|
|
288
|
+
frames = config.spinner_frames
|
|
289
|
+
glyph = frames[frame % frames.size] if node.state == :running && live?
|
|
334
290
|
elapsed = " #{pastel.bright_black("(#{Duration.format(node.seconds)})")}" if node.seconds
|
|
335
|
-
"#{pastel.bright_black(rows.fetch(node))}#{
|
|
291
|
+
"#{pastel.bright_black(rows.fetch(node))}#{Theme.marker(pastel, node.state, glyph)} #{text(node)}#{elapsed}"
|
|
336
292
|
end
|
|
337
293
|
|
|
338
294
|
# What follows the glyph: the name, then the detail while the task
|
data/lib/dry/cli/ui/widgets.rb
CHANGED
|
@@ -8,6 +8,10 @@ module Dry
|
|
|
8
8
|
# namespace touches a TTY toolkit class.
|
|
9
9
|
module Widgets
|
|
10
10
|
autoload :Box, File.expand_path("widgets/box", __dir__)
|
|
11
|
+
autoload :Multi, File.expand_path("widgets/multi", __dir__)
|
|
12
|
+
autoload :MultiProgress, File.expand_path("widgets/multi_progress", __dir__)
|
|
13
|
+
autoload :MultiSpinner, File.expand_path("widgets/multi_spinner", __dir__)
|
|
14
|
+
autoload :Pool, File.expand_path("widgets/pool", __dir__)
|
|
11
15
|
autoload :Outcome, File.expand_path("widgets/outcome", __dir__)
|
|
12
16
|
autoload :Progress, File.expand_path("widgets/progress", __dir__)
|
|
13
17
|
autoload :Prompt, File.expand_path("widgets/prompt", __dir__)
|
data/lib/dry/cli/ui.rb
CHANGED
|
@@ -31,13 +31,45 @@ module Dry
|
|
|
31
31
|
# input stream is exhausted.
|
|
32
32
|
class NonInteractiveError < Error; end
|
|
33
33
|
|
|
34
|
+
autoload :Configuration, File.expand_path("ui/configuration", __dir__)
|
|
34
35
|
autoload :Console, File.expand_path("ui/console", __dir__)
|
|
35
36
|
autoload :Duration, File.expand_path("ui/duration", __dir__)
|
|
36
37
|
autoload :Line, File.expand_path("ui/line", __dir__)
|
|
38
|
+
autoload :StatusBar, File.expand_path("ui/status_bar", __dir__)
|
|
37
39
|
autoload :Terminal, File.expand_path("ui/terminal", __dir__)
|
|
38
40
|
autoload :Theme, File.expand_path("ui/theme", __dir__)
|
|
39
41
|
autoload :Widgets, File.expand_path("ui/widgets", __dir__)
|
|
40
42
|
|
|
43
|
+
class << self
|
|
44
|
+
# Make process-wide settings. A block taking an argument receives the
|
|
45
|
+
# configuration; any other block runs against it.
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# Dry::CLI::UI.configure do
|
|
49
|
+
# spinner_format :dots
|
|
50
|
+
# bar_format :box
|
|
51
|
+
# bar_color :cyan
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# @return [Configuration]
|
|
55
|
+
def configure(&block)
|
|
56
|
+
block.arity == 1 ? yield(config) : config.instance_eval(&block)
|
|
57
|
+
config
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Configuration] the process-wide settings
|
|
61
|
+
def config
|
|
62
|
+
@config ||= Configuration.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Forget every process-wide setting.
|
|
66
|
+
#
|
|
67
|
+
# @return [void]
|
|
68
|
+
def reset!
|
|
69
|
+
@config = nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
41
73
|
# The console this command presents through. Writes to the command's own
|
|
42
74
|
# `out` and `err` when dry-cli has set them, and to `$stdout` and
|
|
43
75
|
# `$stderr` otherwise.
|
data/sig/dry/cli/ui.rbs
CHANGED
|
@@ -11,6 +11,28 @@ module Dry
|
|
|
11
11
|
|
|
12
12
|
def ui: () -> Console
|
|
13
13
|
|
|
14
|
+
def self.configure: () { (Configuration config) -> void } -> Configuration
|
|
15
|
+
def self.config: () -> Configuration
|
|
16
|
+
def self.reset!: () -> void
|
|
17
|
+
|
|
18
|
+
class Configuration
|
|
19
|
+
type spinner_definition = Symbol | { interval: Numeric, frames: Array[String] | String }
|
|
20
|
+
type bar_definition = Symbol | { complete: String, incomplete: String }
|
|
21
|
+
|
|
22
|
+
def spinner_format: (?spinner_definition value) -> spinner_definition
|
|
23
|
+
def spinner_format=: (spinner_definition value) -> spinner_definition
|
|
24
|
+
def bar_format: (?bar_definition value) -> bar_definition
|
|
25
|
+
def bar_format=: (bar_definition value) -> bar_definition
|
|
26
|
+
def bar_color: (?Symbol? value) -> Symbol?
|
|
27
|
+
def bar_color=: (Symbol? value) -> Symbol?
|
|
28
|
+
def bar_background: (?Symbol? value) -> Symbol?
|
|
29
|
+
def bar_background=: (Symbol? value) -> Symbol?
|
|
30
|
+
def spinner_frames: () -> Array[String]
|
|
31
|
+
def spinner_frame_seconds: () -> Float
|
|
32
|
+
def bar_complete: () -> String
|
|
33
|
+
def bar_incomplete: () -> String
|
|
34
|
+
end
|
|
35
|
+
|
|
14
36
|
class Line
|
|
15
37
|
def self.call: [T] (^(Line) -> T job, Line line) -> T
|
|
16
38
|
def initialize: () ?{ (String detail) -> void } -> void
|
|
@@ -33,7 +55,10 @@ module Dry
|
|
|
33
55
|
def status: (*_ToS words, ?level: Symbol) -> nil
|
|
34
56
|
def popup: (*_ToS paragraphs, ?title: String?, ?width: Integer?) -> nil
|
|
35
57
|
def spinner: [T] (String label) { (Line line) -> T } -> T
|
|
58
|
+
def multi_spinner: (String title, ?concurrent: (bool | Integer)) { (untyped spinners) -> void } -> Array[untyped]
|
|
36
59
|
def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
|
|
60
|
+
def multi_progress: (String title, ?concurrent: (bool | Integer)) { (untyped bars) -> void } -> Array[untyped]
|
|
61
|
+
def status_bar: [T] (?String? title, ?hints: Array[String] | String) { () -> T } -> T
|
|
37
62
|
def tasks: (?String? title, ?concurrent: (bool | Integer)) { (untyped tasks) -> void } -> nil
|
|
38
63
|
def table: (Array[Array[_ToS]] rows, ?header: Array[_ToS]?) -> nil
|
|
39
64
|
def prompt: (String question, ?default: untyped, ?choices: (Array[String] | Hash[String, untyped])?) -> untyped
|
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.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Konstantin Gredeskoul
|
|
@@ -183,15 +183,21 @@ files:
|
|
|
183
183
|
- examples/bin/mycli
|
|
184
184
|
- lib/dry-cli-ui.rb
|
|
185
185
|
- lib/dry/cli/ui.rb
|
|
186
|
+
- lib/dry/cli/ui/configuration.rb
|
|
186
187
|
- lib/dry/cli/ui/console.rb
|
|
187
188
|
- lib/dry/cli/ui/duration.rb
|
|
188
189
|
- lib/dry/cli/ui/line.rb
|
|
190
|
+
- lib/dry/cli/ui/status_bar.rb
|
|
189
191
|
- lib/dry/cli/ui/terminal.rb
|
|
190
192
|
- lib/dry/cli/ui/theme.rb
|
|
191
193
|
- lib/dry/cli/ui/version.rb
|
|
192
194
|
- lib/dry/cli/ui/widgets.rb
|
|
193
195
|
- lib/dry/cli/ui/widgets/box.rb
|
|
196
|
+
- lib/dry/cli/ui/widgets/multi.rb
|
|
197
|
+
- lib/dry/cli/ui/widgets/multi_progress.rb
|
|
198
|
+
- lib/dry/cli/ui/widgets/multi_spinner.rb
|
|
194
199
|
- lib/dry/cli/ui/widgets/outcome.rb
|
|
200
|
+
- lib/dry/cli/ui/widgets/pool.rb
|
|
195
201
|
- lib/dry/cli/ui/widgets/progress.rb
|
|
196
202
|
- lib/dry/cli/ui/widgets/prompt.rb
|
|
197
203
|
- lib/dry/cli/ui/widgets/spinner.rb
|