tty-prompt 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/Gemfile +8 -4
- data/README.md +229 -32
- data/benchmarks/speed.rb +27 -0
- data/examples/collect.rb +19 -0
- data/examples/expand.rb +29 -0
- data/lib/tty-prompt.rb +3 -0
- data/lib/tty/prompt.rb +82 -21
- data/lib/tty/prompt/answers_collector.rb +59 -0
- data/lib/tty/prompt/choice.rb +9 -2
- data/lib/tty/prompt/choices.rb +17 -1
- data/lib/tty/prompt/confirm_question.rb +140 -0
- data/lib/tty/prompt/enum_list.rb +15 -12
- data/lib/tty/prompt/expander.rb +288 -0
- data/lib/tty/prompt/list.rb +14 -8
- data/lib/tty/prompt/mask_question.rb +2 -2
- data/lib/tty/prompt/multi_list.rb +14 -4
- data/lib/tty/prompt/question.rb +8 -10
- data/lib/tty/prompt/slider.rb +9 -7
- data/lib/tty/prompt/version.rb +1 -1
- data/spec/unit/ask_spec.rb +68 -0
- data/spec/unit/choice/from_spec.rb +7 -1
- data/spec/unit/choices/find_by_spec.rb +10 -0
- data/spec/unit/choices/pluck_spec.rb +4 -4
- data/spec/unit/collect_spec.rb +33 -0
- data/spec/unit/converters/convert_bool_spec.rb +1 -1
- data/spec/unit/enum_select_spec.rb +20 -1
- data/spec/unit/expand_spec.rb +198 -0
- data/spec/unit/multi_select_spec.rb +39 -2
- data/spec/unit/select_spec.rb +28 -5
- data/spec/unit/slider_spec.rb +15 -0
- data/spec/unit/yes_no_spec.rb +149 -14
- data/tty-prompt.gemspec +1 -2
- metadata +15 -17
data/lib/tty/prompt/enum_list.rb
CHANGED
@@ -11,14 +11,17 @@ module TTY
|
|
11
11
|
#
|
12
12
|
# @api public
|
13
13
|
def initialize(prompt, options = {})
|
14
|
-
@prompt
|
15
|
-
@
|
16
|
-
@
|
17
|
-
@
|
18
|
-
@
|
19
|
-
@
|
20
|
-
@
|
21
|
-
@
|
14
|
+
@prompt = prompt
|
15
|
+
@prefix = options.fetch(:prefix) { @prompt.prefix }
|
16
|
+
@enum = options.fetch(:enum) { ')' }
|
17
|
+
@default = options.fetch(:default) { 1 }
|
18
|
+
@active_color = options.fetch(:active_color) { @prompt.active_color }
|
19
|
+
@help_color = options.fetch(:help_color) { @prompt.help_color }
|
20
|
+
@error_color = options.fetch(:error_color) { @prompt.error_color }
|
21
|
+
@done = false
|
22
|
+
@failure = false
|
23
|
+
@active = @default
|
24
|
+
@choices = Choices.new
|
22
25
|
|
23
26
|
@prompt.subscribe(self)
|
24
27
|
end
|
@@ -161,7 +164,7 @@ module TTY
|
|
161
164
|
#
|
162
165
|
# @api private
|
163
166
|
def render_question
|
164
|
-
header = "#{@
|
167
|
+
header = "#{@prefix}#{@question} #{render_header}"
|
165
168
|
@prompt.puts(header)
|
166
169
|
return if @done
|
167
170
|
@prompt.print(render_menu)
|
@@ -172,7 +175,7 @@ module TTY
|
|
172
175
|
# @api private
|
173
176
|
def render_error
|
174
177
|
error = 'Please enter a valid index'
|
175
|
-
@prompt.print("\n" + @prompt.decorate('>>',
|
178
|
+
@prompt.print("\n" + @prompt.decorate('>>', @error_color) + ' ' + error)
|
176
179
|
@prompt.print(@prompt.cursor.prev_line)
|
177
180
|
@prompt.print(@prompt.cursor.forward(render_footer.size))
|
178
181
|
end
|
@@ -186,7 +189,7 @@ module TTY
|
|
186
189
|
return '' unless @done
|
187
190
|
return '' unless @active
|
188
191
|
selected_item = "#{@choices[@active - 1].name}"
|
189
|
-
@prompt.decorate(selected_item, @
|
192
|
+
@prompt.decorate(selected_item, @active_color)
|
190
193
|
end
|
191
194
|
|
192
195
|
# Render footer for the indexed menu
|
@@ -209,7 +212,7 @@ module TTY
|
|
209
212
|
num = (index + 1).to_s + @enum + Symbols::SPACE
|
210
213
|
selected = Symbols::SPACE * 2 + num + choice.name
|
211
214
|
output << if index + 1 == @active
|
212
|
-
@prompt.decorate("#{selected}", @
|
215
|
+
@prompt.decorate("#{selected}", @active_color)
|
213
216
|
else
|
214
217
|
selected
|
215
218
|
end
|
@@ -0,0 +1,288 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
require 'tty/prompt/question'
|
4
|
+
|
5
|
+
module TTY
|
6
|
+
class Prompt
|
7
|
+
# A class responsible for rendering expanding options
|
8
|
+
# Used by {Prompt} to display key options question.
|
9
|
+
#
|
10
|
+
# @api private
|
11
|
+
class Expander
|
12
|
+
HELP_CHOICE = {
|
13
|
+
key: 'h',
|
14
|
+
name: 'print help',
|
15
|
+
value: :help
|
16
|
+
}
|
17
|
+
|
18
|
+
# Create instance of Expander
|
19
|
+
#
|
20
|
+
# @api public
|
21
|
+
def initialize(prompt, options = {})
|
22
|
+
@prompt = prompt
|
23
|
+
@prefix = options.fetch(:prefix) { @prompt.prefix }
|
24
|
+
@default = options.fetch(:default) { 1 }
|
25
|
+
@active_color = options.fetch(:active_color) { @prompt.active_color }
|
26
|
+
@help_color = options.fetch(:help_color) { @prompt.help_color }
|
27
|
+
@choices = Choices.new
|
28
|
+
@selected = nil
|
29
|
+
@done = false
|
30
|
+
@status = :collapsed
|
31
|
+
@hint = nil
|
32
|
+
@default_key = false
|
33
|
+
|
34
|
+
@prompt.subscribe(self)
|
35
|
+
end
|
36
|
+
|
37
|
+
def expanded?
|
38
|
+
@status == :expanded
|
39
|
+
end
|
40
|
+
|
41
|
+
def collapsed?
|
42
|
+
@status == :collapsed
|
43
|
+
end
|
44
|
+
|
45
|
+
def expand
|
46
|
+
@status = :expanded
|
47
|
+
end
|
48
|
+
|
49
|
+
# Respond to submit event
|
50
|
+
#
|
51
|
+
# @api public
|
52
|
+
def keyenter(_)
|
53
|
+
if @input.nil? || @input.empty?
|
54
|
+
@input = @choices[@default - 1].key
|
55
|
+
@default_key = true
|
56
|
+
end
|
57
|
+
|
58
|
+
selected = select_choice(@input)
|
59
|
+
|
60
|
+
if selected && selected.key.to_s == 'h'
|
61
|
+
expand
|
62
|
+
@selected = nil
|
63
|
+
@input = ''
|
64
|
+
elsif selected
|
65
|
+
@done = true
|
66
|
+
@selected = selected
|
67
|
+
else
|
68
|
+
@input = ''
|
69
|
+
end
|
70
|
+
end
|
71
|
+
alias_method :keyreturn, :keyenter
|
72
|
+
|
73
|
+
# Respond to key press event
|
74
|
+
#
|
75
|
+
# @api public
|
76
|
+
def keypress(event)
|
77
|
+
if [:backspace, :delete].include?(event.key.name)
|
78
|
+
@input.chop! unless @input.empty?
|
79
|
+
elsif event.value =~ /^[^\e\n\r]/
|
80
|
+
@input += event.value
|
81
|
+
end
|
82
|
+
@selected = select_choice(@input)
|
83
|
+
if @selected && !@default_key && collapsed?
|
84
|
+
@hint = @selected.name
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Select choice by given key
|
89
|
+
#
|
90
|
+
# @return [Choice]
|
91
|
+
#
|
92
|
+
# @api private
|
93
|
+
def select_choice(key)
|
94
|
+
@choices.find_by(:key, key)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Set default value.
|
98
|
+
#
|
99
|
+
# @api public
|
100
|
+
def default(value = (not_set = true))
|
101
|
+
return @default if not_set
|
102
|
+
@default = value
|
103
|
+
end
|
104
|
+
|
105
|
+
# Add a single choice
|
106
|
+
#
|
107
|
+
# @api public
|
108
|
+
def choice(value, &block)
|
109
|
+
if block
|
110
|
+
@choices << value.update(value: block)
|
111
|
+
else
|
112
|
+
@choices << value
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
# Add multiple choices
|
117
|
+
#
|
118
|
+
# @param [Array[Object]] values
|
119
|
+
# the values to add as choices
|
120
|
+
#
|
121
|
+
# @api public
|
122
|
+
def choices(values)
|
123
|
+
values.each { |val| choice(val) }
|
124
|
+
end
|
125
|
+
|
126
|
+
# Execute this prompt
|
127
|
+
#
|
128
|
+
# @api public
|
129
|
+
def call(message, possibilities, &block)
|
130
|
+
choices(possibilities)
|
131
|
+
@message = message
|
132
|
+
block.call(self) if block
|
133
|
+
setup_defaults
|
134
|
+
choice(HELP_CHOICE)
|
135
|
+
render
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
# Create possible keys with current choice highlighted
|
141
|
+
#
|
142
|
+
# @return [String]
|
143
|
+
#
|
144
|
+
# @api private
|
145
|
+
def possible_keys
|
146
|
+
keys = @choices.pluck(:key)
|
147
|
+
default_key = keys[@default - 1]
|
148
|
+
if @selected
|
149
|
+
index = keys.index(@selected.key)
|
150
|
+
keys[index] = @prompt.decorate(keys[index], @active_color)
|
151
|
+
elsif @input.to_s.empty? && default_key
|
152
|
+
keys[@default - 1] = @prompt.decorate(default_key, @active_color)
|
153
|
+
end
|
154
|
+
keys.join(',')
|
155
|
+
end
|
156
|
+
|
157
|
+
# @api private
|
158
|
+
def render
|
159
|
+
@input = ''
|
160
|
+
until @done
|
161
|
+
render_question
|
162
|
+
read_input
|
163
|
+
refresh
|
164
|
+
end
|
165
|
+
render_question
|
166
|
+
render_answer
|
167
|
+
end
|
168
|
+
|
169
|
+
# @api private
|
170
|
+
def render_answer
|
171
|
+
@selected.value
|
172
|
+
end
|
173
|
+
|
174
|
+
def render_header
|
175
|
+
header = "#{@prefix}#{@message} "
|
176
|
+
if @done
|
177
|
+
selected_item = "#{@selected.name}"
|
178
|
+
header << @prompt.decorate(selected_item, @active_color)
|
179
|
+
elsif collapsed?
|
180
|
+
header << %[(enter "h" for help) ]
|
181
|
+
header << "[#{possible_keys}] "
|
182
|
+
header << @input
|
183
|
+
end
|
184
|
+
header
|
185
|
+
end
|
186
|
+
|
187
|
+
# @api private
|
188
|
+
def render_hint
|
189
|
+
hint = "\n"
|
190
|
+
hint << @prompt.decorate('>> ', @active_color)
|
191
|
+
hint << @hint
|
192
|
+
@prompt.print(hint)
|
193
|
+
@prompt.print(@prompt.cursor.prev_line)
|
194
|
+
@prompt.print(@prompt.cursor.forward(@prompt.strip(render_header).size))
|
195
|
+
end
|
196
|
+
|
197
|
+
# @api private
|
198
|
+
def render_question
|
199
|
+
header = render_header
|
200
|
+
@prompt.print(header)
|
201
|
+
render_hint if @hint
|
202
|
+
@prompt.print("\n") if @done
|
203
|
+
|
204
|
+
if !@done && expanded?
|
205
|
+
@prompt.print(render_menu)
|
206
|
+
@prompt.print(render_footer)
|
207
|
+
end
|
208
|
+
end
|
209
|
+
|
210
|
+
def render_footer
|
211
|
+
" Choice [#{@choices[@default - 1].key}]: #{@input}"
|
212
|
+
end
|
213
|
+
|
214
|
+
def read_input
|
215
|
+
@prompt.read_keypress
|
216
|
+
end
|
217
|
+
|
218
|
+
# @api private
|
219
|
+
def count_lines
|
220
|
+
lines = render_header.scan("\n").length + 1
|
221
|
+
if @hint
|
222
|
+
lines += @hint.scan("\n").length + 1
|
223
|
+
elsif expanded?
|
224
|
+
lines += @choices.length
|
225
|
+
lines += render_footer.scan("\n").length + 1
|
226
|
+
end
|
227
|
+
lines
|
228
|
+
end
|
229
|
+
|
230
|
+
# Refresh the current input
|
231
|
+
#
|
232
|
+
# @api private
|
233
|
+
def refresh
|
234
|
+
lines = count_lines
|
235
|
+
if @hint && (!@selected || @done)
|
236
|
+
@hint = nil
|
237
|
+
@prompt.print(@prompt.clear_lines(lines, :down))
|
238
|
+
@prompt.print(@prompt.cursor.prev_line)
|
239
|
+
elsif expanded?
|
240
|
+
@prompt.print(@prompt.clear_lines(lines))
|
241
|
+
else
|
242
|
+
@prompt.print(@prompt.clear_line)
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Render help menu
|
247
|
+
#
|
248
|
+
# @api private
|
249
|
+
def render_menu
|
250
|
+
output = "\n"
|
251
|
+
@choices.each do |choice|
|
252
|
+
chosen = %(#{choice.key} - #{choice.name})
|
253
|
+
if @selected && @selected.key == choice.key
|
254
|
+
chosen = @prompt.decorate(chosen, @active_color)
|
255
|
+
end
|
256
|
+
output << ' ' + chosen + "\n"
|
257
|
+
end
|
258
|
+
output
|
259
|
+
end
|
260
|
+
|
261
|
+
def setup_defaults
|
262
|
+
validate_choices
|
263
|
+
end
|
264
|
+
|
265
|
+
def validate_choices
|
266
|
+
errors = []
|
267
|
+
keys = []
|
268
|
+
@choices.each do |choice|
|
269
|
+
if choice.key.nil?
|
270
|
+
errors << "Choice #{choice.name} is missing a :key attribute"
|
271
|
+
next
|
272
|
+
end
|
273
|
+
if choice.key.length != 1
|
274
|
+
errors << "Choice key `#{choice.key}` is more than one character long."
|
275
|
+
end
|
276
|
+
if choice.key.to_s == 'h'
|
277
|
+
errors << "Choice key `#{choice.key}` is reserved for help menu."
|
278
|
+
end
|
279
|
+
if keys.include?(choice.key)
|
280
|
+
errors << "Choice key `#{choice.key}` is a duplicate."
|
281
|
+
end
|
282
|
+
keys << choice.key if choice.key
|
283
|
+
end
|
284
|
+
errors.each { |err| fail ConfigurationError, err }
|
285
|
+
end
|
286
|
+
end # Expander
|
287
|
+
end # Prompt
|
288
|
+
end # TTY
|
data/lib/tty/prompt/list.rb
CHANGED
@@ -25,15 +25,17 @@ module TTY
|
|
25
25
|
# @api public
|
26
26
|
def initialize(prompt, options = {})
|
27
27
|
@prompt = prompt
|
28
|
-
@
|
29
|
-
@done = false
|
28
|
+
@prefix = options.fetch(:prefix) { @prompt.prefix }
|
30
29
|
@enum = options.fetch(:enum) { nil }
|
31
30
|
@default = Array[options.fetch(:default) { 1 }]
|
32
31
|
@active = @default.first
|
33
32
|
@choices = Choices.new
|
34
|
-
@
|
33
|
+
@active_color = options.fetch(:active_color) { @prompt.active_color }
|
34
|
+
@help_color = options.fetch(:help_color) { @prompt.help_color }
|
35
35
|
@marker = options.fetch(:marker) { Symbols::ITEM_SELECTED }
|
36
36
|
@help = options[:help]
|
37
|
+
@first_render = true
|
38
|
+
@done = false
|
37
39
|
|
38
40
|
@prompt.subscribe(self)
|
39
41
|
end
|
@@ -143,6 +145,10 @@ module TTY
|
|
143
145
|
# @api private
|
144
146
|
def validate_defaults
|
145
147
|
@default.each do |d|
|
148
|
+
if d.nil? || d.to_s.empty?
|
149
|
+
fail ConfigurationError,
|
150
|
+
"default index must be an integer in range (1 - #{@choices.size})"
|
151
|
+
end
|
146
152
|
if d < 1 || d > @choices.size
|
147
153
|
fail ConfigurationError,
|
148
154
|
"default index `#{d}` out of range (1 - #{@choices.size})"
|
@@ -193,7 +199,7 @@ module TTY
|
|
193
199
|
#
|
194
200
|
# @api private
|
195
201
|
def render_question
|
196
|
-
header = "#{@
|
202
|
+
header = "#{@prefix}#{@question} #{render_header}"
|
197
203
|
@prompt.puts(header)
|
198
204
|
@first_render = false
|
199
205
|
@prompt.print(render_menu) unless @done
|
@@ -204,7 +210,7 @@ module TTY
|
|
204
210
|
# @return [String]
|
205
211
|
def help
|
206
212
|
return @help unless @help.nil?
|
207
|
-
self.class::HELP % [enumerate? ?
|
213
|
+
self.class::HELP % [enumerate? ? " or number (1-#{@choices.size})" : '']
|
208
214
|
end
|
209
215
|
|
210
216
|
# Render initial help and selected choice
|
@@ -215,9 +221,9 @@ module TTY
|
|
215
221
|
def render_header
|
216
222
|
if @done
|
217
223
|
selected_item = "#{@choices[@active - 1].name}"
|
218
|
-
@prompt.decorate(selected_item, @
|
224
|
+
@prompt.decorate(selected_item, @active_color)
|
219
225
|
elsif @first_render
|
220
|
-
@prompt.decorate(help,
|
226
|
+
@prompt.decorate(help, @help_color)
|
221
227
|
end
|
222
228
|
end
|
223
229
|
|
@@ -230,7 +236,7 @@ module TTY
|
|
230
236
|
num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
|
231
237
|
message = if index + 1 == @active
|
232
238
|
selected = @marker + Symbols::SPACE + num + choice.name
|
233
|
-
@prompt.decorate("#{selected}", @
|
239
|
+
@prompt.decorate("#{selected}", @active_color)
|
234
240
|
else
|
235
241
|
Symbols::SPACE * 2 + num + choice.name
|
236
242
|
end
|
@@ -49,11 +49,11 @@ module TTY
|
|
49
49
|
#
|
50
50
|
# @api private
|
51
51
|
def render_question
|
52
|
-
header = "#{
|
52
|
+
header = "#{@prefix}#{message} "
|
53
53
|
if echo?
|
54
54
|
masked = "#{@mask * "#{@input}".length}"
|
55
55
|
if @done_masked && !@failure
|
56
|
-
masked = @prompt.decorate(masked, @
|
56
|
+
masked = @prompt.decorate(masked, @active_color)
|
57
57
|
end
|
58
58
|
header += masked
|
59
59
|
end
|
@@ -47,16 +47,26 @@ module TTY
|
|
47
47
|
@active = @default.last unless @selected.empty?
|
48
48
|
end
|
49
49
|
|
50
|
+
# Generate selected items names
|
51
|
+
#
|
52
|
+
# @return [String]
|
53
|
+
#
|
54
|
+
# @api private
|
55
|
+
def selected_names
|
56
|
+
@selected.map(&:name).join(', ')
|
57
|
+
end
|
58
|
+
|
50
59
|
# Render initial help text and then currently selected choices
|
51
60
|
#
|
52
61
|
# @api private
|
53
62
|
def render_header
|
63
|
+
instructions = @prompt.decorate(help, :bright_black)
|
54
64
|
if @done
|
55
|
-
@prompt.decorate(
|
65
|
+
@prompt.decorate(selected_names, @active_color)
|
56
66
|
elsif @selected.size.nonzero?
|
57
|
-
@
|
67
|
+
selected_names + (@first_render ? " #{instructions}" : '')
|
58
68
|
elsif @first_render
|
59
|
-
|
69
|
+
instructions
|
60
70
|
end
|
61
71
|
end
|
62
72
|
|
@@ -79,7 +89,7 @@ module TTY
|
|
79
89
|
indicator = (index + 1 == @active) ? @marker : Symbols::SPACE
|
80
90
|
indicator += Symbols::SPACE
|
81
91
|
message = if @selected.include?(choice)
|
82
|
-
selected = @prompt.decorate(Symbols::RADIO_CHECKED,
|
92
|
+
selected = @prompt.decorate(Symbols::RADIO_CHECKED, @active_color)
|
83
93
|
selected + Symbols::SPACE + num + choice.name
|
84
94
|
else
|
85
95
|
Symbols::RADIO_UNCHECKED + Symbols::SPACE + num + choice.name
|