tty-prompt 0.11.0 → 0.12.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +66 -7
  4. data/examples/key_events.rb +11 -0
  5. data/examples/keypress.rb +3 -5
  6. data/examples/multiline.rb +9 -0
  7. data/examples/pause.rb +7 -0
  8. data/lib/tty/prompt.rb +82 -44
  9. data/lib/tty/prompt/confirm_question.rb +20 -36
  10. data/lib/tty/prompt/enum_list.rb +32 -23
  11. data/lib/tty/prompt/expander.rb +35 -31
  12. data/lib/tty/prompt/keypress.rb +91 -0
  13. data/lib/tty/prompt/list.rb +38 -23
  14. data/lib/tty/prompt/mask_question.rb +4 -7
  15. data/lib/tty/prompt/multi_list.rb +3 -1
  16. data/lib/tty/prompt/multiline.rb +71 -0
  17. data/lib/tty/prompt/question.rb +33 -35
  18. data/lib/tty/prompt/reader.rb +154 -38
  19. data/lib/tty/prompt/reader/codes.rb +4 -4
  20. data/lib/tty/prompt/reader/console.rb +1 -1
  21. data/lib/tty/prompt/reader/history.rb +145 -0
  22. data/lib/tty/prompt/reader/key_event.rb +4 -0
  23. data/lib/tty/prompt/reader/line.rb +162 -0
  24. data/lib/tty/prompt/reader/mode.rb +2 -2
  25. data/lib/tty/prompt/reader/win_console.rb +5 -1
  26. data/lib/tty/prompt/slider.rb +18 -12
  27. data/lib/tty/prompt/timeout.rb +48 -0
  28. data/lib/tty/prompt/version.rb +1 -1
  29. data/spec/unit/ask_spec.rb +15 -0
  30. data/spec/unit/converters/convert_bool_spec.rb +1 -0
  31. data/spec/unit/keypress_spec.rb +35 -6
  32. data/spec/unit/multi_select_spec.rb +18 -0
  33. data/spec/unit/multiline_spec.rb +67 -9
  34. data/spec/unit/question/default_spec.rb +1 -0
  35. data/spec/unit/question/echo_spec.rb +8 -0
  36. data/spec/unit/question/in_spec.rb +13 -0
  37. data/spec/unit/question/required_spec.rb +31 -2
  38. data/spec/unit/question/validate_spec.rb +39 -9
  39. data/spec/unit/reader/history_spec.rb +172 -0
  40. data/spec/unit/reader/key_event_spec.rb +12 -8
  41. data/spec/unit/reader/line_spec.rb +110 -0
  42. data/spec/unit/reader/publish_keypress_event_spec.rb +11 -0
  43. data/spec/unit/reader/read_line_spec.rb +32 -2
  44. data/spec/unit/reader/read_multiline_spec.rb +21 -7
  45. data/spec/unit/select_spec.rb +40 -1
  46. data/spec/unit/yes_no_spec.rb +48 -4
  47. metadata +14 -3
  48. data/lib/tty/prompt/history.rb +0 -16
@@ -76,12 +76,14 @@ module TTY
76
76
  # @return [Array[nil,Object]]
77
77
  #
78
78
  # @api private
79
- def render_answer
79
+ def answer
80
80
  @selected.map(&:value)
81
81
  end
82
82
 
83
83
  # Render menu with choices to select from
84
84
  #
85
+ # @return [String]
86
+ #
85
87
  # @api private
86
88
  def render_menu
87
89
  output = ''
