tty-prompt 0.18.1 → 0.23.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +598 -256
  4. data/lib/tty-prompt.rb +1 -2
  5. data/lib/tty/prompt.rb +192 -144
  6. data/lib/tty/prompt/answers_collector.rb +5 -5
  7. data/lib/tty/prompt/{enum_paginator.rb → block_paginator.rb} +20 -19
  8. data/lib/tty/prompt/choice.rb +9 -10
  9. data/lib/tty/prompt/choices.rb +30 -12
  10. data/lib/tty/prompt/confirm_question.rb +42 -16
  11. data/lib/tty/prompt/const.rb +17 -0
  12. data/lib/tty/prompt/converter_dsl.rb +6 -7
  13. data/lib/tty/prompt/converter_registry.rb +31 -26
  14. data/lib/tty/prompt/converters.rb +139 -32
  15. data/lib/tty/prompt/enum_list.rb +81 -26
  16. data/lib/tty/prompt/errors.rb +31 -0
  17. data/lib/tty/prompt/evaluator.rb +2 -2
  18. data/lib/tty/prompt/expander.rb +45 -15
  19. data/lib/tty/prompt/keypress.rb +33 -36
  20. data/lib/tty/prompt/list.rb +198 -63
  21. data/lib/tty/prompt/mask_question.rb +11 -8
  22. data/lib/tty/prompt/multi_list.rb +131 -28
  23. data/lib/tty/prompt/multiline.rb +9 -7
  24. data/lib/tty/prompt/paginator.rb +38 -26
  25. data/lib/tty/prompt/question.rb +92 -37
  26. data/lib/tty/prompt/question/checks.rb +20 -2
  27. data/lib/tty/prompt/question/modifier.rb +4 -2
  28. data/lib/tty/prompt/question/validation.rb +3 -3
  29. data/lib/tty/prompt/selected_choices.rb +77 -0
  30. data/lib/tty/prompt/slider.rb +125 -30
  31. data/lib/tty/prompt/statement.rb +3 -3
  32. data/lib/tty/prompt/suggestion.rb +7 -6
  33. data/lib/tty/prompt/symbols.rb +58 -34
  34. data/lib/tty/prompt/test.rb +36 -0
  35. data/lib/tty/prompt/timer.rb +75 -0
  36. data/lib/tty/prompt/utils.rb +1 -3
  37. data/lib/tty/prompt/version.rb +1 -1
  38. metadata +29 -227
  39. data/Rakefile +0 -8
  40. data/examples/ask.rb +0 -7
  41. data/examples/ask_valid.rb +0 -12
  42. data/examples/collect.rb +0 -21
  43. data/examples/echo.rb +0 -11
  44. data/examples/enum_select.rb +0 -7
  45. data/examples/enum_select_disabled.rb +0 -16
  46. data/examples/enum_select_paged.rb +0 -9
  47. data/examples/enum_select_wrapped.rb +0 -15
  48. data/examples/expand.rb +0 -29
  49. data/examples/in.rb +0 -9
  50. data/examples/inputs.rb +0 -10
  51. data/examples/key_events.rb +0 -15
  52. data/examples/keypress.rb +0 -9
  53. data/examples/mask.rb +0 -13
  54. data/examples/multi_select.rb +0 -8
  55. data/examples/multi_select_disabled.rb +0 -17
  56. data/examples/multi_select_paged.rb +0 -9
  57. data/examples/multi_select_wrapped.rb +0 -15
  58. data/examples/multiline.rb +0 -9
  59. data/examples/pause.rb +0 -9
  60. data/examples/select.rb +0 -24
  61. data/examples/select_disabled.rb +0 -18
  62. data/examples/select_enum.rb +0 -8
  63. data/examples/select_filtered.rb +0 -11
  64. data/examples/select_paginated.rb +0 -11
  65. data/examples/select_wrapped.rb +0 -15
  66. data/examples/slider.rb +0 -6
  67. data/examples/validation.rb +0 -9
  68. data/examples/yes_no.rb +0 -7
  69. data/lib/tty/prompt/messages.rb +0 -49
  70. data/lib/tty/prompt/timeout.rb +0 -78
  71. data/lib/tty/test_prompt.rb +0 -20
  72. data/spec/spec_helper.rb +0 -45
  73. data/spec/unit/ask_spec.rb +0 -132
  74. data/spec/unit/choice/eql_spec.rb +0 -22
  75. data/spec/unit/choice/from_spec.rb +0 -96
  76. data/spec/unit/choices/add_spec.rb +0 -12
  77. data/spec/unit/choices/each_spec.rb +0 -13
  78. data/spec/unit/choices/find_by_spec.rb +0 -10
  79. data/spec/unit/choices/new_spec.rb +0 -10
  80. data/spec/unit/choices/pluck_spec.rb +0 -9
  81. data/spec/unit/collect_spec.rb +0 -96
  82. data/spec/unit/converters/convert_bool_spec.rb +0 -58
  83. data/spec/unit/converters/convert_char_spec.rb +0 -11
  84. data/spec/unit/converters/convert_custom_spec.rb +0 -14
  85. data/spec/unit/converters/convert_date_spec.rb +0 -34
  86. data/spec/unit/converters/convert_file_spec.rb +0 -18
  87. data/spec/unit/converters/convert_number_spec.rb +0 -39
  88. data/spec/unit/converters/convert_path_spec.rb +0 -15
  89. data/spec/unit/converters/convert_range_spec.rb +0 -22
  90. data/spec/unit/converters/convert_regex_spec.rb +0 -12
  91. data/spec/unit/converters/convert_string_spec.rb +0 -21
  92. data/spec/unit/converters/on_error_spec.rb +0 -9
  93. data/spec/unit/distance/distance_spec.rb +0 -73
  94. data/spec/unit/enum_paginator_spec.rb +0 -75
  95. data/spec/unit/enum_select_spec.rb +0 -446
  96. data/spec/unit/error_spec.rb +0 -20
  97. data/spec/unit/evaluator_spec.rb +0 -67
  98. data/spec/unit/expand_spec.rb +0 -198
  99. data/spec/unit/keypress_spec.rb +0 -72
  100. data/spec/unit/mask_spec.rb +0 -132
  101. data/spec/unit/multi_select_spec.rb +0 -511
  102. data/spec/unit/multiline_spec.rb +0 -77
  103. data/spec/unit/new_spec.rb +0 -20
  104. data/spec/unit/ok_spec.rb +0 -10
  105. data/spec/unit/paginator_spec.rb +0 -73
  106. data/spec/unit/question/checks_spec.rb +0 -97
  107. data/spec/unit/question/default_spec.rb +0 -31
  108. data/spec/unit/question/echo_spec.rb +0 -38
  109. data/spec/unit/question/in_spec.rb +0 -115
  110. data/spec/unit/question/initialize_spec.rb +0 -12
  111. data/spec/unit/question/modifier/apply_to_spec.rb +0 -24
  112. data/spec/unit/question/modifier/letter_case_spec.rb +0 -41
  113. data/spec/unit/question/modifier/whitespace_spec.rb +0 -51
  114. data/spec/unit/question/modify_spec.rb +0 -41
  115. data/spec/unit/question/required_spec.rb +0 -92
  116. data/spec/unit/question/validate_spec.rb +0 -115
  117. data/spec/unit/question/validation/call_spec.rb +0 -31
  118. data/spec/unit/question/validation/coerce_spec.rb +0 -30
  119. data/spec/unit/result_spec.rb +0 -40
  120. data/spec/unit/say_spec.rb +0 -67
  121. data/spec/unit/select_spec.rb +0 -660
  122. data/spec/unit/slider_spec.rb +0 -100
  123. data/spec/unit/statement/initialize_spec.rb +0 -15
  124. data/spec/unit/subscribe_spec.rb +0 -22
  125. data/spec/unit/suggest_spec.rb +0 -28
  126. data/spec/unit/warn_spec.rb +0 -21
  127. data/spec/unit/yes_no_spec.rb +0 -251
  128. data/tasks/console.rake +0 -11
  129. data/tasks/coverage.rake +0 -11
  130. data/tasks/spec.rake +0 -29
  131. data/tty-prompt.gemspec +0 -33
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'result'
3
+ require_relative "result"
4
4
 
