clack 0.6.1 → 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 +63 -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 +13 -6
- 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 +51 -27
- 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
|
@@ -33,11 +33,21 @@ module Clack
|
|
|
33
33
|
# group_spacing: 1
|
|
34
34
|
# )
|
|
35
35
|
#
|
|
36
|
+
# @example Hash shorthand (group label => value => label)
|
|
37
|
+
# addons = Clack.group_multiselect(
|
|
38
|
+
# message: "Add-ons",
|
|
39
|
+
# options: {
|
|
40
|
+
# "Observability" => { prom: "Prometheus", dd: "Datadog" },
|
|
41
|
+
# "Storage" => %w[s3 gcs]
|
|
42
|
+
# }
|
|
43
|
+
# )
|
|
44
|
+
#
|
|
36
45
|
class GroupMultiselect < Core::Prompt
|
|
37
46
|
include Core::SelectionManager
|
|
38
47
|
|
|
39
48
|
# @param message [String] the prompt message
|
|
40
|
-
# @param options [Array<Hash
|
|
49
|
+
# @param options [Array<Hash>, Hash] groups as an Array of {label:, options:} hashes, or a Hash of
|
|
50
|
+
# group label => options; each group's options accept every shape {Select} accepts (Array or Hash)
|
|
41
51
|
# @param initial_values [Array] values to pre-select
|
|
42
52
|
# @param required [Boolean] require at least one selection (default: true)
|
|
43
53
|
# @param selectable_groups [Boolean] allow toggling all options in a group at once (default: false)
|
|
@@ -86,10 +96,7 @@ module Clack
|
|
|
86
96
|
end
|
|
87
97
|
|
|
88
98
|
def build_frame
|
|
89
|
-
lines = []
|
|
90
|
-
lines << "#{bar}\n"
|
|
91
|
-
lines << "#{symbol_for_state} #{@message}\n"
|
|
92
|
-
lines << help_line
|
|
99
|
+
lines = [frame_header]
|
|
93
100
|
|
|
94
101
|
prev_was_group = false
|
|
95
102
|
@flat_items.each_with_index do |item, idx|
|
|
@@ -97,9 +104,7 @@ module Clack
|
|
|
97
104
|
is_last_in_group = item.is_a?(Core::GroupOption) ? item.last_in_group : false
|
|
98
105
|
|
|
99
106
|
# Add group spacing before groups (except first)
|
|
100
|
-
if is_group && !prev_was_group && idx.positive?
|
|
101
|
-
@group_spacing.times { lines << "#{active_bar}\n" }
|
|
102
|
-
end
|
|
107
|
+
lines.concat(group_spacing_lines) if is_group && !prev_was_group && idx.positive?
|
|
103
108
|
|
|
104
109
|
lines << if is_group
|
|
105
110
|
group_display(item, idx == @option_index)
|
|
@@ -110,10 +115,7 @@ module Clack
|
|
|
110
115
|
prev_was_group = is_group
|
|
111
116
|
end
|
|
112
117
|
|
|
113
|
-
lines <<
|
|
114
|
-
|
|
115
|
-
lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
|
|
116
|
-
|
|
118
|
+
lines << frame_footer
|
|
117
119
|
lines.join
|
|
118
120
|
end
|
|
119
121
|
|
|
@@ -121,17 +123,22 @@ module Clack
|
|
|
121
123
|
|
|
122
124
|
private
|
|
123
125
|
|
|
124
|
-
def normalize_groups(options)
|
|
126
|
+
def normalize_groups(options)
|
|
127
|
+
return options.map { |label, opts| normalize_group(label: label.to_s, options: opts) } if options.is_a?(Hash)
|
|
125
128
|
|
|
126
|
-
|
|
127
|
-
{
|
|
128
|
-
label: group[:label] || group[:group],
|
|
129
|
-
options: group[:options].map { |opt| normalize_option(opt) }
|
|
130
|
-
}
|
|
129
|
+
options.map { |group| normalize_group(group) }
|
|
131
130
|
end
|
|
132
131
|
|
|
133
|
-
def
|
|
134
|
-
|
|
132
|
+
def normalize_group(group)
|
|
133
|
+
label = group[:label] || group[:group]
|
|
134
|
+
opts = group[:options]
|
|
135
|
+
# Duck-typed on purpose: any Enumerable (Set, Range) worked before the
|
|
136
|
+
# guard existed and still does; the message names the documented shapes.
|
|
137
|
+
unless opts.respond_to?(:map)
|
|
138
|
+
raise ArgumentError, "group #{label.inspect} options must be an Array or Hash"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
{label: label, options: Core::OptionsHelper.normalize_list(opts)}
|
|
135
142
|
end
|
|
136
143
|
|
|
137
144
|
def build_flat_items = @groups.flat_map { |group| flatten_group(group) }
|
|
@@ -215,14 +222,31 @@ module Clack
|
|
|
215
222
|
|
|
216
223
|
def update_value = update_selection_value
|
|
217
224
|
|
|
225
|
+
# Blank rail lines between groups; plain newlines when guides are off (upstream behavior).
|
|
226
|
+
def group_spacing_lines
|
|
227
|
+
return [] unless @group_spacing.positive?
|
|
228
|
+
|
|
229
|
+
line = guide? ? "#{active_bar}\n" : "\n"
|
|
230
|
+
Array.new(@group_spacing, line)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Shares upstream MULTISELECT_INSTRUCTIONS with Multiselect.
|
|
234
|
+
def keyboard_hints
|
|
235
|
+
[
|
|
236
|
+
key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"),
|
|
237
|
+
key_hint("Space:", "select"),
|
|
238
|
+
key_hint("Enter:", "confirm")
|
|
239
|
+
]
|
|
240
|
+
end
|
|
241
|
+
|
|
218
242
|
def group_display(item, active)
|
|
219
243
|
if @selectable_groups
|
|
220
244
|
all_selected = item.options.reject { |o| o.disabled }.all? { |o| @selected.include?(o.value) }
|
|
221
245
|
checkbox = all_selected ? Colors.green(Symbols::S_CHECKBOX_SELECTED) : Colors.dim(Symbols::S_CHECKBOX_INACTIVE)
|
|
222
246
|
label = active ? item.label : Colors.dim(item.label)
|
|
223
|
-
"#{active_bar}
|
|
247
|
+
"#{gutter(active_bar)}#{checkbox} #{label}\n"
|
|
224
248
|
else
|
|
225
|
-
"#{active_bar}
|
|
249
|
+
"#{gutter(active_bar)}#{Colors.dim(item.label)}\n"
|
|
226
250
|
end
|
|
227
251
|
end
|
|
228
252
|
|
|
@@ -236,15 +260,15 @@ module Clack
|
|
|
236
260
|
hint = (item.hint && active) ? " #{Colors.dim("(#{item.hint})")}" : ""
|
|
237
261
|
|
|
238
262
|
if item.disabled
|
|
239
|
-
"#{active_bar}
|
|
263
|
+
"#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.strikethrough(Colors.dim(item.label))}\n"
|
|
240
264
|
elsif active && selected
|
|
241
|
-
"#{active_bar}
|
|
265
|
+
"#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{item.label}#{hint}\n"
|
|
242
266
|
elsif active
|
|
243
|
-
"#{active_bar}
|
|
267
|
+
"#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.cyan(Symbols::S_CHECKBOX_ACTIVE)} #{item.label}#{hint}\n"
|
|
244
268
|
elsif selected
|
|
245
|
-
"#{active_bar}
|
|
269
|
+
"#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.green(Symbols::S_CHECKBOX_SELECTED)} #{Colors.dim(item.label)}\n"
|
|
246
270
|
else
|
|
247
|
-
"#{active_bar}
|
|
271
|
+
"#{gutter(active_bar)}#{Colors.dim(prefix)}#{Colors.dim(Symbols::S_CHECKBOX_INACTIVE)} #{Colors.dim(item.label)}\n"
|
|
248
272
|
end
|
|
249
273
|
end
|
|
250
274
|
end
|
|
@@ -25,7 +25,7 @@ module Clack
|
|
|
25
25
|
|
|
26
26
|
# @param message [String] the prompt message
|
|
27
27
|
# @param initial_value [String, nil] pre-filled editable text (can contain newlines)
|
|
28
|
-
# @option opts [Proc, nil] :validate
|
|
28
|
+
# @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator (see {Validators.resolve})
|
|
29
29
|
# @option opts [Hash] additional options passed to {Core::Prompt}
|
|
30
30
|
def initialize(message:, initial_value: nil, **opts)
|
|
31
31
|
super(message:, **opts)
|
|
@@ -67,13 +67,13 @@ module Clack
|
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
def frame_header
|
|
70
|
-
"#{
|
|
70
|
+
"#{guide_line}#{symbol_for_state} #{@message} #{Colors.dim("(Ctrl+D to submit)")}\n#{help_line}"
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def build_frame
|
|
74
74
|
body = @lines.each_with_index.map do |line, idx|
|
|
75
75
|
display = (idx == @line_index) ? value_with_cursor : line
|
|
76
|
-
"#{active_bar}
|
|
76
|
+
"#{gutter(active_bar)}#{display}\n"
|
|
77
77
|
end.join
|
|
78
78
|
|
|
79
79
|
"#{frame_header}#{body}#{frame_footer}"
|
|
@@ -81,13 +81,13 @@ module Clack
|
|
|
81
81
|
|
|
82
82
|
def build_final_frame
|
|
83
83
|
lines = []
|
|
84
|
-
lines <<
|
|
84
|
+
lines << guide_line
|
|
85
85
|
lines << "#{symbol_for_state} #{@message}\n"
|
|
86
86
|
|
|
87
87
|
if @state == :cancel
|
|
88
|
-
@lines.each { |line| lines << "#{
|
|
88
|
+
@lines.each { |line| lines << "#{gutter}#{Colors.strikethrough(Colors.dim(line))}\n" }
|
|
89
89
|
else
|
|
90
|
-
@lines.each { |line| lines << "#{
|
|
90
|
+
@lines.each { |line| lines << "#{gutter}#{Colors.dim(line)}\n" }
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
lines.join
|
|
@@ -5,7 +5,8 @@ module Clack
|
|
|
5
5
|
# Multiple-selection prompt from a list of options.
|
|
6
6
|
#
|
|
7
7
|
# Navigate with arrow keys or j/k. Toggle selection with Space.
|
|
8
|
-
# Supports shortcuts:
|
|
8
|
+
# Supports shortcuts: `a` to toggle all, `i` to invert selection (not
|
|
9
|
+
# listed in the footer; see the README).
|
|
9
10
|
#
|
|
10
11
|
# Options format is the same as {Select}.
|
|
11
12
|
#
|
|
@@ -32,7 +33,7 @@ module Clack
|
|
|
32
33
|
include Core::SelectionManager
|
|
33
34
|
|
|
34
35
|
# @param message [String] the prompt message
|
|
35
|
-
# @param options [Array<Hash, String
|
|
36
|
+
# @param options [Array<Hash, String>, Hash] list of options, or a Hash of value => label
|
|
36
37
|
# @param initial_values [Array] values to pre-select
|
|
37
38
|
# @param required [Boolean] require at least one selection (default: true)
|
|
38
39
|
# @param max_items [Integer, nil] max visible items (enables scrolling)
|
|
@@ -84,23 +85,14 @@ module Clack
|
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
def build_frame
|
|
87
|
-
lines = []
|
|
88
|
-
lines << "#{bar}\n"
|
|
89
|
-
lines << "#{symbol_for_state} #{@message}\n"
|
|
90
|
-
lines << help_line
|
|
88
|
+
lines = [frame_header]
|
|
91
89
|
|
|
92
90
|
visible_options.each_with_index do |opt, idx|
|
|
93
91
|
actual_idx = @scroll_offset + idx
|
|
94
|
-
lines << "#{active_bar}
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
if @state in :error | :warning
|
|
98
|
-
lines.concat(validation_message_lines)
|
|
99
|
-
else
|
|
100
|
-
lines << "#{bar} #{keyboard_hints}\n"
|
|
101
|
-
lines << "#{bar_end}\n"
|
|
92
|
+
lines << "#{gutter(active_bar)}#{option_display(opt, actual_idx)}\n"
|
|
102
93
|
end
|
|
103
94
|
|
|
95
|
+
lines << frame_footer
|
|
104
96
|
lines.join
|
|
105
97
|
end
|
|
106
98
|
|
|
@@ -135,13 +127,14 @@ module Clack
|
|
|
135
127
|
update_selection_value
|
|
136
128
|
end
|
|
137
129
|
|
|
130
|
+
# Upstream MULTISELECT_INSTRUCTIONS; a/i are documented, not listed, so the
|
|
131
|
+
# line stays narrow enough not to soft-wrap in a split pane.
|
|
138
132
|
def keyboard_hints
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
133
|
+
[
|
|
134
|
+
key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"),
|
|
135
|
+
key_hint("Space:", "select"),
|
|
136
|
+
key_hint("Enter:", "confirm")
|
|
143
137
|
]
|
|
144
|
-
Colors.dim(hints.join(Colors.dim(" / ")))
|
|
145
138
|
end
|
|
146
139
|
|
|
147
140
|
def option_display(opt, idx)
|
|
@@ -16,7 +16,7 @@ module Clack
|
|
|
16
16
|
class Password < Core::Prompt
|
|
17
17
|
# @param message [String] the prompt message
|
|
18
18
|
# @param mask [String, nil] character to display (default: "▪")
|
|
19
|
-
# @option opts [Proc, nil] :validate
|
|
19
|
+
# @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator (see {Validators.resolve})
|
|
20
20
|
# @option opts [Hash] additional options passed to {Core::Prompt}
|
|
21
21
|
def initialize(message:, mask: nil, **opts)
|
|
22
22
|
super(message:, **opts)
|
|
@@ -35,7 +35,7 @@ module Clack
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def build_frame
|
|
38
|
-
"#{frame_header}#{active_bar}
|
|
38
|
+
"#{frame_header}#{gutter(active_bar)}#{masked_display}\n#{frame_footer}"
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def final_display = @mask * @value.grapheme_clusters.length
|
data/lib/clack/prompts/path.rb
CHANGED
|
@@ -32,7 +32,8 @@ module Clack
|
|
|
32
32
|
# @param root [String] starting/base directory (default: ".")
|
|
33
33
|
# @param only_directories [Boolean] only show directories (default: false)
|
|
34
34
|
# @param max_items [Integer] max visible suggestions (default: 5)
|
|
35
|
-
# @option opts [Proc, nil] :validate
|
|
35
|
+
# @option opts [Proc, Regexp, Symbol, Array, Hash, nil, false] :validate validator for the final path
|
|
36
|
+
# (see {Validators.resolve})
|
|
36
37
|
# @option opts [Hash] additional options passed to {Core::Prompt}
|
|
37
38
|
def initialize(message:, root: ".", only_directories: false, max_items: 5, **opts)
|
|
38
39
|
super(message:, **opts)
|
|
@@ -104,14 +105,24 @@ module Clack
|
|
|
104
105
|
def build_frame
|
|
105
106
|
suggestion_lines = visible_options.each_with_index.map do |path, idx|
|
|
106
107
|
actual_idx = @scroll_offset + idx
|
|
107
|
-
"#{
|
|
108
|
+
"#{gutter(active_bar)}#{suggestion_display(path, actual_idx == @option_index)}\n"
|
|
108
109
|
end.join
|
|
109
110
|
|
|
110
|
-
"#{frame_header}#{active_bar}
|
|
111
|
+
"#{frame_header}#{gutter(active_bar)}#{input_display}\n#{suggestion_lines}#{frame_footer}"
|
|
111
112
|
end
|
|
112
113
|
|
|
113
114
|
private
|
|
114
115
|
|
|
116
|
+
# Upstream autocomplete footer, completeOnTab variant (Ruby always completes on Tab).
|
|
117
|
+
def keyboard_hints
|
|
118
|
+
[
|
|
119
|
+
key_hint(Symbols::S_ARROWS_UP_DOWN, "to select"),
|
|
120
|
+
key_hint("Tab:", "complete"),
|
|
121
|
+
key_hint("Enter:", "confirm"),
|
|
122
|
+
key_hint("Type:", "to search")
|
|
123
|
+
]
|
|
124
|
+
end
|
|
125
|
+
|
|
115
126
|
def update_suggestions
|
|
116
127
|
base_path = resolve_path(@value)
|
|
117
128
|
|
data/lib/clack/prompts/range.rb
CHANGED
|
@@ -17,6 +17,9 @@ module Clack
|
|
|
17
17
|
# step: 1, initial_value: 4
|
|
18
18
|
# )
|
|
19
19
|
#
|
|
20
|
+
# @example Fractional step
|
|
21
|
+
# opacity = Clack.range(message: "Opacity", min: 0, max: 1, step: 0.1)
|
|
22
|
+
#
|
|
20
23
|
class Range < Core::Prompt
|
|
21
24
|
TRACK_WIDTH = 30
|
|
22
25
|
TRACK_CHAR = "\u2501" # ━ (box drawings heavy horizontal)
|
|
@@ -25,7 +28,10 @@ module Clack
|
|
|
25
28
|
# @param message [String] the prompt message
|
|
26
29
|
# @param min [Numeric] minimum value (default: 0)
|
|
27
30
|
# @param max [Numeric] maximum value (default: 100)
|
|
28
|
-
# @param step [Numeric] increment size (default: 1)
|
|
31
|
+
# @param step [Numeric] increment size (default: 1). Fractional steps such
|
|
32
|
+
# as 0.1 are snapped exactly (0.1 + 0.1 + 0.1 is 0.3, never 0.30000000000000004).
|
|
33
|
+
# The prompt returns an Integer when min and step are both Integers,
|
|
34
|
+
# a Float when either is a Float, and a Rational for Rational inputs.
|
|
29
35
|
# @param initial_value [Numeric, nil] initial value (defaults to min)
|
|
30
36
|
# @param default [Numeric, nil] deprecated alias for initial_value
|
|
31
37
|
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
@@ -38,6 +44,13 @@ module Clack
|
|
|
38
44
|
@min = min
|
|
39
45
|
@max = max
|
|
40
46
|
@step = step
|
|
47
|
+
# Snap arithmetic runs on these exact decimal forms (0.1 => 1/10) so
|
|
48
|
+
# repeated steps never accumulate binary Float error.
|
|
49
|
+
@exact_min = exact(min)
|
|
50
|
+
@exact_step = exact(step)
|
|
51
|
+
# Whether `min + step` promotes to Float, which decides how grid_value
|
|
52
|
+
# converts back. Fixed for the life of the prompt, so computed once.
|
|
53
|
+
@float_grid = (min + step).is_a?(Float)
|
|
41
54
|
@value = clamp(initial_value || default || min)
|
|
42
55
|
end
|
|
43
56
|
|
|
@@ -51,7 +64,7 @@ module Clack
|
|
|
51
64
|
end
|
|
52
65
|
|
|
53
66
|
def build_frame
|
|
54
|
-
"#{frame_header}#{active_bar}
|
|
67
|
+
"#{frame_header}#{gutter(active_bar)}#{slider_display}\n#{frame_footer}"
|
|
55
68
|
end
|
|
56
69
|
|
|
57
70
|
def final_display = format_value(@value)
|
|
@@ -62,10 +75,30 @@ module Clack
|
|
|
62
75
|
@value = clamp(@value + delta)
|
|
63
76
|
end
|
|
64
77
|
|
|
78
|
+
# Clamp to [min, max] and snap to the nearest point on the step grid.
|
|
79
|
+
# The grid position is computed in Rational so a step of 0.1 lands on
|
|
80
|
+
# 0.3, not 0.30000000000000004, then converted back to the caller's type.
|
|
65
81
|
def clamp(val)
|
|
66
82
|
val = val.clamp(@min, @max)
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
steps = ((exact(val) - @exact_min) / @exact_step).round
|
|
84
|
+
grid_value(steps).clamp(@min, @max)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Exact decimal form of a numeric. Floats go through #rationalize so
|
|
88
|
+
# 0.1 becomes 1/10 rather than its binary approximation; every other
|
|
89
|
+
# Numeric converts losslessly with #to_r.
|
|
90
|
+
def exact(num)
|
|
91
|
+
num.is_a?(Float) ? num.rationalize : num.to_r
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Value at grid position `steps`, in the type `min + step` promotes to.
|
|
95
|
+
# Float grids take the exact Rational and round once at the end. Every
|
|
96
|
+
# other numeric (Integer, Rational, BigDecimal) is exact under plain
|
|
97
|
+
# arithmetic, so the caller's own `min + n * step` is used unchanged.
|
|
98
|
+
def grid_value(steps)
|
|
99
|
+
return (@exact_min + (steps * @exact_step)).to_f if @float_grid
|
|
100
|
+
|
|
101
|
+
@min + (steps * @step)
|
|
69
102
|
end
|
|
70
103
|
|
|
71
104
|
def format_value(val)
|
data/lib/clack/prompts/select.rb
CHANGED
|
@@ -10,6 +10,8 @@ module Clack
|
|
|
10
10
|
# Options can be:
|
|
11
11
|
# - Strings: `["a", "b", "c"]` (value and label are the same)
|
|
12
12
|
# - Hashes: `[{value: "a", label: "Option A", hint: "details", disabled: false}]`
|
|
13
|
+
# - A Hash: `{ a: "Option A", b: { label: "Option B", hint: "details", disabled: false } }`
|
|
14
|
+
# (keys are values, values are labels or spec hashes; insertion order is display order)
|
|
13
15
|
#
|
|
14
16
|
# @example Basic usage
|
|
15
17
|
# choice = Clack.select(
|
|
@@ -60,7 +62,7 @@ module Clack
|
|
|
60
62
|
def build_frame
|
|
61
63
|
option_lines = visible_options.each_with_index.map do |opt, idx|
|
|
62
64
|
actual_idx = @scroll_offset + idx
|
|
63
|
-
"#{
|
|
65
|
+
"#{gutter(active_bar)}#{option_display(opt, actual_idx == @option_index)}\n"
|
|
64
66
|
end.join
|
|
65
67
|
|
|
66
68
|
"#{frame_header}#{option_lines}#{frame_footer}"
|
|
@@ -70,6 +72,11 @@ module Clack
|
|
|
70
72
|
|
|
71
73
|
private
|
|
72
74
|
|
|
75
|
+
# Upstream SELECT_INSTRUCTIONS
|
|
76
|
+
def keyboard_hints
|
|
77
|
+
[key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"), key_hint("Enter:", "confirm")]
|
|
78
|
+
end
|
|
79
|
+
|
|
73
80
|
def move_cursor(delta)
|
|
74
81
|
super
|
|
75
82
|
update_value
|
|
@@ -5,11 +5,14 @@ module Clack
|
|
|
5
5
|
# Quick selection via keyboard shortcuts.
|
|
6
6
|
#
|
|
7
7
|
# Each option has an associated key. Pressing that key immediately
|
|
8
|
-
# selects the option and submits
|
|
8
|
+
# selects the option and submits (running +validate:+ and +transform:+
|
|
9
|
+
# like every other prompt).
|
|
9
10
|
#
|
|
10
11
|
# Options format:
|
|
11
12
|
# - `{ value: "x", label: "Do X", key: "x" }` - explicit key
|
|
12
13
|
# - `{ value: "create", label: "Create" }` - key defaults to first char
|
|
14
|
+
# - `{ create: "Create", quit: "Quit" }` - Hash shorthand (value => label, key defaults to first char)
|
|
15
|
+
# - `{ create: { label: "New project", key: "n" } }` - Hash shorthand with explicit key
|
|
13
16
|
#
|
|
14
17
|
# @example Basic usage
|
|
15
18
|
# action = Clack.select_key(
|
|
@@ -23,65 +26,85 @@ module Clack
|
|
|
23
26
|
#
|
|
24
27
|
class SelectKey < Core::Prompt
|
|
25
28
|
# @param message [String] the prompt message
|
|
26
|
-
# @param options [Array<Hash
|
|
29
|
+
# @param options [Array<Hash>, Hash] options with :value, :label, and optionally :key, :hint,
|
|
30
|
+
# or a Hash of value => label (or value => {label:, key:, hint:})
|
|
31
|
+
# @param case_sensitive [Boolean] match keys exactly instead of case-insensitively (default: false)
|
|
32
|
+
# @param initial_value [Object, nil] value of the option highlighted at start; Enter submits it.
|
|
33
|
+
# The highlight is a color effect, so when colors are off mark the default in that option's +hint:+.
|
|
34
|
+
# A value that matches no option is ignored with a warning on stderr.
|
|
27
35
|
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
28
|
-
def initialize(message:, options:, **opts)
|
|
36
|
+
def initialize(message:, options:, case_sensitive: false, initial_value: nil, **opts)
|
|
29
37
|
super(message:, **opts)
|
|
30
38
|
@options = normalize_options(options)
|
|
39
|
+
@case_sensitive = case_sensitive
|
|
40
|
+
@option_index = find_initial_index(initial_value)
|
|
41
|
+
@value = current_option&.value
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
protected
|
|
34
45
|
|
|
46
|
+
# Option keys take precedence over every action except :cancel, so a
|
|
47
|
+
# custom alias such as `"y" => :enter` cannot hide a `y` option.
|
|
35
48
|
def handle_key(key)
|
|
36
49
|
return if terminal_state?
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
idx = @options.find_index { |opt| key_match?(opt.key, key) }
|
|
52
|
+
return super if idx.nil? || Core::Settings.action?(key) == :cancel
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else
|
|
44
|
-
opt = @options.find { |o| o.key&.downcase == key&.downcase }
|
|
45
|
-
return unless opt
|
|
46
|
-
|
|
47
|
-
@value = opt.value
|
|
48
|
-
@state = :submit
|
|
49
|
-
end
|
|
54
|
+
@option_index = idx
|
|
55
|
+
@value = current_option.value
|
|
56
|
+
submit
|
|
50
57
|
end
|
|
51
58
|
|
|
52
|
-
def
|
|
53
|
-
lines = []
|
|
54
|
-
lines << "#{bar}\n"
|
|
55
|
-
lines << "#{symbol_for_state} #{@message}\n"
|
|
56
|
-
lines << help_line
|
|
59
|
+
def can_submit? = !@option_index.nil?
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
def build_frame
|
|
62
|
+
option_lines = @options.each_with_index.map do |opt, idx|
|
|
63
|
+
"#{gutter(active_bar)}#{option_display(opt, active_row?(idx))}\n"
|
|
64
|
+
end.join
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
lines.join
|
|
66
|
+
"#{frame_header}#{option_lines}#{frame_footer}"
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
def final_display =
|
|
69
|
+
def final_display = current_option ? current_option.label.to_s : ""
|
|
67
70
|
|
|
68
71
|
private
|
|
69
72
|
|
|
73
|
+
def current_option = @option_index && @options[@option_index]
|
|
74
|
+
|
|
70
75
|
def normalize_options(options)
|
|
71
|
-
options.map do |opt|
|
|
76
|
+
Core::OptionsHelper.option_entries(options).map do |opt|
|
|
72
77
|
Clack::Core::SelectKeyOption.new(
|
|
73
78
|
value: opt[:value],
|
|
74
79
|
label: opt[:label] || opt[:value].to_s,
|
|
75
|
-
key: opt[:key] || opt[:value].to_s[0],
|
|
80
|
+
key: (opt[:key] || opt[:value].to_s[0])&.to_s,
|
|
76
81
|
hint: opt[:hint]
|
|
77
82
|
)
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
81
|
-
def
|
|
82
|
-
|
|
86
|
+
def find_initial_index(initial_value)
|
|
87
|
+
return nil if initial_value.nil?
|
|
88
|
+
|
|
89
|
+
idx = @options.find_index { |opt| opt.value == initial_value }
|
|
90
|
+
warn "[clack] ignoring unknown initial_value: #{initial_value.inspect}" unless idx
|
|
91
|
+
idx
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def key_match?(option_key, key)
|
|
95
|
+
return false if option_key.nil? || key.nil?
|
|
96
|
+
|
|
97
|
+
@case_sensitive ? option_key == key : option_key.downcase == key.downcase
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The highlighted row: only meaningful while the prompt is still open.
|
|
101
|
+
def active_row?(idx) = idx == @option_index && !terminal_state?
|
|
102
|
+
|
|
103
|
+
def option_display(opt, active)
|
|
104
|
+
badge = Colors.cyan("[#{opt.key}]")
|
|
105
|
+
badge = Colors.inverse(badge) if active
|
|
83
106
|
hint = opt.hint ? " #{Colors.dim("(#{opt.hint})")}" : ""
|
|
84
|
-
"#{
|
|
107
|
+
"#{badge} #{opt.label}#{hint}"
|
|
85
108
|
end
|
|
86
109
|
end
|
|
87
110
|
end
|