tty-prompt 0.18.1 → 0.19.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +174 -63
  4. data/Rakefile +2 -2
  5. data/examples/ask_blank.rb +9 -0
  6. data/examples/enum_select_disabled.rb +1 -1
  7. data/examples/enum_select_paged.rb +1 -1
  8. data/examples/expand_auto.rb +29 -0
  9. data/examples/mask.rb +1 -1
  10. data/examples/multi_select.rb +1 -1
  11. data/examples/multi_select_disabled_paged.rb +22 -0
  12. data/examples/multi_select_paged.rb +1 -1
  13. data/examples/select_disabled_paged.rb +22 -0
  14. data/examples/select_paginated.rb +1 -1
  15. data/lib/tty/prompt.rb +46 -10
  16. data/lib/tty/prompt/{enum_paginator.rb → block_paginator.rb} +19 -18
  17. data/lib/tty/prompt/choice.rb +1 -3
  18. data/lib/tty/prompt/enum_list.rb +31 -9
  19. data/lib/tty/prompt/expander.rb +19 -1
  20. data/lib/tty/prompt/keypress.rb +30 -35
  21. data/lib/tty/prompt/list.rb +112 -40
  22. data/lib/tty/prompt/mask_question.rb +2 -3
  23. data/lib/tty/prompt/multi_list.rb +36 -12
  24. data/lib/tty/prompt/paginator.rb +37 -25
  25. data/lib/tty/prompt/question.rb +29 -5
  26. data/lib/tty/prompt/slider.rb +16 -8
  27. data/lib/tty/prompt/symbols.rb +30 -6
  28. data/lib/tty/prompt/timer.rb +75 -0
  29. data/lib/tty/prompt/version.rb +1 -1
  30. data/spec/spec_helper.rb +18 -2
  31. data/spec/unit/ask_spec.rb +45 -4
  32. data/spec/unit/{enum_paginator_spec.rb → block_paginator_spec.rb} +14 -5
  33. data/spec/unit/choice/from_spec.rb +16 -0
  34. data/spec/unit/enum_select_spec.rb +104 -32
  35. data/spec/unit/expand_spec.rb +104 -12
  36. data/spec/unit/keypress_spec.rb +2 -8
  37. data/spec/unit/mask_spec.rb +9 -1
  38. data/spec/unit/multi_select_spec.rb +348 -118
  39. data/spec/unit/paginator_spec.rb +29 -10
  40. data/spec/unit/select_spec.rb +390 -108
  41. data/spec/unit/slider_spec.rb +48 -6
  42. data/spec/unit/timer_spec.rb +29 -0
  43. data/tty-prompt.gemspec +4 -6
  44. metadata +17 -46
  45. data/lib/tty/prompt/timeout.rb +0 -78
@@ -22,6 +22,7 @@ module TTY
22
22
  @prompt = prompt
23
23
  @prefix = options.fetch(:prefix) { @prompt.prefix }
24
24
  @default = options.fetch(:default) { 1 }
25
+ @auto_hint = options.fetch(:auto_hint) { false }
25
26
  @active_color = options.fetch(:active_color) { @prompt.active_color }
26
27
  @help_color = options.fetch(:help_color) { @prompt.help_color }
27
28
  @choices = Choices.new
@@ -62,6 +63,7 @@ module TTY
62
63
  elsif selected
63
64
  @done = true
64
65
  @selected = selected
66
+ @hint = nil
65
67
  else
66
68
  @input = ''
67
69
  end
@@ -77,6 +79,7 @@ module TTY
77
79
  elsif event.value =~ /^[^\e\n\r]/
78
80
  @input += event.value
79
81
  end
82
+
80
83
  @selected = select_choice(@input)
81
84
  if @selected && !@default_key && collapsed?
82
85
  @hint = @selected.name
@@ -208,6 +211,7 @@ module TTY
208
211
  #
209
212
  # @api private
210
213
  def render_question
214
+ load_auto_hint if @auto_hint
211
215
  header = render_header
212
216
  header << render_hint if @hint
213
217
  header << "\n" if @done
@@ -219,6 +223,20 @@ module TTY
219
223
  header
220
224
  end
221
225
 