5
5
  module TTY
6
6
  class Prompt
@@ -23,7 +23,7 @@ module TTY
23
23
  def check(proc = nil, &block)
24
24
  results << (proc || block)
25
25
  end
26
- alias_method :<<, :check
26
+ alias << check
27
27
  end # Evaluator
28
28
  end # Prompt
29
29
  end # TTY
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'choices'
3
+ require_relative "choices"
4
4
 
5
5
  module TTY
6
6
  class Prompt
@@ -10,20 +10,25 @@ module TTY
10
10
  # @api private
11
11
  class Expander
12
12
  HELP_CHOICE = {
13
- key: 'h',
14
- name: 'print help',
13
+ key: "h",
14
+ name: "print help",
15
15
  value: :help
16
16
  }.freeze
17
17
 
18
+ # Names for delete keys
19
+ DELETE_KEYS = %i[backspace delete].freeze
20
+
18
21
  # Create instance of Expander
19
22
  #
20
23
  # @api public
21
24
  def initialize(prompt, options = {})
22
25
  @prompt = prompt
23
26
  @prefix = options.fetch(:prefix) { @prompt.prefix }
24
- @default = options.fetch(:default) { 1 }
27
+ @default = options.fetch(:default, 1)
28
+ @auto_hint = options.fetch(:auto_hint, false)
25
29
  @active_color = options.fetch(:active_color) { @prompt.active_color }
