tty-prompt 0.10.1 → 0.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -1
  3. data/CHANGELOG.md +30 -0
  4. data/README.md +39 -9
  5. data/examples/echo.rb +5 -1
  6. data/examples/inputs.rb +10 -0
  7. data/examples/mask.rb +6 -2
  8. data/examples/multi_select.rb +1 -1
  9. data/examples/multi_select_paged.rb +9 -0
  10. data/examples/select.rb +5 -5
  11. data/examples/slider.rb +1 -1
  12. data/lib/tty-prompt.rb +2 -36
  13. data/lib/tty/prompt.rb +49 -8
  14. data/lib/tty/prompt/choices.rb +2 -0
  15. data/lib/tty/prompt/confirm_question.rb +6 -1
  16. data/lib/tty/prompt/converter_dsl.rb +9 -6
  17. data/lib/tty/prompt/converter_registry.rb +27 -19
  18. data/lib/tty/prompt/converters.rb +16 -22
  19. data/lib/tty/prompt/enum_list.rb +8 -4
  20. data/lib/tty/prompt/enum_paginator.rb +2 -0
  21. data/lib/tty/prompt/evaluator.rb +1 -1
  22. data/lib/tty/prompt/expander.rb +1 -1
  23. data/lib/tty/prompt/list.rb +21 -11
  24. data/lib/tty/prompt/mask_question.rb +15 -6
  25. data/lib/tty/prompt/multi_list.rb +12 -10
  26. data/lib/tty/prompt/question.rb +38 -36
  27. data/lib/tty/prompt/question/modifier.rb +2 -0
  28. data/lib/tty/prompt/question/validation.rb +5 -4
  29. data/lib/tty/prompt/reader.rb +104 -58
  30. data/lib/tty/prompt/reader/codes.rb +103 -63
  31. data/lib/tty/prompt/reader/console.rb +57 -0
  32. data/lib/tty/prompt/reader/key_event.rb +51 -88
  33. data/lib/tty/prompt/reader/mode.rb +5 -5
  34. data/lib/tty/prompt/reader/win_api.rb +29 -0
  35. data/lib/tty/prompt/reader/win_console.rb +49 -0
  36. data/lib/tty/prompt/slider.rb +10 -6
  37. data/lib/tty/prompt/suggestion.rb +1 -1
  38. data/lib/tty/prompt/symbols.rb +52 -10
  39. data/lib/tty/prompt/version.rb +1 -1
  40. data/lib/tty/{prompt/test.rb → test_prompt.rb} +2 -1
  41. data/spec/unit/ask_spec.rb +8 -16
  42. data/spec/unit/converters/convert_bool_spec.rb +1 -2
  43. data/spec/unit/converters/on_error_spec.rb +9 -0
  44. data/spec/unit/enum_paginator_spec.rb +16 -0
  45. data/spec/unit/enum_select_spec.rb +69 -25
  46. data/spec/unit/expand_spec.rb +14 -14
  47. data/spec/unit/mask_spec.rb +66 -29
  48. data/spec/unit/multi_select_spec.rb +120 -74
  49. data/spec/unit/new_spec.rb +5 -3
  50. data/spec/unit/paginator_spec.rb +16 -0
  51. data/spec/unit/question/default_spec.rb +2 -4
  52. data/spec/unit/question/echo_spec.rb +2 -3
  53. data/spec/unit/question/in_spec.rb +9 -14
  54. data/spec/unit/question/modifier/letter_case_spec.rb +32 -11
  55. data/spec/unit/question/modifier/whitespace_spec.rb +41 -15
  56. data/spec/unit/question/required_spec.rb +9 -13
  57. data/spec/unit/question/validate_spec.rb +7 -10
  58. data/spec/unit/reader/key_event_spec.rb +36 -50
  59. data/spec/unit/reader/publish_keypress_event_spec.rb +5 -3
  60. data/spec/unit/reader/read_keypress_spec.rb +8 -7
  61. data/spec/unit/reader/read_line_spec.rb +9 -9
  62. data/spec/unit/reader/read_multiline_spec.rb +8 -7
  63. data/spec/unit/select_spec.rb +85 -25
  64. data/spec/unit/slider_spec.rb +43 -16
  65. data/spec/unit/yes_no_spec.rb +14 -28
  66. data/tasks/console.rake +1 -0
  67. data/tty-prompt.gemspec +2 -2
  68. metadata +14 -7
