tty-prompt 0.2.0 → 0.3.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +9 -6
  3. data/CHANGELOG.md +40 -3
  4. data/Gemfile +0 -1
  5. data/README.md +246 -65
  6. data/examples/ask.rb +7 -0
  7. data/examples/echo.rb +7 -0
  8. data/examples/in.rb +7 -0
  9. data/examples/mask.rb +9 -0
  10. data/examples/multi_select.rb +8 -0
  11. data/examples/select.rb +8 -0
  12. data/examples/validation.rb +9 -0
  13. data/examples/yes_no.rb +7 -0
  14. data/lib/tty-prompt.rb +6 -4
  15. data/lib/tty/prompt.rb +100 -25
  16. data/lib/tty/prompt/choice.rb +1 -1
  17. data/lib/tty/prompt/converter_dsl.rb +19 -0
  18. data/lib/tty/prompt/converter_registry.rb +56 -0
  19. data/lib/tty/prompt/converters.rb +77 -0
  20. data/lib/tty/prompt/evaluator.rb +29 -0
  21. data/lib/tty/prompt/list.rb +38 -36
  22. data/lib/tty/prompt/mask_question.rb +85 -0
  23. data/lib/tty/prompt/multi_list.rb +21 -32
  24. data/lib/tty/prompt/question.rb +184 -162
  25. data/lib/tty/prompt/question/checks.rb +85 -0
  26. data/lib/tty/prompt/question/modifier.rb +4 -5
  27. data/lib/tty/prompt/question/validation.rb +29 -35
  28. data/lib/tty/prompt/reader.rb +98 -52
  29. data/lib/tty/prompt/reader/codes.rb +63 -0
  30. data/lib/tty/prompt/reader/key_event.rb +67 -0
  31. data/lib/tty/prompt/reader/mode.rb +66 -0
  32. data/lib/tty/prompt/reader/mode/echo.rb +43 -0
  33. data/lib/tty/prompt/reader/mode/raw.rb +43 -0
  34. data/lib/tty/prompt/result.rb +42 -0
  35. data/lib/tty/prompt/statement.rb +9 -14
  36. data/lib/tty/prompt/suggestion.rb +4 -2
  37. data/lib/tty/prompt/symbols.rb +13 -0
  38. data/lib/tty/prompt/test.rb +3 -2
  39. data/lib/tty/prompt/utils.rb +1 -1
  40. data/lib/tty/prompt/version.rb +1 -1
  41. data/spec/unit/ask_spec.rb +31 -48
  42. data/spec/unit/choice/eql_spec.rb +0 -2
  43. data/spec/unit/choice/from_spec.rb +0 -2
  44. data/spec/unit/choices/add_spec.rb +0 -2
  45. data/spec/unit/choices/each_spec.rb +0 -2
  46. data/spec/unit/choices/new_spec.rb +0 -2
  47. data/spec/unit/choices/pluck_spec.rb +0 -2
  48. data/spec/unit/converters/convert_bool_spec.rb +58 -0
  49. data/spec/unit/{response/read_char_spec.rb → converters/convert_char_spec.rb} +2 -4
  50. data/spec/unit/converters/convert_custom_spec.rb +14 -0
  51. data/spec/unit/converters/convert_date_spec.rb +25 -0
  52. data/spec/unit/converters/convert_file_spec.rb +14 -0
  53. data/spec/unit/{response/read_number_spec.rb → converters/convert_number_spec.rb} +5 -7
  54. data/spec/unit/converters/convert_path_spec.rb +15 -0
  55. data/spec/unit/{response/read_range_spec.rb → converters/convert_range_spec.rb} +3 -5
  56. data/spec/unit/converters/convert_regex_spec.rb +12 -0
  57. data/spec/unit/converters/convert_string_spec.rb +21 -0
  58. data/spec/unit/distance/distance_spec.rb +0 -2
  59. data/spec/unit/error_spec.rb +0 -6
  60. data/spec/unit/evaluator_spec.rb +67 -0
  61. data/spec/unit/keypress_spec.rb +19 -0
  62. data/spec/unit/mask_spec.rb +95 -0
  63. data/spec/unit/multi_select_spec.rb +36 -24
  64. data/spec/unit/multiline_spec.rb +19 -0
  65. data/spec/unit/new_spec.rb +18 -0
  66. data/spec/unit/ok_spec.rb +10 -0
  67. data/spec/unit/question/default_spec.rb +17 -4
  68. data/spec/unit/question/echo_spec.rb +31 -0
  69. data/spec/unit/question/in_spec.rb +48 -16
  70. data/spec/unit/question/initialize_spec.rb +2 -9
  71. data/spec/unit/question/modifier/apply_to_spec.rb +9 -16
  72. data/spec/unit/question/modifier/letter_case_spec.rb +0 -2
  73. data/spec/unit/question/modifier/whitespace_spec.rb +12 -20
  74. data/spec/unit/question/modify_spec.rb +3 -7
  75. data/spec/unit/question/required_spec.rb +20 -14
  76. data/spec/unit/question/validate_spec.rb +20 -19
  77. data/spec/unit/question/validation/call_spec.rb +15 -6
  78. data/spec/unit/question/validation/coerce_spec.rb +17 -11
  79. data/spec/unit/reader/publish_keypress_event_spec.rb +81 -0
  80. data/spec/unit/reader/read_keypress_spec.rb +22 -0
  81. data/spec/unit/reader/read_line_spec.rb +31 -0
  82. data/spec/unit/reader/read_multiline_spec.rb +37 -0
  83. data/spec/unit/result_spec.rb +40 -0
  84. data/spec/unit/say_spec.rb +18 -23
  85. data/spec/unit/select_spec.rb +37 -32
  86. data/spec/unit/statement/initialize_spec.rb +4 -4
  87. data/spec/unit/suggest_spec.rb +0 -2
  88. data/spec/unit/warn_spec.rb +0 -5
  89. data/spec/unit/yes_no_spec.rb +70 -0
  90. data/tty-prompt.gemspec +7 -4
  91. metadata +123 -40
  92. data/lib/tty/prompt/codes.rb +0 -32
  93. data/lib/tty/prompt/cursor.rb +0 -131
  94. data/lib/tty/prompt/error.rb +0 -26
  95. data/lib/tty/prompt/mode.rb +0 -64
  96. data/lib/tty/prompt/mode/echo.rb +0 -41
  97. data/lib/tty/prompt/mode/raw.rb +0 -41
  98. data/lib/tty/prompt/response.rb +0 -247
  99. data/lib/tty/prompt/response_delegation.rb +0 -42
  100. data/spec/unit/cursor/new_spec.rb +0 -74
  101. data/spec/unit/question/character_spec.rb +0 -13
  102. data/spec/unit/reader/getc_spec.rb +0 -42
  103. data/spec/unit/response/read_bool_spec.rb +0 -58
  104. data/spec/unit/response/read_date_spec.rb +0 -16
  105. data/spec/unit/response/read_email_spec.rb +0 -45
  106. data/spec/unit/response/read_multiple_spec.rb +0 -21
  107. data/spec/unit/response/read_spec.rb +0 -69
  108. data/spec/unit/response/read_string_spec.rb +0 -14
