tty-prompt 0.4.0 → 0.5.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.
@@ -11,14 +11,17 @@ module TTY
11
11
  #
12
12
  # @api public
13
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 }
14
+ @prompt = prompt
15
+ @prefix = options.fetch(:prefix) { @prompt.prefix }
16
+ @enum = options.fetch(:enum) { ')' }
17
+ @default = options.fetch(:default) { 1 }
18
+ @active_color = options.fetch(:active_color) { @prompt.active_color }
19
+ @help_color = options.fetch(:help_color) { @prompt.help_color }
20
+ @error_color = options.fetch(:error_color) { @prompt.error_color }
21
+ @done = false
22
+ @failure = false
23
+ @active = @default
24
+ @choices = Choices.new
22
25
 
23
26
  @prompt.subscribe(self)
24
27
  end
@@ -161,7 +164,7 @@ module TTY
161
164
  #
162
165
  # @api private
163
166
  def render_question
164
- header = "#{@prompt.prefix}#{@question} #{render_header}"
167
+ header = "#{@prefix}#{@question} #{render_header}"
165
168
  @prompt.puts(header)
166
169
  return if @done
167
170
  @prompt.print(render_menu)
@@ -172,7 +175,7 @@ module TTY
172
175
  # @api private
173
176
  def render_error
174
177
  error = 'Please enter a valid index'
175
- @prompt.print("\n" + @prompt.decorate('>>', :red) + ' ' + error)
178
+ @prompt.print("\n" + @prompt.decorate('>>', @error_color) + ' ' + error)
176
179
  @prompt.print(@prompt.cursor.prev_line)
177
180
  @prompt.print(@prompt.cursor.forward(render_footer.size))
178
181
  end
@@ -186,7 +189,7 @@ module TTY
186
189
  return '' unless @done
187
190
  return '' unless @active
188
191
  selected_item = "#{@choices[@active - 1].name}"
189
- @prompt.decorate(selected_item, @color)
192
+ @prompt.decorate(selected_item, @active_color)
190
193
  end
191
194
 
192
195
  # Render footer for the indexed menu
@@ -209,7 +212,7 @@ module TTY
209
212
  num = (index + 1).to_s + @enum + Symbols::SPACE
210
213
  selected = Symbols::SPACE * 2 + num + choice.name
211
214
  output << if index + 1 == @active
212
- @prompt.decorate("#{selected}", @color)
215
+ @prompt.decorate("#{selected}", @active_color)
213
216
  else
214
217
  selected
215
218
  end
