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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +66 -7
- data/examples/key_events.rb +11 -0
- data/examples/keypress.rb +3 -5
- data/examples/multiline.rb +9 -0
- data/examples/pause.rb +7 -0
- data/lib/tty/prompt.rb +82 -44
- data/lib/tty/prompt/confirm_question.rb +20 -36
- data/lib/tty/prompt/enum_list.rb +32 -23
- data/lib/tty/prompt/expander.rb +35 -31
- data/lib/tty/prompt/keypress.rb +91 -0
- data/lib/tty/prompt/list.rb +38 -23
- data/lib/tty/prompt/mask_question.rb +4 -7
- data/lib/tty/prompt/multi_list.rb +3 -1
- data/lib/tty/prompt/multiline.rb +71 -0
- data/lib/tty/prompt/question.rb +33 -35
- data/lib/tty/prompt/reader.rb +154 -38
- data/lib/tty/prompt/reader/codes.rb +4 -4
- data/lib/tty/prompt/reader/console.rb +1 -1
- data/lib/tty/prompt/reader/history.rb +145 -0
- data/lib/tty/prompt/reader/key_event.rb +4 -0
- data/lib/tty/prompt/reader/line.rb +162 -0
- data/lib/tty/prompt/reader/mode.rb +2 -2
- data/lib/tty/prompt/reader/win_console.rb +5 -1
- data/lib/tty/prompt/slider.rb +18 -12
- data/lib/tty/prompt/timeout.rb +48 -0
- data/lib/tty/prompt/version.rb +1 -1
- data/spec/unit/ask_spec.rb +15 -0
- data/spec/unit/converters/convert_bool_spec.rb +1 -0
- data/spec/unit/keypress_spec.rb +35 -6
- data/spec/unit/multi_select_spec.rb +18 -0
- data/spec/unit/multiline_spec.rb +67 -9
- data/spec/unit/question/default_spec.rb +1 -0
- data/spec/unit/question/echo_spec.rb +8 -0
- data/spec/unit/question/in_spec.rb +13 -0
- data/spec/unit/question/required_spec.rb +31 -2
- data/spec/unit/question/validate_spec.rb +39 -9
- data/spec/unit/reader/history_spec.rb +172 -0
- data/spec/unit/reader/key_event_spec.rb +12 -8
- data/spec/unit/reader/line_spec.rb +110 -0
- data/spec/unit/reader/publish_keypress_event_spec.rb +11 -0
- data/spec/unit/reader/read_line_spec.rb +32 -2
- data/spec/unit/reader/read_multiline_spec.rb +21 -7
- data/spec/unit/select_spec.rb +40 -1
- data/spec/unit/yes_no_spec.rb +48 -4
- metadata +14 -3
- 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
|
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
|
data/lib/tty/prompt/question.rb
CHANGED
@@ -16,7 +16,12 @@ module TTY
|
|
16
16
|
class Question
|
17
17
|
include Checks
|
18
18
|
|
19
|
-
UndefinedSetting =
|
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
|
-
|
107
|
-
|
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
|
-
@
|
133
|
-
|
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
|
-
|
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.
|
167
|
+
errors.reduce('') do |acc, err|
|
168
168
|
newline = (@echo ? '' : "\n")
|
169
|
-
|
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 [
|
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.
|
186
|
+
output << @prompt.cursor.up(lines)
|
182
187
|
else
|
183
188
|
lines += @errors.count
|
184
189
|
end
|
185
190
|
else
|
186
|
-
@prompt.
|
191
|
+
output << @prompt.cursor.up(lines)
|
187
192
|
end
|
188
|
-
@prompt.
|
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
|
data/lib/tty/prompt/reader.rb
CHANGED
@@ -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
|
-
@
|
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 ==
|
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 =
|
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
|
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(
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
138
|
-
line.
|
139
|
-
|
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
|
-
|
142
|
-
|
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
|
148
|
-
|
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
|
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
|
-
|
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
|
-
|
254
|
+
lines << line unless line.to_s.empty?
|
171
255
|
end
|
256
|
+
break if @stop
|
172
257
|
end
|
173
|
-
|
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
|
-
#
|
269
|
+
# Capture Ctrl+d and Ctrl+z key events
|
184
270
|
#
|
185
|
-
# @
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
def
|
192
|
-
|
193
|
-
|
194
|
-
|
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
|