@@ -0,0 +1,71 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative 'question'
4
+ require_relative 'symbols'
5
+
6
+ module TTY
7
+ class Prompt
8
+ # A prompt responsible for multi line user input
9
+ #
10
+ # @api private
11
+ class Multiline < Question
12
+ HELP = '(Press CTRL-D or CTRL-Z to finish)'.freeze
13
+
14
+ def initialize(prompt, options = {})
15
+ super
16
+ @help = options[:help] || self.class::HELP
17
+ @first_render = true
18
+ @lines_count = 0
19
+
20
+ @prompt.subscribe(self)
21
+ end
22
+
23
+ # Provide help information
24
+ #
25
+ # @return [String]
26
+ #
27
+ # @api public
28
+ def help(value = (not_set = true))
29
+ return @help if not_set
30
+ @help = value
31
+ end
32
+
33
+ def read_input
34
+ @prompt.read_multiline
35
+ end
36
+
37
+ def keyreturn(*)
38
+ @lines_count += 1
39
+ end
40
+ alias keyenter keyreturn
41
+
42
+ def render_question
43
+ header = "#{@prefix}#{message} "
44
+ if !echo?
45
+ header
46
+ elsif @done
47
+ header += @prompt.decorate("#{@input}", @active_color)
48
+ elsif @first_render
49
+ header += @prompt.decorate(help, @help_color)
50
+ @first_render = false
51
+ end
52
+ header += "\n"
53
+ header
54
+ end
55
+
56
+ def process_input(question)
57
+ @lines = read_input
58
+ @input = "#{@lines.first.strip} ..." unless @lines.first.to_s.empty?
59
+ if Utils.blank?(@input)
60
+ @input = default? ? default : nil
61
+ end
62
+ @evaluator.(@lines)
63
+ end
64
+
65
+ def refresh(lines)
66
+ size = @lines_count + lines + 1
67
+ @prompt.clear_lines(size)
68
+ end
69
+ end # Multiline
70
+ end # Prompt
71
+ end # TTY
@@ -16,7 +16,12 @@ module TTY
16
16
  class Question
17
17
  include Checks
18
18
 
19
- UndefinedSetting = Module.new
19
+ UndefinedSetting = Class.new do
20
+ def to_s
21
+ "undefined"
22
+ end
23
+ alias inspect to_s
24
+ end
20
25
 
21
26
  # Store question message
22
27
  # @api public
@@ -38,7 +43,6 @@ module TTY
38
43
  @in = options.fetch(:in) { UndefinedSetting }
39
44
  @modifier = options.fetch(:modifier) { [] }
40
45
  @validation = options.fetch(:validation) { UndefinedSetting }
41
- @read = options.fetch(:read) { UndefinedSetting }
42
46
  @convert = options.fetch(:convert) { UndefinedSetting }
43
47
  @active_color = options.fetch(:active_color) { @prompt.active_color }
44
48
  @help_color = options.fetch(:help_color) { @prompt.help_color }
@@ -103,22 +107,25 @@ module TTY
103
107
  def render
104
108
  @errors = []
105
109
  until @done
106
- lines = render_question
107
- result = process_input
110
+ question = render_question
111
+ @prompt.print(question)
112
+ result = process_input(question)
108
113
  if result.failure?
109
114
  @errors = result.errors
110
- render_error(result.errors)
115
+ @prompt.print(render_error(result.errors))
111
116
  else
112
117
  @done = true
113
118
  end
114
- refresh(lines)
119
+ @prompt.print(refresh(question.lines.count))
115
120
  end
116
- render_question
121
+ @prompt.print(render_question)
117
122
  convert_result(result.value)
118
123
  end
119
124
 
120
125
  # Render question
121
126
  #
127
+ # @return [String]
128
+ #
122
129
  # @api private
123
130
  def render_question
124
131
  header = "#{@prefix}#{message} "
@@ -129,17 +136,15 @@ module TTY
129
136
  elsif default? && !Utils.blank?(@default)
130
137
  header += @prompt.decorate("(#{default})", @help_color) + ' '
131
138
  end
132
- @prompt.print(header)
133
- @prompt.puts if @done
134
-
135
- header.lines.count + (@done ? 1 : 0)
139
+ header << "\n" if @done
140
+ header
136
141
  end
137
142
 
138
143
  # Decide how to handle input from user
139
144
  #
140
145
  # @api private
141
- def process_input
142
- @input = read_input
146
+ def process_input(question)
147
+ @input = read_input(question)
143
148
  if Utils.blank?(@input)
144
149
  @input = default? ? default : nil
