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
@@ -9,8 +9,7 @@ module TTY
9
9
  #
10
10
  # @api private
11
11
  class MultiList < List
12
-
13
- HELP = '(Use arrow keys, press Space to select and Enter to finish)'.freeze
12
+ HELP = '(Use arrow%s keys, press Space to select and Enter to finish)'.freeze
14
13
 
15
14
  # Create instance of TTY::Prompt::MultiList menu.
16
15
  #
@@ -21,14 +20,14 @@ module TTY
21
20
  def initialize(prompt, options)
22
21
  super
23
22
  @selected = []
24
- @help = options.fetch(:help) { HELP }
25
- @default = options.fetch(:default) { [] }
23
+ @help = options[:help]
24
+ @default = options.fetch(:default) { [] }
26
25
  end
27
26
 
28
27
  # Callback fired when space key is pressed
29
28
  #
30
29
  # @api private
31
- def keyspace(event)
30
+ def keyspace(*)
32
31
  active_choice = @choices[@active - 1]
33
32
  if @selected.include?(active_choice)
34
33
  @selected.delete(active_choice)
@@ -57,9 +56,7 @@ module TTY
57
56
  elsif @selected.size.nonzero?
58
57
  @selected.map(&:name).join(', ')
59
58
  elsif @first_render
60
- @prompt.decorate(@help, :bright_black)
61
- else
62
- ''
59
+ @prompt.decorate(help, :bright_black)
63
60
  end
64
61
  end
65
62
 
@@ -76,18 +73,21 @@ module TTY
76
73
  #
77
74
  # @api private
78
75
  def render_menu
76
+ output = ''
79
77
  @choices.each_with_index do |choice, index|
78
+ num = enumerate? ? (index + 1).to_s + @enum + Symbols::SPACE : ''
80
79
  indicator = (index + 1 == @active) ? @marker : Symbols::SPACE
81
80
  indicator += Symbols::SPACE
82
81
  message = if @selected.include?(choice)
83
82
  selected = @prompt.decorate(Symbols::RADIO_CHECKED, :green)
84
- selected + Symbols::SPACE + choice.name
83
+ selected + Symbols::SPACE + num + choice.name
85
84
  else
86
- Symbols::RADIO_UNCHECKED + Symbols::SPACE + choice.name
85
+ Symbols::RADIO_UNCHECKED + Symbols::SPACE + num + choice.name
87
86
  end
88
87
  newline = (index == @choices.length - 1) ? '' : "\n"
89
- @prompt.print(indicator + message + newline)
88
+ output << indicator + message + newline
90
89
  end
90
+ output
91
91
  end
92
92
  end # MultiList
93
93
  end # Prompt
@@ -16,8 +16,6 @@ module TTY
16
16
  include Checks
17
17
  include Converters
18
18
 
19
- BLANK_REGEX = /\A[[:space:]]*\z/o.freeze
20
-
21
19
  UndefinedSetting = Module.new
22
20
 
23
21
  # Store question message
@@ -44,6 +42,7 @@ module TTY
44
42
  @read = options.fetch(:read) { UndefinedSetting }
45
43
  @convert = options.fetch(:convert) { UndefinedSetting }
46
44
  @color = options.fetch(:color) { :green }
45
+ @messages = Utils.deep_copy(options.fetch(:messages) { { } })
47
46
  @done = false
48
47
  @input = nil
49
48
 
@@ -56,6 +55,33 @@ module TTY
56
55
  @evaluator << CheckModifier
57
56
  end
58
57
 
