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
@@ -3,11 +3,17 @@
3
3
  module TTY
4
4
  class Prompt
5
5
  module Symbols
6
- SPACE = " "
6
+ SPACE = " "
7
+ SUCCESS = "✓"
8
+ FAILURE = "✘"
9
+
7
10
  ITEM_SECURE = "•"
8
11
  ITEM_SELECTED = "‣"
9
12
  RADIO_CHECKED = "⬢"
10
13
  RADIO_UNCHECKED = "⬡"
14
+ SLIDER_HANDLE = 'O'
15
+ SLIDER_RANGE = '-'
16
+ SLIDER_END = '|'
11
17
  end # Symbols
12
18
  end # Prompt
13
19
  end # TTY
@@ -4,13 +4,41 @@ module TTY
4
4
  module Utils
5
5
  module_function
6
6
 
7
- def extract_options!(args)
8
- args.last.respond_to?(:to_hash) ? args.pop : {}
9
- end
7
+ BLANK_REGEX = /\A[[:space:]]*\z/o.freeze
10
8
 
9
+ # Extract options hash from array argument
10
+ #
11
+ # @param [Array[Object]] args
12
+ #
13
+ # @api public
11
14
  def extract_options(args)
12
15
  options = args.last
13
16
  options.respond_to?(:to_hash) ? options.to_hash.dup : {}
14
17
  end
18
+
19
+ def extract_options!(args)
20
+ args.last.respond_to?(:to_hash) ? args.pop : {}
21
+ end
22
+
23
+ # Check if value is nil or an empty string
24
+ #
25
+ # @param [Object] value
26
+ # the value to check
27
+ #
28
+ # @return [Boolean]
29
+ #
30
+ # @api public
31
+ def blank?(value)
32
+ value.nil? ||
33
+ value.respond_to?(:empty?) && value.empty? ||
34
+ BLANK_REGEX === value
35
+ end
36
+
37
+ # Deep copy object
38
+ #
39
+ # @api public
40
+ def deep_copy(object)
41
+ Marshal.load(Marshal.dump(object))
42
+ end
15
43
  end # Utils
16
44
  end # TTY
@@ -2,6 +2,6 @@
2
2
 
3
3
  module TTY
4
4
  class Prompt
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end # Prompt
7
7
  end # TTY
data/spec/spec_helper.rb CHANGED
@@ -16,6 +16,7 @@ if RUBY_VERSION > '1.9' and (ENV['COVERAGE'] || ENV['TRAVIS'])
16
16
  end
17
17
 
18
18
  require 'tty-prompt'
19
+ require 'rspec/mocks/matchers/have_received'
19
20
 
20
21
  RSpec.configure do |config|
21
22
  config.expect_with :rspec do |expectations|
@@ -9,7 +9,7 @@ RSpec.describe TTY::Prompt::Question, 'convert bool' do
9
9
  prompt.input.rewind
10
10
  expect {
11
11
  prompt.ask("Do you read books?", convert: :bool)
12
- }.to raise_error(Necromancer::ConversionTypeError)
12
+ }.to raise_error(TTY::Prompt::ConversionError)
13
13
  end
14
14
 
15
15
  it "handles default values" do
@@ -1,8 +1,18 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  RSpec.describe TTY::Prompt::Question, 'convert date' do
4
+
5
+ subject(:prompt) { TTY::TestPrompt.new}
6
+
7
+ it 'fails to convert date' do
8
+ prompt.input << 'invalid'
9
+ prompt.input.rewind
10
+ expect {
11
+ prompt.ask("When were you born?", convert: :date)
12
+ }.to raise_error(TTY::Prompt::ConversionError)
13
+ end
14
+
4
15
  it 'converts date' do
5
- prompt = TTY::TestPrompt.new
6
16
  prompt.input << "20th April 1887"
7
17
  prompt.input.rewind
8
18
  response = prompt.ask("When were your born?", convert: :date)
@@ -13,7 +23,6 @@ RSpec.describe TTY::Prompt::Question, 'convert date' do
13
23
  end
14
24
 
15
25
  it "converts datetime" do
