tty-prompt 0.11.0 → 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
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