@@ -2,27 +2,34 @@
2
2
 
3
3
  module TTY
4
4
  class Prompt
5
+ # Immutable collection of converters for type transformation
6
+ #
7
+ # @api private
5
8
  class ConverterRegistry
6
- def initialize
7
- @_registry = {}
9
+ # Create a registry of conversions
10
+ #
11
+ # @param [Hash] registry
12
+ #
13
+ # @api private
14
+ def initialize(registry = {})
15
+ @_registry = registry.dup.freeze
16
+ freeze
8
17
  end
9
18
 
10
19
  # Register converter
11
20
  #
21
+ # @param [Symbol] name
22
+ # the converter name
23
+ #
12
24
  # @api public
13
- def register(key, contents = nil, &block)
14
- if block_given?
15
- item = block
16
- else
17
- item = contents
18
- end
25
+ def register(name, contents = nil, &block)
26
+ item = block_given? ? block : contents
19
27
 
20
- if key?(key)
21
- fail ArgumentError, "Converter for #{key.inspect} already registered"
22
- else
23
- @_registry[key] = item
28
+ if key?(name)
29
+ raise ArgumentError,
30
+ "Converter for #{name.inspect} already registered"
24
31
  end
25
- self
32
+ self.class.new(@_registry.merge(name => item))
26
33
  end
27
34
 
28
35
  # Check if converter is registered
@@ -37,16 +44,17 @@ module TTY
37
44
  # Execute converter
38
45
  #
39
46
  # @api public
40
- def call(key, input)
41
- if key.respond_to?(:call)
42
- converter = key
47
+ def call(name, input)
48
+ if name.respond_to?(:call)
49
+ converter = name
43
50
  else
44
- converter = @_registry.fetch(key) do
45
- fail ArgumentError, "#{key.inspect} is not registered"
51
+ converter = @_registry.fetch(name) do
52
+ raise ArgumentError, "#{name.inspect} is not registered"
46
53
  end
47
54
  end
48
- converter.call(input)
55
+ converter[input]
49
56
  end
57
+ alias [] call
50
58
 
51
59
  def inspect
52
60
  @_registry.inspect
@@ -2,30 +2,29 @@
2
2
 
3
3
  require 'pathname'
4
4
  require 'necromancer'
5
- require 'tty/prompt/converter_dsl'
5
+
6
+ require_relative 'converter_dsl'
6
7
 
7
8
  module TTY
8
9
  class Prompt
9
10
  module Converters
10
11
  extend ConverterDSL
11
12
 
12
- def self.included(base)
13
- base.class_eval do
14
- def converter_registry
15
- Converters.converter_registry
16
- end
17
- end
18
- end
19
-
13
+ # Delegate Necromancer errors
14
+ #
15
+ # @api private
20
16
  def self.on_error
21
- yield
17
+ if block_given?
18
+ yield
19
+ else
20
+ raise ArgumentError, 'You need to provide a block argument.'
21
+ end
22
22
  rescue Necromancer::ConversionTypeError => e
23
23
  raise ConversionError, e.message
24
24
  end
25
25
 
26
26
  converter(:bool) do |input|
27
- converter = Necromancer.new
28
- on_error { converter.convert(input).to(:boolean, strict: true) }
27
+ on_error { Necromancer.convert(input).to(:boolean, strict: true) }
29
28
  end
30
29
 
31
30
  converter(:string) do |input|
@@ -37,28 +36,23 @@ module TTY
37
36
  end
38
37
 
39
38
  converter(:date) do |input|
40
- converter = Necromancer.new
41
- on_error { converter.convert(input).to(:date, strict: true) }
39
+ on_error { Necromancer.convert(input).to(:date, strict: true) }
42
40
  end
43
41
 
44
42
  converter(:datetime) do |input|
45
- converter = Necromancer.new
46
- on_error { converter.convert(input).to(:datetime, strict: true) }
43
+ on_error { Necromancer.convert(input).to(:datetime, strict: true) }
47
44
  end
