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.
@@ -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
- @start_time = nil
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
- @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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 (uses current if nil)
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) - @start_time
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 && @start_time) ? " #{format_timer}" : ""
179
- [message || @message, suffix]
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
@@ -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 (exceptions are caught).
13
- # Optionally accepts a message-update callable to change
14
- # the spinner message mid-execution.
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
- spinner = Spinner.new(output: @output)
103
- spinner.start(task.title)
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(task.title)
112
- @results << TaskResult.new(title: task.title, status: :success, error: nil)
126
+ spinner.stop(title)
127
+ @results << TaskResult.new(title: title, status: :success, error: nil)
113
128
  rescue => exception
114
- spinner.error(task.title)
115
- @output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.red(exception.message)}"
116
- @results << TaskResult.new(title: task.title, status: :error, error: exception.message)
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
@@ -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 validation proc returning error string or nil
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} #{input_display}\n#{frame_footer}"
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 "#{Colors.gray(Symbols::S_BAR)} #{line.chomp}"
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
- # Returns true on success, false on failure or if command cannot be executed
70
- def command(cmd, type: :info, output: $stdout)
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: 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 stream_with_symbol(source, symbol, color, output)
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 "#{Colors.gray(Symbols::S_BAR)} #{line}"
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 auto-detects from TTY and TERM.
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.
@@ -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 "#{Colors.green(Symbols::S_STEP_SUBMIT)} #{@title}"
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
- bar = Colors.gray(Symbols::S_BAR)
121
+ prefix = line_prefix
110
122
  @buffer.each do |message|
111
- print_message_lines(bar, message)
123
+ print_message_lines(prefix, message)
112
124
  end
113
125
  end
114
126
 
115
127
  def render_full_buffer
116
- bar = Colors.gray(Symbols::S_BAR)
128
+ prefix = line_prefix
117
129
  lines = @retain_log ? (@full_buffer + @buffer) : @buffer
118
130
  lines.each do |message|
119
- print_message_lines(bar, message)
131
+ print_message_lines(prefix, message)
120
132
  end
121
133
  end
122
134
 
123
- def print_message_lines(bar, message)
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 "#{bar} #{Colors.dim(line.chomp)}"
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
- max_w = 0
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)