16
- prompt = TTY::TestPrompt.new
17
26
  prompt.input << "20th April 1887"
18
27
  prompt.input.rewind
19
28
  response = prompt.ask("When were your born?", convert: :datetime)
@@ -9,6 +9,6 @@ RSpec.describe TTY::Prompt::Question, 'convert file' do
9
9
  prompt.input.rewind
10
10
  answer = prompt.ask("Which file to open?", convert: :file)
11
11
  expect(answer).to eq(file)
12
- # expect(File).to have_received(:open).with(/test\.txt/)
12
+ expect(File).to have_received(:open).with(/test\.txt/)
13
13
  end
14
14
  end
@@ -1,8 +1,18 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  RSpec.describe TTY::Prompt::Question, 'convert numbers' do
4
+
5
+ subject(:prompt) { TTY::TestPrompt.new }
6
+
7
+ it 'fails to convert integer' do
8
+ prompt.input << 'invalid'
9
+ prompt.input.rewind
10
+ expect {
11
+ prompt.ask("What temparture?", convert: :int)
12
+ }.to raise_error(TTY::Prompt::ConversionError)
13
+ end
14
+
4
15
  it 'converts integer' do
5
- prompt = TTY::TestPrompt.new
6
16
  prompt.input << 35
7
17
  prompt.input.rewind
8
18
  answer = prompt.ask("What temperature?", convert: :int)
@@ -10,8 +20,15 @@ RSpec.describe TTY::Prompt::Question, 'convert numbers' do
10
20
  expect(answer).to eq(35)
11
21
  end
12
22
 
23
+ it 'fails to convert float' do
24
+ prompt.input << 'invalid'
25
+ prompt.input.rewind
26
+ expect {
27
+ prompt.ask("How tall are you?", convert: :float)
28
+ }.to raise_error(TTY::Prompt::ConversionError)
29
+ end
30
+
13
31
  it 'converts float' do
14
- prompt = TTY::TestPrompt.new
15
32
  number = 6.666
16
33
  prompt.input << number
17
34
  prompt.input.rewind
@@ -9,7 +9,7 @@ RSpec.describe TTY::Prompt::Question, 'convert path' do
9
9
  prompt.input << "/path/to/file"
10
10
  prompt.input.rewind
11
11
  answer = prompt.ask('File location?', convert: :path)
12
- # expect(Pathname).to have_received(:new).with(/path\/to\/file/)
13
12
  expect(answer).to eql(path)
13
+ expect(Pathname).to have_received(:new).with(/path\/to\/file/)
14
14
  end
15
15
  end
@@ -1,8 +1,10 @@
1
1
  # encoding: utf-8
2
2
 
3
3
  RSpec.describe TTY::Prompt::Question, 'convert range' do
4
+
5
+ subject(:prompt) { TTY::TestPrompt.new}
6
+
4
7
  it 'converts with valid range' do
5
- prompt = TTY::TestPrompt.new
6
8
  prompt.input << "20-30"
7
9
  prompt.input.rewind
8
10
  answer = prompt.ask("Which age group?", convert: :range)
@@ -11,11 +13,10 @@ RSpec.describe TTY::Prompt::Question, 'convert range' do
11
13
  end
12
14
 
13
15
  it "fails to convert to range" do
14
- prompt = TTY::TestPrompt.new
15
16
  prompt.input << "abcd"
16
17
  prompt.input.rewind
17
18
  expect {
18
19
  prompt.ask('Which age group?', convert: :range)
19
- }.to raise_error(Necromancer::ConversionTypeError)
20
+ }.to raise_error(TTY::Prompt::ConversionError)
20
21
  end
21
22
  end