@@ -0,0 +1,85 @@
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
+ [value, ["Value #{value} is not included in the range #{question.in}"]]
44
+ end
45
+ end
46
+ end
47
+
48
+ # Check if input requires validation
49
+ class CheckValidation
50
+ def self.call(question, value)
51
+ if !question.validation? ||
52
+ (question.validation? &&
53
+ Validation.new(question.validation).call(value))
54
+ [value]
55
+ else
56
+ [value, ["Your answer is invalid (must match #{question.validation.inspect})"]]
57
+ end
58
+ end
59
+ end
60
+
61
+ # Check if default value provided
62
+ class CheckDefault
63
+ def self.call(question, value)
64
+ if value.nil? && question.default?
65
+ [question.default]
66
+ else
67
+ [value]
68
+ end
69
+ end
70
+ end
71
+
72
+ # Check if input is required
73
+ class CheckRequired
74
+ def self.call(question, value)
75
+ if question.required? && !question.default? && value.nil?
76
+ [value, ['No value provided for required']]
77
+ else
78
+ [value]
79
+ end
80
+ end
81
+ end
82
+ end # Checks
83
+ end # Question
84
+ end # Prompt
85
+ end # TTY
@@ -6,13 +6,12 @@ module TTY
6
6
  # A class representing String modifications.
7
7
  class Modifier
8
8
  attr_reader :modifiers
9
- private :modifiers
10
9
 
11
10
  # Initialize a Modifier
12
11
  #
13
12
  # @api public
14
- def initialize(*modifiers)
15
- @modifiers = Array(modifiers)
13
+ def initialize(modifiers)
14
+ @modifiers = modifiers
16
15
  end
17
16
 