26
30
  @help_color = options.fetch(:help_color) { @prompt.help_color }
31
+ @quiet = options.fetch(:quiet) { @prompt.quiet }
27
32
  @choices = Choices.new
28
33
  @selected = nil
29
34
  @done = false
@@ -55,15 +60,16 @@ module TTY
55
60
 
56
61
  selected = select_choice(@input)
57
62
 
58
- if selected && selected.key.to_s == 'h'
63
+ if selected && selected.key.to_s == "h"
59
64
  expand
60
65
  @selected = nil
61
- @input = ''
66
+ @input = ""
62
67
  elsif selected
63
68
  @done = true
64
69
  @selected = selected
70
+ @hint = nil
65
71
  else
66
- @input = ''
72
+ @input = ""
67
73
  end
68
74
  end
69
75
  alias keyreturn keyenter
@@ -72,11 +78,12 @@ module TTY
72
78
  #
73
79
  # @api public
74
80
  def keypress(event)
75
- if [:backspace, :delete].include?(event.key.name)
81
+ if DELETE_KEYS.include?(event.key.name)
76
82
  @input.chop! unless @input.empty?
77
83
  elsif event.value =~ /^[^\e\n\r]/
78
84
  @input += event.value
79
85
  end
86
+
80
87
  @selected = select_choice(@input)
81
88
  if @selected && !@default_key && collapsed?
82
89
  @hint = @selected.name
@@ -97,9 +104,17 @@ module TTY
97
104
  # @api public
98
105
  def default(value = (not_set = true))
99
106
  return @default if not_set
107
+
100
108
  @default = value
101
109
  end
102
110
 
111
+ # Set quiet mode.
112
+ #
113
+ # @api public
114
+ def quiet(value)
115
+ @quiet = value
116
+ end
117
+
103
118
  # Add a single choice
104
119
  #
105
120
  # @api public
@@ -151,19 +166,19 @@ module TTY
151
166
  elsif @input.to_s.empty? && default_key
152
167
  keys[@default - 1] = @prompt.decorate(default_key, @active_color)
153
168
  end
154
- keys.join(',')
169
+ keys.join(",")
155
170
  end
156
171
 
157
172
  # @api private
158
173
  def render
159
- @input = ''
174
+ @input = ""
160
175
  until @done
161
176
  question = render_question
162
177
  @prompt.print(question)
163
178
  read_input
164
179
  @prompt.print(refresh(question.lines.count))
165
180
  end
166
- @prompt.print(render_question)
181
+ @prompt.print(render_question) unless @quiet
167
182
  answer
168
183
  end
169
184
 
@@ -196,7 +211,7 @@ module TTY
196
211
  #
197
212
  # @api private
198
213
  def render_hint
199
- "\n" + @prompt.decorate('>> ', @active_color) +
214
+ "\n" + @prompt.decorate(">> ", @active_color) +
200
215
  @hint +
201
216
  @prompt.cursor.prev_line +
202
217
  @prompt.cursor.forward(@prompt.strip(render_header).size)
@@ -208,6 +223,7 @@ module TTY
208
223
  #
209
224
  # @api private