58
+ # Stores all the error messages displayed to user
59
+ # The currently supported messages are:
60
+ # * :range?
61
+ # * :required?
62
+ # * :valid?
63
+ attr_reader :messages
64
+
65
+ # Retrieve message based on the key
66
+ #
67
+ # @param [Symbol] name
68
+ # the name of message key
69
+ #
70
+ # @param [Hash] tokens
71
+ # the tokens to evaluate
72
+ #
73
+ # @return [Array[String]]
74
+ #
75
+ # @api private
76
+ def message_for(name, tokens = nil)
77
+ template = @messages[name]
78
+ if template && !template.match(/\%\{/).nil?
79
+ [template % tokens]
80
+ else
81
+ [template || '']
82
+ end
83
+ end
84
+
59
85
  # Call the question
60
86
  #
61
87
  # @param [String] message
@@ -64,7 +90,7 @@ module TTY
64
90
  #
65
91
  # @api public
66
92
  def call(message, &block)
67
- return if blank?(message)
93
+ return if Utils.blank?(message)
68
94
  @message = message
69
95
  block.call(self) if block
70
96
  render
@@ -108,7 +134,7 @@ module TTY
108
134
  # @api private
109
135
  def process_input
110
136
  @input = read_input
111
- if blank?(@input)
137
+ if Utils.blank?(@input)
112
138
  @input = default? ? default : nil
113
139
  end
114
140
  @evaluator.(@input)
@@ -168,7 +194,7 @@ module TTY
168
194
  #
169
195
  # @api private
170
196
  def convert_result(value)
171
- if convert? & !blank?(value)
197
+ if convert? & !Utils.blank?(value)
172
198
  converter_registry.(@convert, value)
173
199
  else
174
200
  value
@@ -220,7 +246,8 @@ module TTY
220
246
  # @return [Boolean]
221
247
  #
222
248
  # @api public
223
- def required(value = (not_set = true))
249
+ def required(value = (not_set = true), message = nil)
250
+ messages[:required?] = message if message
224
251
  return @required if not_set
225
252
  @required = value
226
253
  end
@@ -233,7 +260,8 @@ module TTY
233
260
  # @return [Question]
234
261
  #
235
262
  # @api public
236
- def validate(value = nil, &block)
263
+ def validate(value = nil, message = nil, &block)
264
+ messages[:valid?] = message if message
237
265
  @validation = (value || block)
238
266
  end
239
267
 
@@ -274,7 +302,8 @@ module TTY
274
302
  # @param [String] value
275
303
  #
276
304
  # @api public
277
- def in(value = (not_set = true))
305
+ def in(value = (not_set = true), message = nil)
306
+ messages[:range?] = message if message
278
307
  if in? && !@in.is_a?(Range)
279
308
  @in = converter_registry.(:range, @in)
280
309
  end
@@ -291,12 +320,7 @@ module TTY
291
320
  @in != UndefinedSetting
292
321
  end
293
322
 
294
- def blank?(value)
295
- value.nil? ||
296
- value.respond_to?(:empty?) && value.empty? ||
297
- BLANK_REGEX === value
298
- end
299
-
323
+ # @api public
300
324
  def to_s
301
325
  "#{message}"
302
326
  end
@@ -40,7 +40,8 @@ module TTY
40
40
  (question.in? && question.in.include?(cast(value)))
41
41
  [value]
42
42
  else
43
- [value, ["Value #{value} is not included in the range #{question.in}"]]
43
+ tokens = {value: value, in: question.in}
44
+ [value, question.message_for(:range?, tokens)]
44
45
  end
45
46
  end
46
47
  end
@@ -53,7 +54,8 @@ module TTY
53
54
  Validation.new(question.validation).call(value))
54
55
  [value]
55
56
  else
56
- [value, ["Your answer is invalid (must match #{question.validation.inspect})"]]
57
+ tokens = {valid: question.validation.inspect}
58
+ [value, question.message_for(:valid?, tokens)]
57
59
  end
58
60
  end
59
61
  end
@@ -73,7 +75,7 @@ module TTY
73
75
  class CheckRequired
74
76
  def self.call(question, value)
75
77
  if question.required? && !question.default? && value.nil?
76
- [value, ['No value provided for required']]
78
+ [value, question.message_for(:required?)]
77
79
  else
78
80
  [value]
79
81
  end
@@ -8,6 +8,10 @@ module TTY
8
8
  # A class responsible for shell prompt interactions.
9
9
  class Prompt
10
10
  # A class responsible for reading character input from STDIN
11
+ #
12
+ # Used internally to provide key and line reading functionality
13
+ #
14
+ # @api private
11
15
  class Reader
12
16
  include Wisper::Publisher
13
17
 
@@ -56,6 +60,9 @@ module TTY
56
60
  # Read a single keypress that may include
57
61
  # 2 or 3 escape characters.
