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
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Core
|
|
5
|
+
# The chrome around every prompt frame: the guide rail (gray connector, cyan
|
|
6
|
+
# active bar, yellow error bar), the gutters in front of content lines, the
|
|
7
|
+
# header/footer templates, and the keyboard hint footer.
|
|
8
|
+
#
|
|
9
|
+
# Mixed into {Prompt}. All helpers are private; subclasses call them without
|
|
10
|
+
# a receiver from +build_frame+. {#bar}, {#active_bar}, and {#bar_end} always
|
|
11
|
+
# return the colored symbol; only {#gutter} and {#guide_line} know about
|
|
12
|
+
# +with_guide+, so a frame that builds its own lines from +bar+ still renders
|
|
13
|
+
# exactly as before when guides are on.
|
|
14
|
+
module Chrome
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Store the per-prompt chrome options. Called from Prompt#initialize.
|
|
18
|
+
def setup_chrome(with_guide:, show_instructions:, instructions:)
|
|
19
|
+
unless valid_instructions?(instructions)
|
|
20
|
+
raise ArgumentError, "instructions must be a String or an Array of Strings"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@with_guide = with_guide
|
|
24
|
+
@show_instructions = show_instructions
|
|
25
|
+
@instructions = instructions
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# nil, a String, or an Array of Strings.
|
|
29
|
+
def valid_instructions?(value)
|
|
30
|
+
value.nil? || value.is_a?(String) || (value.is_a?(Array) && value.all?(String))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Resolved on every frame so a global change mid-prompt applies to the next redraw.
|
|
34
|
+
def guide? = Settings.with_guide?(@with_guide)
|
|
35
|
+
|
|
36
|
+
def show_instructions? = Settings.show_instructions?(@show_instructions)
|
|
37
|
+
|
|
38
|
+
# Content-line prefix: the symbol plus two spaces, or nothing without guides.
|
|
39
|
+
def gutter(symbol = bar) = guide? ? "#{symbol} " : ""
|
|
40
|
+
|
|
41
|
+
# A rail-only line (connector or closing corner), or nothing without guides.
|
|
42
|
+
def guide_line(symbol = bar) = guide? ? "#{symbol}\n" : ""
|
|
43
|
+
|
|
44
|
+
# The gray rail: the connector above the message and every final-frame line.
|
|
45
|
+
def bar = Colors.gray(Symbols::S_BAR)
|
|
46
|
+
|
|
47
|
+
# The rail next to the step being answered: cyan while it is open, yellow
|
|
48
|
+
# while it shows a validation error or warning, gray once it is done.
|
|
49
|
+
def active_bar
|
|
50
|
+
case @state
|
|
51
|
+
when :initial, :active then Colors.cyan(Symbols::S_BAR)
|
|
52
|
+
when :error, :warning then Colors.yellow(Symbols::S_BAR)
|
|
53
|
+
else bar
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The closing corner, colored like {#active_bar}.
|
|
58
|
+
def bar_end
|
|
59
|
+
case @state
|
|
60
|
+
when :initial, :active then Colors.cyan(Symbols::S_BAR_END)
|
|
61
|
+
when :error, :warning then Colors.yellow(Symbols::S_BAR_END)
|
|
62
|
+
else Colors.gray(Symbols::S_BAR_END)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def symbol_for_state
|
|
67
|
+
case @state
|
|
68
|
+
when :initial, :active then Colors.cyan(Symbols::S_STEP_ACTIVE)
|
|
69
|
+
when :submit then Colors.green(Symbols::S_STEP_SUBMIT)
|
|
70
|
+
when :cancel then Colors.red(Symbols::S_STEP_CANCEL)
|
|
71
|
+
when :error, :warning then Colors.yellow(Symbols::S_STEP_ERROR)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The help: line sits inside the active rail, like a message continuation.
|
|
76
|
+
def help_line
|
|
77
|
+
return "" unless @help
|
|
78
|
+
|
|
79
|
+
"#{gutter(active_bar)}#{Colors.dim(@help)}\n"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# One keyboard hint: dim key token, normal description ("↑/↓ to navigate").
|
|
83
|
+
def key_hint(key, text) = "#{Colors.dim(key)} #{text}"
|
|
84
|
+
|
|
85
|
+
# Built-in hints for the footer. Subclasses return an Array<String> of
|
|
86
|
+
# key_hint fragments; nil means the prompt has no footer. {#frame_footer}
|
|
87
|
+
# renders the result, so a subclass that defines this hook must not also
|
|
88
|
+
# print its own hint row: the 0.6.2 built-ins wrote one from a private
|
|
89
|
+
# method of this name and closed with bar_end, and a copy of that pattern
|
|
90
|
+
# that calls frame_footer would show the hints twice.
|
|
91
|
+
def keyboard_hints = nil
|
|
92
|
+
|
|
93
|
+
# The hint line under the body rows: the prompt's own hints, or the
|
|
94
|
+
# caller's instructions: text, joined with the hint separator. Empty when
|
|
95
|
+
# hidden or when there is nothing to show. Not guarded on state on purpose:
|
|
96
|
+
# the transient frame rendered right before finalize keeps the line, with
|
|
97
|
+
# a gray gutter, so the redraw erases the same number of rows.
|
|
98
|
+
def instruction_footer
|
|
99
|
+
return "" unless show_instructions?
|
|
100
|
+
|
|
101
|
+
hints = Array(@instructions.nil? ? keyboard_hints : @instructions)
|
|
102
|
+
return "" if hints.empty?
|
|
103
|
+
|
|
104
|
+
"#{gutter(active_bar)}#{hints.join(Symbols::S_HINT_SEPARATOR)}\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Common frame header: connector + symbol/message + help line.
|
|
108
|
+
# @return [String] header lines
|
|
109
|
+
def frame_header
|
|
110
|
+
"#{guide_line}#{symbol_for_state} #{@message}\n#{help_line}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Common frame footer: validation messages, or hint footer + closing corner.
|
|
114
|
+
# @return [String] footer lines
|
|
115
|
+
def frame_footer
|
|
116
|
+
vlns = validation_message_lines
|
|
117
|
+
return vlns.join if vlns.any?
|
|
118
|
+
|
|
119
|
+
"#{instruction_footer}#{guide_line(bar_end)}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build validation message lines for error or warning states.
|
|
123
|
+
# Returns array of lines to append, or empty array if no validation message.
|
|
124
|
+
def validation_message_lines
|
|
125
|
+
case @state
|
|
126
|
+
when :error
|
|
127
|
+
["#{gutter(Colors.yellow(Symbols::S_BAR_END))}#{Colors.yellow(@error_message)}\n"]
|
|
128
|
+
when :warning
|
|
129
|
+
[
|
|
130
|
+
"#{gutter(Colors.yellow(Symbols::S_BAR_END))}#{Colors.yellow(@warning_message)}\n",
|
|
131
|
+
"#{gutter}#{Colors.dim("Press Enter to confirm, or edit your input")}\n"
|
|
132
|
+
]
|
|
133
|
+
else
|
|
134
|
+
[]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/clack/core/ci_mode.rb
CHANGED
|
@@ -11,20 +11,23 @@ module Clack
|
|
|
11
11
|
# Enable explicitly:
|
|
12
12
|
# Clack.update_settings(ci_mode: true)
|
|
13
13
|
#
|
|
14
|
-
# Or auto-detect (non-TTY
|
|
14
|
+
# Or auto-detect (non-TTY input or CI environment variable):
|
|
15
15
|
# Clack.update_settings(ci_mode: :auto)
|
|
16
16
|
module CiMode
|
|
17
17
|
class << self
|
|
18
18
|
# Check if CI mode is currently active.
|
|
19
19
|
#
|
|
20
|
+
# @param input [IO, nil] the prompt's input stream, consulted by +:auto+
|
|
21
|
+
# (default: $stdin; nil also means $stdin, matching KeyReader's
|
|
22
|
+
# console fallback)
|
|
20
23
|
# @return [Boolean]
|
|
21
|
-
def active?
|
|
24
|
+
def active?(input = $stdin)
|
|
22
25
|
setting = Settings.config[:ci_mode]
|
|
23
26
|
case setting
|
|
24
27
|
when true
|
|
25
28
|
true
|
|
26
29
|
when :auto
|
|
27
|
-
!Environment.tty?($stdin) || Environment.ci?
|
|
30
|
+
!Environment.tty?(input || $stdin) || Environment.ci?
|
|
28
31
|
else
|
|
29
32
|
false
|
|
30
33
|
end
|
|
@@ -7,53 +7,113 @@ module Clack
|
|
|
7
7
|
# Reads single keystrokes from the terminal in raw mode.
|
|
8
8
|
# Handles escape sequences for arrow keys and other special keys.
|
|
9
9
|
#
|
|
10
|
+
# Escape sequences come in two shapes: CSI (+ESC [+ ... final byte) and
|
|
11
|
+
# SS3 (+ESC O+ + one byte), the latter used by terminals in application
|
|
12
|
+
# cursor mode (tmux, and after vim or less leave keypad-transmit mode on).
|
|
13
|
+
# Both are assembled here and folded into one canonical code per key via
|
|
14
|
+
# {Settings.normalize_key}, so prompts only ever see +"\e[A"+ for Up.
|
|
15
|
+
#
|
|
10
16
|
# The Escape detection window is tunable via the +CLACK_ESCAPE_TIMEOUT+
|
|
11
17
|
# env var (see {Environment.escape_timeout}) for high-latency links.
|
|
12
18
|
module KeyReader
|
|
19
|
+
# Escape-sequence introducers: CSI ("[") and SS3 ("O").
|
|
20
|
+
SEQUENCE_INTRODUCERS = ["[", "O"].freeze
|
|
21
|
+
|
|
22
|
+
# Upper bound on bytes assembled after the introducer, so a stream of
|
|
23
|
+
# garbage can never keep the reader inside one sequence forever.
|
|
24
|
+
MAX_SEQUENCE_LENGTH = 32
|
|
25
|
+
|
|
26
|
+
# Range of CSI final bytes (+@+ through +~+), per ECMA-48.
|
|
27
|
+
CSI_FINAL_BYTES = (0x40..0x7E)
|
|
28
|
+
|
|
13
29
|
class << self
|
|
14
30
|
# Read a single keystroke in raw mode.
|
|
15
31
|
# When input is an IO backed by a console, uses raw mode.
|
|
16
32
|
# When input is a StringIO or test double, reads directly.
|
|
17
33
|
#
|
|
34
|
+
# End of input (EOF) and a disconnected terminal both return Ctrl+C so
|
|
35
|
+
# the prompt cancels instead of spinning on an exhausted stream.
|
|
36
|
+
#
|
|
18
37
|
# @param input [IO, nil] input stream (defaults to IO.console)
|
|
19
|
-
# @return [String
|
|
38
|
+
# @return [String] the (normalized) key code
|
|
39
|
+
# @raise [NotATerminalError] if the input cannot be put into raw mode
|
|
20
40
|
def read(input = nil)
|
|
21
41
|
io = input || IO.console
|
|
22
|
-
raise
|
|
23
|
-
|
|
24
|
-
# StringIO / test doubles don't support raw mode
|
|
25
|
-
return read_from(io) unless io.respond_to?(:raw)
|
|
42
|
+
raise NotATerminalError, not_a_terminal_message(io) unless io
|
|
26
43
|
|
|
27
|
-
io.raw
|
|
44
|
+
key = io.respond_to?(:raw) ? read_raw(io) : read_from(io)
|
|
45
|
+
Settings.normalize_key(key || Settings::KEY_CTRL_C)
|
|
46
|
+
rescue NotATerminalError
|
|
47
|
+
raise
|
|
28
48
|
rescue Errno::EIO, Errno::EBADF, IOError
|
|
29
49
|
# Terminal disconnected or closed - treat as cancel
|
|
30
|
-
|
|
50
|
+
Settings::KEY_CTRL_C
|
|
31
51
|
end
|
|
32
52
|
|
|
33
53
|
private
|
|
34
54
|
|
|
55
|
+
# EINVAL is what the Windows console layer (and some BSD device nodes)
|
|
56
|
+
# report for a handle that is not a console, where POSIX says ENOTTY.
|
|
57
|
+
def read_raw(io)
|
|
58
|
+
io.raw { |raw_io| read_from(raw_io) }
|
|
59
|
+
rescue Errno::ENOTTY, Errno::ENODEV, Errno::ENXIO, Errno::EINVAL
|
|
60
|
+
raise NotATerminalError, not_a_terminal_message(io)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def not_a_terminal_message(io)
|
|
64
|
+
label = (io.nil? || io.equal?($stdin)) ? "stdin" : "input"
|
|
65
|
+
"#{label} is not an interactive terminal. Run from a terminal, or enable CI mode " \
|
|
66
|
+
"with Clack.update_settings(ci_mode: :auto) to auto-submit defaults when input is piped."
|
|
67
|
+
end
|
|
68
|
+
|
|
35
69
|
def read_from(io)
|
|
36
70
|
char = io.getc
|
|
37
|
-
return char if char.nil? # EOF
|
|
38
71
|
return char unless char == "\e"
|
|
39
72
|
|
|
40
73
|
escape_timeout = Environment.escape_timeout
|
|
74
|
+
|
|
75
|
+
# Check for escape sequence - wait briefly for follow-up
|
|
76
|
+
return char unless readable?(io, escape_timeout)
|
|
77
|
+
|
|
78
|
+
introducer = io.getc.to_s
|
|
79
|
+
return "\e#{introducer}" unless SEQUENCE_INTRODUCERS.include?(introducer)
|
|
80
|
+
|
|
41
81
|
# Subsequent bytes in a sequence normally arrive almost instantly, so
|
|
42
82
|
# the inter-byte wait stays much shorter, but it scales with the
|
|
43
83
|
# escape timeout so high-latency links still assemble full sequences.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# Check for escape sequence - wait briefly for follow-up
|
|
47
|
-
return char unless io.respond_to?(:wait_readable) && io.wait_readable(escape_timeout)
|
|
84
|
+
"\e#{introducer}#{read_sequence(io, introducer, escape_timeout / 5.0)}"
|
|
85
|
+
end
|
|
48
86
|
|
|
49
|
-
|
|
50
|
-
|
|
87
|
+
# Assemble the bytes after a CSI or SS3 introducer. SS3 carries exactly
|
|
88
|
+
# one byte; CSI runs until its final byte so a keystroke typed right
|
|
89
|
+
# after an arrow key is not swallowed into the sequence.
|
|
90
|
+
def read_sequence(io, introducer, timeout)
|
|
91
|
+
bytes = []
|
|
92
|
+
while bytes.length < MAX_SEQUENCE_LENGTH && readable?(io, timeout)
|
|
93
|
+
byte = io.getc.to_s
|
|
94
|
+
break if byte.empty?
|
|
51
95
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
seq += io.getc.to_s
|
|
96
|
+
bytes << byte
|
|
97
|
+
break if introducer == "O" || csi_final?(bytes)
|
|
55
98
|
end
|
|
56
|
-
|
|
99
|
+
bytes.join
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The Linux virtual console sends F1..F5 as +ESC [ [ A+ .. +ESC [ [ E+,
|
|
103
|
+
# so a "[" right after the CSI introducer is a second introducer, not
|
|
104
|
+
# a final byte, even though 0x5B sits inside CSI_FINAL_BYTES.
|
|
105
|
+
#
|
|
106
|
+
# Compares the raw byte rather than the codepoint: +ord+ raises on a
|
|
107
|
+
# byte that is invalid in the input's encoding (line noise, a
|
|
108
|
+
# mis-encoded terminal), and such a byte simply is not a final byte.
|
|
109
|
+
def csi_final?(bytes)
|
|
110
|
+
return false if bytes.length == 1 && bytes.first == "["
|
|
111
|
+
|
|
112
|
+
CSI_FINAL_BYTES.cover?(bytes.last.getbyte(0))
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def readable?(io, timeout)
|
|
116
|
+
io.respond_to?(:wait_readable) && io.wait_readable(timeout)
|
|
57
117
|
end
|
|
58
118
|
end
|
|
59
119
|
end
|
|
@@ -38,17 +38,54 @@ module Clack
|
|
|
38
38
|
# Including classes must implement:
|
|
39
39
|
# - +navigable_items+ [Array] returns the current list to navigate
|
|
40
40
|
module OptionsHelper
|
|
41
|
-
# Normalize options to a consistent
|
|
42
|
-
# Accepts strings, symbols, or hashes with value/label/hint/disabled keys.
|
|
41
|
+
# Normalize options to a consistent list of {Option} value objects.
|
|
43
42
|
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
43
|
+
# Accepts an Array of strings, symbols, or hashes with value/label/hint/disabled
|
|
44
|
+
# keys, or a Hash of value => label (or value => {label:, hint:, disabled:}).
|
|
45
|
+
#
|
|
46
|
+
# @param options [Array, Hash] Raw options in any accepted shape
|
|
47
|
+
# @return [Array<Option>] Normalized options
|
|
48
|
+
# @raise [ArgumentError] if options is nil or empty, or a Hash spec sets :value
|
|
47
49
|
def normalize_options(options)
|
|
48
50
|
raise ArgumentError, "options cannot be empty" if options.nil? || options.empty?
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
OptionsHelper.normalize_list(options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Normalize a list of raw options without the empty check.
|
|
56
|
+
# Used by prompts that build their own list (e.g. GroupMultiselect groups).
|
|
57
|
+
#
|
|
58
|
+
# @param options [Array, Hash] Raw options in any accepted shape
|
|
59
|
+
# @return [Array<Option>] Normalized options
|
|
60
|
+
def self.normalize_list(options)
|
|
61
|
+
option_entries(options).map { |opt| normalize_option(opt) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Expand the Hash shorthand (value => label, or value => spec Hash) into
|
|
65
|
+
# the Array-of-entries shape that {.normalize_option} understands.
|
|
66
|
+
# Anything that is not a Hash is returned unchanged.
|
|
67
|
+
#
|
|
68
|
+
# @param options [Array, Hash] Raw options
|
|
69
|
+
# @return [Array] Option entries (hashes, strings, symbols, or other bare values)
|
|
70
|
+
# @raise [ArgumentError] if a spec Hash sets :value
|
|
71
|
+
def self.option_entries(options)
|
|
72
|
+
return options unless options.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
options.map { |value, spec| hash_entry(value, spec) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Build one option entry from a Hash-shorthand pair.
|
|
78
|
+
# A Hash spec is merged with the value; anything else is the label
|
|
79
|
+
# (nil means "no label", so normalize_option falls back to value.to_s).
|
|
80
|
+
def self.hash_entry(value, spec)
|
|
81
|
+
return {value: value, label: spec&.to_s} unless spec.is_a?(Hash)
|
|
82
|
+
if spec.key?(:value)
|
|
83
|
+
raise ArgumentError, "options entry #{value.inspect} must not set :value (the Hash key is the value)"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
spec.merge(value: value)
|
|
51
87
|
end
|
|
88
|
+
private_class_method :hash_entry
|
|
52
89
|
|
|
53
90
|
# Normalize a single option to an Option value object.
|
|
54
91
|
# @param opt [Hash, String, Symbol] Raw option
|
data/lib/clack/core/prompt.rb
CHANGED
|
@@ -22,11 +22,13 @@ module Clack
|
|
|
22
22
|
# @example Creating a custom prompt
|
|
23
23
|
# class MyPrompt < Clack::Core::Prompt
|
|
24
24
|
# def build_frame
|
|
25
|
-
# "#{
|
|
25
|
+
# "#{frame_header}#{gutter(active_bar)}#{something}\n#{frame_footer}"
|
|
26
26
|
# end
|
|
27
27
|
# end
|
|
28
28
|
#
|
|
29
29
|
class Prompt
|
|
30
|
+
include Chrome
|
|
31
|
+
|
|
30
32
|
# Minimum terminal width for clean rendering.
|
|
31
33
|
# Prompts warn (non-blocking) if the terminal is narrower.
|
|
32
34
|
MIN_TERMINAL_WIDTH = 40
|
|
@@ -82,14 +84,25 @@ module Clack
|
|
|
82
84
|
|
|
83
85
|
# @param message [String] the prompt message to display
|
|
84
86
|
# @param help [String, nil] optional help text shown below the message
|
|
85
|
-
# @param validate [Proc,
|
|
87
|
+
# @param validate [Proc, Regexp, Symbol, Array, Hash, nil, false] validator, resolved by
|
|
88
|
+
# {Validators.resolve}; a proc returns an error string, {Clack::Warning}, or nil.
|
|
89
|
+
# nil or false disables validation
|
|
86
90
|
# @param transform [Symbol, Proc, nil] transformer (symbol shortcut or proc); applied after validation
|
|
91
|
+
# @param with_guide [Boolean, nil] show the guide rail; nil uses Clack.settings[:with_guide]
|
|
92
|
+
# @param show_instructions [Boolean, nil] show the keyboard hint footer; nil uses
|
|
93
|
+
# Clack.settings[:show_instructions]
|
|
94
|
+
# @param instructions [String, Array<String>, nil] replacement hint text, rendered verbatim on one
|
|
95
|
+
# line (newlines are not re-prefixed); nil uses the prompt's built-in hints
|
|
87
96
|
# @param input [IO] input stream (default: $stdin)
|
|
88
97
|
# @param output [IO] output stream (default: $stdout)
|
|
89
|
-
|
|
98
|
+
# @raise [ArgumentError] if instructions is not nil, a String, or an Array of Strings
|
|
99
|
+
def initialize(message:, help: nil, validate: nil, transform: nil,
|
|
100
|
+
with_guide: nil, show_instructions: nil, instructions: nil,
|
|
101
|
+
input: $stdin, output: $stdout)
|
|
90
102
|
@message = message
|
|
91
103
|
@help = help
|
|
92
|
-
|
|
104
|
+
setup_chrome(with_guide:, show_instructions:, instructions:)
|
|
105
|
+
@validate = Validators.resolve(validate)
|
|
93
106
|
@transform = Transformers.resolve(transform)
|
|
94
107
|
@input = input
|
|
95
108
|
@output = output
|
|
@@ -114,8 +127,9 @@ module Clack
|
|
|
114
127
|
# submits or cancels. Returns the final value or {Clack::CANCEL}.
|
|
115
128
|
#
|
|
116
129
|
# @return [Object, Clack::CANCEL] the submitted value or CANCEL sentinel
|
|
130
|
+
# @raise [NotATerminalError] if input is an IO that is not a TTY and CI mode is off
|
|
117
131
|
def run
|
|
118
|
-
return run_ci_mode if CiMode.active?
|
|
132
|
+
return run_ci_mode if CiMode.active?(@input)
|
|
119
133
|
|
|
120
134
|
Prompt.register(self)
|
|
121
135
|
warn_narrow_terminal
|
|
@@ -138,6 +152,11 @@ module Clack
|
|
|
138
152
|
|
|
139
153
|
finalize
|
|
140
154
|
(terminal_state? && @state == :cancel) ? CANCEL : @value
|
|
155
|
+
rescue NotATerminalError
|
|
156
|
+
# The first read failed, so the frame drawn above is orphaned: erase
|
|
157
|
+
# it so the caller's error output is the only thing left on screen.
|
|
158
|
+
finalize("")
|
|
159
|
+
raise
|
|
141
160
|
ensure
|
|
142
161
|
Prompt.unregister(self)
|
|
143
162
|
cleanup_terminal if terminal_setup
|
|
@@ -325,10 +344,12 @@ module Clack
|
|
|
325
344
|
end
|
|
326
345
|
|
|
327
346
|
# Render the final frame after submit/cancel.
|
|
328
|
-
|
|
347
|
+
# @param frame [String] what replaces the live frame (default: the final
|
|
348
|
+
# frame); pass +""+ to erase the live frame and print nothing
|
|
349
|
+
def finalize(frame = build_final_frame)
|
|
329
350
|
restore_cursor
|
|
330
351
|
@output.print Cursor.clear_down
|
|
331
|
-
@output.print
|
|
352
|
+
@output.print frame
|
|
332
353
|
end
|
|
333
354
|
|
|
334
355
|
# Build the final frame shown after interaction ends.
|
|
@@ -338,9 +359,7 @@ module Clack
|
|
|
338
359
|
#
|
|
339
360
|
# @return [String] the final frame content
|
|
340
361
|
def build_final_frame
|
|
341
|
-
"#{
|
|
342
|
-
"#{symbol_for_state} #{@message}\n" \
|
|
343
|
-
"#{bar} #{styled_final_display}\n"
|
|
362
|
+
"#{guide_line}#{symbol_for_state} #{@message}\n#{gutter}#{styled_final_display}\n"
|
|
344
363
|
end
|
|
345
364
|
|
|
346
365
|
# The text to display in the final frame after submit/cancel.
|
|
@@ -358,10 +377,16 @@ module Clack
|
|
|
358
377
|
|
|
359
378
|
# Auto-submit with current defaults in CI/non-interactive mode.
|
|
360
379
|
# Subclass submit overrides (e.g., Text applying default_value) run normally.
|
|
361
|
-
# Validation and transforms are applied.
|
|
362
|
-
#
|
|
380
|
+
# Validation and transforms are applied. A warning is confirmed the way a
|
|
381
|
+
# second Enter would (so the transform still runs); if validation fails, a
|
|
382
|
+
# warning goes to stderr and CANCEL is returned, since there is no
|
|
383
|
+
# interactive way to fix the input.
|
|
363
384
|
def run_ci_mode
|
|
364
385
|
submit
|
|
386
|
+
if @state == :warning
|
|
387
|
+
confirm_warning
|
|
388
|
+
submit
|
|
389
|
+
end
|
|
365
390
|
if @state == :error
|
|
366
391
|
$stderr.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
|
|
367
392
|
return CANCEL
|
|
@@ -396,76 +421,26 @@ module Clack
|
|
|
396
421
|
# Output unavailable - terminal may need manual reset
|
|
397
422
|
end
|
|
398
423
|
|
|
424
|
+
# Move the cursor back to the first row of the previous frame. On a TTY
|
|
425
|
+
# the distance includes the rows the terminal used to soft-wrap lines
|
|
426
|
+
# wider than the pane (a long message, or the keyboard hint footer in a
|
|
427
|
+
# narrow split); counting newlines alone lands one row low per wrapped
|
|
428
|
+
# line and leaves the wrapped remainder on screen after every redraw.
|
|
399
429
|
def restore_cursor
|
|
400
430
|
return unless @prev_frame
|
|
401
431
|
|
|
402
|
-
lines =
|
|
432
|
+
lines = if Environment.tty?(@output)
|
|
433
|
+
Utils.rendered_rows(@prev_frame, Environment.columns(@output))
|
|
434
|
+
else
|
|
435
|
+
@prev_frame.count("\n")
|
|
436
|
+
end
|
|
403
437
|
@output.print Cursor.up(lines) if lines.positive?
|
|
404
438
|
@output.print Cursor.column(1)
|
|
405
439
|
end
|
|
406
440
|
|
|
407
|
-
def bar
|
|
408
|
-
Colors.gray(Symbols::S_BAR)
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def active_bar
|
|
412
|
-
%i[error warning].include?(@state) ? Colors.yellow(Symbols::S_BAR) : bar
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def bar_end
|
|
416
|
-
%i[error warning].include?(@state) ? Colors.yellow(Symbols::S_BAR_END) : Colors.gray(Symbols::S_BAR_END)
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
def help_line
|
|
420
|
-
return "" unless @help
|
|
421
|
-
|
|
422
|
-
"#{bar} #{Colors.dim(@help)}\n"
|
|
423
|
-
end
|
|
424
|
-
|
|
425
441
|
def cursor_block
|
|
426
442
|
Colors.inverse(" ")
|
|
427
443
|
end
|
|
428
|
-
|
|
429
|
-
def symbol_for_state
|
|
430
|
-
case @state
|
|
431
|
-
when :initial, :active then Colors.cyan(Symbols::S_STEP_ACTIVE)
|
|
432
|
-
when :submit then Colors.green(Symbols::S_STEP_SUBMIT)
|
|
433
|
-
when :cancel then Colors.red(Symbols::S_STEP_CANCEL)
|
|
434
|
-
when :error, :warning then Colors.yellow(Symbols::S_STEP_ERROR)
|
|
435
|
-
end
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
# Common frame header: bar + symbol/message + help line.
|
|
439
|
-
# @return [String] header lines
|
|
440
|
-
def frame_header
|
|
441
|
-
"#{bar}\n#{symbol_for_state} #{@message}\n#{help_line}"
|
|
442
|
-
end
|
|
443
|
-
|
|
444
|
-
# Common frame footer: validation messages or bar_end.
|
|
445
|
-
# Replaces the repeated pattern of splicing validation lines.
|
|
446
|
-
# @return [String] footer lines
|
|
447
|
-
def frame_footer
|
|
448
|
-
vlns = validation_message_lines
|
|
449
|
-
return vlns.join if vlns.any?
|
|
450
|
-
|
|
451
|
-
"#{bar_end}\n"
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
# Build validation message lines for error or warning states.
|
|
455
|
-
# Returns array of lines to append, or empty array if no validation message.
|
|
456
|
-
def validation_message_lines
|
|
457
|
-
case @state
|
|
458
|
-
when :error
|
|
459
|
-
["#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n"]
|
|
460
|
-
when :warning
|
|
461
|
-
[
|
|
462
|
-
"#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@warning_message)}\n",
|
|
463
|
-
"#{bar} #{Colors.dim("Press Enter to confirm, or edit your input")}\n"
|
|
464
|
-
]
|
|
465
|
-
else
|
|
466
|
-
[]
|
|
467
|
-
end
|
|
468
|
-
end
|
|
469
444
|
end
|
|
470
445
|
end
|
|
471
446
|
end
|