210
225
  def render_question
226
+ load_auto_hint if @auto_hint
211
227
  header = render_header
212
228
  header << render_hint if @hint
213
229
  header << "\n" if @done
@@ -219,6 +235,20 @@ module TTY
219
235
  header
220
236
  end
221
237
 
238
+ def load_auto_hint
239
+ if @hint.nil? && collapsed?
240
+ if @selected
241
+ @hint = @selected.name
242
+ else
243
+ if @input.empty?
244
+ @hint = @choices[@default - 1].name
245
+ else
246
+ @hint = "invalid option"
247
+ end
248
+ end
249
+ end
250
+ end
251
+
222
252
  def render_footer
223
253
  " Choice [#{@choices[@default - 1].key}]: #{@input}"
224
254
  end
@@ -235,7 +265,7 @@ module TTY
235
265
  #
236
266
  # @api private
237
267
  def refresh(lines)
238
- if @hint && (!@selected || @done)
268
+ if (@hint && (!@selected || @done)) || (@auto_hint && collapsed?)
239
269
  @hint = nil
240
270
  @prompt.clear_lines(lines, :down) +
241
271
  @prompt.cursor.prev_line
@@ -256,7 +286,7 @@ module TTY
256
286
  if @selected && @selected.key == choice.key
257
287
  chosen = @prompt.decorate(chosen, @active_color)
258
288
  end
259
- output << ' ' + chosen + "\n"
289
+ output << " " + chosen + "\n"
260
290
  end
261
291
  output.join
262
292
  end
@@ -276,7 +306,7 @@ module TTY
276
306
  if choice.key.length != 1
277
307
  errors << "Choice key `#{choice.key}` is more than one character long."
278
308
  end
279
- if choice.key.to_s == 'h'
309
+ if choice.key.to_s == "h"
280
310
  errors << "Choice key `#{choice.key}` is reserved for help menu."
281
311
  end
282
312
  if keys.include?(choice.key)
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'question'
4
- require_relative 'symbols'
5
- require_relative 'timeout'
3
+ require_relative "question"
4
+ require_relative "timer"
6
5
 
7
6
  module TTY
8
7
  class Prompt
@@ -13,7 +12,7 @@ module TTY
13
12
  # @param [Hash] options
14
13
  #
15
14
  # @api public
16
- def initialize(prompt, options = {})
15
+ def initialize(prompt, **options)
17
16
  super
18
17
  @echo = options.fetch(:echo) { false }
19
18
  @keys = options.fetch(:keys) { UndefinedSetting }
@@ -21,24 +20,17 @@ module TTY
21
20
  @interval = options.fetch(:interval) {
22
21
  (@timeout != UndefinedSetting && @timeout < 1) ? @timeout : 1
23
22
  }
23
+ @decimals = (@interval.to_s.split(".")[1] || []).size
24
24
  @countdown = @timeout
25
- @interval_handler = proc { |time|
26
- unless @done
27
- question = render_question
28
- line_size = question.size
29
- total_lines = @prompt.count_screen_lines(line_size)
30
- @prompt.print(refresh(question.lines.count, total_lines))
31
- countdown(time)
32
- @prompt.print(render_question)
33
- end
34
- }
35
- @scheduler = Timeout.new(interval_handler: @interval_handler)
25
+ time = timeout? ? Float(@timeout) : nil
26
+ @timer = Timer.new(time, Float(@interval))
36
27
 
37
28
  @prompt.subscribe(self)
38
29
  end
39
30
 
40
31
  def countdown(value = (not_set = true))
41
32
  return @countdown if not_set
33
+
42
34
  @countdown = value
43
35
  end
44
36
 
@@ -55,10 +47,8 @@ module TTY
55
47
  def keypress(event)
56
48
  if any_key?
57
49
  @done = true
58
- @scheduler.cancel
59
50
  elsif @keys.is_a?(Array) && @keys.include?(event.key.name)
60
51
  @done = true
61
- @scheduler.cancel
62
52
  else
63
53
  @done = false
64
54
  end
@@ -66,36 +56,43 @@ module TTY
66
56
 
67
57
  def render_question
68
58
  header = super
69
- header.gsub!(/:countdown/, countdown.to_s)
59
+ if timeout?
60
+ header.gsub!(/:countdown/, format("%.#{@decimals}f", countdown))
61
+ end
70
62
  header