@@ -0,0 +1,93 @@
1
+ # encoding: utf-8
2
+
3
+ RSpec.describe TTY::Prompt do
4
+
5
+ subject(:prompt) { TTY::TestPrompt.new }
6
+
7
+ it "selects default option when return pressed immediately" do
8
+ choices = %w(/bin/nano /usr/bin/vim.basic /usr/bin/vim.tiny)
9
+ prompt.input << "\n"
10
+ prompt.input.rewind
11
+
12
+ expect(prompt.enum_select("Select an editor?", choices)).to eq('/bin/nano')
13
+ expect(prompt.output.string).to eq([
14
+ "Select an editor? \n",
15
+ "\e[32m 1) /bin/nano\e[0m\n",
16
+ " 2) /usr/bin/vim.basic\n",
17
+ " 3) /usr/bin/vim.tiny\n",
18
+ " Choose 1-3 [1]: ",
19
+ "\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[J",
20
+ "Select an editor? \e[32m/bin/nano\e[0m\n"
21
+ ].join)
22
+ end
23
+
24
+ it "selects option by index from the list" do
25
+ choices = %w(/bin/nano /usr/bin/vim.basic /usr/bin/vim.tiny)
26
+ prompt.input << "3\n"
27
+ prompt.input.rewind
28
+
29
+ expect(prompt.enum_select("Select an editor?", choices, default: 2)).to eq('/usr/bin/vim.tiny')
30
+ expect(prompt.output.string).to eq([
31
+ "Select an editor? \n",
32
+ " 1) /bin/nano\n",
33
+ "\e[32m 2) /usr/bin/vim.basic\e[0m\n",
34
+ " 3) /usr/bin/vim.tiny\n",
35
+ " Choose 1-3 [2]: ",
36
+ "\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[J",
37
+ "Select an editor? \n",
38
+ " 1) /bin/nano\n",
39
+ " 2) /usr/bin/vim.basic\n",
40
+ "\e[32m 3) /usr/bin/vim.tiny\e[0m\n",
41
+ " Choose 1-3 [2]: 3",
42
+ "\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[J",
43
+ "Select an editor? \e[32m/usr/bin/vim.tiny\e[0m\n"
44
+ ].join)
45
+ end
46
+
47
+ it "selects option through DSL" do
48
+ prompt.input << "\n"
49
+ prompt.input.rewind
50
+ value = prompt.enum_select("Select an editor?") do |menu|
51
+ menu.default 2
52
+ menu.enum '.'
53
+
54
+ menu.choice "/bin/nano"
55
+ menu.choice "/usr/bin/vim.basic"
56
+ menu.choice "/usr/bin/vim.tiny"
57
+ end
58
+
59
+ expect(value).to eq('/usr/bin/vim.basic')
60
+ expect(prompt.output.string).to eq([
61
+ "Select an editor? \n",
62
+ " 1. /bin/nano\n",
63
+ "\e[32m 2. /usr/bin/vim.basic\e[0m\n",
64
+ " 3. /usr/bin/vim.tiny\n",
65
+ " Choose 1-3 [2]: ",
66
+ "\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[J",
67
+ "Select an editor? \e[32m/usr/bin/vim.basic\e[0m\n"
68
+ ].join)
69
+ end
70
+
71
+ it "selects option through DSL with key and value" do
72
+ prompt.input << "\n"
73
+ prompt.input.rewind
74
+ value = prompt.enum_select("Select an editor?") do |menu|
75
+ menu.default 2
76
+
77
+ menu.choice :nano, '/bin/nano'
78
+ menu.choice :vim, '/usr/bin/vim'
79
+ menu.choice :emacs, '/usr/bin/emacs'
80
+ end
81
+
82
+ expect(value).to eq('/usr/bin/vim')
83
+ expect(prompt.output.string).to eq([
84
+ "Select an editor? \n",
85
+ " 1) nano\n",
86
+ "\e[32m 2) vim\e[0m\n",
87
+ " 3) emacs\n",
88
+ " Choose 1-3 [2]: ",
89
+ "\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[1A\e[1000D\e[K\e[J",
90
+ "Select an editor? \e[32mvim\e[0m\n"
91
+ ].join)
92
+ end
93
+ end
@@ -74,6 +74,8 @@ RSpec.describe TTY::Prompt do
74
74
  prompt.input << " \r"
75
75
  prompt.input.rewind
76
76
  value = prompt.multi_select("Select drinks?") do |menu|
77
+ menu.enum ')'
78
+
77
79
  menu.choice :vodka, {score: 1}
78
80
  menu.choice :beer, 2
79
81
  menu.choice :wine, 3
