austb-tty-prompt 0.13.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 (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