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.
@@ -18,9 +18,11 @@ module Dry
18
18
  class Spinner
19
19
  # @param terminal [Terminal]
20
20
  # @param clock [#call] returns monotonic seconds
21
- def initialize(terminal, clock:)
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, format: :dots, hide_cursor: true,
63
- clear: true)
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
- # Frames a running task cycles through on an animated terminal.
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] true for all at once, false for one
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
- def initialize(terminal, clock:)
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
- return nodes.each { |node| execute(node) } unless concurrent
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: INTERVAL) { tick }.tap(&:execute)
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
- glyph, color = Theme::STATES.fetch(node.state)
333
- glyph = FRAMES[frame % FRAMES.size] if node.state == :running && live?
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))}#{pastel.decorate(glyph, color)} #{text(node)}#{elapsed}"
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
@@ -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.3.1
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