tty-prompt 0.15.0 → 0.16.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.
Files changed (50) hide show
  1. checksums.yaml +5 -5
  2. data/.travis.yml +1 -0
  3. data/CHANGELOG.md +17 -0
  4. data/Gemfile +2 -3
  5. data/README.md +140 -12
  6. data/appveyor.yml +1 -0
  7. data/examples/enum_select_disabled.rb +16 -0
  8. data/examples/{enum_paged.rb → enum_select_paged.rb} +0 -0
  9. data/examples/enum_select_wrapped.rb +15 -0
  10. data/examples/multi_select_disabled.rb +17 -0
  11. data/examples/multi_select_wrapped.rb +15 -0
  12. data/examples/select.rb +3 -1
  13. data/examples/select_disabled.rb +18 -0
  14. data/examples/{enum.rb → select_enum.rb} +0 -0
  15. data/examples/select_filtered.rb +3 -1
  16. data/examples/select_paginated.rb +3 -1
  17. data/examples/select_wrapped.rb +15 -0
  18. data/lib/tty/prompt.rb +1 -0
  19. data/lib/tty/prompt/answers_collector.rb +1 -0
  20. data/lib/tty/prompt/choice.rb +67 -25
  21. data/lib/tty/prompt/choices.rb +1 -0
  22. data/lib/tty/prompt/confirm_question.rb +3 -4
  23. data/lib/tty/prompt/converter_dsl.rb +1 -0
  24. data/lib/tty/prompt/converter_registry.rb +1 -0
  25. data/lib/tty/prompt/converters.rb +1 -0
  26. data/lib/tty/prompt/distance.rb +1 -0
  27. data/lib/tty/prompt/enum_list.rb +58 -17
  28. data/lib/tty/prompt/enum_paginator.rb +1 -0
  29. data/lib/tty/prompt/evaluator.rb +1 -0
  30. data/lib/tty/prompt/expander.rb +14 -14
  31. data/lib/tty/prompt/keypress.rb +1 -1
  32. data/lib/tty/prompt/list.rb +78 -39
  33. data/lib/tty/prompt/mask_question.rb +5 -4
  34. data/lib/tty/prompt/multi_list.rb +13 -3
  35. data/lib/tty/prompt/multiline.rb +6 -5
  36. data/lib/tty/prompt/paginator.rb +1 -0
  37. data/lib/tty/prompt/question.rb +10 -9
  38. data/lib/tty/prompt/question/checks.rb +1 -0
  39. data/lib/tty/prompt/question/modifier.rb +1 -0
  40. data/lib/tty/prompt/question/validation.rb +1 -0
  41. data/lib/tty/prompt/result.rb +1 -0
  42. data/lib/tty/prompt/slider.rb +3 -2
  43. data/lib/tty/prompt/statement.rb +1 -0
  44. data/lib/tty/prompt/suggestion.rb +4 -6
  45. data/lib/tty/prompt/symbols.rb +2 -1
  46. data/lib/tty/prompt/timeout.rb +16 -11
  47. data/lib/tty/prompt/utils.rb +1 -0
  48. data/lib/tty/prompt/version.rb +1 -1
  49. data/lib/tty/test_prompt.rb +1 -0
  50. metadata +11 -5
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'forwardable'
4
5
 
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'question'
4
5
  require_relative 'utils'
@@ -112,10 +113,8 @@ module TTY
112
113
 
113
114
  # @api private
114
115
  def create_suffix
115
- result = ''
116
- result << "#{default ? positive.capitalize : positive.downcase}"
117
- result << '/'
118
- result << "#{default ? negative.downcase : negative.capitalize}"
116
+ (default ? positive.capitalize : positive.downcase) + '/' +
117
+ (default ? negative.downcase : negative.capitalize)
119
118
  end
120
119
 
121
120
  # Create custom conversion
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'converter_registry'
4
5
 
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  module TTY
4
5
  class Prompt
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'pathname'
4
5
  require 'necromancer'
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  module TTY
4
5
  class Prompt
@@ -1,8 +1,12 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
3
+
4
+ require 'English'
2
5
 
3
6
  require_relative 'choices'
4
7
  require_relative 'enum_paginator'
5
8
  require_relative 'paginator'
9
+ require_relative 'symbols'
6
10
 
7
11
  module TTY
8
12
  class Prompt
@@ -11,7 +15,9 @@ module TTY
11
15
  #
12
16
  # @api private
13
17
  class EnumList