48
45
 
49
46
  converter(:int) do |input|
50
- converter = Necromancer.new
51
- on_error { converter.convert(input).to(:integer, strict: true) }
47
+ on_error { Necromancer.convert(input).to(:integer, strict: true) }
52
48
  end
53
49
 
54
50
  converter(:float) do |input|
55
- converter = Necromancer.new
56
- on_error { converter.convert(input).to(:float, strict: true) }
51
+ on_error { Necromancer.convert(input).to(:float, strict: true) }
57
52
  end
58
53
 
59
54
  converter(:range) do |input|
60
- converter = Necromancer.new
61
- on_error { converter.convert(input).to(:range, strict: true) }
55
+ on_error { Necromancer.convert(input).to(:range, strict: true) }
62
56
  end
63
57
 
64
58
  converter(:regexp) do |input|
@@ -1,5 +1,9 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require_relative 'choices'
4
+ require_relative 'enum_paginator'
5
+ require_relative 'paginator'
6
+
3
7
  module TTY
4
8
  class Prompt
5
9
  # A class reponsible for rendering enumerated list menu.
@@ -58,7 +62,7 @@ module TTY
58
62
  #
59
63
  # @api private
60
64
  def paginated?
61
- @choices.size >= page_size
65
+ @choices.size > page_size
62
66
  end
63
67
 
64
68
  # @param [String] text
@@ -157,7 +161,7 @@ module TTY
157
161
  #
158
162
  # @api private
159
163
  def mark_choice_as_active
160
- if (@input.to_i > 1) && !@choices[@input.to_i - 1].nil?
164
+ if (@input.to_i > 0) && !@choices[@input.to_i - 1].nil?
161
165
  @active = @input.to_i
162
166
  else
163
167
  @active = @default
@@ -308,8 +312,8 @@ module TTY
308
312
  def render_menu
309
313
  output = ''
310
314
  @paginator.paginate(@choices, @page_active, @per_page) do |choice, index|
311
- num = (index + 1).to_s + @enum + Symbols::SPACE
312
- selected = Symbols::SPACE * 2 + num + choice.name
315
+ num = (index + 1).to_s + @enum + ' '
316
+ selected = ' ' * 2 + num + choice.name
313
317
  output << if index + 1 == @active
314
318
  @prompt.decorate(selected.to_s, @active_color)
315
319
  else
@@ -1,5 +1,7 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require_relative 'paginator'
4
+
3
5
  module TTY
4
6
  class Prompt
5
7
  class EnumPaginator < Paginator
@@ -1,6 +1,6 @@
1
1
  # encoding: utf-8
2
2
 
3
- require 'tty/prompt/result'
3
+ require_relative 'result'
4
4
 
5
5
  module TTY
6
6
  class Prompt
@@ -1,6 +1,6 @@
1
1
  # encoding: utf-8
2
2
 
3
- require 'tty/prompt/question'
3
+ require_relative 'choices'
4
4
 
5
5
  module TTY
6
6
  class Prompt
@@ -1,5 +1,9 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require_relative 'choices'
4
+ require_relative 'paginator'
5
+ require_relative 'symbols'
6
+
3
7
  module TTY
4
8
  class Prompt
5
9
  # A class responsible for rendering select list menu
@@ -7,6 +11,8 @@ module TTY
7
11
  #
8
12
  # @api private
9
13
  class List
14
+ include Symbols
15
+
10
16
  HELP = '(Use arrow%s keys, press Enter to select)'
11
17
 
12
18
  PAGE_HELP = '(Move up or down to reveal more choices)'
@@ -34,7 +40,7 @@ module TTY
34
40
  @choices = Choices.new
35
41
  @active_color = options.fetch(:active_color) { @prompt.active_color }
36
42
  @help_color = options.fetch(:help_color) { @prompt.help_color }
37
- @marker = options.fetch(:marker) { Symbols::ITEM_SELECTED }
43
+ @marker = options.fetch(:marker) { symbols[:pointer] }
38
44
  @help = options[:help]
39
45
  @first_render = true
40
46
  @done = false