226
+ def load_auto_hint
227
+ if @hint.nil? && collapsed?
228
+ if @selected
229
+ @hint = @selected.name
230
+ else
231
+ if @input.empty?
232
+ @hint = @choices[@default - 1].name
233
+ else
234
+ @hint = "invalid option"
235
+ end
236
+ end
237
+ end
238
+ end
239
+
222
240
  def render_footer
223
241
  " Choice [#{@choices[@default - 1].key}]: #{@input}"
224
242
  end
@@ -235,7 +253,7 @@ module TTY
235
253
  #
236
254
  # @api private
237
255
  def refresh(lines)
238
- if @hint && (!@selected || @done)
256
+ if (@hint && (!@selected || @done)) || (@auto_hint && collapsed?)
239
257
  @hint = nil
240
258
  @prompt.clear_lines(lines, :down) +
241
259
  @prompt.cursor.prev_line
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'question'
4
- require_relative 'symbols'
5
- require_relative 'timeout'
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,18 +20,10 @@ 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
@@ -55,10 +46,8 @@ module TTY
55
46
  def keypress(event)
56
47
  if any_key?
57
48
  @done = true
58
- @scheduler.cancel
59
49
  elsif @keys.is_a?(Array) && @keys.include?(event.key.name)
60
50
  @done = true
61
- @scheduler.cancel
62
51
  else
63
52
  @done = false
64
53
  end
@@ -66,36 +55,42 @@ module TTY
66
55
 
67
56
  def render_question
68
57
  header = super
69
- header.gsub!(/:countdown/, countdown.to_s)
58
+ if timeout?
59
+ header.gsub!(/:countdown/, format("%.#{@decimals}f", countdown))
60
+ end
70
61
  header
71
62
  end
72
63
 
64
+ def interval_handler(time)
65
+ return if @done
66
+
67
+ question = render_question
68
+ line_size = question.size
69
+ total_lines = @prompt.count_screen_lines(line_size)
70
+ @prompt.print(refresh(question.lines.count, total_lines))
71
+ countdown(time)
72
+ @prompt.print(render_question)
73
+ end
74
+
73
75
  def process_input(question)
74
- time do
75
- @prompt.print(render_question)
76
- until @done
77
- @input = @prompt.read_keypress
78
- end
76
+ @prompt.print(render_question)
77
+
78
+ @timer.on_tick do |time|
79
+ interval_handler(time)
80
+ end
81
+
82
+ @timer.while_remaining do |remaining|
83
+ break if @done
84
+ @input = @prompt.read_keypress(nonblock: true)
79
85
  end
86
+ countdown(0) unless @done
87
+
80
88
  @evaluator.(@input)
81
89
  end
82
90
 
83
91
  def refresh(lines, lines_to_clear)
84
92
  @prompt.clear_lines(lines)
85
93
  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
94
  end # Keypress
100
95
  end # Prompt
101
96
  end # TTY
@@ -4,7 +4,7 @@ require 'English'
4
4
 
5
5
  require_relative 'choices'
6
6
  require_relative 'paginator'
7
- require_relative 'symbols'
7
+ require_relative 'block_paginator'
8
8
 
9
9
  module TTY
10
10
  class Prompt
@@ -13,11 +13,7 @@ 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)'
16
+ HELP = '(Use %s arrow%s keys, press Enter to select%s)'
21
17
 
22
18
  # Allowed keys for filter, along with backspace and canc.
23
19
  FILTER_KEYS_MATCHER = /\A([[:alnum:]]|[[:punct:]])\Z/.freeze
@@ -36,7 +32,7 @@ module TTY
36
32
  # the delimiter for the item index
37
33
  #
38
34
  # @api public
39
- def initialize(prompt, options = {})
35
+ def initialize(prompt, **options)
40
36
  check_options_consistency(options)
41
37
 
42
38
  @prompt = prompt
@@ -46,23 +42,30 @@ module TTY
46
42
  @choices = Choices.new
47
43
  @active_color = options.fetch(:active_color) { @prompt.active_color }
48
44
  @help_color = options.fetch(:help_color) { @prompt.help_color }
49
- @marker = options.fetch(:marker) { symbols[:pointer] }
50
45
  @cycle = options.fetch(:cycle) { false }
51
46
  @filterable = options.fetch(:filter) { false }