14
- PAGE_HELP = '(Press tab/right or left to reveal more choices)'.freeze
18
+ include Symbols
19
+
20
+ PAGE_HELP = '(Press tab/right or left to reveal more choices)'
15
21
 
16
22
  # Create instance of EnumList menu.
17
23
  #
@@ -97,8 +103,12 @@ module TTY
97
103
  # the values to add as choices
98
104
  #
99
105
  # @api public
100
- def choices(values)
101
- values.each { |val| choice(*val) }
106
+ def choices(values = (not_set = true))
107
+ if not_set
108
+ @choices
109
+ else
110
+ values.each { |val| @choices << val }
111
+ end
102
112
  end
103
113
 
104
114
  # Call the list menu by passing question and choices
@@ -128,7 +138,11 @@ module TTY
128
138
 
129
139
  def keyreturn(*)
130
140
  @failure = false
131
- if (@input.to_i > 0 && @input.to_i <= @choices.size) || @input.empty?
141
+ num = @input.to_i
142
+ choice_disabled = choices[num - 1] && choices[num - 1].disabled?
143
+ choice_in_range = num > 0 && num <= @choices.size
144
+
145
+ if choice_in_range && !choice_disabled || @input.empty?
132
146
  @done = true
133
147
  else
134
148
  @input = ''
@@ -162,7 +176,11 @@ module TTY
162
176
  #
163
177
  # @api private
164
178
  def mark_choice_as_active
165
- if (@input.to_i > 0) && !@choices[@input.to_i - 1].nil?
179
+ next_active = @choices[@input.to_i - 1]
180
+
181
+ if next_active && next_active.disabled?
182
+ # noop
183
+ elsif (@input.to_i > 0) && next_active
166
184
  @active = @input.to_i
167
185
  else
168
186
  @active = @default
@@ -174,9 +192,15 @@ module TTY
174
192
  #
175
193
  # @api private
176
194
  def validate_defaults
177
- return if @default >= 1 && @default <= @choices.size
178
- raise ConfigurationError,
179
- "default index `#{@default}` out of range (1 - #{@choices.size})"
195
+ msg = if @default.nil? || @default.to_s.empty?
196
+ "default index must be an integer in range (1 - #{choices.size})"
197
+ elsif @default < 1 || @default > @choices.size
198
+ "default index #{@default} out of range (1 - #{@choices.size})"
199
+ elsif choices[@default - 1] && choices[@default - 1].disabled?
200
+ "default index #{@default} matches disabled choice item"
201
+ end
202
+
203
+ raise(ConfigurationError, msg) if msg
180
204
  end
181
205
 
182
206
  # Setup default option and active selection
@@ -205,12 +229,24 @@ module TTY
205
229
  @prompt.print(render_page_help)
206
230
  end
207
231
  @prompt.read_keypress
208
- @prompt.print(refresh(question.lines.count))
232
+ question_lines = question.split($INPUT_RECORD_SEPARATOR, -1)
233
+ @prompt.print(refresh(question_lines_count(question_lines)))
209
234
  end
210
235
  @prompt.print(render_question)
211
236
  answer
212
237
  end
213
238
 
239
+ # Count how many screen lines the question spans
240
+ #
241
+ # @return [Integer]
242
+ #
243
+ # @api private
244
+ def question_lines_count(question_lines)
245
+ question_lines.reduce(0) do |acc, line|
246
+ acc + @prompt.count_screen_lines(line)
247
+ end
248
+ end
249
+
214
250
  # Find value for the choice selected
215
251
  #
216
252
  # @return [nil, Object]
@@ -239,12 +275,12 @@ module TTY
239
275
  #
240
276
  # @api private
241
277
  def render_question
242
- header = "#{@prefix}#{@question} #{render_header}\n"
278
+ header = ["#{@prefix}#{@question} #{render_header}\n"]
243
279
  unless @done
244
280
  header << render_menu
245
281
  header << render_footer
246
282
  end
247
- header
283
+ header.join
248
284
  end
249
285
 
250
286
  # Error message when incorrect index chosen
@@ -320,18 +356,23 @@ module TTY
320
356
  #
321
357
  # @api private
322
358
  def render_menu
323
- output = ''
359
+ output = []
360
+
324
361
  @paginator.paginate(@choices, @page_active, @per_page) do |choice, index|
325
362
  num = (index + 1).to_s + @enum + ' '