58
62
  #
63
+ # @param [Boolean] echo
64
+ # whether to echo chars back or not, defaults to false
65
+ #
59
66
  # @return [String]
60
67
  #
61
68
  # @api public
@@ -64,7 +71,7 @@ module TTY
64
71
  mode.echo(echo) do
65
72
  mode.raw(true) do
66
73
  key = read_char
67
- publish_keypress_event(key) if key
74
+ emit_key_event(key) if key
68
75
  exit 130 if key == Codes::CTRL_C
69
76
  key
70
77
  end
@@ -107,7 +114,7 @@ module TTY
107
114
  mode.echo(echo) do
108
115
  while (char = input.getbyte) &&
109
116
  !(char == CARRIAGE_RETURN || char == NEWLINE)
110
- publish_keypress_event(convert_byte(char))
117
+ emit_key_event(convert_byte(char))
111
118
  line = handle_char(line, char)
112
119
  end
113
120
  end
@@ -145,25 +152,12 @@ module TTY
145
152
  # @return [nil]
146
153
  #
147
154
  # @api public
148
- def publish_keypress_event(char)
149
- event = KeyEvent.from(char)
150
- event_name = parse_key_event(event)
151
- publish(event_name, event) unless event_name.nil?
155
+ def emit_key_event(key)
156
+ event = KeyEvent.from(key)
157
+ publish(:"key#{event.key.name}", event) if event.emit?
152
158
  publish(:keypress, event)
153
159
  end
154
160
 
155
- # Interpret the key and provide event name
156
- #
157
- # @return [Symbol]
158
- #
159
- # @api public
160
- def parse_key_event(event)
161
- return if event.key.nil?
162
- permitted_events = %w(up down left right space return enter num)
163
- return unless permitted_events.include?("#{event.key.name}")
164
- :"key#{event.key.name}"
165
- end
166
-
167
161
  private
168
162
 
169
163
  trap('SIGINT') { exit 130 }
@@ -39,15 +39,21 @@ module TTY
39
39
  CTRL_H = "\b"
40
40
  CTRL_L = "\f"
41
41
 
42
- F1 = "\eOP"
43
- F2 = "\eOQ"
44
- F3 = "\eOR"
45
- F4 = "\eOS"
46
-
47
- F1_ALT = "\e[11~"
48
- F2_ALT = "\e[12~"
49
- F3_ALT = "\e[13~"
50
- F4_ALT = "\e[14~"
42
+ F1_XTERM = "\eOP"
43
+ F2_XTERM = "\eOQ"
44
+ F3_XTERM = "\eOR"
45
+ F4_XTERM = "\eOS"
46
+
47
+ F1_GNOME = "\e[11~"
48
+ F2_GNOME = "\e[12~"
49
+ F3_GNOME = "\e[13~"
50
+ F4_GNOME = "\e[14~"
51
+
52
+ F1_WIN = "\e[[A"
53
+ F2_WIN = "\e[[B"
54
+ F3_WIN = "\e[[C"
55
+ F4_WIN = "\e[[D"
56
+ F5_WIN = "\e[[E"
51
57
 
52
58
  F5 = "\e[15~"
53
59
  F6 = "\e[17~"
@@ -21,46 +21,73 @@ module TTY
21
21
  #
22
22
  # @api public
23
23
  class KeyEvent < Struct.new(:value, :key)
24
- META_KEY_CODE_RE = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
24
+ META_KEY_CODE_RE = /^(?:\e)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/
25
25
 
26
26
  def self.from(char)
27
27
  key = Key.new
28
28
  case char
29
- when Codes::RETURN
30
- key.name = :return
31
- when Codes::LINEFEED
32
- key.name = :enter
33
- when Codes::TAB
34
- key.name = :tab
35
- when Codes::BACKSPACE
29
+ when Codes::RETURN then key.name = :return
30
+ when Codes::LINEFEED then key.name = :enter
31
+ when Codes::TAB then key.name = :tab
32
+ when Codes::BACKSPACE, Codes::CTRL_H,
33
+ "#{Codes::ESCAPE}#{Codes::BACKSPACE}",
34
+ "#{Codes::ESCAPE}#{Codes::CTRL_H}"
36
35
  key.name = :backspace