71
63
  end
72
64
 
65
+ def interval_handler(time)
66
+ return if @done
67
+
68
+ question = render_question
69
+ line_size = question.size
70
+ total_lines = @prompt.count_screen_lines(line_size)
71
+ @prompt.print(refresh(question.lines.count, total_lines))
72
+ countdown(time)
73
+ @prompt.print(render_question)
74
+ end
75
+
73
76
  def process_input(question)
74
- time do
75
- @prompt.print(render_question)
76
- until @done
77
- @input = @prompt.read_keypress
78
- end
77
+ @prompt.print(render_question)
78
+
79
+ @timer.on_tick do |time|
80
+ interval_handler(time)
81
+ end
82
+
83
+ @timer.while_remaining do |remaining|
84
+ break if @done
85
+
86
+ @input = @prompt.read_keypress(nonblock: true)
79
87
  end
88
+ countdown(0) unless @done
89
+
80
90
  @evaluator.(@input)
81
91
  end
82
92
 
83
93
  def refresh(lines, lines_to_clear)
84
94
  @prompt.clear_lines(lines)
85
95
  end
86
-
87
- # Wait for keypress or timeout
88
- #
89
- # @api private
90
- def time(&job)
91
- if timeout?
92
- time = Float(@timeout)
93
- interval = Float(@interval)
94
- @scheduler.timeout(time, interval, &job)
95
- else
96
- job.()
97
- end
98
- end
99
96
  end # Keypress
100
97
  end # Prompt
101
98
  end # TTY
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'English'
3
+ require "English"
4
4
 
5
- require_relative 'choices'
6
- require_relative 'paginator'
7
- require_relative 'symbols'
5
+ require_relative "choices"
6
+ require_relative "paginator"
7
+ require_relative "block_paginator"
8
8
 
9
9
  module TTY
10
10
  class Prompt
@@ -13,15 +13,12 @@ module TTY
13
13
  #
14
14
  # @api private
15
15
  class List
16
- include Symbols
17
-
18
- HELP = '(Use arrow%s keys, press Enter to select%s)'
19
-
20
- PAGE_HELP = '(Move up or down to reveal more choices)'
21
-
22
16
  # Allowed keys for filter, along with backspace and canc.
23
17
  FILTER_KEYS_MATCHER = /\A([[:alnum:]]|[[:punct:]])\Z/.freeze
24
18
 
19
+ # Checks type of default parameter to be integer
20
+ INTEGER_MATCHER = /\A\d+\Z/.freeze
21
+
25
22
  # Create instance of TTY::Prompt::List menu.
26
23
  #
27
24
  # @param Hash options
@@ -36,7 +33,7 @@ module TTY
36
33
  # the delimiter for the item index
37
34
  #
38
35
  # @api public
39
- def initialize(prompt, options = {})
36
+ def initialize(prompt, **options)
40
37
  check_options_consistency(options)
41
38
 
42
39
  @prompt = prompt
@@ -46,23 +43,33 @@ module TTY
46
43
  @choices = Choices.new
47
44
  @active_color = options.fetch(:active_color) { @prompt.active_color }
48
45
  @help_color = options.fetch(:help_color) { @prompt.help_color }
49
- @marker = options.fetch(:marker) { symbols[:pointer] }
50
46
  @cycle = options.fetch(:cycle) { false }
51
47
  @filterable = options.fetch(:filter) { false }
48
+ @symbols = @prompt.symbols.merge(options.fetch(:symbols, {}))
49
+ @quiet = options.fetch(:quiet) { @prompt.quiet }
52
50
  @filter = []
51
+ @filter_cache = {}
53
52
  @help = options[:help]
53
+ @show_help = options.fetch(:show_help) { :start }
54
54
  @first_render = true
55
55
  @done = false
56
56
  @per_page = options[:per_page]
57
- @page_help = options[:page_help] || PAGE_HELP
58
57
  @paginator = Paginator.new
58
+ @block_paginator = BlockPaginator.new
59
+ @by_page = false
60
+ @paging_changed = false
59
61
  end
60
62
 
61
- # Set marker
63
+ # Change symbols used by this prompt
64
+ #
65
+ # @param [Hash] new_symbols
66
+ # the new symbols to use
62
67
  #
