tty-prompt 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +4 -1
  3. data/CHANGELOG.md +15 -0
  4. data/Gemfile +2 -2
  5. data/README.md +185 -25
  6. data/examples/enum.rb +8 -0
  7. data/examples/enum_select.rb +7 -0
  8. data/examples/in.rb +3 -1
  9. data/examples/slider.rb +6 -0
  10. data/lib/tty-prompt.rb +8 -2
  11. data/lib/tty/prompt.rb +63 -13
  12. data/lib/tty/prompt/converters.rb +12 -6
  13. data/lib/tty/prompt/enum_list.rb +222 -0
  14. data/lib/tty/prompt/list.rb +48 -15
  15. data/lib/tty/prompt/multi_list.rb +11 -11
  16. data/lib/tty/prompt/question.rb +38 -14
  17. data/lib/tty/prompt/question/checks.rb +5 -3
  18. data/lib/tty/prompt/reader.rb +12 -18
  19. data/lib/tty/prompt/reader/codes.rb +15 -9
  20. data/lib/tty/prompt/reader/key_event.rb +51 -24
  21. data/lib/tty/prompt/slider.rb +170 -0
  22. data/lib/tty/prompt/symbols.rb +7 -1
  23. data/lib/tty/prompt/utils.rb +31 -3
  24. data/lib/tty/prompt/version.rb +1 -1
  25. data/spec/spec_helper.rb +1 -0
  26. data/spec/unit/converters/convert_bool_spec.rb +1 -1
  27. data/spec/unit/converters/convert_date_spec.rb +11 -2
  28. data/spec/unit/converters/convert_file_spec.rb +1 -1
  29. data/spec/unit/converters/convert_number_spec.rb +19 -2
  30. data/spec/unit/converters/convert_path_spec.rb +1 -1
  31. data/spec/unit/converters/convert_range_spec.rb +4 -3
  32. data/spec/unit/enum_select_spec.rb +93 -0
  33. data/spec/unit/multi_select_spec.rb +14 -12
  34. data/spec/unit/question/checks_spec.rb +97 -0
  35. data/spec/unit/reader/key_event_spec.rb +67 -0
  36. data/spec/unit/select_spec.rb +15 -16
  37. data/spec/unit/slider_spec.rb +54 -0
  38. data/tty-prompt.gemspec +2 -1
  39. metadata +31 -5
  40. data/.ruby-version +0 -1
@@ -0,0 +1,6 @@
1
+ # encoding: utf-8
2
+
3
+ require 'tty-prompt'
4
+
5
+ prompt = TTY::Prompt.new
6
+ prompt.slider("What size?", min: 32, max: 54, step: 2)
data/lib/tty-prompt.rb CHANGED
@@ -8,12 +8,14 @@ require 'tty-platform'
8
8
  require 'tty/prompt'
9
9
  require 'tty/prompt/choice'
10
10
  require 'tty/prompt/choices'
11
+ require 'tty/prompt/enum_list'
11
12
  require 'tty/prompt/evaluator'
12
13
  require 'tty/prompt/list'
13
14
  require 'tty/prompt/multi_list'
14
15
  require 'tty/prompt/question'
15
16
  require 'tty/prompt/mask_question'
16
17
  require 'tty/prompt/reader'
18
+ require 'tty/prompt/slider'
17
19
  require 'tty/prompt/statement'
18
20
  require 'tty/prompt/suggestion'
19
21
  require 'tty/prompt/symbols'
@@ -24,5 +26,9 @@ require 'tty/prompt/version'
24
26
  # A collection of small libraries for building CLI apps,
25
27
  # each following unix philosophy of focused task
26
28
  module TTY
27
- PromptConfigurationError = Class.new(StandardError)
28
- end
29
+ class Prompt
30
+ ConfigurationError = Class.new(StandardError)
31
+
32
+ ConversionError = Class.new(StandardError)
33
+ end
34
+ end # TTY
data/lib/tty/prompt.rb CHANGED
@@ -44,6 +44,14 @@ module TTY
44
44
 
45
45
  def_delegators :@output, :print, :puts, :flush
46
46
 