37
- when Codes::DELETE
38
- key.name = :delete
39
- when Codes::SPACE
40
- key.name = :space
41
- when Codes::CTRL_C, Codes::ESCAPE
42
- key.name = :escape
43
- when proc { |c| c <= "\x1a" }
44
- codes = char.each_codepoint.to_a
45
- key.name = "#{codes}"
46
- key.ctrl = true
47
- when /\d/
36
+ when Codes::DELETE then key.name = :delete
37
+ when Codes::SPACE then key.name = :space
38
+ when Codes::CTRL_C, Codes::ESCAPE then key.name = :escape
39
+ when proc { |c| c.length == 1 && c =~ /[a-z]/ }
40
+ key.name = char
41
+ when proc { |c| c.length == 1 && c =~ /[A-Z]/ }
42
+ key.name = char.downcase
43
+ key.shift = true
44
+ when /^\d+$/
48
45
  key.name = :num
49
- when META_KEY_CODE_RE
46
+ when META_KEY_CODE_RE # ansi escape
50
47
  key.meta = true
48
+
51
49
  case char
52
- when Codes::KEY_UP, Codes::CTRL_K, Codes::CTRL_P
50
+ # f1 - f12
51
+ when Codes::F1_XTERM, Codes::F1_GNOME, Codes::F1_WIN then key.name = :f1
52
+ when Codes::F2_XTERM, Codes::F2_GNOME, Codes::F2_WIN then key.name = :f2
53
+ when Codes::F3_XTERM, Codes::F3_GNOME, Codes::F3_WIN then key.name = :f3
54
+ when Codes::F4_XTERM, Codes::F4_GNOME, Codes::F4_WIN then key.name = :f4
55
+ when Codes::F5 then key.name = :f5
56
+ when Codes::F6 then key.name = :f6
57
+ when Codes::F7 then key.name = :f7
58
+ when Codes::F8 then key.name = :f8
59
+ when Codes::F9 then key.name = :f9
60
+ when Codes::F10 then key.name = :f10
61
+ when Codes::F11 then key.name = :f11
62
+ when Codes::F12 then key.name = :f12
63
+ # navigation
64
+ when Codes::KEY_UP, Codes::KEY_UP_ALT, Codes::CTRL_K, Codes::CTRL_P
53
65
  key.name = :up
54
- when Codes::KEY_DOWN, Codes::CTRL_J, Codes::CTRL_N
66
+ when Codes::KEY_DOWN, Codes::KEY_DOWN_ALT, Codes::CTRL_J, Codes::CTRL_N
55
67
  key.name = :down
56
- when Codes::KEY_RIGHT, Codes::CTRL_L
68
+ when Codes::KEY_RIGHT, Codes::KEY_RIGHT_ALT, Codes::CTRL_L
57
69
  key.name = :right
58
- when Codes::KEY_LEFT, Codes::CTRL_H
70
+ when Codes::KEY_LEFT, Codes::KEY_LEFT_ALT, Codes::CTRL_H
59
71
  key.name = :left
72
+ when Codes::KEY_CLEAR, Codes::KEY_CLEAR_ALT
73
+ key.name = :clear
74
+ when Codes::KEY_END, Codes::KEY_END_ALT
75
+ key.name = :end
76
+ when Codes::KEY_HOME, Codes::KEY_HOME_ALT
77
+ key.name = :home
60
78
  end
61
79
  end
62
80
  new(char, key)
63
81
  end
82
+
83
+ # Check if key event can be emitted
84
+ #
85
+ # @return [Boolean]
86
+ #
87
+ # @api public
88
+ def emit?
89
+ !key.nil? && !key.name.nil?
90
+ end
64
91
  end # KeyEvent
65
92
  end # Reader
66
93
  end # Prompt
