tty-prompt 0.18.1 → 0.23.0

Sign up to get free protection for your applications and to get access to all the features.
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