47
+ def self.messages
48
+ {
49
+ range?: 'Value %{value} must be within the range %{in}',
50
+ valid?: 'Your answer is invalid (must match %{valid})',
51
+ required?: 'Value must be provided'
52
+ }
53
+ end
54
+
47
55
  # Initialize a Prompt
48
56
  #
49
57
  # @api public
@@ -77,7 +85,7 @@ module TTY
77
85
  # @api public
78
86
  def ask(message, *args, &block)
79
87
  options = Utils.extract_options!(args)
80
-
88
+ options.merge!(self.class.messages)
81
89
  question = Question.new(self, options)
82
90
  question.call(message, &block)
83
91
  end
@@ -143,17 +151,7 @@ module TTY
143
151
  #
144
152
  # @api public
145
153
  def select(question, *args, &block)
146
- options = Utils.extract_options!(args)
147
- choices = if block
148
- []
149
- elsif args.empty?
150
- options
151
- else
152
- args.flatten
153
- end
154
-
155
- list = List.new(self, options)
156
- list.call(question, choices, &block)
154
+ invoke_select(List, question, *args, &block)
157
155
  end
158
156
 
159
157
  # Ask a question with multiple attributes activated
@@ -173,6 +171,40 @@ module TTY
173
171
  #
174
172
  # @api public
175
173
  def multi_select(question, *args, &block)
174
+ invoke_select(MultiList, question, *args, &block)
175
+ end
176
+
177
+ # Ask a question with indexed list
178
+ #
179
+ # @example
180
+ # prompt = TTY::Prompt.new
181
+ # editors = %w(emacs nano vim)
182
+ # prompt.enum_select(EnumList, "Select editor: ", editors)
183
+ #
184
+ # @param [String] question
185
+ # the question to ask
186
+ #
187
+ # @param [Array[Object]] choices
188
+ # the choices to select from
189
+ #
190
+ # @return [String]
191
+ #
192
+ # @api public
193
+ def enum_select(question, *args, &block)
194
+ invoke_select(EnumList, question, *args, &block)
195
+ end
196
+
197
+ # Invoke a list type of prompt
198
+ #
199
+ # @example
200
+ # prompt = TTY::Prompt.new
201
+ # editors = %w(emacs nano vim)
202
+ # prompt.invoke_select(EnumList, "Select editor: ", editors)
203
+ #
204
+ # @return [String]
205
+ #
206
+ # @api public
207
+ def invoke_select(object, question, *args, &block)
176
208
  options = Utils.extract_options!(args)
177
209
  choices = if block
178
210
  []
@@ -182,7 +214,7 @@ module TTY
182
214
  args.flatten
183
215
  end
184
216
 
185
- list = MultiList.new(self, options)
217
+ list = object.new(self, options)
186
218
  list.call(question, choices, &block)
187
219
  end
188
220
 
@@ -203,6 +235,24 @@ module TTY
203
235
  ask(question, *args, &block)
204
236
  end
205
237
 
238
+ # Ask a question with a range slider
239
+ #
240
+ # @example
241
+ # prompt = TTY::Prompt.new
242
+ # prompt.slider('What size?', min: 32, max: 54, step: 2)
243
+ #
244
+ # @param [String] question
245
+ # the question to ask
246
+ #
247
+ # @return [String]
248
+ #
249
+ # @api public
250
+ def slider(question, *args, &block)
251
+ options = Utils.extract_options!(args)
252
+ slider = Slider.new(self, options)
253
+ slider.call(question, &block)
254
+ end
255
+
206
256
  # A shortcut method to ask the user negative question and return
207
257
  # true for 'no' reply.
208
258
  #
@@ -17,9 +17,15 @@ module TTY
17
17
  end
18
18
  end
19
19
 
20
+ def self.on_error
21
+ yield
22
+ rescue Necromancer::ConversionTypeError => e
23
+ raise ConversionError, e.message
24
+ end
25
+
20
26
  converter(:bool) do |input|
21
27
  converter = Necromancer.new
22
- converter.convert(input).to(:boolean, strict: true)
28
+ on_error { converter.convert(input).to(:boolean, strict: true) }
23
29
  end
24
30
 
25
31
  converter(:string) do |input|
@@ -32,27 +38,27 @@ module TTY
32
38
 