63
68
  # @api public
64
- def marker(value)
65
- @marker = value
69
+ def symbols(new_symbols = (not_set = true))
70
+ return @symbols if not_set
71
+
72
+ @symbols.merge!(new_symbols)
66
73
  end
67
74
 
68
75
  # Set default option selected
@@ -72,6 +79,32 @@ module TTY
72
79
  @default = default_values
73
80
  end
74
81
 
82
+ # Select paginator based on the current navigation key
83
+ #
84
+ # @return [Paginator]
85
+ #
86
+ # @api private
87
+ def paginator
88
+ @by_page ? @block_paginator : @paginator
89
+ end
90
+
91
+ # Synchronize paginators start positions
92
+ #
93
+ # @api private
94
+ def sync_paginators
95
+ if @by_page
96
+ if @paginator.start_index
97
+ @block_paginator.reset!
98
+ @block_paginator.start_index = @paginator.start_index
99
+ end
100
+ else
101
+ if @block_paginator.start_index
102
+ @paginator.reset!
103
+ @paginator.start_index = @block_paginator.start_index
104
+ end
105
+ end
106
+ end
107
+
75
108
  # Set number of items per page
76
109
  #
77
110
  # @api public
@@ -92,13 +125,6 @@ module TTY
92
125
  choices.size > page_size
93
126
  end
94
127
 
95
- # @param [String] text
96
- # the help text to display per page
97
- # @api pbulic
98
- def page_help(text)
99
- @page_help = text
100
- end
101
-
102
128
  # Provide help information
103
129
  #
104
130
  # @param [String] value
@@ -113,20 +139,46 @@ module TTY
113
139
  @help = (@help.nil? && !not_set) ? value : default_help
114
140
  end
115
141
 
116
- # Default help text
142
+ # Change when help is displayed
117
143
  #
118
144
  # @api public
119
- def default_help
120
- # Note that enumeration and filter are mutually exclusive
121
- tokens = if enumerate?
122
- [" or number (1-#{choices.size})", '']
123
- elsif filterable?
124
- ['', ', and letter keys to filter']
125
- else
126
- ['', '']
127
- end
145
+ def show_help(value = (not_set = true))
146
+ return @show_ehlp if not_set
147
+
148
+ @show_help = value
149
+ end
150
+
151
+ # Information about arrow keys
152
+ #
153
+ # @return [String]
154
+ #
155
+ # @api private
156
+ def arrows_help
157
+ up_down = @symbols[:arrow_up] + "/" + @symbols[:arrow_down]
158
+ left_right = @symbols[:arrow_left] + "/" + @symbols[:arrow_right]
128
159
 
129
- format(self.class::HELP, *tokens)
160
+ arrows = [up_down]
161
+ arrows << "/" if paginated?
162
+ arrows << left_right if paginated?
163
+ arrows.join
164
+ end
165
+
166
+ # Default help text
167
+ #
168
+ # Note that enumeration and filter are mutually exclusive
169
+ #
170
+ # @a public
171
+ def default_help
172
+ str = []
173
+ str << "(Press "
174
+ str << "#{arrows_help} arrow"
175
+ str << " or 1-#{choices.size} number" if enumerate?
176
+ str << " to move"
177
+ str << (filterable? ? "," : " and")
178
+ str << " Enter to select"
179
+ str << " and letters to filter" if filterable?
180
+ str << ")"
181
+ str.join
130
182
  end
131
183
 
132
184
  # Set selecting active index using number pad
@@ -136,10 +188,18 @@ module TTY
136
188
  @enum = value
137
189
  end
138
190
 
191
+ # Set whether selected answers are echoed
192
+ #
193
+ # @api public
194
+ def quiet(value)
195
+ @quiet = value
196
+ end
197
+
139
198
  # Add a single choice
140
199
  #
141
200
  # @api public
142
201
  def choice(*value, &block)
202
+ @filter_cache = {}
143
203
  if block
144
204
  @choices << (value << block)
145
205
  else
@@ -159,12 +219,13 @@ module TTY
159
219
  if !filterable? || @filter.empty?
160
220
  @choices
161
221
  else