@@ -81,19 +83,19 @@ RSpec.describe TTY::Prompt do
81
83
  end
82
84
  expect(value).to eq([{score: 1}])
83
85
  expect(prompt.output.string).to eq([
84
- "\e[?25lSelect drinks? \e[90m(Use arrow keys, press Space to select and Enter to finish)\e[0m\n",
85
- "‣ ⬡ vodka\n",
86
- " ⬡ beer\n",
87
- " ⬡ wine\n",
88
- " ⬡ whisky\n",
89
- " ⬡ bourbon",
86
+ "\e[?25lSelect drinks? \e[90m(Use arrow or number (0-9) keys, press Space to select and Enter to finish)\e[0m\n",
87
+ "‣ ⬡ 1) vodka\n",
88
+ " ⬡ 2) beer\n",
89
+ " ⬡ 3) wine\n",
90
+ " ⬡ 4) whisky\n",
91
+ " ⬡ 5) bourbon",
90
92
  "\e[1000D\e[K\e[1A" * 5, "\e[1000D\e[K",
91
93
  "Select drinks? vodka\n",
92
- "‣ \e[32m⬢\e[0m vodka\n",
93
- " ⬡ beer\n",
94
- " ⬡ wine\n",
95
- " ⬡ whisky\n",
96
- " ⬡ bourbon",
94
+ "‣ \e[32m⬢\e[0m 1) vodka\n",
95
+ " ⬡ 2) beer\n",
96
+ " ⬡ 3) wine\n",
97
+ " ⬡ 4) whisky\n",
98
+ " ⬡ 5) bourbon",
97
99
  "\e[1000D\e[K\e[1A" * 5, "\e[1000D\e[K",
98
100
  "Select drinks? \e[32mvodka\e[0m\n\e[?25h"
99
101
  ].join)
@@ -151,7 +153,7 @@ RSpec.describe TTY::Prompt do
151
153
  menu.choice :whisky, {score: 40}
152
154
  menu.choice :bourbon, {score: 50}
153
155
  end
154
- }.to raise_error(TTY::PromptConfigurationError,
156
+ }.to raise_error(TTY::Prompt::ConfigurationError,
155
157
  /default index `6` out of range \(1 - 5\)/)
156
158
  end
157
159
 
