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
@@ -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