162
- @choices.select do |choice|
163
- !choice.disabled? &&
164
- choice.name.downcase.include?(@filter.join.downcase)
222
+ filter_value = @filter.join.downcase
223
+ @filter_cache[filter_value] ||= @choices.enabled.select do |choice|
224
+ choice.name.to_s.downcase.include?(filter_value)
165
225
  end
166
226
  end
167
227
  else
228
+ @filter_cache = {}
168
229
  values.each { |val| @choices << val }
169
230
  end
170
231
  end
@@ -198,6 +259,7 @@ module TTY
198
259
  value = event.value.to_i
199
260
  return unless (1..choices.count).cover?(value)
200
261
  return if choices[value - 1].disabled?
262
+
201
263
  @active = value
202
264
  end
203
265
 
@@ -223,6 +285,9 @@ module TTY
223
285
 
224
286
  @active = prev_active if prev_active
225
287
  end
288
+
289
+ @paging_changed = @by_page
290
+ @by_page = false
226
291
  end
227
292
 
228
293
  def keydown(*)
@@ -237,9 +302,52 @@ module TTY
237
302
 
238
303
  @active = next_active if next_active
239
304
  end
305
+ @paging_changed = @by_page
306
+ @by_page = false
240
307
  end
241
308
  alias keytab keydown
242
309
 
310
+ # Moves all choices page by page keeping the current selected item
311
+ # at the same level on each page.
312
+ #
313
+ # When the choice on a page is outside of next page range then
314
+ # adjust it to the last item, otherwise leave unchanged.
315
+ def keyright(*)
316
+ choices_size = choices.size
317
+ if (@active + page_size) <= choices_size
318
+ searchable = ((@active + page_size)..choices_size)
319
+ @active = search_choice_in(searchable)
320
+ elsif @active <= choices_size # last page shorter
321
+ current = @active % page_size
322
+ remaining = choices_size % page_size
323
+
324
+ if current.zero? || (remaining > 0 && current > remaining)
325
+ searchable = choices_size.downto(0).to_a
326
+ @active = search_choice_in(searchable)
327
+ elsif @cycle
328
+ searchable = ((current.zero? ? page_size : current)..choices_size)
329
+ @active = search_choice_in(searchable)
330
+ end
331
+ end
332
+
333
+ @paging_changed = !@by_page
334
+ @by_page = true
335
+ end
336
+ alias keypage_down keyright
337
+
338
+ def keyleft(*)
339
+ if (@active - page_size) > 0
340
+ searchable = ((@active - page_size)..choices.size)
341
+ @active = search_choice_in(searchable)
342
+ elsif @cycle
343
+ searchable = choices.size.downto(1).to_a
344
+ @active = search_choice_in(searchable)
345
+ end
346
+ @paging_changed = !@by_page
347
+ @by_page = true
348
+ end
349
+ alias keypage_up keyleft
350
+
243
351
  def keypress(event)
244
352
  return unless filterable?
245
353
 
@@ -274,14 +382,19 @@ module TTY
274
382
 
275
383
  # Setup default option and active selection
276
384
  #
385
+ # @return [Integer]
386
+ #
277
387
  # @api private
278
388
  def setup_defaults
279
389
  validate_defaults
280
390
 
281
- if !@default.empty?
391
+ if @default.empty?
392
+ # no default, pick the first non-disabled choice
393
+ @active = choices.index { |choice| !choice.disabled? } + 1
394
+ elsif @default.first.to_s =~ INTEGER_MATCHER
282
395
  @active = @default.first
283
- else
284
- @active = @choices.index { |choice| !choice.disabled? } + 1
396
+ elsif default_choice = choices.find_by(:name, @default.first)
397
+ @active = choices.index(default_choice) + 1
285
398
  end
286
399
  end
287
400
 
@@ -296,16 +409,35 @@ module TTY
296
409
  @default.each do |d|
297
410
  msg = if d.nil? || d.to_s.empty?
298
411
  "default index must be an integer in range (1 - #{choices.size})"
412
+ elsif d.to_s !~ INTEGER_MATCHER
413
+ validate_default_name(d)
299
414
  elsif d < 1 || d > choices.size
300
415
  "default index `#{d}` out of range (1 - #{choices.size})"
301
- elsif choices[d - 1] && choices[d - 1].disabled?
302
- "default index `#{d}` matches disabled choice item"
416
+ elsif (dflt_choice = choices[d - 1]) && dflt_choice.disabled?
417
+ "default index `#{d}` matches disabled choice"
303
418
  end