145
150
  end
@@ -149,43 +154,43 @@ module TTY
149
154
  # Process input
150
155
  #
151
156
  # @api private
152
- def read_input
153
- case @read
154
- when :keypress
155
- @prompt.read_keypress
156
- when :multiline
157
- @prompt.read_multiline.each(&:chomp!)
158
- else
159
- @prompt.read_line(echo: echo).chomp
160
- end
157
+ def read_input(question)
158
+ @prompt.read_line(question, echo: echo).chomp
161
159
  end
162
160
 
163
161
  # Handle error condition
164
162
  #
163
+ # @return [String]
164
+ #
165
165
  # @api private
166
166
  def render_error(errors)
167
- errors.each do |err|
167
+ errors.reduce('') do |acc, err|
168
168
  newline = (@echo ? '' : "\n")
169
- @prompt.print(newline + @prompt.decorate('>>', :red) + ' ' + err)
169
+ acc << newline + @prompt.decorate('>>', :red) + ' ' + err
170
+ acc
170
171
  end
171
172
  end
172
173
 
173
174
  # Determine area of the screen to clear
174
175
  #
175
- # @param [Array[String]] errors
176
+ # @param [Integer] lines
177
+ # number of lines to clear
178
+ #
179
+ # @return [String]
176
180
  #
177
181
  # @api private
178
182
  def refresh(lines)
183
+ output = ''
179
184
  if @done
180
185
  if @errors.count.zero? && @echo
181
- @prompt.print(@prompt.cursor.up(lines))
186
+ output << @prompt.cursor.up(lines)
182
187
  else
183
188
  lines += @errors.count
184
189
  end
185
190
  else
186
- @prompt.print(@prompt.cursor.up(lines))
191
+ output << @prompt.cursor.up(lines)
187
192
  end
188
- @prompt.print(@prompt.clear_lines(lines))
193
+ output + @prompt.clear_lines(lines)
189
194
  end
190
195
 
191
196
  # Convert value to expected type
@@ -201,13 +206,6 @@ module TTY
201
206
  end
202
207
  end
203
208
 
204
- # Set reader type
205
- #
206
- # @api public
207
- def read(value)
208
- @read = value
209
- end
210
-
211
209
  # Specify answer conversion
212
210
  #
213
211
  # @api public
@@ -3,6 +3,8 @@
3
3
  require 'wisper'
4
4
  require 'rbconfig'
5
5
 
6
+ require_relative 'reader/history'
7
+ require_relative 'reader/line'
6
8
  require_relative 'reader/key_event'
7
9
  require_relative 'reader/console'
8
10
  require_relative 'reader/win_console'
@@ -29,6 +31,11 @@ module TTY
29
31
 
30
32
  attr_reader :env
31
33
 
34
+ attr_reader :track_history
35
+ alias track_history? track_history
36
+
37
+ attr_reader :console
38
+
32
39
  # Key codes
33
40
  CARRIAGE_RETURN = 13
34
41
  NEWLINE = 10
@@ -37,13 +44,42 @@ module TTY
37
44
 
38
45
  # Initialize a Reader
39
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
+ #
40
57
  # @api public
41
58
  def initialize(input = $stdin, output = $stdout, options = {})
42
59
  @input = input
43
60
  @output = output
44
61
  @interrupt = options.fetch(:interrupt) { :error }
45
62
  @env = options.fetch(:env) { ENV }
46
- @console = windows? ? WinConsole.new(input) : Console.new(input)
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
47
83
  end
48
84
 
49
85
  # Get input in unbuffered mode.
@@ -83,7 +119,7 @@ module TTY
83
119
  char = codes ? codes.pack('U*') : nil
84
120
 
85
121
  trigger_key_event(char) if char
86
- handle_interrupt if char == @console.keys[:ctrl_c]
122
+ handle_interrupt if char == console.keys[:ctrl_c]
87
123
  char
88
124
  end
89
125
  alias read_char read_keypress
@@ -98,7 +134,7 @@ module TTY
98
134
  # @api private