@@ -66,13 +72,17 @@ module TTY
66
72
  @per_page = value
67
73
  end
68
74
 
75
+ def page_size
76
+ (@per_page || Paginator::DEFAULT_PAGE_SIZE)
77
+ end
78
+
69
79
  # Check if list is paginated
70
80
  #
71
81
  # @return [Boolean]
72
82
  #
73
83
  # @api private
74
84
  def paginated?
75
- @choices.size >= (@per_page || Paginator::DEFAULT_PAGE_SIZE)
85
+ @choices.size > page_size
76
86
  end
77
87
 
78
88
  # @param [String] text
@@ -134,7 +144,7 @@ module TTY
134
144
  def keynum(event)
135
145
  return unless enumerate?
136
146
  value = event.value.to_i
137
- return unless (1..@choices.count).include?(value)
147
+ return unless (1..@choices.count).cover?(value)
138
148
  @active = value
139
149
  end
140
150
 
@@ -171,11 +181,11 @@ module TTY
171
181
  def validate_defaults
172
182
  @default.each do |d|
173
183
  if d.nil? || d.to_s.empty?
174
- fail ConfigurationError,
184
+ raise ConfigurationError,
175
185
  "default index must be an integer in range (1 - #{@choices.size})"
176
186
  end
177
187
  if d < 1 || d > @choices.size
178
- fail ConfigurationError,
188
+ raise ConfigurationError,
179
189
  "default index `#{d}` out of range (1 - #{@choices.size})"
180
190
  end
181
191
  end
@@ -197,10 +207,9 @@ module TTY
197
207
  refresh(lines)
198
208
  end
199
209
  render_question
200
- answer = render_answer
210
+ render_answer
201
211
  ensure
202
212
  @prompt.print(@prompt.show)
203
- answer
204
213
  end
205
214
 
206
215
  # Find value for the choice selected
@@ -266,14 +275,15 @@ module TTY
266
275
  def render_menu
267
276
  output = ''
268
277
  @paginator.paginate(@choices, @active, @per_page) do |choice, index|
269
- num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
278
+ num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
270
279
  message = if index + 1 == @active
271
- selected = @marker + Symbols::SPACE + num + choice.name
280
+ selected = @marker + ' ' + num + choice.name
272
281
  @prompt.decorate("#{selected}", @active_color)
273
282
  else
274
- Symbols::SPACE * 2 + num + choice.name
283
+ ' ' * 2 + num + choice.name
275
284
  end
276
- newline = (index == @paginator.max_index) ? '' : "\n"
285
+ max_index = paginated? ? @paginator.max_index : @choices.size - 1
286
+ newline = (index == max_index) ? '' : "\n"
277
287
  output << (message + newline)
278
288
  end
279
289
  output
@@ -1,5 +1,8 @@
1
1
  # encoding: utf-8
2
2
 
3
+ require_relative 'question'
4
+ require_relative 'symbols'
5
+
3
6
  module TTY
4
7
  class Prompt
5
8
  class MaskQuestion < Question
@@ -11,7 +14,7 @@ module TTY
11
14
  # @api public
12
15
  def initialize(prompt, options = {})
13
16
  super
14
- @mask = options.fetch(:mask) { Symbols::ITEM_SECURE }
17
+ @mask = options.fetch(:mask) { Symbols.symbols[:dot] }
15
18
  @done_masked = false
16
19
  @failure = false
17
20
  @prompt.subscribe(self)
@@ -54,15 +57,19 @@ module TTY
54
57
  masked = "#{@mask * "#{@input}".length}"
55
58
  if @done_masked && !@failure
56
59
  masked = @prompt.decorate(masked, @active_color)
60
+ elsif @done_masked && @failure
61
+ masked = @prompt.decorate(masked, @error_color)
57
62
  end
58
63
  header += masked
59
64
  end
60
65
  @prompt.print(header)
61
- @prompt.print("\n") if @done
66
+ @prompt.puts if @done
67
+
68
+ header.lines.count + (@done ? 1 : 0)
62
69
  end
63
70
 
64
- def render_error_or_finish(result)
65
- @failure = result.failure?
71
+ def render_error(errors)
72
+ @failure = !errors.empty?
66
73
  super