47
+ @symbols = @prompt.symbols.merge(options.fetch(:symbols, {}))
52
48
  @filter = []
49
+ @filter_cache = {}
53
50
  @help = options[:help]
54
51
  @first_render = true
55
52
  @done = false
56
53
  @per_page = options[:per_page]
57
- @page_help = options[:page_help] || PAGE_HELP
58
54
  @paginator = Paginator.new
55
+ @block_paginator = BlockPaginator.new
56
+ @by_page = false
57
+ @paging_changed = false
59
58
  end
60
59
 
61
- # Set marker
60
+ # Change symbols used by this prompt
61
+ #
62
+ # @param [Hash] new_symbols
63
+ # the new symbols to use
62
64
  #
63
65
  # @api public
64
- def marker(value)
65
- @marker = value
66
+ def symbols(new_symbols = (not_set = true))
67
+ return @symbols if not_set
68
+ @symbols.merge!(new_symbols)
66
69
  end
67
70
 
68
71
  # Set default option selected
@@ -72,6 +75,32 @@ module TTY
72
75
  @default = default_values
73
76
  end
74
77
 
78
+ # Select paginator based on the current navigation key
79
+ #
80
+ # @return [Paginator]
81
+ #
82
+ # @api private
83
+ def paginator
84
+ @by_page ? @block_paginator : @paginator
85
+ end
86
+
87
+ # Synchronize paginators start positions
88
+ #
89
+ # @api private
90
+ def sync_paginators
91
+ if @by_page
92
+ if @paginator.start_index
93
+ @block_paginator.reset!
94
+ @block_paginator.start_index = @paginator.start_index
95
+ end
96
+ else
97
+ if @block_paginator.start_index
98
+ @paginator.reset!
99
+ @paginator.start_index = @block_paginator.start_index
100
+ end
101
+ end
102
+ end
103
+
75
104
  # Set number of items per page
76
105
  #
77
106
  # @api public
@@ -92,13 +121,6 @@ module TTY
92
121
  choices.size > page_size
93
122
  end
94
123
 
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
124
  # Provide help information
103
125
  #
104
126
  # @param [String] value
@@ -113,6 +135,21 @@ module TTY
113
135
  @help = (@help.nil? && !not_set) ? value : default_help
114
136
  end
115
137
 
138
+ # Information about arrow keys
139
+ #
140
+ # @return [String]
141
+ #
142
+ # @api private
143
+ def arrows_help
144
+ up_down = @symbols[:arrow_up] + "/" + @symbols[:arrow_down]
145
+ left_right = @symbols[:arrow_left] + "/" + @symbols[:arrow_right]
146
+
147
+ arrows = [up_down]
148
+ arrows << " and " if paginated?
149
+ arrows << left_right if paginated?
150
+ arrows.join
151
+ end
152
+
116
153
  # Default help text
117
154
  #
118
155
  # @api public
@@ -126,7 +163,7 @@ module TTY
126
163
  ['', '']
127
164
  end
128
165
 
129
- format(self.class::HELP, *tokens)
166
+ format(self.class::HELP, arrows_help, *tokens)
130
167
  end
131
168
 
132
169
  # Set selecting active index using number pad
@@ -140,6 +177,7 @@ module TTY
140
177
  #
141
178
  # @api public
142
179
  def choice(*value, &block)
180
+ @filter_cache = {}
143
181
  if block
144
182
  @choices << (value << block)
145
183
  else
@@ -159,12 +197,14 @@ module TTY
159
197
  if !filterable? || @filter.empty?
160
198
  @choices
161
199
  else
162
- @choices.select do |choice|
200
+ filter_value = @filter.join.downcase
201
+ @filter_cache[filter_value] ||= @choices.select do |choice|
163
202
  !choice.disabled? &&
164
- choice.name.downcase.include?(@filter.join.downcase)
203
+ choice.name.downcase.include?(filter_value)
165
204
  end
166
205
  end
167
206
  else
207
+ @filter_cache = {}
168
208
  values.each { |val| @choices << val }
169
209
  end
170
210
  end
@@ -223,6 +263,9 @@ module TTY
223
263
 
224
264
  @active = prev_active if prev_active
225
265
  end
