tty-prompt 0.10.1 → 0.11.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -1
  3. data/CHANGELOG.md +30 -0
  4. data/README.md +39 -9
  5. data/examples/echo.rb +5 -1
  6. data/examples/inputs.rb +10 -0
  7. data/examples/mask.rb +6 -2
  8. data/examples/multi_select.rb +1 -1
  9. data/examples/multi_select_paged.rb +9 -0
  10. data/examples/select.rb +5 -5
  11. data/examples/slider.rb +1 -1
  12. data/lib/tty-prompt.rb +2 -36
  13. data/lib/tty/prompt.rb +49 -8
  14. data/lib/tty/prompt/choices.rb +2 -0
  15. data/lib/tty/prompt/confirm_question.rb +6 -1
  16. data/lib/tty/prompt/converter_dsl.rb +9 -6
  17. data/lib/tty/prompt/converter_registry.rb +27 -19
  18. data/lib/tty/prompt/converters.rb +16 -22
  19. data/lib/tty/prompt/enum_list.rb +8 -4
  20. data/lib/tty/prompt/enum_paginator.rb +2 -0
  21. data/lib/tty/prompt/evaluator.rb +1 -1
  22. data/lib/tty/prompt/expander.rb +1 -1
  23. data/lib/tty/prompt/list.rb +21 -11
  24. data/lib/tty/prompt/mask_question.rb +15 -6
  25. data/lib/tty/prompt/multi_list.rb +12 -10
  26. data/lib/tty/prompt/question.rb +38 -36
  27. data/lib/tty/prompt/question/modifier.rb +2 -0
  28. data/lib/tty/prompt/question/validation.rb +5 -4
  29. data/lib/tty/prompt/reader.rb +104 -58
  30. data/lib/tty/prompt/reader/codes.rb +103 -63
  31. data/lib/tty/prompt/reader/console.rb +57 -0
  32. data/lib/tty/prompt/reader/key_event.rb +51 -88
  33. data/lib/tty/prompt/reader/mode.rb +5 -5
  34. data/lib/tty/prompt/reader/win_api.rb +29 -0
  35. data/lib/tty/prompt/reader/win_console.rb +49 -0
  36. data/lib/tty/prompt/slider.rb +10 -6
  37. data/lib/tty/prompt/suggestion.rb +1 -1
  38. data/lib/tty/prompt/symbols.rb +52 -10
  39. data/lib/tty/prompt/version.rb +1 -1
  40. data/lib/tty/{prompt/test.rb → test_prompt.rb} +2 -1
  41. data/spec/unit/ask_spec.rb +8 -16
  42. data/spec/unit/converters/convert_bool_spec.rb +1 -2
  43. data/spec/unit/converters/on_error_spec.rb +9 -0
  44. data/spec/unit/enum_paginator_spec.rb +16 -0
  45. data/spec/unit/enum_select_spec.rb +69 -25
  46. data/spec/unit/expand_spec.rb +14 -14
  47. data/spec/unit/mask_spec.rb +66 -29
  48. data/spec/unit/multi_select_spec.rb +120 -74
  49. data/spec/unit/new_spec.rb +5 -3
  50. data/spec/unit/paginator_spec.rb +16 -0
  51. data/spec/unit/question/default_spec.rb +2 -4
  52. data/spec/unit/question/echo_spec.rb +2 -3
  53. data/spec/unit/question/in_spec.rb +9 -14
  54. data/spec/unit/question/modifier/letter_case_spec.rb +32 -11
  55. data/spec/unit/question/modifier/whitespace_spec.rb +41 -15
  56. data/spec/unit/question/required_spec.rb +9 -13
  57. data/spec/unit/question/validate_spec.rb +7 -10
  58. data/spec/unit/reader/key_event_spec.rb +36 -50
  59. data/spec/unit/reader/publish_keypress_event_spec.rb +5 -3
  60. data/spec/unit/reader/read_keypress_spec.rb +8 -7
  61. data/spec/unit/reader/read_line_spec.rb +9 -9
  62. data/spec/unit/reader/read_multiline_spec.rb +8 -7
  63. data/spec/unit/select_spec.rb +85 -25
  64. data/spec/unit/slider_spec.rb +43 -16
  65. data/spec/unit/yes_no_spec.rb +14 -28
  66. data/tasks/console.rake +1 -0
  67. data/tty-prompt.gemspec +2 -2
  68. metadata +14 -7