99
135
  def get_codes(options = {}, codes = [])
100
136
  opts = { echo: true, raw: false }.merge(options)
101
- char = @console.get_char(opts)
137
+ char = console.get_char(opts)
102
138
  return if char.nil?
103
139
  codes << char.ord
104
140
 
@@ -108,7 +144,7 @@ module TTY
108
144
  !(64..126).include?(codes.last)
109
145
  }
110
146
 
111
- while @console.escape_codes.any?(&condition)
147
+ while console.escape_codes.any?(&condition)
112
148
  get_codes(options, codes)
113
149
  end
114
150
  codes
@@ -118,60 +154,110 @@ module TTY
118
154
  # back to the shell. The input terminates when enter or
119
155
  # return key is pressed.
120
156
  #
157
+ # @param [String] prompt
158
+ # the prompt to display before input
159
+ #
121
160
  # @param [Boolean] echo
122
161
  # if true echo back characters, output nothing otherwise
123
162
  #
124
163
  # @return [String]
125
164
  #
126
165
  # @api public
127
- def read_line(options = {})
128
- opts = { echo: true, raw: false }.merge(options)
129
- line = ''
130
- backspaces = 0
131
- delete_char = proc { |c| c == BACKSPACE || c == DELETE }
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"
132
173
 
133
- while (codes = get_codes(opts)) && (code = codes[0])
174
+ while (codes = unbufferred { get_codes(opts) }) && (code = codes[0])
134
175
  char = codes.pack('U*')
135
176
  trigger_key_event(char)
136
177
 
137
- if delete_char[code]
138
- line.slice!(-1, 1)
139
- backspaces -= 1
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
140
200
  else
141
- line << char
142
- backspaces = line.size
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
143
216
  end
144
217
 
145
218
  break if (code == CARRIAGE_RETURN || code == NEWLINE)
146
219
 
147
- if delete_char[code] && opts[:echo]
148
- output.print(' ' + (backspaces >= 0 ? "\b" : ''))
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
149
226
  end
150
227
  end
151
- line
228
+ add_to_history(line.to_s.rstrip) if track_history?
229
+ line.to_s
152
230
  end
153
231
 
154
- # Read multiple lines and terminate when empty line is submitted.
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
155
238
  #
156
239
  # @yield [String] line
157
240
  #
158
241
  # @return [Array[String]]
159
242
  #
160
243
  # @api public
161
- def read_multiline
162
- response = []
244
+ def read_multiline(prompt = '')
245
+ @stop = false
246
+ lines = []
163
247
  loop do
164
- line = read_line
248
+ line = read_line(prompt)
165
249
  break if !line || line == ''
166
- next if line !~ /\S/
250
+ next if line !~ /\S/ && !@stop
167
251
  if block_given?
168
- yield(line)
252
+ yield(line) unless line.to_s.empty?
169
253
  else
170
- response << line
254
+ lines << line unless line.to_s.empty?
171
255
  end
256
+ break if @stop
172
257
  end
173
- response
258
+ lines
174
259
  end
260
+ alias read_lines read_multiline
175
261
 
176
262
  # Expose event broadcasting
177
263
  #
@@ -180,18 +266,35 @@ module TTY
180
266
  publish(event, *args)
181
267
  end
182
268
 
183
- # Publish event
269
+ # Capture Ctrl+d and Ctrl+z key events
184
270
  #
185
- # @param [String] char
186
- # the key pressed
187
- #
188
- # @return [nil]
189
- #
190
- # @api public
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)
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
195
298
  end
196
299
 
197
300
  # Inspect class name and public attributes
@@ -204,6 +307,20 @@ module TTY
204
307
 
205
308
  private
206
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
+
207
324
  # Handle input interrupt based on provided value
208
325
  #
209
326
  # @api private
@@ -228,7 +345,6 @@ module TTY
228
345
  #
229
346
  # @api public
230
347
  def windows?
231
- return false if env["TTY_TEST"] == true
232
348
  ::File::ALT_SEPARATOR == "\\"
233
349
  end
234
350
  end # Reader