18
17
  # Change supplied value according to the given string transformation.
@@ -26,8 +25,8 @@ module TTY
26
25
  # @api private
27
26
  def apply_to(value)
28
27
  modifiers.reduce(value) do |result, mod|
29
- result = Modifier.letter_case mod, result
30
- Modifier.whitespace mod, result
28
+ result = Modifier.letter_case(mod, result)
29
+ Modifier.whitespace(mod, result)
31
30
  end
32
31
  end
33
32
 
@@ -5,53 +5,47 @@ module TTY
5
5
  class Question
6
6
  # A class representing question validation.
7
7
  class Validation
8
- # @api private
9
- attr_reader :validation
10
- private :validation
8
+ attr_reader :pattern
9
+
10
+ VALIDATORS = {
11
+ email: /^[a-z0-9._%+-]+@([a-z0-9-]+\.)+[a-z]{2,6}$/i
12
+ }
11
13
 
12
14
  # Initialize a Validation
13
15
  #
14
- # @param [Object] validation
16
+ # @param [Object] pattern
15
17
  #
16
18
  # @return [undefined]
17
19
  #
18
20
  # @api private
19
- def initialize(validation = nil)
20
- @validation = validation ? coerce(validation) : validation
21
+ def initialize(pattern)
22
+ @pattern = coerce(pattern)
21
23
  end
22
24
 
23
25
  # Convert validation into known type.
24
26
  #
25
- # @param [Object] validation
27
+ # @param [Object] pattern
26
28
  #
27
- # @raise [TTY::ValidationCoercion] failed to convert validation
29
+ # @raise [TTY::ValidationCoercion]
30
+ # raised when failed to convert validation
28
31
  #
29
32
  # @api private
30
- def coerce(validation)
31
- case validation
32
- when Proc
33
- validation
34
- when Regexp, String
35
- Regexp.new(validation.to_s)
33
+ def coerce(pattern)
34
+ case pattern
35
+ when String, Symbol, Proc
36
+ pattern
37
+ when Regexp
38
+ Regexp.new(pattern.to_s)
36
39
  else
37
- fail ValidationCoercion, "Wrong type, got #{validation.class}"
40
+ fail ValidationCoercion, "Wrong type, got #{pattern.class}"
38
41
  end
39
42
  end
40
43
 
41
- # Check if validation is required
42
- #
43
- # @return [Boolean]
44
- #
45
- # @api public
46
- def validate?
47
- !!validation
48
- end
49
-
50
44
  # Test if the input passes the validation
51
45
  #
52
46
  # @example
53
- # Validation.new
54
- # validation.valid?(input) # => true
47
+ # Validation.new(/pattern/)
48
+ # validation.call(input) # => true
55
49
  #
56
50
  # @param [Object] input
57
51
  # the input to validate
@@ -60,15 +54,15 @@ module TTY
60
54
  #
61
55
  # @api public
62
56
  def call(input)
63
- if validate? && input
64
- input = input.to_s
65
- if validation.is_a?(Regexp) && validation =~ input
66
- elsif validation.is_a?(Proc) && validation.call(input)
67
- else fail InvalidArgument, "Invalid input for #{input}"
68
- end
69
- true
70
- else
71
- false
57
+ if pattern.is_a?(String) || pattern.is_a?(Symbol)
58
+ VALIDATORS.key?(pattern.to_sym)
59
+ !VALIDATORS[pattern.to_sym].match(input).nil?
60
+ elsif pattern.is_a?(Regexp)
61
+ !pattern.match(input).nil?
62
+ elsif pattern.is_a?(Proc)
63
+ result = pattern.call(input)
64
+ result.nil? ? false : result
65
+ else false
72
66
  end
73
67
  end
74
68
  end # Validation
@@ -1,28 +1,37 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require 'wisper'
4
+ require 'tty/prompt/reader/key_event'
5
+ require 'tty/prompt/reader/mode'
6
+
3
7
  module TTY
4
8
  # A class responsible for shell prompt interactions.
5
9
  class Prompt
6
10
  # A class responsible for reading character input from STDIN
7
11
  class Reader
8
- # @api private
9
- attr_reader :prompt
10
- private :prompt
12
+ include Wisper::Publisher
11
13
 
12
14
  attr_reader :mode
13
15
 
16
+ attr_reader :input
17
+
18
+ attr_reader :output
19
+
14
20
  # Key input constants for decimal codes
15
21
  CARRIAGE_RETURN = 13.freeze