266
+
267
+ @paging_changed = @by_page
268
+ @by_page = false
226
269
  end
227
270
 
228
271
  def keydown(*)
@@ -237,9 +280,50 @@ module TTY
237
280
 
238
281
  @active = next_active if next_active
239
282
  end
283
+ @paging_changed = @by_page
284
+ @by_page = false
240
285
  end
241
286
  alias keytab keydown
242
287
 
288
+ # Moves all choices page by page keeping the current selected item
289
+ # at the same level on each page.
290
+ #
291
+ # When the choice on a page is outside of next page range then
292
+ # adjust it to the last item, otherwise leave unchanged.
293
+ def keyright(*)
294
+ if (@active + page_size) <= @choices.size
295
+ searchable = ((@active + page_size)..choices.length)
296
+ @active = search_choice_in(searchable)
297
+ elsif @active <= @choices.size # last page shorter
298
+ current = @active % page_size
299
+ remaining = @choices.size % page_size
300
+ if current.zero? || (remaining > 0 && current > remaining)
301
+ searchable = @choices.size.downto(0).to_a
302
+ @active = search_choice_in(searchable)
303
+ elsif @cycle
304
+ searchable = ((current.zero? ? page_size : current)..choices.length)
305
+ @active = search_choice_in(searchable)
306
+ end
307
+ end
308
+
309
+ @paging_changed = !@by_page
310
+ @by_page = true
311
+ end
312
+ alias keypage_down keyright
313
+
314
+ def keyleft(*)
315
+ if (@active - page_size) > 0
316
+ searchable = ((@active - page_size)..choices.length)
317
+ @active = search_choice_in(searchable)
318
+ elsif @cycle
319
+ searchable = @choices.size.downto(1).to_a
320
+ @active = search_choice_in(searchable)
321
+ end
322
+ @paging_changed = !@by_page
323
+ @by_page = true
324
+ end
325
+ alias keypage_up keyleft
326
+
243
327
  def keypress(event)
244
328
  return unless filterable?
245
329
 
@@ -372,7 +456,6 @@ module TTY
372
456
  @first_render = false
373
457
  unless @done
374
458
  header << render_menu
375
- header << render_footer
376
459
  end
377
460
  header.join
378
461
  end
@@ -419,36 +502,25 @@ module TTY
419
502
  def render_menu
420
503
  output = []
421
504
 
422
- @paginator.paginate(choices, @active, @per_page) do |choice, index|
505
+ sync_paginators if @paging_changed
506
+ paginator.paginate(choices, @active, @per_page) do |choice, index|
423
507
  num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
424
508
  message = if index + 1 == @active && !choice.disabled?
425
- selected = "#{@marker} #{num}#{choice.name}"
509
+ selected = "#{@symbols[:marker]} #{num}#{choice.name}"
426
510
  @prompt.decorate(selected.to_s, @active_color)
427
511
  elsif choice.disabled?
428
- @prompt.decorate(symbols[:cross], :red) +
512
+ @prompt.decorate(@symbols[:cross], :red) +
429
513
  " #{num}#{choice.name} #{choice.disabled}"
430
514
  else
431
515
  " #{num}#{choice.name}"
432
516
  end
433
- max_index = paginated? ? @paginator.max_index : choices.size - 1
434
- newline = (index == max_index) ? '' : "\n"
517
+ end_index = paginated? ? paginator.end_index : choices.size - 1
518
+ newline = (index == end_index) ? '' : "\n"
435
519
  output << (message + newline)
436
520
  end
437
521
 
438
522
  output.join
439
523
  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
524
  end # List
453
525
  end # Prompt
454
526
  end # TTY
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'question'
4
- require_relative 'symbols'
5
4
 
6
5
  module TTY
7
6
  class Prompt
@@ -12,9 +11,9 @@ module TTY
12
11
  # @option options [String] :mask
13
12
  #
14
13
  # @api public
15
- def initialize(prompt, options = {})
14
+ def initialize(prompt, **options)
16
15
  super
17
- @mask = options.fetch(:mask) { Symbols.symbols[:dot] }
16
+ @mask = options.fetch(:mask) { @prompt.symbols[:dot] }
18
17
  @done_masked = false
19
18
  @failure = false
20
19
  end