33
39
  converter(:date) do |input|
34
40
  converter = Necromancer.new
35
- converter.convert(input).to(:date)
41
+ on_error { converter.convert(input).to(:date, strict: true) }
36
42
  end
37
43
 
38
44
  converter(:datetime) do |input|
39
45
  converter = Necromancer.new
40
- converter.convert(input).to(:datetime)
46
+ on_error { converter.convert(input).to(:datetime, strict: true) }
41
47
  end
42
48
 
43
49
  converter(:int) do |input|
44
50
  converter = Necromancer.new
45
- converter.convert(input).to(:integer)
51
+ on_error { converter.convert(input).to(:integer, strict: true) }
46
52
  end
47
53
 
48
54
  converter(:float) do |input|
49
55
  converter = Necromancer.new
50
- converter.convert(input).to(:float)
56
+ on_error { converter.convert(input).to(:float, strict: true) }
51
57
  end
52
58
 
53
59
  converter(:range) do |input|
54
60
  converter = Necromancer.new
55
- converter.convert(input).to(:range, strict: true)
61
+ on_error { converter.convert(input).to(:range, strict: true) }
56
62
  end
57
63
 
58
64
  converter(:regexp) do |input|
@@ -0,0 +1,222 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ class Prompt
5
+ # A class reponsible for rendering enumerated list menu.
6
+ # Used by {Prompt} to display static choice menu.
7
+ #
8
+ # @api private
9
+ class EnumList
10
+ # Create instance of EnumList menu.
11
+ #
12
+ # @api public
13
+ def initialize(prompt, options = {})
14
+ @prompt = prompt
15
+ @done = false
16
+ @failure = false
17
+ @enum = options.fetch(:enum) { ')' }
18
+ @default = options.fetch(:default) { 1 }
19
+ @active = @default
20
+ @choices = Choices.new
21
+ @color = options.fetch(:color) { :green }
22
+
23
+ @prompt.subscribe(self)
24
+ end
25
+
26
+ # Set default option selected
27
+ #
28
+ # @api public
29
+ def default(default)
30
+ @default = default
31
+ end
32
+
33
+ # Set selecting active index using number pad
34
+ #
35
+ # @api public
36
+ def enum(value)
37
+ @enum = value
38
+ end
39
+
40
+ # Add a single choice
41
+ #
42
+ # @api public
43
+ def choice(*value, &block)
44
+ if block
45
+ @choices << (value << block)
46
+ else
47
+ @choices << value
48
+ end
49
+ end
50
+
51
+ # Add multiple choices
52
+ #
53
+ # @param [Array[Object]] values
54
+ # the values to add as choices
55
+ #
56
+ # @api public
57
+ def choices(values)
58
+ values.each { |val| choice(*val) }
59
+ end
60
+
61
+ # Call the list menu by passing question and choices
62
+ #
63
+ # @param [String] question
64
+ #
65
+ # @param
66
+ # @api public
67
+ def call(question, possibilities, &block)
68
+ choices(possibilities)
69
+ @question = question
70
+ block.call(self) if block
71
+ setup_defaults
72
+ render
73
+ end
74
+
75
+ def keypress(event)
76
+ if [:backspace, :delete].include?(event.key.name)
77
+ @input.chop! unless @input.empty?
78
+ mark_choice_as_active
79
+ elsif event.value =~ /^\d+$/
80
+ @input += event.value
81
+ mark_choice_as_active
82
+ end
83
+ end
84
+
85
+ def keyreturn(*)
86
+ @failure = false
87
+ if (@input.to_i > 0 && @input.to_i <= @choices.size) || @input.empty?
88
+ @done = true
89
+ else
90
+ @input = ''
91
+ @failure = true
92
+ end
93
+ end
94
+ alias_method :keyenter, :keyreturn
95
+
96
+ private
97
+
98
+ def mark_choice_as_active
99
+ if !@choices[@input.to_i - 1].nil?
100
+ @active = @input.to_i
101
+ else
102
+ @active = nil
103
+ end
104
+ end
105
+
106
+ # Setup default option and active selection
107
+ #
108
+ # @api private
109
+ def setup_defaults
110
+ validate_defaults
111
+ @active = @default
112
+ end
113
+
114
+ # Validate default indexes to be within range
115
+ #
116
+ # @api private
117
+ def validate_defaults
118
+ return if @default >= 1 && @default <= @choices.size
119
+ fail PromptConfigurationError,
120
+ "default index `#{d}` out of range (1 - #{@choices.size})"
121
+ end
122
+
123
+ # Render a selection list.
124
+ #
125
+ # By default the result is printed out.
126
+ #
127
+ # @return [Object] value
128
+ # return the selected value
129
+ #
130
+ # @api private
131
+ def render
132
+ @input = ''
133
+ until @done
134
+ render_question
135
+ @prompt.read_keypress
136
+ refresh
137
+ end
138
+ render_question
139
+ render_answer
140
+ end
141
+
142
+ # Find value for the choice selected
143
+ #
144
+ # @return [nil, Object]
145
+ #
146
+ # @api private
147
+ def render_answer
148
+ @choices[@active - 1].value
149
+ end
150
+
151
+ # Determine area of the screen to clear
152
+ #
153
+ # @api private
154
+ def refresh
155
+ lines = @question.scan("\n").length + @choices.length + 2
156
+ @prompt.print(@prompt.clear_lines(lines))
157
+ @prompt.print(@prompt.cursor.clear_screen_down)
158
+ end
159
+
160
+ # Render question with the menu options
161
+ #
162
+ # @api private
163
+ def render_question
164
+ header = "#{@prompt.prefix}#{@question} #{render_header}"
165
+ @prompt.puts(header)
166
+ return if @done
167
+ @prompt.print(render_menu)
168
+ @prompt.print(render_footer)
169
+ render_error if @failure
170
+ end
171
+
172
+ # @api private
173
+ def render_error
174
+ error = 'Please enter a valid index'
175
+ @prompt.print("\n" + @prompt.decorate('>>', :red) + ' ' + error)
176
+ @prompt.print(@prompt.cursor.prev_line)
177
+ @prompt.print(@prompt.cursor.forward(render_footer.size))
178
+ end
179
+
180
+ # Render chosen option
181
+ #
182
+ # @return [String]
183
+ #
184
+ # @api private
185
+ def render_header
186
+ return '' unless @done
187
+ return '' unless @active
188
+ selected_item = "#{@choices[@active - 1].name}"
189
+ @prompt.decorate(selected_item, @color)
190
+ end
191
+
192
+ # Render footer for the indexed menu
193
+ #
194
+ # @return [String]
195
+ #
196
+ # @api private
197
+ def render_footer
198
+ " Choose 1-#{@choices.size} [#{@default}]: #{@input}"
199
+ end
200
+
201
+ # Render menu with indexed choices to select from
202
+ #
203
+ # @return [String]
204
+ #
205
+ # @api private
206
+ def render_menu
207
+ output = ''
208
+ @choices.each_with_index do |choice, index|
209
+ num = (index + 1).to_s + @enum + Symbols::SPACE
210
+ selected = Symbols::SPACE * 2 + num + choice.name
211
+ output << if index + 1 == @active
212
+ @prompt.decorate("#{selected}", @color)
213
+ else
214
+ selected
215
+ end
216
+ output << "\n"
217
+ end
218
+ output
219
+ end
220
+ end # EnumList
221
+ end # Prompt
222
+ end # TTY
@@ -7,7 +7,7 @@ module TTY
7
7
  #