@@ -48,6 +48,7 @@ module TTY
48
48
  #
49
49
  # @api public
50
50
  def self.letter_case(mod, value)
51
+ return value unless value.is_a?(String)
51
52
  case mod
52
53
  when :up, :upcase, :uppercase
53
54
  value.upcase
@@ -73,6 +74,7 @@ module TTY
73
74
  #
74
75
  # @api public
75
76
  def self.whitespace(mod, value)
77
+ return value unless value.is_a?(String)
76
78
  case mod
77
79
  when :trim, :strip
78
80
  value.strip
@@ -5,11 +5,12 @@ module TTY
5
5
  class Question
6
6
  # A class representing question validation.
7
7
  class Validation
8
- attr_reader :pattern
9
-
8
+ # Available validator names
10
9
  VALIDATORS = {
11
10
  email: /^[a-z0-9._%+-]+@([a-z0-9-]+\.)+[a-z]{2,6}$/i
12
- }
11
+ }.freeze
12
+
13
+ attr_reader :pattern
13
14
 
14
15
  # Initialize a Validation
15
16
  #
@@ -37,7 +38,7 @@ module TTY
37
38
  when Regexp
38
39
  Regexp.new(pattern.to_s)
39
40
  else
40
- fail ValidationCoercion, "Wrong type, got #{pattern.class}"
41
+ raise ValidationCoercion, "Wrong type, got #{pattern.class}"
41
42
  end
42
43
  end
43
44
 
@@ -1,8 +1,11 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  require 'wisper'
4
- require 'tty/prompt/reader/key_event'
5
- require 'tty/prompt/reader/mode'
4
+ require 'rbconfig'
5
+
6
+ require_relative 'reader/key_event'
7
+ require_relative 'reader/console'
8
+ require_relative 'reader/win_console'
6
9
 
7
10
  module TTY
8
11
  # A class responsible for shell prompt interactions.
@@ -14,92 +17,101 @@ module TTY
14
17
  # @api private
15
18
  class Reader
16
19
  include Wisper::Publisher
20
+
17
21
  # Raised when the user hits the interrupt key(Control-C)
18
22
  #
19
23
  # @api public
20
24
  InputInterrupt = Class.new(StandardError)
21
25
 
22
- attr_reader :mode
23
-
24
26
  attr_reader :input
25
27
 
26
28
  attr_reader :output
27
29
 
28
- # Key input constants for decimal codes
30
+ attr_reader :env
31
+
32
+ # Key codes
29
33
  CARRIAGE_RETURN = 13
30
34
  NEWLINE = 10
31
35
  BACKSPACE = 127
32
36
  DELETE = 8
33
37
 
34
- CSI = "\e[".freeze
35
-
36
38
  # Initialize a Reader
37
39
  #
38
40
  # @api public
39
- def initialize(input, output, options = {})
41
+ def initialize(input = $stdin, output = $stdout, options = {})
40
42
  @input = input
41
43
  @output = output
42
- @mode = Mode.new
43
44
  @interrupt = options.fetch(:interrupt) { :error }
45
+ @env = options.fetch(:env) { ENV }
46
+ @console = windows? ? WinConsole.new(input) : Console.new(input)
44
47
  end
45
48
 
46
49
  # Get input in unbuffered mode.
47
50
  #
48
51
  # @example
49
- # buffer do
52
+ # unbufferred do
50
53
  # ...
51
54
  # end
52
55
  #
53
- # @return [String]
54
- #
55
56
  # @api public
56
- def buffer(&block)
57
+ def unbufferred(&block)
57
58
  bufferring = output.sync
58
59
  # Immediately flush output
59
60
  output.sync = true
60
- value = block.call if block_given?
61
+ block[] if block_given?
62
+ ensure
61
63
  output.sync = bufferring
62
- value
63
64
  end
64
65
 
65
- # Read a single keypress that may include
66
- # 2 or 3 escape characters.
66
+ # Read a keypress including invisible multibyte codes
67
+ # and return a character as a string.
68
+ # Nothing is echoed to the console. This call will block for a
69
+ # single keypress, but will not wait for Enter to be pressed.
67
70
  #
