tty-prompt 0.3.0 → 0.4.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 (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