8
8
  # @api private
9
9
  class List
10
- HELP = '(Use arrow keys, press Enter to select)'.freeze
10
+ HELP = '(Use arrow%s keys, press Enter to select)'.freeze
11
11
 
12
12
  # Create instance of TTY::Prompt::List menu.
13
13
  #
@@ -19,18 +19,21 @@ module TTY
19
19
  # the color for the selected item, defualts to :green
20
20
  # @option options [Symbol] :marker
21
21
  # the marker for the selected item
22
+ # @option options [String] :enum
23
+ # the delimiter for the item index
22
24
  #
23
25
  # @api public
24
26
  def initialize(prompt, options = {})
25
27
  @prompt = prompt
26
28
  @first_render = true
27
29
  @done = false
30
+ @enum = options.fetch(:enum) { nil }
28
31
  @default = Array[options.fetch(:default) { 1 }]
29
32
  @active = @default.first
30
33
  @choices = Choices.new
31
34
  @color = options.fetch(:color) { :green }
32
35
  @marker = options.fetch(:marker) { Symbols::ITEM_SELECTED }
33
- @help = options.fetch(:help) { HELP }
36
+ @help = options[:help]
34
37
 
35
38
  @prompt.subscribe(self)
36
39
  end
@@ -49,6 +52,13 @@ module TTY
49
52
  @default = default_values