68
- # @param [Boolean] echo
71
+ # @param [Hash[Symbol]] options
72
+ # @option options [Boolean] echo
69
73
  # whether to echo chars back or not, defaults to false
74
+ # @option options [Boolean] raw
75
+ # whenther raw mode enabled, defaults to true
70
76
  #
71
77
  # @return [String]
72
78
  #
73
79
  # @api public
74
- def read_keypress(echo = false)
75
- buffer do
76
- mode.echo(echo) do
77
- mode.raw(true) do
78
- key = read_char
79
- emit_key_event(key) if key
80
- handle_interrupt if key == Codes::CTRL_C
81
- key
82
- end
83
- end
84
- end
80
+ def read_keypress(options = {})
81
+ opts = { echo: false, raw: true }.merge(options)
82
+ codes = unbufferred { get_codes(opts) }
83
+ char = codes ? codes.pack('U*') : nil
84
+
85
+ trigger_key_event(char) if char
86
+ handle_interrupt if char == @console.keys[:ctrl_c]
87
+ char
85
88
  end
89
+ alias read_char read_keypress
86
90
 
87
- # Reads single character including invisible multibyte codes
91
+ # Get input code points
88
92
  #
89
- # @params [Integer] bytes
90
- # the number of bytes to read
93
+ # @param [Hash[Symbol]] options
94
+ # @param [Array[Integer]] codes
91
95
  #
92
- # @return [String]
96
+ # @return [Array[Integer]]
93
97
  #
94
- # @api public
95
- def read_char(bytes = 1)
96
- chars = input.getc
97
- return if chars.nil?
98
- while CSI.start_with?(chars) || chars.start_with?(CSI) &&
99
- !(64..126).include?(chars.each_codepoint.to_a.last)
100
- chars << read_char(bytes + 1)
98
+ # @api private
99
+ def get_codes(options = {}, codes = [])
100
+ opts = { echo: true, raw: false }.merge(options)
101
+ char = @console.get_char(opts)
102
+ return if char.nil?
103
+ codes << char.ord
104
+
105
+ condition = proc { |escape|
106
+ (codes - escape).empty? ||
107
+ (escape - codes).empty? &&
108
+ !(64..126).include?(codes.last)
109
+ }
110
+
111
+ while @console.escape_codes.any?(&condition)
112
+ get_codes(options, codes)
101
113
  end
102
- chars
114
+ codes
103
115
  end
104
116
 
105
117
  # Get a single line from STDIN. Each key pressed is echoed
@@ -112,19 +124,28 @@ module TTY
112
124
  # @return [String]
113
125
  #
114
126
  # @api public
115
- def read_line(echo = true)
127
+ def read_line(options = {})
128
+ opts = { echo: true, raw: false }.merge(options)
116
129
  line = ''
117
- buffer do
118
- mode.echo(echo) do
119
- while (char = read_char) && (char_byte = char.unpack('c*')[0]) &&
120
- !(char_byte == CARRIAGE_RETURN || char_byte == NEWLINE)
121
- emit_key_event(char)
122
- if char_byte == BACKSPACE || char_byte == DELETE
123
- line = line.slice(-1, 1) unless line.empty?
124
- else
125
- line << char
126
- end
127
- end
130
+ backspaces = 0
131
+ delete_char = proc { |c| c == BACKSPACE || c == DELETE }
132
+
133
+ while (codes = get_codes(opts)) && (code = codes[0])
134
+ char = codes.pack('U*')
135
+ trigger_key_event(char)
136
+
137
+ if delete_char[code]
138
+ line.slice!(-1, 1)
139
+ backspaces -= 1
140
+ else
141
+ line << char
142
+ backspaces = line.size
143
+ end
144
+
145
+ break if (code == CARRIAGE_RETURN || code == NEWLINE)
146
+
147
+ if delete_char[code] && opts[:echo]
148
+ output.print(' ' + (backspaces >= 0 ? "\b" : ''))
128
149
  end
129
150
  end
130
151
  line
@@ -152,18 +173,33 @@ module TTY
152
173
  response
153
174
  end
154
175
 
176
+ # Expose event broadcasting
177
+ #
178
+ # @api public
179
+ def trigger(event, *args)
180
+ publish(event, *args)
181
+ end
182
+
155
183
  # Publish event