@@ -0,0 +1,170 @@
1
+ # encoding: utf-8
2
+
3
+ module TTY
4
+ # A class responsible for shell prompt interactions.
5
+ class Prompt
6
+ # A class responsible for gathering numeric input from range
7
+ #
8
+ # @api public
9
+ class Slider
10
+ HELP = '(Use arrow keys, press Enter to select)'.freeze
11
+
12
+ # Initailize a Slider
13
+ #
14
+ # @api public
15
+ def initialize(prompt, options = {})
16
+ @prompt = prompt
17
+ @first_render = true
18
+ @done = false
19
+ @color = options.fetch(:color) { :green }
20
+ @min = options.fetch(:min) { 0 }
21
+ @max = options.fetch(:max) { 10 }
22
+ @step = options.fetch(:step) { 1 }
23
+ @default = options[:default]
24
+
25
+ @prompt.subscribe(self)
26
+ end
27
+
28
+ # Setup initial active position
29
+ #
30
+ # @return [Integer]
31
+ #
32
+ # @api private
33
+ def initial
34
+ if @default.nil?
35
+ range.size / 2
36
+ else
37
+ range.index(@default)
38
+ end
39
+ end
40
+
41
+ # Range of numbers to render
42
+ #
43
+ # @return [Array[Integer]]
44
+ #
45
+ # @apip private
46
+ def range
47
+ (@min..@max).step(@step).to_a
48
+ end
49
+
50
+ # @api public
51
+ def default(value)
52
+ @default = value
53
+ end
54
+
55
+ # @api public
56
+ def min(value)
57
+ @min = value
58
+ end
59
+
60
+ # @api public
61
+ def max(value)
62
+ @max = value
63
+ end
64
+
65
+ # @api public
66
+ def step(value)
67
+ @step = value
68
+ end
69
+
70
+ # Call the slider by passing question
71
+ #
72
+ # @param [String] question
73
+ # the question to ask
74
+ #
75
+ # @apu public
76
+ def call(question, &block)
77
+ @question = question
78
+ block.call(self) if block
79
+ @active = initial
80
+ render
81
+ end
82
+
83
+ def keyleft(*)
84
+ @active -= 1 if @active > 0
85
+ end
86
+ alias_method :keydown, :keyleft
87
+
88
+ def keyright(*)
89
+ @active += 1 if (@active + @step) <= range.size
90
+ end
91
+ alias_method :keyup, :keyright
92
+
93
+ def keyreturn(*)
94
+ @done = true
95
+ end
96
+ alias_method :keyspace, :keyreturn
97
+
98
+ private
99
+
100
+ # Render an interactive range slider.
101
+ #
102
+ # @api private
103
+ def render
104
+ @prompt.print(@prompt.hide)
105
+ until @done
106
+ render_question
107
+ @prompt.read_keypress
108
+ refresh
109
+ end
110
+ render_question
111
+ answer = render_answer
112
+ ensure
113
+ @prompt.print(@prompt.show)
114
+ answer
115
+ end
116
+
117
+ # Clear screen
118
+ #
119
+ # @api private
120
+ def refresh
121
+ lines = @question.scan("\n").length + 2
122
+ @prompt.print(@prompt.clear_lines(lines))
123
+ end
124
+
125
+ # @return [Integer]
126
+ #
127
+ # @api private
128
+ def render_answer
129
+ range[@active]
130
+ end
131
+
132
+ # Render question with the slider
133
+ #
134
+ # @api private
135
+ def render_question
136
+ header = "#{@prompt.prefix}#{@question} #{render_header}"
137
+ @prompt.puts(header)
138
+ @first_render = false
139
+ @prompt.print(render_slider) unless @done
140
+ end
141
+
142
+ # Render actual answer or help
143
+ #
144
+ # @api private
145
+ def render_header
146
+ if @done
147
+ @prompt.decorate(render_answer.to_s, @color)
148
+ elsif @first_render
149
+ @prompt.decorate(HELP, :bright_black)
150
+ end
151
+ end
152
+
153
+ # Render slider representation
154
+ #
155
+ # @return [String]
156
+ #
157
+ # @api private
158
+ def render_slider
159
+ output = ''
160
+ output << Symbols::SLIDER_END
161
+ output << '-' * @active
162
+ output << @prompt.decorate(Symbols::SLIDER_HANDLE, @color)
163
+ output << '-' * (range.size - @active - 1)
164
+ output << Symbols::SLIDER_END
165
+ output << " #{range[@active]}"
166
+ output
167
+ end
168
+ end # Slider
169
+ end # Prompt
170
+ end # TTY