tty-prompt 0.18.1 → 0.19.0

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