16
22
  NEWLINE = 10.freeze
17
23
  BACKSPACE = 127.freeze
18
24
  DELETE = 8.freeze
19
25
 
26
+ CSI = "\e[".freeze
27
+
20
28
  # Initialize a Reader
21
29
  #
22
30
  # @api public
23
- def initialize(prompt = Prompt.new)
24
- @prompt = prompt
25
- @mode = Mode.new
31
+ def initialize(input, output)
32
+ @input = input
33
+ @output = output
34
+ @mode = Mode.new
26
35
  end
27
36
 
28
37
  # Get input in unbuffered mode.
@@ -36,13 +45,11 @@ module TTY
36
45
  #
37
46
  # @api public
38
47
  def buffer(&block)
39
- bufferring = prompt.output.sync
48
+ bufferring = output.sync
40
49
  # Immediately flush output
41
- prompt.output.sync = true
42
-
50
+ output.sync = true
43
51
  value = block.call if block_given?
44
-
45
- prompt.output.sync = bufferring
52
+ output.sync = bufferring
46
53
  value
47
54
  end
48
55
 
@@ -52,11 +59,14 @@ module TTY
52
59
  # @return [String]
53
60
  #
54
61
  # @api public
55
- def read_keypress
62
+ def read_keypress(echo = false)
56
63
  buffer do
57
- mode.echo(false) do
64
+ mode.echo(echo) do
58
65
  mode.raw(true) do
59
- read_char
66
+ key = read_char
67
+ publish_keypress_event(key) if key
68
+ exit 130 if key == Codes::CTRL_C
69
+ key
60
70
  end
61
71
  end
62
72
  end
@@ -68,78 +78,114 @@ module TTY
68
78
  #
69
79
  # @api public
70
80
  def read_char
71
- chars = prompt.input.getc.chr
72
- if chars == "\e"
73
- chars = prompt.input.read_nonblock(3) rescue chars
74
- chars = prompt.input.read_nonblock(2) rescue chars
75
- chars = "\e" + chars
81
+ chars = input.sysread(1)
82
+ while CSI.start_with?(chars) ||
83
+ chars.start_with?(CSI) &&
84
+ !(64..126).include?(chars.each_codepoint.to_a.last)
85
+ next_char = read_char
86
+ chars << next_char
76
87
  end
77
88
  chars
78
- rescue
89
+ rescue EOFError
90
+ # Finished processing
79
91
  chars
80
92
  end
81
93
 
82
- # Get a value from STDIN one key at a time. Each key press is echoed back
83
- # to the shell masked with character(if given). The input finishes when
84
- # enter key is pressed.
94
+ # Get a single line from STDIN
95
+ # Each key pressed is echoed back to the shell.
96
+ # The input terminates when enter or return key is pressed.
85
97
  #
86
- # @param [String] mask
87
- # the character to use as mask
98
+ # @param [Boolean] echo
99
+ # if true echo back characters, output nothing otherwise
88
100
  #
89
101
  # @return [String]
90
102
  #
91
103
  # @api public
92
- def getc(mask = (not_set = true))
93
- value = ''
104
+ def read_line(echo = true)
105
+ line = ''
94
106
  buffer do
95
- begin
96
- while (char = prompt.input.getbyte) &&
107
+ mode.echo(echo) do
108
+ while (char = input.getbyte) &&
97
109
  !(char == CARRIAGE_RETURN || char == NEWLINE)
98
- value = handle_char value, char, not_set, mask
110
+ publish_keypress_event(convert_byte(char))
111
+ line = handle_char(line, char)
99
112
  end
100
- ensure
101
- mode.echo_on
102
113
  end
103
114
  end
104
- value
115
+ line
116
+ end
117
+
118
+ # Read multiple lines and terminate when empty line is submitted.
119
+ #
120
+ # @yield [String] line
121
+ #
122
+ # @return [Array[String]]
123
+ #
124
+ # @api public
125
+ def read_multiline
126
+ response = []
127
+ loop do
128
+ line = read_line
129
+ break if !line || line == ''
130
+ next if line !~ /\S/
131
+ if block_given?
132
+ yield(line)
133
+ else
134
+ response << line
135
+ end
136
+ end
137
+ response
105
138
  end
106
139
 
107
- # Get a value from STDIN using line input.
140
+ # Publish event
141
+ #
142
+ # @param [String] key
143
+ # the key pressed
144
+ #
145
+ # @return [nil]
108
146
  #
109
147
  # @api public