156
184
  #
157
- # @param [String] key
185
+ # @param [String] char
158
186
  # the key pressed
159
187
  #
160
188
  # @return [nil]
161
189
  #
162
190
  # @api public
163
- def emit_key_event(key)
164
- event = KeyEvent.from(key)
165
- publish(:"key#{event.key.name}", event) if event.emit?
166
- publish(:keypress, event)
191
+ def trigger_key_event(char)
192
+ event = KeyEvent.from(@console.keys, char)
193
+ trigger(:"key#{event.key.name}", event) if event.trigger?
194
+ trigger(:keypress, event)
195
+ end
196
+
197
+ # Inspect class name and public attributes
198
+ # @return [String]
199
+ #
200
+ # @api public
201
+ def inspect
202
+ "#<#{self.class}: @input=#{input}, @output=#{output}>"
167
203
  end
168
204
 
169
205
  private
@@ -185,6 +221,16 @@ module TTY
185
221
  raise InputInterrupt
186
222
  end
187
223
  end
224
+
225
+ # Check if Windowz mode
226
+ #
227
+ # @return [Boolean]
228
+ #
229
+ # @api public
230
+ def windows?
231
+ return false if env["TTY_TEST"] == true
232
+ ::File::ALT_SEPARATOR == "\\"
233
+ end
188
234
  end # Reader
189
235
  end # Prompt
190
236
  end # TTY
@@ -4,77 +4,117 @@ module TTY
4
4
  class Prompt
5
5
  class Reader
6
6
  module Codes
7
- BACKSPACE = "\x7f"
8
- DELETE = "\004"
9
- ESCAPE = "\e"
10
- LINEFEED = "\n"
11
- RETURN = "\r"
12
- SPACE = " "
13
- TAB = "\t"
7
+ def ctrl_keys
8
+ {
9
+ ctrl_a: ?\C-a,
10
+ ctrl_b: ?\C-b,
11
+ ctrl_c: ?\C-c,
12
+ ctrl_d: ?\C-d,
13
+ ctrl_e: ?\C-e,
14
+ ctrl_f: ?\C-f,
15
+ ctrl_g: ?\C-g,
16
+ ctrl_h: ?\C-h,
17
+ ctrl_i: ?\C-i,
18
+ ctrl_j: ?\C-j,
19
+ ctrl_k: ?\C-k,
20
+ ctrl_l: ?\C-l,
21
+ ctrl_m: ?\C-m,
22
+ ctrl_n: ?\C-n,
23
+ ctrl_o: ?\C-o,
24
+ ctrl_p: ?\C-p,
25
+ ctrl_q: ?\C-q,
26
+ ctrl_r: ?\C-r,
27
+ ctrl_s: ?\C-s,
28
+ ctrl_t: ?\C-t,
29
+ ctrl_u: ?\C-u,
30
+ ctrl_v: ?\C-v,
31
+ ctrl_w: ?\C-w,
32
+ ctrl_x: ?\C-x,
33
+ ctrl_y: ?\C-y,
34
+ ctrl_z: ?\C-z
35
+ }
36
+ end
37
+ module_function :ctrl_keys
14
38
 