67
74
  end
68
75
 
@@ -71,13 +78,15 @@ module TTY
71
78
  # @private
72
79
  def read_input
73
80
  @done_masked = false
81
+ @failure = false
74
82
  @input = ''
83
+
75
84
  until @done_masked
76
- @prompt.read_keypress(echo?)
85
+ @prompt.read_keypress
77
86
  @prompt.print(@prompt.clear_line)
78
87
  render_question
79
88
  end
80
- @prompt.print("\n")
89
+ @prompt.puts
81
90
  @input
82
91
  end
83
92
  end # MaskQuestion
@@ -1,6 +1,6 @@
1
1
  # encoding: utf-8
2
2
 
3
- require 'tty/prompt/list'
3
+ require_relative 'list'
4
4
 
5
5
  module TTY
6
6
  class Prompt
@@ -22,6 +22,7 @@ module TTY
22
22
  @selected = []
23
23
  @help = options[:help]
24
24
  @default = Array(options[:default])
25
+ @echo = options.fetch(:echo, true)
25
26
  end
26
27
 
27
28
  # Callback fired when space key is pressed
@@ -61,9 +62,9 @@ module TTY
61
62
  # @api private
62
63
  def render_header
63
64
  instructions = @prompt.decorate(help, :bright_black)
64
- if @done
65
+ if @done && @echo
65
66
  @prompt.decorate(selected_names, @active_color)
66
- elsif @selected.size.nonzero?
67
+ elsif @selected.size.nonzero? && @echo
67
68
  selected_names + (@first_render ? " #{instructions}" : '')
68
69
  elsif @first_render
69
70
  instructions
@@ -85,16 +86,17 @@ module TTY
85
86
  def render_menu
86
87
  output = ''
87
88
  @paginator.paginate(@choices, @active, @per_page) do |choice, index|
88
- num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
89
- indicator = (index + 1 == @active) ? @marker : Symbols::SPACE
90
- indicator += Symbols::SPACE
89
+ num = enumerate? ? (index + 1).to_s + @enum + ' ' : ''
90
+ indicator = (index + 1 == @active) ? @marker : ' '
91
+ indicator += ' '
91
92
  message = if @selected.include?(choice)
92
- selected = @prompt.decorate(Symbols::RADIO_CHECKED, @active_color)
93
- selected + Symbols::SPACE + num + choice.name
93
+ selected = @prompt.decorate(symbols[:radio_on], @active_color)
94
+ selected + ' ' + num + choice.name
94
95
  else
95
- Symbols::RADIO_UNCHECKED + Symbols::SPACE + num + choice.name
96
+ symbols[:radio_off] + ' ' + num + choice.name
96
97
  end
97
- newline = (index == @paginator.max_index) ? '' : "\n"
98
+ max_index = paginated? ? @paginator.max_index : @choices.size - 1
99
+ newline = (index == max_index) ? '' : "\n"
98
100
  output << indicator + message + newline
99
101
  end
100
102
  output
@@ -1,10 +1,11 @@
1
1
  # encoding: utf-8
2
2
 
3
- require 'tty/prompt/question/modifier'
4
- require 'tty/prompt/question/validation'
5
- require 'tty/prompt/question/checks'
6
- require 'tty/prompt/converter_dsl'
7
- require 'tty/prompt/converters'
3
+ require_relative 'converters'
4
+ require_relative 'evaluator'
5
+ require_relative 'question/modifier'
6
+ require_relative 'question/validation'
7
+ require_relative 'question/checks'
8
+ require_relative 'utils'
8
9
 
9
10
  module TTY
10
11
  # A class responsible for shell prompt interactions.
@@ -14,7 +15,6 @@ module TTY
14
15
  # @api public
15
16
  class Question
16
17
  include Checks
17
- include Converters
18
18
 
19
19
  UndefinedSetting = Module.new
20
20
 
@@ -42,6 +42,7 @@ module TTY
42
42
  @convert = options.fetch(:convert) { UndefinedSetting }
43
43
  @active_color = options.fetch(:active_color) { @prompt.active_color }
44
44
  @help_color = options.fetch(:help_color) { @prompt.help_color }
