tty2-prompt 0.23.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +14 -0
  3. data/LICENSE.txt +23 -0
  4. data/README.md +52 -0
  5. data/lib/tty2/prompt/answers_collector.rb +78 -0
  6. data/lib/tty2/prompt/block_paginator.rb +59 -0
  7. data/lib/tty2/prompt/choice.rb +147 -0
  8. data/lib/tty2/prompt/choices.rb +129 -0
  9. data/lib/tty2/prompt/confirm_question.rb +158 -0
  10. data/lib/tty2/prompt/const.rb +17 -0
  11. data/lib/tty2/prompt/converter_dsl.rb +21 -0
  12. data/lib/tty2/prompt/converter_registry.rb +69 -0
  13. data/lib/tty2/prompt/converters.rb +182 -0
  14. data/lib/tty2/prompt/distance.rb +49 -0
  15. data/lib/tty2/prompt/enum_list.rb +433 -0
  16. data/lib/tty2/prompt/errors.rb +31 -0
  17. data/lib/tty2/prompt/evaluator.rb +29 -0
  18. data/lib/tty2/prompt/expander.rb +321 -0
  19. data/lib/tty2/prompt/keypress.rb +98 -0
  20. data/lib/tty2/prompt/list.rb +589 -0
  21. data/lib/tty2/prompt/mask_question.rb +96 -0
  22. data/lib/tty2/prompt/multi_list.rb +224 -0
  23. data/lib/tty2/prompt/multiline.rb +72 -0
  24. data/lib/tty2/prompt/paginator.rb +111 -0
  25. data/lib/tty2/prompt/question/checks.rb +105 -0
  26. data/lib/tty2/prompt/question/modifier.rb +96 -0
  27. data/lib/tty2/prompt/question/validation.rb +72 -0
  28. data/lib/tty2/prompt/question.rb +391 -0
  29. data/lib/tty2/prompt/result.rb +42 -0
  30. data/lib/tty2/prompt/selected_choices.rb +77 -0
  31. data/lib/tty2/prompt/slider.rb +286 -0
  32. data/lib/tty2/prompt/statement.rb +55 -0
  33. data/lib/tty2/prompt/suggestion.rb +113 -0
  34. data/lib/tty2/prompt/symbols.rb +89 -0
  35. data/lib/tty2/prompt/test.rb +36 -0
  36. data/lib/tty2/prompt/timer.rb +75 -0
  37. data/lib/tty2/prompt/utils.rb +42 -0
  38. data/lib/tty2/prompt/version.rb +7 -0
  39. data/lib/tty2/prompt.rb +589 -0
  40. data/lib/tty2-prompt.rb +1 -0
  41. metadata +148 -0
