dry-cli-ui 0.3.1 → 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.
@@ -16,15 +16,21 @@ module Dry
16
16
  class Handle
17
17
  # @param total [Integer]
18
18
  # @param bar [TTY::ProgressBar, nil]
19
- def initialize(total, bar)
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
 
@@ -46,32 +52,80 @@ module Dry
46
52
  attr_writer :current
47
53
  end
48
54
 
49
- # Columns kept for the label, percentage, count and ETA around the bar.
50
- CHROME = 34
55
+ # Columns kept for the brackets, percentage, count and ETA around the bar.
56
+ CHROME = 36
51
57
 
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
+
72
+ # A bar between brackets, painted as the configuration says: the
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}.
75
+ #
76
+ # @param pastel [Pastel::Delegator] a no-op when colour is off
77
+ # @param config [Configuration]
78
+ # @param ratio [Float] how much is finished, from 0 to 1
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}
81
+ # @return [String]
82
+ def self.bar(pastel, config, ratio, columns, color: nil)
83
+ filled = (ratio * columns).floor
84
+ "[#{complete(pastel, config, color) * filled}#{incomplete(pastel, config) * (columns - filled)}]"
85
+ end
86
+
87
+ # @param pastel [Pastel::Delegator]
88
+ # @param config [Configuration]
89
+ # @param color [Symbol, nil] the style; nil for {Configuration#bar_color}
90
+ # @return [String] one finished cell, painted
91
+ def self.complete(pastel, config, color = nil)
92
+ pastel.decorate(config.bar_complete, *[color || config.bar_color, config.bar_background].compact)
93
+ end
94
+
95
+ # @param pastel [Pastel::Delegator]
96
+ # @param config [Configuration]
97
+ # @return [String] one unfinished cell, painted
98
+ def self.incomplete(pastel, config)
99
+ pastel.decorate(config.bar_incomplete, *[config.bar_background].compact)
100
+ end
101
+
55
102
  # @param terminal [Terminal]
56
103
  # @param clock [#call] returns monotonic seconds
57
- def initialize(terminal, clock:)
104
+ # @param config [Configuration] where the bar's characters come from
105
+ def initialize(terminal, clock:, config: UI.config)
58
106
  @terminal = terminal
59
107
  @clock = clock
108
+ @config = config
60
109
  end
61
110
 
62
111
  # Runs the block with a progress bar.
63
112
  #
64
113
  # @param label [String]
65
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}
66
117
  # @yieldparam progress [Handle]
67
118
  # @return [Object] whatever the block returns
68
- # @raise [ArgumentError] when total is not a non-negative Integer
69
- def run(label, total:)
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)
70
122
  raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
71
123
 
124
+ Progress.color(color)
72
125
  started = clock.call
73
- bar = start(label, total)
74
- handle = Handle.new(total, bar)
126
+ bar = start(label, total, color)
127
+ handle = Handle.new(total, bar, color: color)
128
+ terminal.started(handle, label, progress: handle)
75
129
  ok = false
76
130
  result = yield handle
77
131
  ok = true
@@ -79,6 +133,7 @@ module Dry
79
133
  ensure
80
134
  if handle
81
135
  bar&.stop
136
+ terminal.finished(handle, ok)
82
137
  summary = "#{label} #{handle.current}/#{total}"
83
138
  terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
84
139
  end
@@ -92,22 +147,26 @@ module Dry
92
147
  # @return [#call]
93
148
  attr_reader :clock
94
149
 
150
+ # @return [Configuration]
151
+ attr_reader :config
152
+
95
153
  # @param label [String]
96
154
  # @param total [Integer]
155
+ # @param color [Symbol, nil]
97
156
  # @return [TTY::ProgressBar, nil]
98
- def start(label, total)
157
+ def start(label, total, color)
99
158
  unless terminal.animated? && total.positive?
100
159
  terminal.puts("#{label}...")
101
160
  return
102
161
  end
103
162
 
104
163
  TTY::ProgressBar.new(
105
- "#{label} :bar :percent :current/:total ETA :eta",
164
+ "#{label} [:bar] :percent :current/:total ETA :eta",
106
165
  total: total,
107
166
  width: [terminal.width - label.length - CHROME, MIN_BAR].max,
108
167
  output: terminal.io,
109
- complete: "█",
110
- incomplete: "░",
168
+ complete: Progress.complete(terminal.pastel, config, color),
169
+ incomplete: Progress.incomplete(terminal.pastel, config),
111
170
  clear: true,
112
171
  hide_cursor: true
113
172
  )
@@ -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
36
- def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
58
+ def multi_spinner: (String title, ?concurrent: (bool | Integer)) { (untyped spinners) -> void } -> Array[untyped]
59
+ def progress: [T] (String label, total: Integer, ?color: Symbol?) { (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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Konstantin Gredeskoul
@@ -176,22 +176,29 @@ files:
176
176
  - CHANGELOG.md
177
177
  - LICENSE.txt
178
178
  - README.md
179
- - SPECIFICATION.md
179
+ - examples/.envrc
180
+ - examples/.gitignore
180
181
  - examples/Gemfile
181
182
  - examples/Gemfile.lock
182
183
  - examples/README.md
183
184
  - examples/bin/mycli
184
185
  - lib/dry-cli-ui.rb
185
186
  - lib/dry/cli/ui.rb
187
+ - lib/dry/cli/ui/configuration.rb
186
188
  - lib/dry/cli/ui/console.rb
187
189
  - lib/dry/cli/ui/duration.rb
188
190
  - lib/dry/cli/ui/line.rb
191
+ - lib/dry/cli/ui/status_bar.rb
189
192
  - lib/dry/cli/ui/terminal.rb
190
193
  - lib/dry/cli/ui/theme.rb
191
194
  - lib/dry/cli/ui/version.rb
192
195
  - lib/dry/cli/ui/widgets.rb
193
196
  - lib/dry/cli/ui/widgets/box.rb
197
+ - lib/dry/cli/ui/widgets/multi.rb
198
+ - lib/dry/cli/ui/widgets/multi_progress.rb
199
+ - lib/dry/cli/ui/widgets/multi_spinner.rb
194
200
  - lib/dry/cli/ui/widgets/outcome.rb
201
+ - lib/dry/cli/ui/widgets/pool.rb
195
202
  - lib/dry/cli/ui/widgets/progress.rb
196
203
  - lib/dry/cli/ui/widgets/prompt.rb
197
204
  - lib/dry/cli/ui/widgets/spinner.rb