45
+ @error_color = options.fetch(:error_color) { :red }
45
46
  @messages = Utils.deep_copy(options.fetch(:messages) { { } })
46
47
  @done = false
47
48
  @input = nil
@@ -100,12 +101,17 @@ module TTY
100
101
  #
101
102
  # @api private
102
103
  def render
104
+ @errors = []
103
105
  until @done
104
- render_question
106
+ lines = render_question
105
107
  result = process_input
106
- errors = result.errors
107
- render_error_or_finish(result)
108
- refresh(errors.count)
108
+ if result.failure?
109
+ @errors = result.errors
110
+ render_error(result.errors)
111
+ else
112
+ @done = true
113
+ end
114
+ refresh(lines)
109
115
  end
110
116
  render_question
111
117
  convert_result(result.value)
@@ -124,7 +130,9 @@ module TTY
124
130
  header += @prompt.decorate("(#{default})", @help_color) + ' '
125
131
  end
126
132
  @prompt.print(header)
127
- @prompt.print("\n") if @done
133
+ @prompt.puts if @done
134
+
135
+ header.lines.count + (@done ? 1 : 0)
128
136
  end
129
137
 
130
138
  # Decide how to handle input from user
@@ -146,43 +154,37 @@ module TTY
146
154
  when :keypress
147
155
  @prompt.read_keypress
148
156
  when :multiline
149
- @prompt.read_multiline
157
+ @prompt.read_multiline.each(&:chomp!)
150
158
  else
151
- @prompt.read_line(echo)
159
+ @prompt.read_line(echo: echo).chomp
152
160
  end
153
161
  end
154
162
 
155
163
  # Handle error condition
156
164
  #
157
165
  # @api private
158
- def render_error_or_finish(result)
159
- if result.failure?
160
- result.errors.each do |err|
161
- @prompt.print(@prompt.clear_line)
162
- @prompt.print(@prompt.decorate('>>', :red) + ' ' + err)
163
- end
164
- @prompt.print(@prompt.cursor.up(result.errors.count))
165
- else
166
- @done = true
167
- if result.errors.count.nonzero?
168
- @prompt.print(@prompt.cursor.down(result.errors.count))
169
- end
166
+ def render_error(errors)
167
+ errors.each do |err|
168
+ newline = (@echo ? '' : "\n")
169
+ @prompt.print(newline + @prompt.decorate('>>', :red) + ' ' + err)
170
170
  end
171
171
  end
172
172
 
173
173
  # Determine area of the screen to clear
174
174
  #
175
- # @param [Integer] errors
175
+ # @param [Array[String]] errors
176
176
  #
177
177
  # @api private
178
- def refresh(errors = nil)
179
- lines = @message.scan("\n").length
180
- lines += ((!echo? || errors.nonzero?) ? 1 : 2) # clear user enter
181
-
182
- if errors.nonzero? && @done
183
- lines += errors
178
+ def refresh(lines)
179
+ if @done
180
+ if @errors.count.zero? && @echo
181
+ @prompt.print(@prompt.cursor.up(lines))
182
+ else
183
+ lines += @errors.count
184
+ end
185
+ else
186
+ @prompt.print(@prompt.cursor.up(lines))
184
187
  end
185
-
186
188
  @prompt.print(@prompt.clear_lines(lines))
187
189
  end
188
190
 
@@ -193,7 +195,7 @@ module TTY
193
195
  # @api private
194
196
  def convert_result(value)
195
197
  if convert? & !Utils.blank?(value)
196
- converter_registry.(@convert, value)
198
+ Converters.convert(@convert, value)
197
199
  else
198
200
  value
199
201
  end
@@ -303,10 +305,10 @@ module TTY
303
305
  def in(value = (not_set = true), message = nil)
304
306
  messages[:range?] = message if message
305
307
  if in? && !@in.is_a?(Range)
306
- @in = converter_registry.(:range, @in)
308
+ @in = Converters.convert(:range, @in)
307
309
  end
308
310
  return @in if not_set
309
- @in = converter_registry.(:range, value)
311
+ @in = Converters.convert(:range, value)
310
312
  end
311
313
 
312
314
  # Check if range is set