326
- selected = ' ' * 2 + num + choice.name
327
- output << if index + 1 == @active
328
- @prompt.decorate(selected.to_s, @active_color)
363
+ selected = num + choice.name
364
+ output << if index + 1 == @active && !choice.disabled?
365
+ (' ' * 2) + @prompt.decorate(selected, @active_color)
366
+ elsif choice.disabled?
367
+ @prompt.decorate(symbols[:cross], :red) + ' ' +
368
+ selected + ' ' + choice.disabled.to_s
329
369
  else
330
- selected
370
+ (' ' * 2) + selected
331
371
  end
332
372
  output << "\n"
333
373
  end
334
- output
374
+
375
+ output.join
335
376
  end
336
377
  end # EnumList
337
378
  end # Prompt
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'paginator'
4
5
 
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'result'
4
5
 
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'choices'
4
5
 
@@ -13,7 +14,7 @@ module TTY
13
14
  key: 'h',
14
15
  name: 'print help',
15
16
  value: :help
16
- }
17
+ }.freeze
17
18
 
18
19
  # Create instance of Expander
19
20
  #
@@ -68,7 +69,7 @@ module TTY
68
69
  @input = ''
69
70
  end
70
71
  end
71
- alias_method :keyreturn, :keyenter
72
+ alias keyreturn keyenter
72
73
 
73
74
  # Respond to key press event
74
75
  #
@@ -178,16 +179,16 @@ module TTY
178
179
  #
179
180
  # @api private
180
181
  def render_header
181
- header = "#{@prefix}#{@message} "
182
+ header = ["#{@prefix}#{@message} "]
182
183
  if @done
183
- selected_item = "#{@selected.name}"
184
+ selected_item = @selected.name.to_s
184
185
  header << @prompt.decorate(selected_item, @active_color)
185
186
  elsif collapsed?
186
187
  header << %[(enter "h" for help) ]
187
188
  header << "[#{possible_keys}] "
188
189
  header << @input
189
190
  end
190
- header
191
+ header.join
191
192
  end
192
193
 
193
194
  # Show hint for selected option key
@@ -196,11 +197,10 @@ module TTY
196
197
  #
197
198
  # @api private
198
199
  def render_hint
199
- hint = "\n"
200
- hint << @prompt.decorate('>> ', @active_color)
201
- hint << @hint
202
- hint << @prompt.cursor.prev_line
203
- hint << @prompt.cursor.forward(@prompt.strip(render_header).size)
200
+ "\n" + @prompt.decorate('>> ', @active_color) +
201
+ @hint +
202
+ @prompt.cursor.prev_line +
203
+ @prompt.cursor.forward(@prompt.strip(render_header).size)
204
204
  end
205
205
 
206
206
  # Render question with menu
@@ -251,7 +251,7 @@ module TTY
251
251
  #
252
252
  # @api private
253
253
  def render_menu
254
- output = "\n"
254
+ output = ["\n"]
255
255
  @choices.each do |choice|