@@ -0,0 +1,288 @@
1
+ # encoding: utf-8
2
+
3
+ require 'tty/prompt/question'
4
+
5
+ module TTY
6
+ class Prompt
7
+ # A class responsible for rendering expanding options
8
+ # Used by {Prompt} to display key options question.
9
+ #
10
+ # @api private
11
+ class Expander
12
+ HELP_CHOICE = {
13
+ key: 'h',
14
+ name: 'print help',
15
+ value: :help
16
+ }
17
+
18
+ # Create instance of Expander
19
+ #
20
+ # @api public
21
+ def initialize(prompt, options = {})
22
+ @prompt = prompt
23
+ @prefix = options.fetch(:prefix) { @prompt.prefix }
24
+ @default = options.fetch(:default) { 1 }
25
+ @active_color = options.fetch(:active_color) { @prompt.active_color }
26
+ @help_color = options.fetch(:help_color) { @prompt.help_color }
27
+ @choices = Choices.new
28
+ @selected = nil
29
+ @done = false
30
+ @status = :collapsed
31
+ @hint = nil
32
+ @default_key = false
33
+
34
+ @prompt.subscribe(self)
35
+ end
36
+
37
+ def expanded?
38
+ @status == :expanded
39
+ end
40
+
41
+ def collapsed?
42
+ @status == :collapsed
43
+ end
44
+
45
+ def expand
46
+ @status = :expanded
47
+ end
48
+
49
+ # Respond to submit event
50
+ #
51
+ # @api public
52
+ def keyenter(_)
53
+ if @input.nil? || @input.empty?
54
+ @input = @choices[@default - 1].key
55
+ @default_key = true
56
+ end
57
+
58
+ selected = select_choice(@input)
59
+
60
+ if selected && selected.key.to_s == 'h'
61
+ expand
62
+ @selected = nil
63
+ @input = ''
64
+ elsif selected
65
+ @done = true
66
+ @selected = selected
67
+ else
68
+ @input = ''
69
+ end
70
+ end
71
+ alias_method :keyreturn, :keyenter
72
+
73
+ # Respond to key press event
74
+ #
75
+ # @api public
76
+ def keypress(event)
77
+ if [:backspace, :delete].include?(event.key.name)
78
+ @input.chop! unless @input.empty?
79
+ elsif event.value =~ /^[^\e\n\r]/
80
+ @input += event.value
81
+ end
82
+ @selected = select_choice(@input)
83
+ if @selected && !@default_key && collapsed?
84
+ @hint = @selected.name
85
+ end
86
+ end
87
+
88
+ # Select choice by given key
89
+ #
90
+ # @return [Choice]
91
+ #
92
+ # @api private
93
+ def select_choice(key)
94
+ @choices.find_by(:key, key)
95
+ end
96
+
97
+ # Set default value.
98
+ #
99
+ # @api public
100
+ def default(value = (not_set = true))
101
+ return @default if not_set
102
+ @default = value
103
+ end
104
+
105
+ # Add a single choice
106
+ #
107
+ # @api public
108
+ def choice(value, &block)
109
+ if block
110
+ @choices << value.update(value: block)
111
+ else
112
+ @choices << value
113
+ end
114
+ end
115
+
116
+ # Add multiple choices
117
+ #
118
+ # @param [Array[Object]] values
119
+ # the values to add as choices
120
+ #
121
+ # @api public
122
+ def choices(values)
123
+ values.each { |val| choice(val) }
124
+ end
125
+
126
+ # Execute this prompt
127
+ #
128
+ # @api public
129
+ def call(message, possibilities, &block)
130
+ choices(possibilities)
131
+ @message = message
132
+ block.call(self) if block
133
+ setup_defaults
134
+ choice(HELP_CHOICE)
135
+ render
136
+ end
137
+
138
+ private
139
+
140
+ # Create possible keys with current choice highlighted
141
+ #
142
+ # @return [String]
143
+ #
144
+ # @api private
145
+ def possible_keys
146
+ keys = @choices.pluck(:key)
147
+ default_key = keys[@default - 1]
148
+ if @selected
149
+ index = keys.index(@selected.key)
150
+ keys[index] = @prompt.decorate(keys[index], @active_color)
151
+ elsif @input.to_s.empty? && default_key
152
+ keys[@default - 1] = @prompt.decorate(default_key, @active_color)
153
+ end
154
+ keys.join(',')
155
+ end
156
+
157
+ # @api private
158
+ def render
159
+ @input = ''
160
+ until @done
161
+ render_question
162
+ read_input
163
+ refresh
164
+ end
165
+ render_question
166
+ render_answer
167
+ end
168
+
169
+ # @api private
170
+ def render_answer
171
+ @selected.value
172
+ end
173
+
174
+ def render_header
175
+ header = "#{@prefix}#{@message} "
176
+ if @done
177
+ selected_item = "#{@selected.name}"
178
+ header << @prompt.decorate(selected_item, @active_color)
179
+ elsif collapsed?
180
+ header << %[(enter "h" for help) ]
181
+ header << "[#{possible_keys}] "
182
+ header << @input
183
+ end
184
+ header
185
+ end
186
+
187
+ # @api private
188
+ def render_hint
189
+ hint = "\n"
190
+ hint << @prompt.decorate('>> ', @active_color)
191
+ hint << @hint
192
+ @prompt.print(hint)
193
+ @prompt.print(@prompt.cursor.prev_line)
194
+ @prompt.print(@prompt.cursor.forward(@prompt.strip(render_header).size))
195
+ end
196
+
197
+ # @api private
198
+ def render_question
199
+ header = render_header
200
+ @prompt.print(header)
201
+ render_hint if @hint
202
+ @prompt.print("\n") if @done
203
+
204
+ if !@done && expanded?
205
+ @prompt.print(render_menu)
206
+ @prompt.print(render_footer)
207
+ end
208
+ end
209
+
210
+ def render_footer
211
+ " Choice [#{@choices[@default - 1].key}]: #{@input}"
212
+ end
213
+
214
+ def read_input
215
+ @prompt.read_keypress
216
+ end
217
+
218
+ # @api private
219
+ def count_lines
220
+ lines = render_header.scan("\n").length + 1
221
+ if @hint
222
+ lines += @hint.scan("\n").length + 1
223
+ elsif expanded?
224
+ lines += @choices.length
225
+ lines += render_footer.scan("\n").length + 1
226
+ end
227
+ lines
228
+ end
229
+
230
+ # Refresh the current input
231
+ #
232
+ # @api private
233
+ def refresh
234
+ lines = count_lines
235
+ if @hint && (!@selected || @done)
236
+ @hint = nil
237
+ @prompt.print(@prompt.clear_lines(lines, :down))
238
+ @prompt.print(@prompt.cursor.prev_line)
239
+ elsif expanded?
240
+ @prompt.print(@prompt.clear_lines(lines))
241
+ else
242
+ @prompt.print(@prompt.clear_line)
243
+ end
244
+ end
245
+
246
+ # Render help menu
247
+ #
248
+ # @api private
249
+ def render_menu
250
+ output = "\n"
251
+ @choices.each do |choice|
252
+ chosen = %(#{choice.key} - #{choice.name})
253
+ if @selected && @selected.key == choice.key
254
+ chosen = @prompt.decorate(chosen, @active_color)
255
+ end
256
+ output << ' ' + chosen + "\n"
257
+ end
258
+ output
259
+ end
260
+
261
+ def setup_defaults
262
+ validate_choices
263
+ end
264
+
265
+ def validate_choices
266
+ errors = []
267
+ keys = []
268
+ @choices.each do |choice|
269
+ if choice.key.nil?
270
+ errors << "Choice #{choice.name} is missing a :key attribute"
271
+ next
272
+ end
273
+ if choice.key.length != 1
274
+ errors << "Choice key `#{choice.key}` is more than one character long."
275
+ end
276
+ if choice.key.to_s == 'h'
277
+ errors << "Choice key `#{choice.key}` is reserved for help menu."
278
+ end
279
+ if keys.include?(choice.key)
280
+ errors << "Choice key `#{choice.key}` is a duplicate."
281
+ end
282
+ keys << choice.key if choice.key
283
+ end
284
+ errors.each { |err| fail ConfigurationError, err }
285
+ end
286
+ end # Expander
287
+ end # Prompt
288
+ end # TTY
@@ -25,15 +25,17 @@ module TTY
25
25
  # @api public
26
26
  def initialize(prompt, options = {})
27
27
  @prompt = prompt
28
- @first_render = true
29
- @done = false
28
+ @prefix = options.fetch(:prefix) { @prompt.prefix }
30
29
  @enum = options.fetch(:enum) { nil }
31
30
  @default = Array[options.fetch(:default) { 1 }]
32
31
  @active = @default.first
33
32
  @choices = Choices.new
34
- @color = options.fetch(:color) { :green }
33
+ @active_color = options.fetch(:active_color) { @prompt.active_color }
34
+ @help_color = options.fetch(:help_color) { @prompt.help_color }
35
35
  @marker = options.fetch(:marker) { Symbols::ITEM_SELECTED }
36
36
  @help = options[:help]
37
+ @first_render = true
38
+ @done = false
37
39
 
38
40
  @prompt.subscribe(self)
39
41
  end
@@ -143,6 +145,10 @@ module TTY
143
145
  # @api private
144
146
  def validate_defaults
145
147
  @default.each do |d|
148
+ if d.nil? || d.to_s.empty?
149
+ fail ConfigurationError,
150
+ "default index must be an integer in range (1 - #{@choices.size})"
151
+ end
146
152
  if d < 1 || d > @choices.size
147
153
  fail ConfigurationError,
148
154
  "default index `#{d}` out of range (1 - #{@choices.size})"
@@ -193,7 +199,7 @@ module TTY
193
199
  #
194
200
  # @api private
195
201
  def render_question
196
- header = "#{@prompt.prefix}#{@question} #{render_header}"
202
+ header = "#{@prefix}#{@question} #{render_header}"
197
203
  @prompt.puts(header)
198
204
  @first_render = false
199
205
  @prompt.print(render_menu) unless @done
@@ -204,7 +210,7 @@ module TTY
204
210
  # @return [String]
205
211
  def help
206
212
  return @help unless @help.nil?
207
- self.class::HELP % [enumerate? ? ' or number (0-9)' : '']
213
+ self.class::HELP % [enumerate? ? " or number (1-#{@choices.size})" : '']
208
214
  end
209
215
 
210
216
  # Render initial help and selected choice
@@ -215,9 +221,9 @@ module TTY
215
221
  def render_header
216
222
  if @done
217
223
  selected_item = "#{@choices[@active - 1].name}"
218
- @prompt.decorate(selected_item, @color)
224
+ @prompt.decorate(selected_item, @active_color)
219
225
  elsif @first_render
220
- @prompt.decorate(help, :bright_black)
226
+ @prompt.decorate(help, @help_color)
221
227
  end
222
228
  end
223
229
 
@@ -230,7 +236,7 @@ module TTY
230
236
  num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
231
237
  message = if index + 1 == @active
232
238
  selected = @marker + Symbols::SPACE + num + choice.name
233
- @prompt.decorate("#{selected}", @color)
239
+ @prompt.decorate("#{selected}", @active_color)
234
240
  else
235
241
  Symbols::SPACE * 2 + num + choice.name
236
242
  end
@@ -49,11 +49,11 @@ module TTY
49
49
  #
50
50
  # @api private
51
51
  def render_question
52
- header = "#{prompt.prefix}#{message} "
52
+ header = "#{@prefix}#{message} "
53
53
  if echo?
54
54
  masked = "#{@mask * "#{@input}".length}"
55
55
  if @done_masked && !@failure
56
- masked = @prompt.decorate(masked, @color)
56
+ masked = @prompt.decorate(masked, @active_color)
57
57
  end
58
58
  header += masked
59
59
  end
@@ -47,16 +47,26 @@ module TTY
47
47
  @active = @default.last unless @selected.empty?
48
48
  end
49
49
 
50
+ # Generate selected items names
51
+ #
52
+ # @return [String]
53
+ #
54
+ # @api private
55
+ def selected_names
56
+ @selected.map(&:name).join(', ')
57
+ end
58
+
50
59
  # Render initial help text and then currently selected choices
51
60
  #
52
61
  # @api private
53
62
  def render_header
63
+ instructions = @prompt.decorate(help, :bright_black)
54
64
  if @done
55
- @prompt.decorate(@selected.map(&:name).join(', '), :green)
65
+ @prompt.decorate(selected_names, @active_color)
56
66
  elsif @selected.size.nonzero?
57
- @selected.map(&:name).join(', ')
67
+ selected_names + (@first_render ? " #{instructions}" : '')
58
68
  elsif @first_render
59
- @prompt.decorate(help, :bright_black)
69
+ instructions
60
70
  end
61
71
  end
62
72
 
@@ -79,7 +89,7 @@ module TTY
79
89
  indicator = (index + 1 == @active) ? @marker : Symbols::SPACE
80
90
  indicator += Symbols::SPACE
81
91
  message = if @selected.include?(choice)
82
- selected = @prompt.decorate(Symbols::RADIO_CHECKED, :green)
92
+ selected = @prompt.decorate(Symbols::RADIO_CHECKED, @active_color)
83
93
  selected + Symbols::SPACE + num + choice.name
84
94
  else
85
95
  Symbols::RADIO_UNCHECKED + Symbols::SPACE + num + choice.name