50
53
  end
51
54
 
55
+ # Set selecting active index using number pad
56
+ #
57
+ # @api public
58
+ def enum(value)
59
+ @enum = value
60
+ end
61
+
52
62
  # Add a single choice
53
63
  #
54
64
  # @api public
@@ -84,23 +94,37 @@ module TTY
84
94
  render
85
95
  end
86
96
 
87
- def keyescape(event)
97
+ # Check if list is enumerated
98
+ #
99
+ # @return [Boolean]
100
+ def enumerate?
101
+ !@enum.nil?
102
+ end
103
+
104
+ def keynum(event)
105
+ return unless enumerate?
106
+ value = event.value.to_i
107
+ return unless (1..@choices.count).include?(value)
108
+ @active = value
109
+ end
110
+
111
+ def keyescape(*)
88
112
  exit 130
89
113
  end
90
114
 
91
- def keyspace(event)
115
+ def keyspace(*)
92
116
  @done = true
93
117
  end
94
118
 
95
- def keyreturn(event)
119
+ def keyreturn(*)
96
120
  @done = true
97
121
  end
98
122
 
99
- def keyup(event)
123
+ def keyup(*)
100
124
  @active = (@active == 1) ? @choices.length : @active - 1
101
125
  end
102
126
 
103
- def keydown(event)
127
+ def keydown(*)
104
128
  @active = (@active == @choices.length) ? 1 : @active + 1
105
129
  end
106
130
 
@@ -120,7 +144,7 @@ module TTY
120
144
  def validate_defaults
121
145
  @default.each do |d|
122
146
  if d < 1 || d > @choices.size
123
- fail PromptConfigurationError,
147
+ fail ConfigurationError,
124
148
  "default index `#{d}` out of range (1 - #{@choices.size})"
125
149
  end
126
150
  end
@@ -172,7 +196,15 @@ module TTY
172
196
  header = "#{@prompt.prefix}#{@question} #{render_header}"
173
197
  @prompt.puts(header)
174
198
  @first_render = false
175
- render_menu unless @done
199
+ @prompt.print(render_menu) unless @done
200
+ end
201
+
202
+ # Provide help information
203
+ #
204
+ # @return [String]
205
+ def help
206
+ return @help unless @help.nil?
207
+ self.class::HELP % [enumerate? ? ' or number (0-9)' : '']
176
208
  end
177
209
 
178
210
  # Render initial help and selected choice
@@ -185,9 +217,7 @@ module TTY
185
217
  selected_item = "#{@choices[@active - 1].name}"
186
218
  @prompt.decorate(selected_item, @color)
187
219
  elsif @first_render
188
- @prompt.decorate(@help, :bright_black)
189
- else
190
- ''
220
+ @prompt.decorate(help, :bright_black)
191
221
  end
192
222
  end
193
223
 
@@ -195,16 +225,19 @@ module TTY
195
225
  #
196
226
  # @api private
197
227
  def render_menu
228
+ output = ''
198
229
  @choices.each_with_index do |choice, index|
230
+ num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
199
231
  message = if index + 1 == @active
200
- selected = @marker + Symbols::SPACE + choice.name
232
+ selected = @marker + Symbols::SPACE + num + choice.name
201
233
  @prompt.decorate("#{selected}", @color)
202
234
  else
203
- Symbols::SPACE * 2 + choice.name
235
+ Symbols::SPACE * 2 + num + choice.name
204
236
  end
205
237
  newline = (index == @choices.length - 1) ? '' : "\n"
206
- @prompt.print(message + newline)
238
+ output << (message + newline)
207
239
  end
240
+ output
208
241
  end
209
242
  end # List
210
243
  end # Prompt