@@ -0,0 +1,589 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ require_relative "choices"
6
+ require_relative "paginator"
7
+ require_relative "block_paginator"
8
+
9
+ module TTY2
10
+ class Prompt
11
+ # A class responsible for rendering select list menu
12
+ # Used by {Prompt} to display interactive menu.
13
+ #
14
+ # @api private
15
+ class List
16
+ # Allowed keys for filter, along with backspace and canc.
17
+ FILTER_KEYS_MATCHER = /\A([[:alnum:]]|[[:punct:]])\Z/.freeze
18
+
19
+ # Checks type of default parameter to be integer
20
+ INTEGER_MATCHER = /\A\d+\Z/.freeze
21
+
22
+ # Create instance of TTY2::Prompt::List menu.
23
+ #
24
+ # @param Hash options
25
+ # the configuration options
26
+ # @option options [Symbol] :default
27
+ # the default active choice, defaults to 1
28
+ # @option options [Symbol] :color
29
+ # the color for the selected item, defualts to :green
30
+ # @option options [Symbol] :marker
31
+ # the marker for the selected item
32
+ # @option options [String] :enum
33
+ # the delimiter for the item index
34
+ #
35
+ # @api public
36
+ def initialize(prompt, **options)
37
+ check_options_consistency(options)
38
+
39
+ @prompt = prompt
40
+ @prefix = options.fetch(:prefix) { @prompt.prefix }
41
+ @enum = options.fetch(:enum) { nil }
42
+ @default = Array(options[:default])
43
+ @choices = Choices.new
44
+ @active_color = options.fetch(:active_color) { @prompt.active_color }
45
+ @help_color = options.fetch(:help_color) { @prompt.help_color }
46
+ @cycle = options.fetch(:cycle) { false }
47
+ @filterable = options.fetch(:filter) { false }
48
+ @symbols = @prompt.symbols.merge(options.fetch(:symbols, {}))
49
+ @quiet = options.fetch(:quiet) { @prompt.quiet }
50
+ @filter = []
51
+ @filter_cache = {}
52
+ @help = options[:help]
53
+ @show_help = options.fetch(:show_help) { :start }
54
+ @first_render = true
55
+ @done = false
56
+ @per_page = options[:per_page]
57
+ @paginator = Paginator.new
58
+ @block_paginator = BlockPaginator.new
59
+ @by_page = false
60
+ @paging_changed = false
61
+ end
62
+
63
+ # Change symbols used by this prompt
64
+ #
65
+ # @param [Hash] new_symbols
66
+ # the new symbols to use
67
+ #
68
+ # @api public
69
+ def symbols(new_symbols = (not_set = true))
70
+ return @symbols if not_set
71
+
72
+ @symbols.merge!(new_symbols)
73
+ end
74
+
75
+ # Set default option selected
76
+ #
77
+ # @api public
78
+ def default(*default_values)
79
+ @default = default_values
80
+ end
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
+
108
+ # Set number of items per page
109
+ #
110
+ # @api public
111
+ def per_page(value)
112
+ @per_page = value
113
+ end
114
+
115
+ def page_size
116
+ (@per_page || Paginator::DEFAULT_PAGE_SIZE)
117
+ end
118
+
119
+ # Check if list is paginated
120
+ #
121
+ # @return [Boolean]
122
+ #
123
+ # @api private
124
+ def paginated?
125
+ choices.size > page_size
126
+ end
127
+
128
+ # Provide help information
129
+ #
130
+ # @param [String] value
131
+ # the new help text
132
+ #
133
+ # @return [String]
134
+ #
135
+ # @api public
136
+ def help(value = (not_set = true))
137
+ return @help if !@help.nil? && not_set
138
+
139
+ @help = (@help.nil? && !not_set) ? value : default_help
140
+ end
141
+
142
+ # Change when help is displayed
143
+ #
144
+ # @api public
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]
159
+
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
182
+ end
183
+
184
+ # Set selecting active index using number pad
185
+ #
186
+ # @api public
187
+ def enum(value)
188
+ @enum = value
189
+ end
190
+
191
+ # Set whether selected answers are echoed
192
+ #
193
+ # @api public
194
+ def quiet(value)
195
+ @quiet = value
196
+ end
197
+
198
+ # Add a single choice
199
+ #
200
+ # @api public
201
+ def choice(*value, &block)
202
+ @filter_cache = {}
203
+ if block
204
+ @choices << (value << block)
205
+ else
206
+ @choices << value
207
+ end
208
+ end
209
+
210
+ # Add multiple choices, or return them.
211
+ #
212
+ # @param [Array[Object]] values
213
+ # the values to add as choices; if not passed, the current
214
+ # choices are displayed.
215
+ #
216
+ # @api public
217
+ def choices(values = (not_set = true))
218
+ if not_set
219
+ if !filterable? || @filter.empty?
220
+ @choices
221
+ else
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)
225
+ end
226
+ end
227
+ else
228
+ @filter_cache = {}
229
+ values.each { |val| @choices << val }
230
+ end
231
+ end
232
+
233
+ # Call the list menu by passing question and choices
234
+ #
235
+ # @param [String] question
236
+ #
237
+ # @param
238
+ # @api public
239
+ def call(question, possibilities, &block)
240
+ choices(possibilities)
241
+ @question = question
242
+ block.call(self) if block
243
+ setup_defaults
244
+ @prompt.subscribe(self) do
245
+ render
246
+ end
247
+ end
248
+
249
+ # Check if list is enumerated
250
+ #
251
+ # @return [Boolean]
252
+ def enumerate?
253
+ !@enum.nil?
254
+ end
255
+
256
+ def keynum(event)
257
+ return unless enumerate?
258
+
259
+ value = event.value.to_i
260
+ return unless (1..choices.count).cover?(value)
261
+ return if choices[value - 1].disabled?
262
+
263
+ @active = value
264
+ end
265
+
266
+ def keyenter(*)
267
+ @done = true unless choices.empty?
268
+ end
269
+ alias keyreturn keyenter
270
+ alias keyspace keyenter
271
+
272
+ def search_choice_in(searchable)
273
+ searchable.find { |i| !choices[i - 1].disabled? }
274
+ end
275
+
276
+ def keyup(*)
277
+ searchable = (@active - 1).downto(1).to_a
278
+ prev_active = search_choice_in(searchable)
279
+
280
+ if prev_active
281
+ @active = prev_active
282
+ elsif @cycle
283
+ searchable = choices.length.downto(1).to_a
284
+ prev_active = search_choice_in(searchable)
285
+
286
+ @active = prev_active if prev_active
287
+ end
288
+
289
+ @paging_changed = @by_page
290
+ @by_page = false
291
+ end
292
+
293
+ def keydown(*)
294
+ searchable = ((@active + 1)..choices.length)
295
+ next_active = search_choice_in(searchable)
296
+
297
+ if next_active
298
+ @active = next_active
299
+ elsif @cycle
300
+ searchable = (1..choices.length)
301
+ next_active = search_choice_in(searchable)
302
+
303
+ @active = next_active if next_active
304
+ end
305
+ @paging_changed = @by_page
306
+ @by_page = false
307
+ end
308
+ alias keytab keydown
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
+
351
+ def keypress(event)
352
+ return unless filterable?
353
+
354
+ if event.value =~ FILTER_KEYS_MATCHER
355
+ @filter << event.value
356
+ @active = 1
357
+ end
358
+ end
359
+
360
+ def keydelete(*)
361
+ return unless filterable?
362
+
363
+ @filter.clear
364
+ @active = 1
365
+ end
366
+
367
+ def keybackspace(*)
368
+ return unless filterable?
369
+
370
+ @filter.pop
371
+ @active = 1
372
+ end
373
+
374
+ private
375
+
376
+ def check_options_consistency(options)
377
+ if options.key?(:enum) && options.key?(:filter)
378
+ raise ConfigurationError,
379
+ "Enumeration can't be used with filter"
380
+ end
381
+ end
382
+
383
+ # Setup default option and active selection
384
+ #
385
+ # @return [Integer]
386
+ #
387
+ # @api private
388
+ def setup_defaults
389
+ validate_defaults
390
+
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
395
+ @active = @default.first
396
+ elsif default_choice = choices.find_by(:name, @default.first)
397
+ @active = choices.index(default_choice) + 1
398
+ end
399
+ end
400
+
401
+ # Validate default indexes to be within range
402
+ #
403
+ # @raise [ConfigurationError]
404
+ # raised when the default index is either non-integer,
405
+ # out of range or clashes with disabled choice item.
406
+ #
407
+ # @api private
408
+ def validate_defaults
409
+ @default.each do |d|
410
+ msg = if d.nil? || d.to_s.empty?
411
+ "default index must be an integer in range (1 - #{choices.size})"
412
+ elsif d.to_s !~ INTEGER_MATCHER
413
+ validate_default_name(d)
414
+ elsif d < 1 || d > choices.size
415
+ "default index `#{d}` out of range (1 - #{choices.size})"
416
+ elsif (dflt_choice = choices[d - 1]) && dflt_choice.disabled?
417
+ "default index `#{d}` matches disabled choice"
418
+ end
419
+
420
+ raise(ConfigurationError, msg) if msg
421
+ end
422
+ end
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
+
441
+ # Render a selection list.
442
+ #
443
+ # By default the result is printed out.
444
+ #
445
+ # @return [Object] value
446
+ # return the selected value
447
+ #
448
+ # @api private
449
+ def render
450
+ @prompt.print(@prompt.hide)
451
+ until @done
452
+ question = render_question
453
+ @prompt.print(question)
454
+ @prompt.read_keypress
455
+
456
+ # Split manually; if the second line is blank (when there are no
457
+ # matching lines), it won't be included by using String#lines.
458
+ question_lines = question.split($INPUT_RECORD_SEPARATOR, -1)
459
+
460
+ @prompt.print(refresh(question_lines_count(question_lines)))
461
+ end
462
+ @prompt.print(render_question) unless @quiet
463
+ answer
464
+ ensure
465
+ @prompt.print(@prompt.show)
466
+ end
467
+
468
+ # Count how many screen lines the question spans
469
+ #
470
+ # @return [Integer]
471
+ #
472
+ # @api private
473
+ def question_lines_count(question_lines)
474
+ question_lines.reduce(0) do |acc, line|
475
+ acc + @prompt.count_screen_lines(line)
476
+ end
477
+ end
478
+
479
+ # Find value for the choice selected
480
+ #
481
+ # @return [nil, Object]
482
+ #
483
+ # @api private
484
+ def answer
485
+ choices[@active - 1].value
486
+ end
487
+
488
+ # Clear screen lines
489
+ #
490
+ # @param [String]
491
+ #
492
+ # @api private
493
+ def refresh(lines)
494
+ @prompt.clear_lines(lines)
495
+ end
496
+
497
+ # Render question with instructions and menu
498
+ #
499
+ # @return [String]
500
+ #
501
+ # @api private
502
+ def render_question
503
+ header = ["#{@prefix}#{@question} #{render_header}\n"]
504
+ @first_render = false
505
+ unless @done
506
+ header << render_menu
507
+ end
508
+ header.join
509
+ end
510
+
511
+ # Is filtering enabled?
512
+ #
513
+ # @return [Boolean]
514
+ #
515
+ # @api private
516
+ def filterable?
517
+ @filterable
518
+ end
519
+
520
+ # Header part showing the current filter
521
+ #
522
+ # @return String
523
+ #
524
+ # @api private
525
+ def filter_help
526
+ "(Filter: #{@filter.join.inspect})"
527
+ end
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
+
543
+ # Render initial help and selected choice
544
+ #
545
+ # @return [String]
546
+ #
547
+ # @api private
548
+ def render_header
549
+ if @done
550
+ selected_item = choices[@active - 1].name
551
+ @prompt.decorate(selected_item.to_s, @active_color)
552
+ elsif (@first_render && (help_start? || help_always?)) ||
553
+ (help_always? && !@filter.any?)
554
+ @prompt.decorate(help, @help_color)
555
+ elsif filterable? && @filter.any?
556
+ @prompt.decorate(filter_help, @help_color)
557
+ end
558
+ end
559
+
560
+ # Render menu with choices to select from
561
+ #
562
+ # @return [String]
563
+ #
564
+ # @api private
565
+ def render_menu
566
+ output = []
567
+
568
+ sync_paginators if @paging_changed
569
+ paginator.paginate(choices, @active, @per_page) do |choice, index|
570
+ num = enumerate? ? (index + 1).to_s + @enum + " " : ""
571
+ message = if index + 1 == @active && !choice.disabled?
572
+ selected = "#{@symbols[:marker]} #{num}#{choice.name}"
573
+ @prompt.decorate(selected.to_s, @active_color)
574
+ elsif choice.disabled?
575
+ @prompt.decorate(@symbols[:cross], :red) +
576
+ " #{num}#{choice.name} #{choice.disabled}"
577
+ else
578
+ " #{num}#{choice.name}"
579
+ end
580
+ end_index = paginated? ? paginator.end_index : choices.size - 1
581
+ newline = (index == end_index) ? "" : "\n"
582
+ output << (message + newline)
583
+ end
584
+
585
+ output.join
586
+ end
587
+ end # List
588
+ end # Prompt
589
+ end # TTY2
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "question"
4
+
5
+ module TTY2
6
+ class Prompt
7
+ class MaskQuestion < Question
8
+ # Names for delete keys
9
+ DELETE_KEYS = %i[backspace delete].freeze
10
+
11
+ # Create masked question
12
+ #
13
+ # @param [Hash] options
14
+ # @option options [String] :mask
15
+ #
16
+ # @api public
17
+ def initialize(prompt, **options)
18
+ super
19
+ @mask = options.fetch(:mask) { @prompt.symbols[:dot] }
20
+ @done_masked = false
21
+ @failure = false
22
+ end
23
+
24
+ # Set character for masking the STDIN input
25
+ #
26
+ # @param [String] char
27
+ #
28
+ # @return [self]
29
+ #
30
+ # @api public
31
+ def mask(char = (not_set = true))
32
+ return @mask if not_set
33
+
34
+ @mask = char
35
+ end
36
+
37
+ def keyreturn(_event)
38
+ @done_masked = true
39
+ end
40
+
41
+ def keyenter(_event)
42
+ @done_masked = true
43
+ end
44
+
45
+ def keypress(event)
46
+ if DELETE_KEYS.include?(event.key.name)
47
+ @input.chop! unless @input.empty?
48
+ elsif event.value =~ /^[^\e\n\r]/
49
+ @input += event.value
50
+ end
51
+ end
52
+
53
+ # Render question and input replaced with masked character
54
+ #
55
+ # @api private
56
+ def render_question
57
+ header = ["#{@prefix}#{message} "]
58
+ if echo?
59
+ masked = @mask.to_s * @input.to_s.length
60
+ if @done_masked && !@failure
61
+ masked = @prompt.decorate(masked, @active_color)
62
+ elsif @done_masked && @failure
63
+ masked = @prompt.decorate(masked, @error_color)
64
+ end
65
+ header << masked
66
+ end
67
+ header << "\n" if @done
68
+ header.join
69
+ end
70
+
71
+ def render_error(errors)
72
+ @failure = !errors.empty?
73
+ super
74
+ end
75
+
76
+ # Read input from user masked by character
77
+ #
78
+ # @private
79
+ def read_input(question)
80
+ @done_masked = false
81
+ @failure = false
82
+ @input = ""
83
+ @prompt.print(question)
84
+ until @done_masked
85
+ @prompt.read_keypress
86
+ question = render_question
87
+ total_lines = @prompt.count_screen_lines(question)
88
+ @prompt.print(@prompt.clear_lines(total_lines))
89
+ @prompt.print(render_question)
90
+ end
91
+ @prompt.puts
92
+ @input
93
+ end
94
+ end # MaskQuestion
95
+ end # Prompt
96
+ end # TTY2