304
419
 
305
420
  raise(ConfigurationError, msg) if msg
306
421
  end
307
422
  end
308
423
 
424
+ # Validate default choice name
425
+ #
426
+ # @param [String] name
427
+ # the name to verify
428
+ #
429
+ # @return [String]
430
+ #
431
+ # @api private
432
+ def validate_default_name(name)
433
+ default_choice = choices.find_by(:name, name.to_s)
434
+ if default_choice.nil?
435
+ "no choice found for the default name: #{name.inspect}"
436
+ elsif default_choice.disabled?
437
+ "default name #{name.inspect} matches disabled choice"
438
+ end
439
+ end
440
+
309
441
  # Render a selection list.
310
442
  #
311
443
  # By default the result is printed out.
@@ -327,7 +459,7 @@ module TTY
327
459
 
328
460
  @prompt.print(refresh(question_lines_count(question_lines)))
329
461
  end
330
- @prompt.print(render_question)
462
+ @prompt.print(render_question) unless @quiet
331
463
  answer
332
464
  ensure
333
465
  @prompt.print(@prompt.show)
@@ -372,7 +504,6 @@ module TTY
372
504
  @first_render = false
373
505
  unless @done
374
506
  header << render_menu
375
- header << render_footer
376
507
  end
377
508
  header.join
378
509
  end
@@ -395,6 +526,20 @@ module TTY
395
526
  "(Filter: #{@filter.join.inspect})"
396
527
  end
397
528
 
529
+ # Check if help is shown only on start
530
+ #
531
+ # @api private
532
+ def help_start?
533
+ @show_help =~ /start/i
534
+ end
535
+
536
+ # Check if help is always displayed
537
+ #
538
+ # @api private
539
+ def help_always?
540
+ @show_help =~ /always/i
541
+ end
542
+
398
543
  # Render initial help and selected choice
399
544
  #
400
545
  # @return [String]
@@ -404,7 +549,8 @@ module TTY
404
549
  if @done
405
550
  selected_item = choices[@active - 1].name
406
551
  @prompt.decorate(selected_item.to_s, @active_color)
407
- elsif @first_render
552
+ elsif (@first_render && (help_start? || help_always?)) ||
553
+ (help_always? && !@filter.any?)
408
554
  @prompt.decorate(help, @help_color)
409
555
  elsif filterable? && @filter.any?
410
556
  @prompt.decorate(filter_help, @help_color)
@@ -419,36 +565,25 @@ module TTY
419
565
  def render_menu
420
566
  output = []
421
567
 
422
- @paginator.paginate(choices, @active, @per_page) do |choice, index|
423
- num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
568
+ sync_paginators if @paging_changed
569
+ paginator.paginate(choices, @active, @per_page) do |choice, index|
570
+ num = enumerate? ? (index + 1).to_s + @enum + " " : ""
424
571
  message = if index + 1 == @active && !choice.disabled?
425
- selected = "#{@marker} #{num}#{choice.name}"
572
+ selected = "#{@symbols[:marker]} #{num}#{choice.name}"
426
573
  @prompt.decorate(selected.to_s, @active_color)
427
574
  elsif choice.disabled?
428
- @prompt.decorate(symbols[:cross], :red) +
575
+ @prompt.decorate(@symbols[:cross], :red) +
429
576
  " #{num}#{choice.name} #{choice.disabled}"
430
577
  else
431
578
  " #{num}#{choice.name}"
432
579
  end
433
- max_index = paginated? ? @paginator.max_index : choices.size - 1
434
- newline = (index == max_index) ? '' : "\n"
580
+ end_index = paginated? ? paginator.end_index : choices.size - 1
581
+ newline = (index == end_index) ? "" : "\n"
435
582
  output << (message + newline)
436
583
  end
437
584
 
438
585
  output.join
439
586
  end
440
-
441
- # Render page info footer
442
- #
443
- # @return [String]
444
- #
445
- # @api private
446
- def render_footer
447
- return '' unless paginated?
448
-
449
- colored_footer = @prompt.decorate(@page_help, @help_color)
450
- "\n" + colored_footer
451
- end
452
587
  end # List
453
588
  end # Prompt
454
589
  end # TTY