@@ -0,0 +1,97 @@
1
+ # encoding: utf-8
2
+
3
+ RSpec.describe TTY::Prompt::Question do
4
+
5
+ subject(:prompt) { TTY::TestPrompt.new }
6
+
7
+ it "passes range check" do
8
+ question = described_class.new(prompt)
9
+ question.in 1..10
10
+
11
+ result = TTY::Prompt::Question::Checks::CheckRange.call(question, 2)
12
+
13
+ expect(result).to eq([2])
14
+ end
15
+
16
+ it "fails range check" do
17
+ question = described_class.new(prompt, messages: TTY::Prompt.messages)
18
+ question.in 1..10
19
+
20
+ result = TTY::Prompt::Question::Checks::CheckRange.call(question, 11)
21
+
22
+ expect(result).to eq([11, ["Value 11 must be within the range 1..10"]])
23
+ end
24
+
25
+ it "fails range check" do
26
+ question = described_class.new(prompt)
27
+ question.in 1..10, 'Outside of range!'
28
+
29
+ result = TTY::Prompt::Question::Checks::CheckRange.call(question, 11)
30
+
31
+ expect(result).to eq([11, ['Outside of range!']])
32
+ end
33
+
34
+ it "passes validation check" do
35
+ question = described_class.new(prompt)
36
+ question.validate(/\A\d{5}\Z/)
37
+
38
+ result = TTY::Prompt::Question::Checks::CheckValidation.call(question, '12345')
39
+
40
+ expect(result).to eq(['12345'])
41
+ end
42
+
43
+ it "fails validation check" do
44
+ question = described_class.new(prompt, messages: TTY::Prompt.messages)
45
+ question.validate(/\A\d{5}\Z/)
46
+
47
+ result = TTY::Prompt::Question::Checks::CheckValidation.call(question, '123')
48
+
49
+ expect(result).to eq(['123', ['Your answer is invalid (must match /\\A\\d{5}\\Z/)']])
50
+ end
51
+
52
+ it "fails validation check with inlined custom message" do
53
+ question = described_class.new(prompt)
54
+ question.validate(/\A\w+@\w+\.\w+\Z/, 'Invalid email address')
55
+
56
+ result = TTY::Prompt::Question::Checks::CheckValidation.call(question, 'piotr@com')
57
+
58
+ expect(result).to eq(['piotr@com', ['Invalid email address']])
59
+ end
60
+
61
+ it "fails validation check with custom message" do
62
+ question = described_class.new(prompt)
63
+ question.validate(/\A\w+@\w+\.\w+\Z/)
64
+ question.messages[:valid?] = 'Invalid email address'
65
+
66
+ result = TTY::Prompt::Question::Checks::CheckValidation.call(question, 'piotr@com')
67
+
68
+ expect(result).to eq(['piotr@com', ['Invalid email address']])
69
+ end
70
+
71
+ it "passes required check" do
72
+ question = described_class.new(prompt)
73
+ question.required true
74
+
75
+ result = TTY::Prompt::Question::Checks::CheckRequired.call(question, 'Piotr')
76
+
77
+ expect(result).to eq(['Piotr'])
78
+ end
79
+
80
+ it "fails required check" do
81
+ question = described_class.new(prompt, messages: TTY::Prompt.messages)
82
+ question.required true
83
+
84
+ result = TTY::Prompt::Question::Checks::CheckRequired.call(question, nil)
85
+
86
+ expect(result).to eq([nil, ['Value must be provided']])
87
+ end
88
+
89
+ it "fails required check with custom message" do
90
+ question = described_class.new(prompt)
91
+ question.required true, 'Required input'
92
+
93
+ result = TTY::Prompt::Question::Checks::CheckRequired.call(question, nil)
94
+
95
+ expect(result).to eq([nil, ['Required input']])
96
+ end
97
+ end
@@ -0,0 +1,67 @@
1
+ # encoding
2
+
3
+ RSpec.describe TTY::Prompt::Reader::KeyEvent, '::from' do
4
+
5
+ it "parses ctrl+h" do
6
+ event = described_class.from("\b")
7
+ expect(event.key.name).to eq(:backspace)
8
+ expect(event.value).to eq("\b")
9
+ end
10
+
11
+ it "parses lowercase char" do
12
+ event = described_class.from('a')
13
+ expect(event.key.name).to eq('a')
14
+ expect(event.value).to eq('a')
15
+ end
16
+
17
+ it "parses uppercase char" do
18
+ event = described_class.from('A')
19
+ expect(event.key.name).to eq('a')
20
+ expect(event.value).to eq('A')
21
+ end
22
+
23
+ it "parses f5 key" do
24
+ event = described_class.from("\e[15~")
25
+ expect(event.key.name).to eq(:f5)
26
+ end
27
+
28
+ it "parses up key" do
29
+ event = described_class.from("\e[A")
30
+ expect(event.key.name).to eq(:up)
31
+ end
32
+
33
+ it "parses up key on gnome" do
34
+ event = described_class.from("\eOA")
35
+ expect(event.key.name).to eq(:up)
36
+ end
37
+
38
+ it "parses down key" do
39
+ event = described_class.from("\e[B")
40
+ expect(event.key.name).to eq(:down)
41
+ end
42
+
43
+ it "parses right key" do
44
+ event = described_class.from("\e[C")
45
+ expect(event.key.name).to eq(:right)
46
+ end
47
+
48
+ it "parses left key" do
49
+ event = described_class.from("\e[D")
50
+ expect(event.key.name).to eq(:left)
51
+ end
52
+
53
+ it "parses clear key" do
54
+ event = described_class.from("\e[E")
55
+ expect(event.key.name).to eq(:clear)
56
+ end
57
+
58
+ it "parses end key" do
59
+ event = described_class.from("\e[F")
60
+ expect(event.key.name).to eq(:end)
61
+ end
62
+
63
+ it "parses home key" do
64
+ event = described_class.from("\e[H")
65
+ expect(event.key.name).to eq(:home)
66
+ end
67
+ end