clack 0.6.2 → 0.7.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 +55 -0
- data/README.md +281 -14
- data/examples/migration_from_tty_prompt.rb +13 -4
- data/lib/clack/box.rb +11 -5
- data/lib/clack/colors.rb +9 -4
- data/lib/clack/core/chrome.rb +139 -0
- data/lib/clack/core/ci_mode.rb +6 -3
- data/lib/clack/core/key_reader.rb +78 -18
- data/lib/clack/core/options_helper.rb +43 -6
- data/lib/clack/core/prompt.rb +47 -72
- data/lib/clack/core/settings.rb +87 -6
- data/lib/clack/environment.rb +28 -4
- data/lib/clack/errors.rb +19 -0
- data/lib/clack/log.rb +31 -17
- data/lib/clack/note.rb +12 -5
- data/lib/clack/prompts/autocomplete.rb +13 -12
- data/lib/clack/prompts/autocomplete_multiselect.rb +12 -21
- data/lib/clack/prompts/confirm.rb +30 -12
- data/lib/clack/prompts/date.rb +1 -1
- data/lib/clack/prompts/group_multiselect.rb +48 -32
- data/lib/clack/prompts/multiline_text.rb +6 -6
- data/lib/clack/prompts/multiselect.rb +12 -19
- data/lib/clack/prompts/password.rb +2 -2
- data/lib/clack/prompts/path.rb +14 -3
- data/lib/clack/prompts/range.rb +37 -4
- data/lib/clack/prompts/select.rb +8 -1
- data/lib/clack/prompts/select_key.rb +53 -30
- data/lib/clack/prompts/spinner.rb +198 -17
- data/lib/clack/prompts/tasks.rb +34 -11
- data/lib/clack/prompts/text.rb +2 -2
- data/lib/clack/stream.rb +38 -18
- data/lib/clack/symbols.rb +6 -1
- data/lib/clack/task_log.rb +24 -8
- data/lib/clack/utils.rb +18 -6
- data/lib/clack/validators.rb +136 -7
- data/lib/clack/version.rb +1 -1
- data/lib/clack.rb +197 -31
- metadata +3 -1
|
@@ -11,6 +11,13 @@ module Clack
|
|
|
11
11
|
# - `:dots` - animating dots after message (default)
|
|
12
12
|
# - `:timer` - elapsed time display [Xs] or [Xm Ys]
|
|
13
13
|
#
|
|
14
|
+
# Exit safety: a spinner still running when the process exits, whether by
|
|
15
|
+
# `exit` (including `exit 0`), Ctrl+C, or an uncaught exception, prints its
|
|
16
|
+
# cancel or error line first and stops the animation thread, instead of
|
|
17
|
+
# leaving a half-drawn frame. `Clack.spin` does the same when its block
|
|
18
|
+
# exits early. Exit and signals count as cancelled; any other exception
|
|
19
|
+
# counts as an error.
|
|
20
|
+
#
|
|
14
21
|
# @example Basic usage
|
|
15
22
|
# s = Clack.spinner
|
|
16
23
|
# s.start("Installing...")
|
|
@@ -31,19 +38,132 @@ module Clack
|
|
|
31
38
|
# do_step_2
|
|
32
39
|
# s.stop("All done!")
|
|
33
40
|
#
|
|
41
|
+
# @example Cancel and error messages
|
|
42
|
+
# s = Clack.spinner(cancel_message: "Deploy aborted", error_message: "Deploy failed",
|
|
43
|
+
# on_cancel: -> { release_lock })
|
|
44
|
+
# s.start("Deploying")
|
|
45
|
+
# s.cancel # => "■ Deploy aborted", then on_cancel runs
|
|
46
|
+
#
|
|
34
47
|
class Spinner
|
|
48
|
+
# Running spinners, so the process-exit hook can finish them. A module
|
|
49
|
+
# rather than class-level state so reek counts its ivars separately.
|
|
50
|
+
#
|
|
51
|
+
# Lock order: a spinner calls {register}/{unregister} while holding its own
|
|
52
|
+
# mutex (membership changes with the state flip), so the registry mutex
|
|
53
|
+
# nests inside a spinner mutex and never the reverse. {abandon_active}
|
|
54
|
+
# snapshots the list and releases the registry mutex before calling any
|
|
55
|
+
# spinner.
|
|
56
|
+
# @api private
|
|
57
|
+
module Registry
|
|
58
|
+
@active = []
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
@exit_hook_installed = false
|
|
61
|
+
@owner_pid = nil
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
# @api private
|
|
65
|
+
# @return [Array<Spinner>] snapshot of running spinners
|
|
66
|
+
def active = @mutex.synchronize { @active.dup }
|
|
67
|
+
|
|
68
|
+
# @api private
|
|
69
|
+
# Track a spinner that just started; installs the exit hook on first use.
|
|
70
|
+
# @param spinner [Spinner]
|
|
71
|
+
# @return [nil]
|
|
72
|
+
def register(spinner)
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
install_exit_hook
|
|
75
|
+
@active << spinner unless @active.include?(spinner)
|
|
76
|
+
end
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @api private
|
|
81
|
+
# Forget a spinner that finished or was cleared.
|
|
82
|
+
# @param spinner [Spinner]
|
|
83
|
+
# @return [nil]
|
|
84
|
+
def unregister(spinner)
|
|
85
|
+
@mutex.synchronize { @active.delete(spinner) }
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @api private
|
|
90
|
+
# Finish every running spinner as abandoned (see Spinner#abandon).
|
|
91
|
+
# A spinner whose output stream fails (closed, EPIPE at exit) is
|
|
92
|
+
# reported with Kernel#warn and the rest are still finished.
|
|
93
|
+
# @param exception [Exception, nil] the exception ending the process, typically $!
|
|
94
|
+
# @return [nil]
|
|
95
|
+
def abandon_active(exception = nil)
|
|
96
|
+
active.each do |spinner|
|
|
97
|
+
spinner.abandon(exception)
|
|
98
|
+
rescue => error
|
|
99
|
+
warn_failure(error)
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Registered on the first start (not at require time) so it runs before
|
|
107
|
+
# at_exit blocks the application registered earlier: the final line lands
|
|
108
|
+
# before their output instead of after it.
|
|
109
|
+
#
|
|
110
|
+
# A forked child inherits this hook and the registry contents, both of
|
|
111
|
+
# which belong to the parent. The hook checks the pid it was installed
|
|
112
|
+
# under so the child never finishes the parent's spinners (that would
|
|
113
|
+
# print the cancel line into the shared stdout and run on_cancel in the
|
|
114
|
+
# wrong process). When the child starts a spinner of its own, the
|
|
115
|
+
# inherited entries are dropped and a hook for the child is installed.
|
|
116
|
+
def install_exit_hook
|
|
117
|
+
pid = Process.pid
|
|
118
|
+
return if @exit_hook_installed && @owner_pid == pid
|
|
119
|
+
|
|
120
|
+
@active.clear if @exit_hook_installed
|
|
121
|
+
@exit_hook_installed = true
|
|
122
|
+
@owner_pid = pid
|
|
123
|
+
at_exit do
|
|
124
|
+
abandon_active($!) if Process.pid == pid
|
|
125
|
+
rescue => exception
|
|
126
|
+
warn_failure(exception)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def warn_failure(exception)
|
|
131
|
+
warn "clack: failed to finish spinner: #{exception.class}: #{exception.message}"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
35
136
|
# @param indicator [:dots, :timer] animation style (default: :dots)
|
|
36
137
|
# @param frames [Array<String>, nil] custom spinner frames
|
|
37
138
|
# @param delay [Float, nil] delay between frames in seconds
|
|
38
139
|
# @param style_frame [Proc, nil] proc to style each frame character
|
|
140
|
+
# @param cancel_message [String, nil] text for {#cancel} with no argument and for
|
|
141
|
+
# Ctrl+C / exit while running (default: the global messages[:cancel], "Cancelled")
|
|
142
|
+
# @param error_message [String, nil] text for {#error} with no argument and for an
|
|
143
|
+
# uncaught exception while running (default: the global messages[:error],
|
|
144
|
+
# "Something went wrong")
|
|
145
|
+
# @param on_cancel [#call, nil] called with no arguments after the spinner is
|
|
146
|
+
# cancelled; a StandardError raised by it is reported with Kernel#warn and swallowed
|
|
147
|
+
# @param with_guide [Boolean, nil] print the guide rail line above the spinner
|
|
148
|
+
# (default: Clack.settings[:with_guide])
|
|
39
149
|
# @param output [IO] output stream (default: $stdout)
|
|
150
|
+
# @raise [ArgumentError] if cancel_message/error_message are not Strings or
|
|
151
|
+
# on_cancel does not respond to #call
|
|
40
152
|
def initialize(
|
|
41
153
|
indicator: :dots,
|
|
42
154
|
frames: nil,
|
|
43
155
|
delay: nil,
|
|
44
156
|
style_frame: nil,
|
|
157
|
+
cancel_message: nil,
|
|
158
|
+
error_message: nil,
|
|
159
|
+
on_cancel: nil,
|
|
160
|
+
with_guide: nil,
|
|
45
161
|
output: $stdout
|
|
46
162
|
)
|
|
163
|
+
validate_options(cancel_message, error_message, on_cancel)
|
|
164
|
+
@messages = {cancel: cancel_message, error: error_message}.freeze
|
|
165
|
+
@on_cancel = on_cancel
|
|
166
|
+
@with_guide = with_guide
|
|
47
167
|
@output = output
|
|
48
168
|
@indicator = indicator
|
|
49
169
|
@frames = frames || Symbols::SPINNER_FRAMES
|
|
@@ -54,7 +174,9 @@ module Clack
|
|
|
54
174
|
@thread = nil
|
|
55
175
|
@frame_idx = 0
|
|
56
176
|
@prev_frame = nil
|
|
57
|
-
|
|
177
|
+
# The pid that started the spinner and the monotonic start time; nil
|
|
178
|
+
# while idle. One hash so the class stays within reek's ivar limit.
|
|
179
|
+
@run = nil
|
|
58
180
|
@mutex = Mutex.new
|
|
59
181
|
end
|
|
60
182
|
|
|
@@ -70,11 +192,14 @@ module Clack
|
|
|
70
192
|
@state = :running
|
|
71
193
|
@prev_frame = nil
|
|
72
194
|
@frame_idx = 0
|
|
73
|
-
@
|
|
195
|
+
@run = {pid: Process.pid, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}.freeze
|
|
196
|
+
# Under the mutex so a concurrent finish cannot slip between the
|
|
197
|
+
# state flip and registration and leave a finished spinner tracked.
|
|
198
|
+
Registry.register(self)
|
|
74
199
|
end
|
|
75
200
|
|
|
76
201
|
@output.print Core::Cursor.hide
|
|
77
|
-
@output.print "#{Colors.gray(Symbols::S_BAR)}\n"
|
|
202
|
+
@output.print "#{Colors.gray(Symbols::S_BAR)}\n" if Core::Settings.with_guide?(@with_guide)
|
|
78
203
|
|
|
79
204
|
@thread = Thread.new { spin_loop }
|
|
80
205
|
self
|
|
@@ -89,14 +214,16 @@ module Clack
|
|
|
89
214
|
|
|
90
215
|
# Stop with error state.
|
|
91
216
|
#
|
|
92
|
-
# @param message [String, nil] error message (
|
|
217
|
+
# @param message [String, nil] error message (default: +error_message:+,
|
|
218
|
+
# then the global messages[:error])
|
|
93
219
|
def error(message = nil)
|
|
94
220
|
finish(:error, message)
|
|
95
221
|
end
|
|
96
222
|
|
|
97
|
-
# Stop with cancelled state
|
|
223
|
+
# Stop with cancelled state and run +on_cancel+.
|
|
98
224
|
#
|
|
99
|
-
# @param message [String, nil] cancellation message
|
|
225
|
+
# @param message [String, nil] cancellation message (default: +cancel_message:+,
|
|
226
|
+
# then the global messages[:cancel])
|
|
100
227
|
def cancel(message = nil)
|
|
101
228
|
finish(:cancelled, message)
|
|
102
229
|
end
|
|
@@ -113,6 +240,7 @@ module Clack
|
|
|
113
240
|
def clear
|
|
114
241
|
@mutex.synchronize do
|
|
115
242
|
@state = :idle
|
|
243
|
+
Registry.unregister(self)
|
|
116
244
|
end
|
|
117
245
|
@thread&.join
|
|
118
246
|
restore_cursor
|
|
@@ -120,14 +248,64 @@ module Clack
|
|
|
120
248
|
@output.print Core::Cursor.show
|
|
121
249
|
end
|
|
122
250
|
|
|
251
|
+
# @return [Boolean] true once {#cancel} has finished the spinner
|
|
123
252
|
def cancelled? = @mutex.synchronize { @state == :cancelled }
|
|
124
253
|
|
|
254
|
+
# @return [Boolean] true between start and the first of stop/error/cancel/clear
|
|
255
|
+
def running? = @mutex.synchronize { @state == :running }
|
|
256
|
+
|
|
257
|
+
# @api private
|
|
258
|
+
# Finish a spinner whose caller is not coming back. Exit and signals end
|
|
259
|
+
# cancelled, any other exception ends in the error state, nil (break, throw,
|
|
260
|
+
# or a forgotten stop at process exit) ends cancelled. No-op unless running.
|
|
261
|
+
# @param exception [Exception, nil]
|
|
262
|
+
# @return [void]
|
|
263
|
+
def abandon(exception = nil)
|
|
264
|
+
return unless running?
|
|
265
|
+
|
|
266
|
+
crash?(exception) ? error : cancel
|
|
267
|
+
end
|
|
268
|
+
|
|
125
269
|
private
|
|
126
270
|
|
|
271
|
+
def validate_options(cancel_message, error_message, on_cancel)
|
|
272
|
+
raise ArgumentError, "cancel_message must be a String" unless cancel_message.nil? || cancel_message.is_a?(String)
|
|
273
|
+
raise ArgumentError, "error_message must be a String" unless error_message.nil? || error_message.is_a?(String)
|
|
274
|
+
raise ArgumentError, "on_cancel must respond to #call" unless on_cancel.nil? || on_cancel.respond_to?(:call)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def crash?(exception) = !(exception.nil? || exception.is_a?(SystemExit) || exception.is_a?(SignalException))
|
|
278
|
+
|
|
279
|
+
# Resolved at finish time, not construction, so update_settings after
|
|
280
|
+
# Clack.spinner still applies.
|
|
281
|
+
def default_final_message(end_state)
|
|
282
|
+
case end_state
|
|
283
|
+
when :cancelled then @messages[:cancel] || Core::Settings.message(:cancel)
|
|
284
|
+
when :error then @messages[:error] || Core::Settings.message(:error)
|
|
285
|
+
else @message
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def final_symbol(end_state)
|
|
290
|
+
case end_state
|
|
291
|
+
when :success then Colors.green(Symbols::S_STEP_SUBMIT)
|
|
292
|
+
when :error then Colors.red(Symbols::S_STEP_ERROR)
|
|
293
|
+
when :cancelled then Colors.red(Symbols::S_STEP_CANCEL)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The hook runs during exit, signal unwinding, and at_exit, where a raise
|
|
298
|
+
# would replace the in-flight SystemExit or change the exit status.
|
|
299
|
+
def run_on_cancel
|
|
300
|
+
@on_cancel&.call
|
|
301
|
+
rescue => exception
|
|
302
|
+
warn "clack: on_cancel raised: #{exception.class}: #{exception.message}"
|
|
303
|
+
end
|
|
304
|
+
|
|
127
305
|
def remove_trailing_dots(msg) = msg.to_s.sub(/\.+$/, "")
|
|
128
306
|
|
|
129
307
|
def format_timer
|
|
130
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @
|
|
308
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @run[:started_at]
|
|
131
309
|
min = (elapsed / 60).to_i
|
|
132
310
|
secs = (elapsed % 60).to_i
|
|
133
311
|
min.positive? ? "[#{min}m #{secs}s]" : "[#{secs}s]"
|
|
@@ -174,24 +352,27 @@ module Clack
|
|
|
174
352
|
return unless @state == :running
|
|
175
353
|
|
|
176
354
|
@state = end_state
|
|
355
|
+
# Unregistered with the state flip, before any print can raise, so
|
|
356
|
+
# the registry never retains a finished spinner.
|
|
357
|
+
Registry.unregister(self)
|
|
358
|
+
# A child of a block-less fork inherits this running spinner and, on
|
|
359
|
+
# exit, unwinds through the parent's Clack.spin rescue with it. The
|
|
360
|
+
# parent owns the final line and on_cancel, so the child only marks
|
|
361
|
+
# its copy finished instead of printing into the shared stdout.
|
|
362
|
+
return unless @run[:pid] == Process.pid
|
|
363
|
+
|
|
177
364
|
thread_to_join = @thread
|
|
178
|
-
suffix = (@indicator == :timer
|
|
179
|
-
[message ||
|
|
365
|
+
suffix = (@indicator == :timer) ? " #{format_timer}" : ""
|
|
366
|
+
[message || default_final_message(end_state), suffix]
|
|
180
367
|
end
|
|
181
368
|
|
|
182
369
|
thread_to_join&.join(5)
|
|
183
370
|
thread_to_join&.kill if thread_to_join&.alive?
|
|
184
371
|
|
|
185
372
|
@output.print "\r#{Core::Cursor.clear_to_end}"
|
|
186
|
-
|
|
187
|
-
symbol = case end_state
|
|
188
|
-
when :success then Colors.green(Symbols::S_STEP_SUBMIT)
|
|
189
|
-
when :error then Colors.red(Symbols::S_STEP_ERROR)
|
|
190
|
-
when :cancelled then Colors.red(Symbols::S_STEP_CANCEL)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
@output.print "#{symbol} #{msg}#{timer_suffix}\n"
|
|
373
|
+
@output.print "#{final_symbol(end_state)} #{msg}#{timer_suffix}\n"
|
|
194
374
|
@output.print Core::Cursor.show
|
|
375
|
+
run_on_cancel if end_state == :cancelled
|
|
195
376
|
end
|
|
196
377
|
|
|
197
378
|
def restore_cursor
|
data/lib/clack/prompts/tasks.rb
CHANGED
|
@@ -9,9 +9,10 @@ module Clack
|
|
|
9
9
|
#
|
|
10
10
|
# Each task is a hash with:
|
|
11
11
|
# - +:title+ - display title
|
|
12
|
-
# - +:task+ - Proc to execute
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# - +:task+ - Proc to execute. A StandardError is caught and recorded
|
|
13
|
+
# as a result; exit, signals, and other exceptions finish the
|
|
14
|
+
# spinner and propagate. Optionally accepts a message-update
|
|
15
|
+
# callable to change the spinner message mid-execution.
|
|
15
16
|
# - +:enabled+ - optional boolean (default true). When false,
|
|
16
17
|
# the task is skipped entirely.
|
|
17
18
|
#
|
|
@@ -64,8 +65,10 @@ module Clack
|
|
|
64
65
|
TaskResult = Data.define(:title, :status, :error)
|
|
65
66
|
|
|
66
67
|
# @param tasks [Array<Hash>] tasks with :title, :task, and optional :enabled keys
|
|
68
|
+
# @param with_guide [Boolean, nil] show the guide rail on each spinner and error
|
|
69
|
+
# detail line (default: Clack.settings[:with_guide])
|
|
67
70
|
# @param output [IO] output stream (default: $stdout)
|
|
68
|
-
def initialize(tasks:, output: $stdout)
|
|
71
|
+
def initialize(tasks:, with_guide: nil, output: $stdout)
|
|
69
72
|
@tasks = tasks.map do |task_data|
|
|
70
73
|
Task.new(
|
|
71
74
|
title: task_data[:title],
|
|
@@ -73,6 +76,7 @@ module Clack
|
|
|
73
76
|
enabled: task_data.fetch(:enabled, true)
|
|
74
77
|
)
|
|
75
78
|
end
|
|
79
|
+
@with_guide = with_guide
|
|
76
80
|
@output = output
|
|
77
81
|
@results = []
|
|
78
82
|
end
|
|
@@ -98,9 +102,20 @@ module Clack
|
|
|
98
102
|
|
|
99
103
|
private
|
|
100
104
|
|
|
105
|
+
# Gutter in front of the red error detail line under a failed task.
|
|
106
|
+
def error_prefix
|
|
107
|
+
Core::Settings.with_guide?(@with_guide) ? "#{Colors.gray(Symbols::S_BAR)} " : ""
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Exit, signals, break, and throw end the spinner with the cancel line and
|
|
111
|
+
# propagate; a non-StandardError prints the error line and propagates.
|
|
112
|
+
# Only StandardError is recorded as a result. +title.to_s+ on the error
|
|
113
|
+
# calls keeps a title-less task printing a blank line rather than the
|
|
114
|
+
# configured error message.
|
|
101
115
|
def run_task(task)
|
|
102
|
-
|
|
103
|
-
spinner.
|
|
116
|
+
title = task.title
|
|
117
|
+
spinner = Spinner.new(output: @output, with_guide: @with_guide)
|
|
118
|
+
spinner.start(title)
|
|
104
119
|
|
|
105
120
|
begin
|
|
106
121
|
if task.task.arity.zero?
|
|
@@ -108,12 +123,20 @@ module Clack
|
|
|
108
123
|
else
|
|
109
124
|
task.task.call(spinner.method(:message))
|
|
110
125
|
end
|
|
111
|
-
spinner.stop(
|
|
112
|
-
@results << TaskResult.new(title:
|
|
126
|
+
spinner.stop(title)
|
|
127
|
+
@results << TaskResult.new(title: title, status: :success, error: nil)
|
|
113
128
|
rescue => exception
|
|
114
|
-
spinner.error(
|
|
115
|
-
@output.puts "#{
|
|
116
|
-
@results << TaskResult.new(title:
|
|
129
|
+
spinner.error(title.to_s)
|
|
130
|
+
@output.puts "#{error_prefix}#{Colors.red(exception.message)}"
|
|
131
|
+
@results << TaskResult.new(title: title, status: :error, error: exception.message)
|
|
132
|
+
rescue SystemExit, SignalException
|
|
133
|
+
spinner.cancel
|
|
134
|
+
raise
|
|
135
|
+
rescue Exception # standard:disable Lint/RescueException
|
|
136
|
+
spinner.error(title.to_s)
|
|
137
|
+
raise
|
|
138
|
+
ensure
|
|
139
|
+
spinner.abandon
|
|
117
140
|
end
|
|
118
141
|
end
|
|
119
142
|
end
|
data/lib/clack/prompts/text.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Clack
|
|
|
47
47
|
# @param initial_value [String, nil] pre-filled editable text
|
|
48
48
|
# @param completions [Array<String>, Proc, nil] tab completion candidates. Array of
|
|
49
49
|
# strings or a proc that receives current input and returns candidates.
|
|
50
|
-
# @option opts [Proc, nil] :validate
|
|
50
|
+
# @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator (see {Validators.resolve})
|
|
51
51
|
# @option opts [Hash] additional options passed to {Core::Prompt}
|
|
52
52
|
def initialize(message:, placeholder: nil, default_value: nil, initial_value: nil, completions: nil, **opts)
|
|
53
53
|
super(message:, **opts)
|
|
@@ -88,7 +88,7 @@ module Clack
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def build_frame
|
|
91
|
-
"#{frame_header}#{active_bar}
|
|
91
|
+
"#{frame_header}#{gutter(active_bar)}#{input_display}\n#{frame_footer}"
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
private
|
data/lib/clack/stream.rb
CHANGED
|
@@ -6,70 +6,85 @@ require "stringio"
|
|
|
6
6
|
module Clack
|
|
7
7
|
# Stream logging utility for iterables, enumerables, and IO streams.
|
|
8
8
|
# Similar to Log but works with streaming data in real-time.
|
|
9
|
+
#
|
|
10
|
+
# Every method accepts +with_guide:+ (default: the global setting). With
|
|
11
|
+
# guides off the level symbol stays on the first line and continuation
|
|
12
|
+
# lines lose their rail prefix.
|
|
9
13
|
module Stream
|
|
10
14
|
class << self
|
|
11
15
|
# Stream lines with an info symbol (cyan).
|
|
12
16
|
# @param source [IO, String, Enumerable] data source to stream
|
|
17
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
13
18
|
# @param output [IO] output stream
|
|
14
19
|
# @yield [line] optional block called for each line
|
|
15
20
|
# @return [void]
|
|
16
|
-
def info(source, output: $stdout, &block)
|
|
17
|
-
stream_with_symbol(source, Symbols::S_INFO, :cyan, output, &block)
|
|
21
|
+
def info(source, with_guide: nil, output: $stdout, &block)
|
|
22
|
+
stream_with_symbol(source, Symbols::S_INFO, :cyan, with_guide, output, &block)
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# Stream lines with a success symbol (green).
|
|
21
26
|
# @param source [IO, String, Enumerable] data source to stream
|
|
27
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
22
28
|
# @param output [IO] output stream
|
|
23
29
|
# @yield [line] optional block called for each line
|
|
24
30
|
# @return [void]
|
|
25
|
-
def success(source, output: $stdout, &block)
|
|
26
|
-
stream_with_symbol(source, Symbols::S_SUCCESS, :green, output, &block)
|
|
31
|
+
def success(source, with_guide: nil, output: $stdout, &block)
|
|
32
|
+
stream_with_symbol(source, Symbols::S_SUCCESS, :green, with_guide, output, &block)
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
# Stream lines with a step symbol (green).
|
|
30
36
|
# @param source [IO, String, Enumerable] data source to stream
|
|
37
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
31
38
|
# @param output [IO] output stream
|
|
32
39
|
# @yield [line] optional block called for each line
|
|
33
40
|
# @return [void]
|
|
34
|
-
def step(source, output: $stdout, &block)
|
|
35
|
-
stream_with_symbol(source, Symbols::S_STEP_SUBMIT, :green, output, &block)
|
|
41
|
+
def step(source, with_guide: nil, output: $stdout, &block)
|
|
42
|
+
stream_with_symbol(source, Symbols::S_STEP_SUBMIT, :green, with_guide, output, &block)
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
# Stream lines with a warning symbol (yellow).
|
|
39
46
|
# @param source [IO, String, Enumerable] data source to stream
|
|
47
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
40
48
|
# @param output [IO] output stream
|
|
41
49
|
# @yield [line] optional block called for each line
|
|
42
50
|
# @return [void]
|
|
43
|
-
def warn(source, output: $stdout, &block)
|
|
44
|
-
stream_with_symbol(source, Symbols::S_WARN, :yellow, output, &block)
|
|
51
|
+
def warn(source, with_guide: nil, output: $stdout, &block)
|
|
52
|
+
stream_with_symbol(source, Symbols::S_WARN, :yellow, with_guide, output, &block)
|
|
45
53
|
end
|
|
46
54
|
|
|
47
55
|
# Stream lines with an error symbol (red).
|
|
48
56
|
# @param source [IO, String, Enumerable] data source to stream
|
|
57
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
49
58
|
# @param output [IO] output stream
|
|
50
59
|
# @yield [line] optional block called for each line
|
|
51
60
|
# @return [void]
|
|
52
|
-
def error(source, output: $stdout, &block)
|
|
53
|
-
stream_with_symbol(source, Symbols::S_ERROR, :red, output, &block)
|
|
61
|
+
def error(source, with_guide: nil, output: $stdout, &block)
|
|
62
|
+
stream_with_symbol(source, Symbols::S_ERROR, :red, with_guide, output, &block)
|
|
54
63
|
end
|
|
55
64
|
|
|
56
|
-
# Stream lines with a plain bar prefix (no symbol).
|
|
65
|
+
# Stream lines with a plain bar prefix (no symbol), or bare lines with guides off.
|
|
57
66
|
# @param source [IO, String, Enumerable] data source to stream
|
|
67
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
58
68
|
# @param output [IO] output stream
|
|
59
69
|
# @return [void]
|
|
60
|
-
def message(source, output: $stdout)
|
|
70
|
+
def message(source, with_guide: nil, output: $stdout)
|
|
71
|
+
prefix = rail_prefix(with_guide)
|
|
61
72
|
each_line(source) do |line|
|
|
62
|
-
output.puts "#{
|
|
73
|
+
output.puts "#{prefix}#{line.chomp}"
|
|
63
74
|
output.flush
|
|
64
75
|
end
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
# Stream from a subprocess command.
|
|
68
79
|
# Usage: Clack.stream.command("npm install", type: :info)
|
|
69
|
-
#
|
|
70
|
-
|
|
80
|
+
# @param cmd [String] the shell command to run
|
|
81
|
+
# @param type [Symbol] which stream method renders the output (:info, :success, :step, :warn, :error)
|
|
82
|
+
# @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
|
|
83
|
+
# @param output [IO] output stream
|
|
84
|
+
# @return [Boolean] true on success, false on failure or if the command cannot be executed
|
|
85
|
+
def command(cmd, type: :info, with_guide: nil, output: $stdout)
|
|
71
86
|
IO.popen(cmd, err: %i[child out]) do |io|
|
|
72
|
-
send(type, io, output:
|
|
87
|
+
send(type, io, with_guide:, output:)
|
|
73
88
|
end
|
|
74
89
|
$CHILD_STATUS.success?
|
|
75
90
|
rescue Errno::ENOENT
|
|
@@ -78,15 +93,20 @@ module Clack
|
|
|
78
93
|
|
|
79
94
|
private
|
|
80
95
|
|
|
81
|
-
def
|
|
96
|
+
def rail_prefix(with_guide)
|
|
97
|
+
Core::Settings.with_guide?(with_guide) ? "#{Colors.gray(Symbols::S_BAR)} " : ""
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def stream_with_symbol(source, symbol, color, with_guide, output)
|
|
82
101
|
first = true
|
|
102
|
+
prefix = rail_prefix(with_guide)
|
|
83
103
|
each_line(source) do |line|
|
|
84
104
|
line = line.chomp
|
|
85
105
|
if first
|
|
86
106
|
output.puts "#{Colors.send(color, symbol)} #{line}"
|
|
87
107
|
first = false
|
|
88
108
|
else
|
|
89
|
-
output.puts "#{
|
|
109
|
+
output.puts "#{prefix}#{line}"
|
|
90
110
|
end
|
|
91
111
|
output.flush
|
|
92
112
|
yield line if block_given?
|
data/lib/clack/symbols.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Clack
|
|
|
7
7
|
class << self
|
|
8
8
|
# Check if unicode output is enabled.
|
|
9
9
|
# CLACK_UNICODE=1 forces unicode, CLACK_UNICODE=0 forces ASCII.
|
|
10
|
-
# Otherwise
|
|
10
|
+
# Otherwise follows {Environment.colors_supported?} (TTY, TERM, NO_COLOR, FORCE_COLOR).
|
|
11
11
|
def unicode?
|
|
12
12
|
return @unicode if defined?(@unicode)
|
|
13
13
|
|
|
@@ -87,6 +87,11 @@ module Clack
|
|
|
87
87
|
# Unicode error log symbol, or ASCII fallback.
|
|
88
88
|
S_ERROR = unicode? ? "■" : "x"
|
|
89
89
|
|
|
90
|
+
# Keyboard hint pieces used by the instruction footer
|
|
91
|
+
S_ARROWS_UP_DOWN = unicode? ? "↑/↓" : "up/down"
|
|
92
|
+
# Unicode bullet separator between hints, or ASCII fallback.
|
|
93
|
+
S_HINT_SEPARATOR = unicode? ? " • " : " | "
|
|
94
|
+
|
|
90
95
|
# File system
|
|
91
96
|
S_FOLDER = unicode? ? "📁" : "[D]"
|
|
92
97
|
# Unicode file icon, or ASCII fallback.
|
data/lib/clack/task_log.rb
CHANGED
|
@@ -15,11 +15,16 @@ module Clack
|
|
|
15
15
|
# @param title [String] Title displayed at the top
|
|
16
16
|
# @param limit [Integer, nil] Max lines to show (older lines scroll out)
|
|
17
17
|
# @param retain_log [Boolean] Keep full log history for display on error
|
|
18
|
+
# @param with_guide [Boolean, nil] Show the guide rail around the title and log lines
|
|
19
|
+
# (default: Clack.settings[:with_guide])
|
|
18
20
|
# @param output [IO] Output stream
|
|
19
|
-
def initialize(title:, limit: nil, retain_log: false, output: $stdout)
|
|
21
|
+
def initialize(title:, limit: nil, retain_log: false, with_guide: nil, output: $stdout)
|
|
20
22
|
@title = title
|
|
21
23
|
@limit = limit
|
|
22
24
|
@retain_log = retain_log
|
|
25
|
+
# Resolved once: the title is printed right here, and every later redraw
|
|
26
|
+
# must clear the same number of lines, so the setting cannot change mid-log.
|
|
27
|
+
@guide = Core::Settings.with_guide?(with_guide)
|
|
23
28
|
@output = output
|
|
24
29
|
@buffer = []
|
|
25
30
|
@full_buffer = []
|
|
@@ -80,8 +85,15 @@ module Clack
|
|
|
80
85
|
private
|
|
81
86
|
|
|
82
87
|
def render_title
|
|
88
|
+
title_line = "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{@title}"
|
|
89
|
+
unless @guide
|
|
90
|
+
@output.puts title_line
|
|
91
|
+
@lines_written = 1
|
|
92
|
+
return
|
|
93
|
+
end
|
|
94
|
+
|
|
83
95
|
@output.puts Colors.gray(Symbols::S_BAR)
|
|
84
|
-
@output.puts
|
|
96
|
+
@output.puts title_line
|
|
85
97
|
@output.puts Colors.gray(Symbols::S_BAR)
|
|
86
98
|
@lines_written = 3
|
|
87
99
|
end
|
|
@@ -106,23 +118,27 @@ module Clack
|
|
|
106
118
|
end
|
|
107
119
|
|
|
108
120
|
def render_buffer
|
|
109
|
-
|
|
121
|
+
prefix = line_prefix
|
|
110
122
|
@buffer.each do |message|
|
|
111
|
-
print_message_lines(
|
|
123
|
+
print_message_lines(prefix, message)
|
|
112
124
|
end
|
|
113
125
|
end
|
|
114
126
|
|
|
115
127
|
def render_full_buffer
|
|
116
|
-
|
|
128
|
+
prefix = line_prefix
|
|
117
129
|
lines = @retain_log ? (@full_buffer + @buffer) : @buffer
|
|
118
130
|
lines.each do |message|
|
|
119
|
-
print_message_lines(
|
|
131
|
+
print_message_lines(prefix, message)
|
|
120
132
|
end
|
|
121
133
|
end
|
|
122
134
|
|
|
123
|
-
def
|
|
135
|
+
def line_prefix
|
|
136
|
+
@guide ? "#{Colors.gray(Symbols::S_BAR)} " : ""
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def print_message_lines(prefix, message)
|
|
124
140
|
message.each_line do |line|
|
|
125
|
-
@output.puts "#{
|
|
141
|
+
@output.puts "#{prefix}#{Colors.dim(line.chomp)}"
|
|
126
142
|
end
|
|
127
143
|
end
|
|
128
144
|
|
data/lib/clack/utils.rb
CHANGED
|
@@ -19,6 +19,23 @@ module Clack
|
|
|
19
19
|
display_width(strip_ansi(text))
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Count the terminal rows +text+ occupies once printed: one per line, plus
|
|
23
|
+
# one more for every +width+ columns a line overflows, since the terminal
|
|
24
|
+
# soft-wraps lines wider than the pane. A line exactly +width+ wide stays
|
|
25
|
+
# on one row (the terminal defers the wrap until the next character).
|
|
26
|
+
# Trailing text without a newline only counts the rows it wrapped onto;
|
|
27
|
+
# the cursor is still on its last row.
|
|
28
|
+
# @param text [String] rendered text, ANSI codes allowed
|
|
29
|
+
# @param width [Integer] terminal width in columns
|
|
30
|
+
# @return [Integer] rows
|
|
31
|
+
def rendered_rows(text, width)
|
|
32
|
+
text.to_s.each_line.sum do |line|
|
|
33
|
+
columns = visible_length(line.chomp)
|
|
34
|
+
rows = columns.zero? ? 1 : (columns + width - 1) / width
|
|
35
|
+
line.end_with?("\n") ? rows : rows - 1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
22
39
|
# Calculate the terminal display width (columns) of a string.
|
|
23
40
|
# ASCII and most chars: width 1. CJK ideographs, fullwidth forms, common emoji: width 2.
|
|
24
41
|
# Zero-width joiners, combining marks, variation selectors: width 0.
|
|
@@ -170,12 +187,7 @@ module Clack
|
|
|
170
187
|
# Width of a grapheme cluster: the max char_width among its codepoints.
|
|
171
188
|
# Handles ZWJ emoji sequences, combining marks, and flag sequences correctly.
|
|
172
189
|
def grapheme_width(cluster)
|
|
173
|
-
|
|
174
|
-
cluster.each_char do |char|
|
|
175
|
-
w = char_width(char)
|
|
176
|
-
max_w = w if w > max_w
|
|
177
|
-
end
|
|
178
|
-
max_w
|
|
190
|
+
cluster.each_char.map { |char| char_width(char) }.max || 0
|
|
179
191
|
end
|
|
180
192
|
|
|
181
193
|
def char_width(char)
|