austb-tty-prompt 0.13.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +14 -0
  3. data/.rspec +3 -0
  4. data/.travis.yml +25 -0
  5. data/CHANGELOG.md +218 -0
  6. data/CODE_OF_CONDUCT.md +49 -0
  7. data/Gemfile +19 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +1132 -0
  10. data/Rakefile +8 -0
  11. data/appveyor.yml +23 -0
  12. data/benchmarks/speed.rb +27 -0
  13. data/examples/ask.rb +15 -0
  14. data/examples/collect.rb +19 -0
  15. data/examples/echo.rb +11 -0
  16. data/examples/enum.rb +8 -0
  17. data/examples/enum_paged.rb +9 -0
  18. data/examples/enum_select.rb +7 -0
  19. data/examples/expand.rb +29 -0
  20. data/examples/in.rb +9 -0
  21. data/examples/inputs.rb +10 -0
  22. data/examples/key_events.rb +11 -0
  23. data/examples/keypress.rb +9 -0
  24. data/examples/mask.rb +13 -0
  25. data/examples/multi_select.rb +8 -0
  26. data/examples/multi_select_paged.rb +9 -0
  27. data/examples/multiline.rb +9 -0
  28. data/examples/pause.rb +7 -0
  29. data/examples/select.rb +18 -0
  30. data/examples/select_paginated.rb +9 -0
  31. data/examples/slider.rb +6 -0
  32. data/examples/validation.rb +9 -0
  33. data/examples/yes_no.rb +7 -0
  34. data/lib/tty-prompt.rb +4 -0
  35. data/lib/tty/prompt.rb +535 -0
  36. data/lib/tty/prompt/answers_collector.rb +59 -0
  37. data/lib/tty/prompt/choice.rb +90 -0
  38. data/lib/tty/prompt/choices.rb +110 -0
  39. data/lib/tty/prompt/confirm_question.rb +129 -0
  40. data/lib/tty/prompt/converter_dsl.rb +22 -0
  41. data/lib/tty/prompt/converter_registry.rb +64 -0
  42. data/lib/tty/prompt/converters.rb +77 -0
  43. data/lib/tty/prompt/distance.rb +49 -0
  44. data/lib/tty/prompt/enum_list.rb +337 -0
  45. data/lib/tty/prompt/enum_paginator.rb +56 -0
  46. data/lib/tty/prompt/evaluator.rb +29 -0
  47. data/lib/tty/prompt/expander.rb +292 -0
  48. data/lib/tty/prompt/keypress.rb +94 -0
  49. data/lib/tty/prompt/list.rb +317 -0
  50. data/lib/tty/prompt/mask_question.rb +91 -0
  51. data/lib/tty/prompt/multi_list.rb +108 -0
  52. data/lib/tty/prompt/multiline.rb +71 -0
  53. data/lib/tty/prompt/paginator.rb +88 -0
  54. data/lib/tty/prompt/question.rb +333 -0
  55. data/lib/tty/prompt/question/checks.rb +87 -0
  56. data/lib/tty/prompt/question/modifier.rb +94 -0
  57. data/lib/tty/prompt/question/validation.rb +72 -0
  58. data/lib/tty/prompt/reader.rb +352 -0
  59. data/lib/tty/prompt/reader/codes.rb +121 -0
  60. data/lib/tty/prompt/reader/console.rb +57 -0
  61. data/lib/tty/prompt/reader/history.rb +145 -0
  62. data/lib/tty/prompt/reader/key_event.rb +91 -0
  63. data/lib/tty/prompt/reader/line.rb +162 -0
  64. data/lib/tty/prompt/reader/mode.rb +44 -0
  65. data/lib/tty/prompt/reader/win_api.rb +29 -0
  66. data/lib/tty/prompt/reader/win_console.rb +53 -0
  67. data/lib/tty/prompt/result.rb +42 -0
  68. data/lib/tty/prompt/slider.rb +182 -0
  69. data/lib/tty/prompt/statement.rb +55 -0
  70. data/lib/tty/prompt/suggestion.rb +115 -0
  71. data/lib/tty/prompt/symbols.rb +61 -0
  72. data/lib/tty/prompt/timeout.rb +69 -0
  73. data/lib/tty/prompt/utils.rb +44 -0
  74. data/lib/tty/prompt/version.rb +7 -0
  75. data/lib/tty/test_prompt.rb +20 -0
  76. data/tasks/console.rake +11 -0
  77. data/tasks/coverage.rake +11 -0
  78. data/tasks/spec.rake +29 -0
  79. data/tty-prompt.gemspec +32 -0
  80. metadata +243 -0