15
- KEY_UP = "[A"
16
- KEY_DOWN = "[B"
17
- KEY_RIGHT = "[C"
18
- KEY_LEFT = "[D"
19
- KEY_CLEAR = "[E"
20
- KEY_END = "[F"
21
- KEY_HOME = "[H"
22
- KEY_DELETE = "[3"
39
+ def keys
40
+ {
41
+ tab: "\t",
42
+ enter: "\n",
43
+ return: "\r",
44
+ escape: "\e",
45
+ space: " ",
46
+ backspace: ?\C-?,
47
+ insert: "\e[2~",
48
+ delete: "\e[3~",
49
+ page_up: "\e[5~",
50
+ page_down: "\e[6~",
23
51
 
24
- KEY_UP_XTERM = "OA"
25
- KEY_DOWN_XTERM = "OB"
26
- KEY_RIGHT_XTERM = "OC"
27
- KEY_LEFT_XTERM = "OD"
28
- KEY_CLEAR_XTERM = "OE"
29
- KEY_END_XTERM = "OF"
30
- KEY_HOME_XTERM = "OH"
31
- KEY_DELETE_XTERM = "O3"
52
+ up: "\e[A",
53
+ down: "\e[B",
54
+ right: "\e[C",
55
+ left: "\e[D",
56
+ clear: "\e[E",
57
+ end: "\e[F",
58
+ home: "\e[H",
32
59
 
33
- KEY_UP_SHIFT = "[a"
34
- KEY_DOWN_SHIFT = "[b"
35
- KEY_RIGHT_SHIFT = "[c"
36
- KEY_LEFT_SHIFT = "[d"
37
- KEY_CLEAR_SHIFT = "[e"
60
+ f1_xterm: "\eOP",
61
+ f2_xterm: "\eOQ",
62
+ f3_xterm: "\eOR",
63
+ f4_xterm: "\eOS",
38
64
 
39
- KEY_UP_CTRL = "0a"
40
- KEY_DOWN_CTRL = "0b"
41
- KEY_RIGHT_CTRL = "0c"
42
- KEY_LEFT_CTRL = "0d"
43
- KEY_CLEAR_CTRL = "0e"
65
+ f1: "\e[11~",
66
+ f2: "\e[12~",
67
+ f3: "\e[13~",
68
+ f4: "\e[14~",
69
+ f5: "\e[15~",
70
+ f6: "\e[17~",
71
+ f7: "\e[18~",
72
+ f8: "\e[19~",
73
+ f9: "\e[20~",
74
+ f10: "\e[21~",
75
+ f11: "\e[23~",
76
+ f12: "\e[24~"
77
+ }.merge(ctrl_keys)
78
+ end
79
+ module_function :keys
44
80
 
45
- CTRL_J = "\x0A"
46
- CTRL_N = "\x0E"
47
- CTRL_K = "\x0B"
48
- CTRL_P = "\x10"
49
- SIGINT = "\x03"
50
- CTRL_C = "\x03"
51
- CTRL_H = "\b"
52
- CTRL_L = "\f"
81
+ def win_keys
82
+ {
83
+ tab: "\t",
84
+ enter: "\r",
85
+ return: "\r",
86
+ escape: "\e",
87
+ space: " ",
88
+ backspace: "\b",
89
+ insert: [224, 82].pack('U*'),
90
+ delete: [224, 83].pack('U*'),
91
+ page_up: [224, 73].pack('U*'),
92
+ page_down: [224, 81].pack('U*'),
53
93
 
54
- F1_XTERM = "OP"
55
- F2_XTERM = "OQ"
56
- F3_XTERM = "OR"
57
- F4_XTERM = "OS"
94
+ up: [224, 72].pack('U*'),
95
+ down: [224, 80].pack('U*'),
96
+ right: [224, 77].pack('U*'),
97
+ left: [224, 75].pack('U*'),
98
+ clear: [224, 83].pack('U*'),
99
+ end: [224, 79].pack('U*'),
100
+ home: [224, 71].pack('U*'),
58
101
 
59
- F1_GNOME = "[11~"
60
- F2_GNOME = "[12~"
61
- F3_GNOME = "[13~"
62
- F4_GNOME = "[14~"
102
+ f1: "\x00;",
103
+ f2: "\x00<",
104
+ f3: "\x00",
105
+ f4: "\x00=",
106
+ f5: "\x00?",
107
+ f6: "\x00@",
108
+ f7: "\x00A",
109
+ f8: "\x00B",
110
+ f9: "\x00C",
111
+ f10: "\x00D",
112
+ f11: "\x00\x85",
113
+ f12: "\x00\x86"
114
+ }.merge(ctrl_keys)
115
+ end
116
+ module_function :win_keys
63
117
 
64
- F1_WIN = "[[A"
65
- F2_WIN = "[[B"
66
- F3_WIN = "[[C"
67
- F4_WIN = "[[D"
68
- F5_WIN = "[[E"
69
-
70
- F5 = "[15~"
71
- F6 = "[17~"
72
- F7 = "[18~"
73
- F8 = "[19~"
74
- F9 = "[20~"
75
- F10 = "[21~"
76
- F11 = "[23~"
77
- F12 = "[24~"
78
118
  end # Codes
79
119
  end # Reader
80
120
  end # Prompt