256
256
  chosen = %(#{choice.key} - #{choice.name})
257
257
  if @selected && @selected.key == choice.key
@@ -259,7 +259,7 @@ module TTY
259
259
  end
260
260
  output << ' ' + chosen + "\n"
261
261
  end
262
- output
262
+ output.join
263
263
  end
264
264
 
265
265
  def setup_defaults
@@ -271,7 +271,7 @@ module TTY
271
271
  keys = []
272
272
  @choices.each do |choice|
273
273
  if choice.key.nil?
274
- errors << "Choice #{choice.name} is missing a :key attribute"
274
+ errors << "Choice #{choice.name} is missing a :key attribute"
275
275
  next
276
276
  end
277
277
  if choice.key.length != 1
@@ -285,7 +285,7 @@ module TTY
285
285
  end
286
286
  keys << choice.key if choice.key
287
287
  end
288
- errors.each { |err| fail ConfigurationError, err }
288
+ errors.each { |err| raise ConfigurationError, err }
289
289
  end
290
290
  end # Expander
291
291
  end # Prompt
@@ -1,4 +1,5 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
4
  require_relative 'question'
4
5
  require_relative 'symbols'
@@ -95,7 +96,6 @@ module TTY
95
96
  else
96
97
  job.()
97
98
  end
98
- rescue Timeout::Error
99
99
  end
100
100
  end # Keypress
101
101
  end # Prompt
@@ -1,6 +1,7 @@
1
1
  # encoding: utf-8
2
+ # frozen_string_literal: true
2
3
 
3
- require "English"
4
+ require 'English'
4
5
 
5
6
  require_relative 'choices'
6
7
  require_relative 'paginator'
@@ -15,9 +16,9 @@ module TTY
15
16
  class List
16
17
  include Symbols
17
18
 
18
- HELP = '(Use arrow%s keys, press Enter to select%s)'
19
+ HELP = '(Use arrow%s keys, press Enter to select%s)'.freeze
19
20
 
20
- PAGE_HELP = '(Move up or down to reveal more choices)'
21
+ PAGE_HELP = '(Move up or down to reveal more choices)'.freeze
21
22
 
22
23
  # Allowed keys for filter, along with backspace and canc.
23
24
  FILTER_KEYS_MATCHER = /\A\w\Z/
@@ -49,7 +50,7 @@ module TTY
49
50
  @help_color = options.fetch(:help_color) { @prompt.help_color }
50
51
  @marker = options.fetch(:marker) { symbols[:pointer] }
51
52
  @cycle = options.fetch(:cycle) { false }
52
- @filter = options.fetch(:filter) { false } ? "" : nil
53
+ @filter = options.fetch(:filter) { false } ? '' : nil
53
54
  @help = options[:help]
54
55
  @first_render = true
55
56
  @done = false
@@ -121,11 +122,11 @@ module TTY
121
122
  def default_help
122
123
  # Note that enumeration and filter are mutually exclusive
123
124
  tokens = if enumerate?
124
- [" or number (1-#{choices.size})", ""]
125
+ [" or number (1-#{choices.size})", '']
125
126
  elsif @filter
126
- ["", ", and letter keys to filter"]
127
+ ['', ", and letter keys to filter"]
127
128
  else
128
- ["", ""]
129
+ ['', '']
129
130
  end
130
131
 
131
132
  format(self.class::HELP, *tokens)
@@ -162,11 +163,12 @@ module TTY
162
163
  @choices
163
164
  else
164
165
  @choices.select do |_choice|
165
- _choice.name.downcase.include?(@filter.downcase)
166
+ !_choice.disabled? &&
167
+ _choice.name.downcase.include?(@filter.downcase)
166
168
  end
167
169
  end
168
170
  else
169
- Array(values).each { |val| choice(*val) }
171
+ values.each { |val| @choices << val }
170
172
  end
171
173
  end
172
174
 
@@ -195,6 +197,7 @@ module TTY
195
197
  return unless enumerate?
196
198
  value = event.value.to_i
197
199
  return unless (1..choices.count).cover?(value)
200
+ return if choices[value - 1].disabled?
198
201
  @active = value
199
202
  end
200
203
 
@@ -204,19 +207,35 @@ module TTY
204
207
  alias keyreturn keyenter
205
208
  alias keyspace keyenter
206
209
 
210
+ def search_choice_in(searchable)
211
+ searchable.find { |i| !choices[i - 1].disabled? }
212
+ end
213
+
207
214
  def keyup(*)
208
- if @active == 1
209
- @active = choices.length if @cycle
210
- else
211
- @active -= 1
215
+ searchable = (@active - 1).downto(1).to_a
216
+ prev_active = search_choice_in(searchable)
217
+
218
+ if prev_active
219
+ @active = prev_active
220
+ elsif @cycle
221
+ searchable = (choices.length).downto(1).to_a
222
+ prev_active = search_choice_in(searchable)
223
+
224
+ @active = prev_active if prev_active
212
225
  end
213
226
  end
214
227
 
215
228
  def keydown(*)
216
- if @active == choices.length
217
- @active = 1 if @cycle
218
- else
219
- @active += 1
229
+ searchable = ((@active + 1)..choices.length)
230
+ next_active = search_choice_in(searchable)
231
+
232
+ if next_active
233
+ @active = next_active
234
+ elsif @cycle
235
+ searchable = (1..choices.length)
236
+ next_active = search_choice_in(searchable)
237
+
238
+ @active = next_active if next_active
220
239
  end
221
240
  end
222
241
  alias keytab keydown
@@ -233,7 +252,7 @@ module TTY
233
252
  def keydelete(*)
234
253
  return unless @filter
235
254
 
236
- @filter = ""
255
+ @filter = ''
237
256
  @active = 1
238
257
  end
239
258
 
@@ -263,17 +282,22 @@ module TTY
263
282
 
264
283
  # Validate default indexes to be within range
265
284
  #
285
+ # @raise [ConfigurationError]
286
+ # raised when the default index is either non-integer,
287
+ # out of range or clashes with disabled choice item.
288
+ #
266
289
  # @api private
267
290
  def validate_defaults
268
291
  @default.each do |d|
269
- if d.nil? || d.to_s.empty?
270
- raise ConfigurationError,
271
- "default index must be an integer in range (1 - #{choices.size})"
272
- end
273
- if d < 1 || d > choices.size
274
- raise ConfigurationError,
275
- "default index `#{d}` out of range (1 - #{choices.size})"
276
- end
292
+ msg = if d.nil? || d.to_s.empty?
293
+ "default index must be an integer in range (1 - #{choices.size})"
294
+ elsif d < 1 || d > choices.size
295
+ "default index `#{d}` out of range (1 - #{choices.size})"
296
+ elsif choices[d - 1] && choices[d - 1].disabled?
297
+ "default index `#{d}` matches disabled choice item"
298
+ end
299
+
300
+ raise(ConfigurationError, msg) if msg
277
301
  end
278
302
  end
279
303
 
@@ -296,7 +320,7 @@ module TTY
296
320
  # matching lines), it won't be included by using String#lines.
297
321
  question_lines = question.split($INPUT_RECORD_SEPARATOR, -1)
298
322
 
299
- @prompt.print(refresh(question_lines.count))
323
+ @prompt.print(refresh(question_lines_count(question_lines)))
300
324
  end
301
325
  @prompt.print(render_question)
302
326
  answer
@@ -304,6 +328,17 @@ module TTY
304
328
  @prompt.print(@prompt.show)
305
329
  end
306
330
 
331
+ # Count how many screen lines the question spans
332
+ #
333
+ # @return [Integer]
334
+ #
335
+ # @api private
336
+ def question_lines_count(question_lines)
337
+ question_lines.reduce(0) do |acc, line|
338
+ acc + @prompt.count_screen_lines(line)
339
+ end
340
+ end
341
+
307
342
  # Find value for the choice selected
308
343
  #
309
344
  # @return [nil, Object]
@@ -328,12 +363,13 @@ module TTY
328
363
  #
329
364
  # @api private
330
365
  def render_question
331
- header = "#{@prefix}#{@question} #{render_header}\n"
366
+ header = ["#{@prefix}#{@question} #{render_header}\n"]
332
367
  @first_render = false
333
- rendered_menu = render_menu
334
- rendered_menu << render_footer
335
- header << rendered_menu unless @done
336
- header
368
+ unless @done
369
+ header << render_menu
370
+ header << render_footer
371
+ end
372
+ header.join
337
373
  end
338
374
 
339
375
  # Header part showing the current filter
@@ -352,11 +388,11 @@ module TTY
352
388
  # @api private
353
389
  def render_header
354
390
  if @done
355
- selected_item = "#{choices[@active - 1].name}"
391
+ selected_item = choices[@active - 1].name
356
392
  @prompt.decorate(selected_item, @active_color)
357
393
  elsif @first_render
358
394
  @prompt.decorate(help, @help_color)
359
- elsif @filter.to_s != ""
395
+ elsif @filter.to_s != ''
360
396
  @prompt.decorate(filter_help, @help_color)
361
397
  end
362
398
  end
@@ -367,13 +403,16 @@ module TTY
367
403
  #
368
404
  # @api private
369
405
  def render_menu
370
- output = ''
406
+ output = []
371
407
 
372
408
  @paginator.paginate(choices, @active, @per_page) do |choice, index|
373
409
  num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
374
- message = if index + 1 == @active
410
+ message = if index + 1 == @active && !choice.disabled?
375
411
  selected = @marker + ' ' + num + choice.name
376
- @prompt.decorate("#{selected}", @active_color)
412
+ @prompt.decorate(selected.to_s, @active_color)
413
+ elsif choice.disabled?
414
+ @prompt.decorate(symbols[:cross], :red) +
415
+ ' ' + num + choice.name + ' ' + choice.disabled.to_s
377
416
  else
378
417
  ' ' * 2 + num + choice.name
379
418
  end
@@ -382,7 +421,7 @@ module TTY
382
421
  output << (message + newline)
383
422
  end
384
423
 
385
- output
424
+ output.join
386
425
  end
387
426
 
388
427
  # Render page info footer
@@ -393,7 +432,7 @@ module TTY
393
432
  def render_footer
394
433
  return '' unless paginated?
395
434
  colored_footer = @prompt.decorate(@page_help, @help_color)
396
- "\n" << colored_footer
435
+ "\n" + colored_footer
397
436
  end
398
437
  end # List
399
438
  end # Prompt