@@ -0,0 +1,87 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ class Prompt
5
+ class Question
6
+ module Checks
7
+ # Check if modifications are applicable
8
+ class CheckModifier
9
+ def self.call(question, value)
10
+ if !question.modifier.nil? || question.modifier
11
+ [Modifier.new(question.modifier).apply_to(value)]
12
+ else
13
+ [value]
14
+ end
15
+ end
16
+ end
17
+
18
+ # Check if value is within range
19
+ class CheckRange
20
+ def self.float?(value)
21
+ !/[-+]?(\d*[.])?\d+/.match(value.to_s).nil?
22
+ end
23
+
24
+ def self.int?(value)
25
+ !/^[-+]?\d+$/.match(value.to_s).nil?
26
+ end
27
+
28
+ def self.cast(value)
29
+ if float?(value)
30
+ value.to_f
31
+ elsif int?(value)
32
+ value.to_i
33
+ else
34
+ value
35
+ end
36
+ end
37
+
38
+ def self.call(question, value)
39
+ if !question.in? ||
40
+ (question.in? && question.in.include?(cast(value)))
41
+ [value]
42
+ else
43
+ tokens = {value: value, in: question.in}
44
+ [value, question.message_for(:range?, tokens)]
45
+ end
46
+ end
47
+ end
48
+
49
+ # Check if input requires validation
50
+ class CheckValidation
51
+ def self.call(question, value)
52
+ if !question.validation? || (question.required? && value.nil?) ||
53
+ (question.validation? &&
54
+ Validation.new(question.validation).call(value))
55
+ [value]
56
+ else
57
+ tokens = {valid: question.validation.inspect}
58
+ [value, question.message_for(:valid?, tokens)]
59
+ end
60
+ end
61
+ end
62
+
63
+ # Check if default value provided
64
+ class CheckDefault
65
+ def self.call(question, value)
66
+ if value.nil? && question.default?
67
+ [question.default]
68
+ else
69
+ [value]
70
+ end
71
+ end
72
+ end
73
+
74
+ # Check if input is required
75
+ class CheckRequired
76
+ def self.call(question, value)
77
+ if question.required? && !question.default? && value.nil?
78
+ [value, question.message_for(:required?)]
79
+ else
80
+ [value]
81
+ end
82
+ end
83
+ end
84
+ end # Checks
85
+ end # Question
86
+ end # Prompt
87
+ end # TTY
@@ -0,0 +1,94 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ class Prompt
5
+ class Question
6
+ # A class representing String modifications.
7
+ class Modifier
8
+ attr_reader :modifiers
9
+
10
+ # Initialize a Modifier
11
+ #
12
+ # @api public
13
+ def initialize(modifiers)
14
+ @modifiers = modifiers
15
+ end
16
+
17
+ # Change supplied value according to the given string transformation.
18
+ # Valid settings are:
19
+ #
20
+ # @param [String] value
21
+ # the string to be modified
22
+ #
23
+ # @return [String]
24
+ #
25
+ # @api private
26
+ def apply_to(value)
27
+ modifiers.reduce(value) do |result, mod|
28
+ result = Modifier.letter_case(mod, result)
29
+ Modifier.whitespace(mod, result)
30
+ end
31
+ end
32
+
33
+ # Changes letter casing in a string according to valid modifications.
34
+ # For invalid modification option the string is preserved.
35
+ #
36
+ # @param [Symbol] mod
37
+ # the modification to change the string
38
+ #
39
+ # @option mod [Symbol] :up change to upper case
40
+ # @option mod [Symbol] :upcase change to upper case
41
+ # @option mod [Symbol] :uppercase change to upper case
42
+ # @option mod [Symbol] :down change to lower case
43
+ # @option mod [Symbol] :downcase change to lower case
44
+ # @option mod [Symbol] :capitalize change all words to start
45
+ # with uppercase case letter
46
+ #
47
+ # @return [String]
48
+ #
49
+ # @api public
50
+ def self.letter_case(mod, value)
51
+ return value unless value.is_a?(String)
52
+ case mod
53
+ when :up, :upcase, :uppercase
54
+ value.upcase
55
+ when :down, :downcase, :lowercase
56
+ value.downcase
57
+ when :capitalize
58
+ value.capitalize
59
+ else
60
+ value
61
+ end
62
+ end
63
+
64
+ # Changes whitespace in a string according to valid modifications.
65
+ #
66
+ # @param [Symbol] mod
67
+ # the modification to change the string
68
+ #
69
+ # @option mod [String] :trim, :strip
70
+ # remove whitespace for the start and end
71
+ # @option mod [String] :chomp remove record separator from the end
72
+ # @option mod [String] :collapse remove any duplicate whitespace
73
+ # @option mod [String] :remove remove all whitespace
74
+ #
75
+ # @api public
76
+ def self.whitespace(mod, value)
77
+ return value unless value.is_a?(String)
78
+ case mod
79
+ when :trim, :strip
80
+ value.strip
81
+ when :chomp
82
+ value.chomp
83
+ when :collapse
84
+ value.gsub(/\s+/, ' ')
85
+ when :remove
86
+ value.gsub(/\s+/, '')
87
+ else
88
+ value
89
+ end
90
+ end
91
+ end # Modifier
92
+ end # Question
93
+ end # Prompt
94
+ end # TTY
@@ -0,0 +1,72 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ class Prompt
5
+ class Question
6
+ # A class representing question validation.
7
+ class Validation
8
+ # Available validator names
9
+ VALIDATORS = {
10
+ email: /^[a-z0-9._%+-]+@([a-z0-9-]+\.)+[a-z]{2,6}$/i
11
+ }.freeze
12
+
13
+ attr_reader :pattern
14
+
15
+ # Initialize a Validation
16
+ #
17
+ # @param [Object] pattern
18
+ #
19
+ # @return [undefined]
20
+ #
21
+ # @api private
22
+ def initialize(pattern)
23
+ @pattern = coerce(pattern)
24
+ end
25
+
26
+ # Convert validation into known type.
27
+ #
28
+ # @param [Object] pattern
29
+ #
30
+ # @raise [TTY::ValidationCoercion]
31
+ # raised when failed to convert validation
32
+ #
33
+ # @api private
34
+ def coerce(pattern)
35
+ case pattern
36
+ when String, Symbol, Proc
37
+ pattern
38
+ when Regexp
39
+ Regexp.new(pattern.to_s)
40
+ else
41
+ raise ValidationCoercion, "Wrong type, got #{pattern.class}"
42
+ end
43
+ end
44
+
45
+ # Test if the input passes the validation
46
+ #
47
+ # @example
48
+ # Validation.new(/pattern/)
49
+ # validation.call(input) # => true
50
+ #
51
+ # @param [Object] input
52
+ # the input to validate
53
+ #
54
+ # @return [Boolean]
55
+ #
56
+ # @api public
57
+ def call(input)
58
+ if pattern.is_a?(String) || pattern.is_a?(Symbol)
59
+ VALIDATORS.key?(pattern.to_sym)
60
+ !VALIDATORS[pattern.to_sym].match(input).nil?
61
+ elsif pattern.is_a?(Regexp)
62
+ !pattern.match(input).nil?
63
+ elsif pattern.is_a?(Proc)
64
+ result = pattern.call(input)
65
+ result.nil? ? false : result
66
+ else false
67
+ end
68
+ end
69
+ end # Validation
70
+ end # Question
71
+ end # Prompt
72
+ end # TTY
@@ -0,0 +1,352 @@
1
+ # encoding: utf-8
2
+
3
+ require 'wisper'
4
+ require 'rbconfig'
5
+
6
+ require_relative 'reader/history'
7
+ require_relative 'reader/line'
8
+ require_relative 'reader/key_event'
9
+ require_relative 'reader/console'
10
+ require_relative 'reader/win_console'
11
+
12
+ module TTY
13
+ # A class responsible for shell prompt interactions.
14
+ class Prompt
15
+ # A class responsible for reading character input from STDIN
16
+ #
17
+ # Used internally to provide key and line reading functionality
18
+ #
19
+ # @api private
20
+ class Reader
21
+ include Wisper::Publisher
22
+
23
+ # Raised when the user hits the interrupt key(Control-C)
24
+ #
25
+ # @api public
26
+ InputInterrupt = Class.new(StandardError)
27
+
28
+ attr_reader :input
29
+
30
+ attr_reader :output
31
+
32
+ attr_reader :env
33
+
34
+ attr_reader :track_history
35
+ alias track_history? track_history
36
+
37
+ attr_reader :console
38
+
39
+ # Key codes
40
+ CARRIAGE_RETURN = 13
41
+ NEWLINE = 10
42
+ BACKSPACE = 127
43
+ DELETE = 8
44
+
45
+ # Initialize a Reader
46
+ #
47
+ # @param [IO] input
48
+ # the input stream
49
+ # @param [IO] output
50
+ # the output stream
51
+ # @param [Hash] options
52
+ # @option options [Symbol] :interrupt
53
+ # handling of Ctrl+C key out of :signal, :exit, :noop
54
+ # @option options [Boolean] :track_history
55
+ # disable line history tracking, true by default
56
+ #
57
+ # @api public
58
+ def initialize(input = $stdin, output = $stdout, options = {})
59
+ @input = input
60
+ @output = output
61
+ @interrupt = options.fetch(:interrupt) { :error }
62
+ @env = options.fetch(:env) { ENV }
63
+ @track_history = options.fetch(:track_history) { true }
64
+ @console = select_console(input)
65
+ @history = History.new do |h|
66
+ h.duplicates = false
67
+ h.exclude = proc { |line| line.strip == '' }
68
+ end
69
+ @stop = false # gathering input
70
+
71
+ subscribe(self)
72
+ end
73
+
74
+ # Select appropriate console
75
+ #
76
+ # @api private
77
+ def select_console(input)
78
+ if windows? && !env['TTY_TEST']
79
+ WinConsole.new(input)
80
+ else
81
+ Console.new(input)
82
+ end
83
+ end
84
+
85
+ # Get input in unbuffered mode.
86
+ #
87
+ # @example
88
+ # unbufferred do
89
+ # ...
90
+ # end
91
+ #
92
+ # @api public
93
+ def unbufferred(&block)
94
+ bufferring = output.sync
95
+ # Immediately flush output
96
+ output.sync = true
97
+ block[] if block_given?
98
+ ensure
99
+ output.sync = bufferring
100
+ end
101
+
102
+ # Read a keypress including invisible multibyte codes
103
+ # and return a character as a string.
104
+ # Nothing is echoed to the console. This call will block for a
105
+ # single keypress, but will not wait for Enter to be pressed.
106
+ #
107
+ # @param [Hash[Symbol]] options
108
+ # @option options [Boolean] echo
109
+ # whether to echo chars back or not, defaults to false
110
+ # @option options [Boolean] raw
111
+ # whenther raw mode enabled, defaults to true
112
+ #
113
+ # @return [String]
114
+ #
115
+ # @api public
116
+ def read_keypress(options = {})
117
+ opts = { echo: false, raw: true }.merge(options)
118
+ codes = unbufferred { get_codes(opts) }
119
+ char = codes ? codes.pack('U*') : nil
120
+
121
+ trigger_key_event(char) if char
122
+ handle_interrupt if char == console.keys[:ctrl_c]
123
+ char
124
+ end
125
+ alias read_char read_keypress
126
+
127
+ # Get input code points
128
+ #
129
+ # @param [Hash[Symbol]] options
130
+ # @param [Array[Integer]] codes
131
+ #
132
+ # @return [Array[Integer]]
133
+ #
134
+ # @api private
135
+ def get_codes(options = {}, codes = [])
136
+ opts = { echo: true, raw: false }.merge(options)
137
+ char = console.get_char(opts)
138
+ return if char.nil?
139
+ codes << char.ord
140
+
141
+ condition = proc { |escape|
142
+ (codes - escape).empty? ||
143
+ (escape - codes).empty? &&
144
+ !(64..126).include?(codes.last)
145
+ }
146
+
147
+ while console.escape_codes.any?(&condition)
148
+ get_codes(options, codes)
149
+ end
150
+ codes
151
+ end
152
+
153
+ # Get a single line from STDIN. Each key pressed is echoed
154
+ # back to the shell. The input terminates when enter or
155
+ # return key is pressed.
156
+ #
157
+ # @param [String] prompt
158
+ # the prompt to display before input
159
+ #
160
+ # @param [Boolean] echo
161
+ # if true echo back characters, output nothing otherwise
162
+ #
163
+ # @return [String]
164
+ #
165
+ # @api public
166
+ def read_line(*args)
167
+ options = args.last.respond_to?(:to_hash) ? args.pop : {}
168
+ prompt = args.empty? ? '' : args.pop
169
+ opts = { echo: true, raw: true }.merge(options)
170
+ line = Line.new('')
171
+ ctrls = console.keys.keys.grep(/ctrl/)
172
+ clear_line = "\e[2K\e[1G"
173
+
174
+ while (codes = unbufferred { get_codes(opts) }) && (code = codes[0])
175
+ char = codes.pack('U*')
176
+ trigger_key_event(char)
177
+
178
+ if console.keys[:backspace] == char || BACKSPACE == code
179
+ next if line.start?
180
+ line.left
181
+ line.delete
182
+ elsif console.keys[:delete] == char || DELETE == code
183
+ line.delete
184
+ elsif [console.keys[:ctrl_d],
185
+ console.keys[:ctrl_z]].include?(char)
186
+ break
187
+ elsif console.keys[:ctrl_c] == char
188
+ handle_interrupt
189
+ elsif ctrls.include?(console.keys.key(char))
190
+ # skip
191
+ elsif console.keys[:up] == char
192
+ next unless history_previous?
193
+ line.replace(history_previous)
194
+ elsif console.keys[:down] == char
195
+ line.replace(history_next? ? history_next : '')
196
+ elsif console.keys[:left] == char
197
+ line.left
198
+ elsif console.keys[:right] == char
199
+ line.right
200
+ else
201
+ if opts[:raw] && code == CARRIAGE_RETURN
202
+ char = "\n"
203
+ line.move_to_end
204
+ end
205
+ line.insert(char)
206
+ end
207
+
208
+ if opts[:raw] && opts[:echo]
209
+ output.print(clear_line)
210
+ output.print(prompt + line.to_s)
211
+ if char == "\n"
212
+ line.move_to_start
213
+ elsif !line.end?
214
+ output.print("\e[#{line.size - line.cursor}D")
215
+ end
216
+ end
217
+
218
+ break if (code == CARRIAGE_RETURN || code == NEWLINE)
219
+
220
+ if (console.keys[:backspace] == char || BACKSPACE == code) && opts[:echo]
221
+ if opts[:raw]
222
+ output.print("\e[1X") unless line.start?
223
+ else
224
+ output.print(?\s + (line.start? ? '' : ?\b))
225
+ end
226
+ end
227
+ end
228
+ add_to_history(line.to_s.rstrip) if track_history?
229
+ line.to_s
230
+ end
231
+
232
+ # Read multiple lines and return them in an array.
233
+ # Skip empty lines in the returned lines array.
234
+ # The input gathering is terminated by Ctrl+d or Ctrl+z.
235
+ #
236
+ # @param [String] prompt
237
+ # the prompt displayed before the input
238
+ #
239
+ # @yield [String] line
240
+ #
241
+ # @return [Array[String]]
242
+ #
243
+ # @api public
244
+ def read_multiline(prompt = '')
245
+ @stop = false
246
+ lines = []
247
+ loop do
248
+ line = read_line(prompt)
249
+ break if !line || line == ''
250
+ next if line !~ /\S/ && !@stop
251
+ if block_given?
252
+ yield(line) unless line.to_s.empty?
253
+ else
254
+ lines << line unless line.to_s.empty?
255
+ end
256
+ break if @stop
257
+ end
258
+ lines
259
+ end
260
+ alias read_lines read_multiline
261
+
262
+ # Expose event broadcasting
263
+ #
264
+ # @api public
265
+ def trigger(event, *args)
266
+ publish(event, *args)
267
+ end
268
+
269
+ # Capture Ctrl+d and Ctrl+z key events
270
+ #
271
+ # @api private
272
+ def keyctrl_d(*)
273
+ @stop = true
274
+ end
275
+ alias keyctrl_z keyctrl_d
276
+
277
+ def add_to_history(line)
278
+ @history.push(line)
279
+ end
280
+
281
+ def history_next?
282
+ @history.next?
283
+ end
284
+
285
+ def history_next
286
+ @history.next
287
+ @history.get
288
+ end
289
+
290
+ def history_previous?
291
+ @history.previous?
292
+ end
293
+
294
+ def history_previous
295
+ line = @history.get
296
+ @history.previous
297
+ line
298
+ end
299
+
300
+ # Inspect class name and public attributes
301
+ # @return [String]
302
+ #
303
+ # @api public
304
+ def inspect
305
+ "#<#{self.class}: @input=#{input}, @output=#{output}>"
306
+ end
307
+
308
+ private
309
+
310
+ # Publish event
311
+ #
312
+ # @param [String] char
313
+ # the key pressed
314
+ #
315
+ # @return [nil]
316
+ #
317
+ # @api private
318
+ def trigger_key_event(char)
319
+ event = KeyEvent.from(console.keys, char)
320
+ trigger(:"key#{event.key.name}", event) if event.trigger?
321
+ trigger(:keypress, event)
322
+ end
323
+
324
+ # Handle input interrupt based on provided value
325
+ #
326
+ # @api private
327
+ def handle_interrupt
328
+ case @interrupt
329
+ when :signal
330
+ Process.kill('SIGINT', Process.pid)
331
+ when :exit
332
+ exit(130)
333
+ when Proc
334
+ @interrupt.call
335
+ when :noop
336
+ return
337
+ else
338
+ raise InputInterrupt
339
+ end
340
+ end
341
+
342
+ # Check if Windowz mode
343
+ #
344
+ # @return [Boolean]
345
+ #
346
+ # @api public
347
+ def windows?
348
+ ::File::ALT_SEPARATOR == '\\'
349
+ end
350
+ end # Reader
351
+ end # Prompt
352
+ end # TTY