110
- def gets
111
- prompt.input.gets
148
+ def publish_keypress_event(char)
149
+ event = KeyEvent.from(char)
150
+ event_name = parse_key_event(event)
151
+ publish(event_name, event) unless event_name.nil?
152
+ publish(:keypress, event)
112
153
  end
113
154
 
114
- # Reads at maximum +maxlen+ characters.
155
+ # Interpret the key and provide event name
115
156
  #
116
- # @param [Integer] maxlen
157
+ # @return [Symbol]
117
158
  #
118
159
  # @api public
119
- def readpartial(maxlen)
120
- prompt.input.readpartial(maxlen)
160
+ def parse_key_event(event)
161
+ return if event.key.nil?
162
+ permitted_events = %w(up down left right space return enter num)
163
+ return unless permitted_events.include?("#{event.key.name}")
164
+ :"key#{event.key.name}"
121
165
  end
122
166
 
123
167
  private
124
168
 
125
- # Handle single character by appending to or removing from output
169
+ trap('SIGINT') { exit 130 }
170
+
171
+ # Convert byte to unicode character
172
+ #
173
+ # @return [String]
126
174
  #
127
175
  # @api private
128
- def handle_char(value, char, not_set, mask)
129
- if char == BACKSPACE || char == DELETE
130
- value.slice!(-1, 1) unless value.empty?
131
- else
132
- print_char char, not_set, mask
133
- value << char
134
- end
135
- value
176
+ def convert_byte(byte)
177
+ Array(byte).pack('U*')
136
178
  end
137
179
 
138
- # Print out character back to shell STDOUT
180
+ # Handle single character by appending to or removing from output
139
181
  #
140
182
  # @api private
141
- def print_char(char, not_set, mask)
142
- prompt.output.putc((not_set || !mask) ? char : mask)
183
+ def handle_char(line, char)
184
+ if char == BACKSPACE || char == DELETE
185
+ line.empty? ? line : line.slice(-1, 1)
186
+ else
187
+ line << char
188
+ end
143
189
  end
144
190
  end # Reader
145
191
  end # Prompt
@@ -0,0 +1,63 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ class Prompt
5
+ class Reader
6
+ module Codes
7
+ BACKSPACE = "\177"
8
+ DELETE = "\004"
9
+ ESCAPE = "\e"
10
+ LINEFEED = "\n"
11
+ RETURN = "\r"
12
+ SPACE = " "
13
+ TAB = "\t"
14
+
15
+ KEY_UP = "\e[A"
16
+ KEY_DOWN = "\e[B"
17
+ KEY_RIGHT = "\e[C"
18
+ KEY_LEFT = "\e[D"
19
+ KEY_CLEAR = "\e[E"
20
+ KEY_END = "\e[F"
21
+ KEY_HOME = "\e[H"
22
+ KEY_DELETE = "\e[3"
23
+
24
+ KEY_UP_ALT = "\eOA"
25
+ KEY_DOWN_ALT = "\eOB"
26
+ KEY_RIGHT_ALT = "\eOC"
27
+ KEY_LEFT_ALT = "\eOD"
28
+ KEY_CLEAR_ALT = "\eOE"
29
+ KEY_END_ALT = "\eOF"
30
+ KEY_HOME_ALT = "\eOH"
31
+ KEY_DELETE_ALT = "\eO3"
32
+
33
+ CTRL_J = "\x0A"
34
+ CTRL_N = "\x0E"
35
+ CTRL_K = "\x0B"
36
+ CTRL_P = "\x10"
37
+ SIGINT = "\x03"
38
+ CTRL_C = "\x03"
39
+ CTRL_H = "\b"
40
+ CTRL_L = "\f"
41
+
42
+ F1 = "\eOP"
43
+ F2 = "\eOQ"
44
+ F3 = "\eOR"
45
+ F4 = "\eOS"
46
+
47
+ F1_ALT = "\e[11~"
48
+ F2_ALT = "\e[12~"
49
+ F3_ALT = "\e[13~"
50
+ F4_ALT = "\e[14~"
51
+
52
+ F5 = "\e[15~"
53
+ F6 = "\e[17~"
54
+ F7 = "\e[18~"
55
+ F8 = "\e[19~"
56
+ F9 = "\e[20~"
57
+ F10 = "\e[21~"
58
+ F11 = "\e[23~"
59
+ F12 = "\e[24~"
60
+ end # Codes
61
+ end # Reader
62
+ end # Prompt
63
+ end # TTY