dev-ui 0.1.1 → 0.1.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cb7feeed4cd2083e294dd368521e85d4240cb954
4
- data.tar.gz: d87bb0bab774010578bfaed70966e87e8a0222b6
3
+ metadata.gz: 32b6a7470b4f74f2448604df24a809d4dd5b6176
4
+ data.tar.gz: 47da2a0c8007c5232d7e447ba74406586fa740f4
5
5
  SHA512:
6
- metadata.gz: b371ef8177c5d3626d75c940d56b3afa40924a70fbdc0bb941b3f33c1e5253eb41118a023c6c3a72e92bd15df2bacc65dc361874cc9f4dd08162d402c9215100
7
- data.tar.gz: 0dfc477dd137c74de7a99336478c7c120c3cd394c5e259e2b758a6777a7d1fe1eae0ab859c0728c9a224d80bd0f28601e5c2088488bf4ba4025c32dc0b42005f
6
+ metadata.gz: 51d64a4852b8b4981c778be9d1e0bbbd98bbe58a18917af5fcf2c05f2f53494380726a9ddb1c48e10481afbeac7ea99a9101562cb9b5464fcbddeef2f4debb40
7
+ data.tar.gz: 3749f235fc1b4e30ac3836aec348f8848172ef6d4ae35299c30b6dfc291caab6e4dca9afa891d5bde83c5e3d21dacb736f34c95d370af5a1d900e407cae86813
@@ -2,13 +2,7 @@ inherit_from:
2
2
  - http://shopify.github.io/ruby-style-guide/rubocop.yml
3
3
 
4
4
  AllCops:
5
- Exclude:
6
- - 'vendor/**/*'
7
- TargetRubyVersion: 2.0
8
-
9
- # This doesn't understand that <<~ doesn't exist in 2.0
10
- Style/IndentHeredoc:
11
- Enabled: false
5
+ TargetRubyVersion: 2.1
12
6
 
13
7
  # This doesn't take into account retrying from an exception
14
8
  Lint/HandleExceptions:
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dev-ui (0.1.0)
4
+ dev-ui (0.1.2)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -20,22 +20,21 @@ GEM
20
20
  ruby-progressbar
21
21
  mocha (1.2.1)
22
22
  metaclass (~> 0.0.1)
23
- parallel (1.11.2)
24
- parser (2.4.0.0)
25
- ast (~> 2.2)
23
+ parallel (1.12.0)
24
+ parser (2.4.0.2)
25
+ ast (~> 2.3)
26
26
  powerpack (0.1.1)
27
- rainbow (2.2.2)
28
- rake
27
+ rainbow (3.0.0)
29
28
  rake (10.5.0)
30
- rubocop (0.49.1)
29
+ rubocop (0.52.0)
31
30
  parallel (~> 1.10)
32
- parser (>= 2.3.3.1, < 3.0)
31
+ parser (>= 2.4.0.2, < 3.0)
33
32
  powerpack (~> 0.1)
34
- rainbow (>= 1.99.1, < 3.0)
33
+ rainbow (>= 2.2.2, < 4.0)
35
34
  ruby-progressbar (~> 1.7)
36
35
  unicode-display_width (~> 1.0, >= 1.0.1)
37
- ruby-progressbar (1.8.1)
38
- unicode-display_width (1.2.1)
36
+ ruby-progressbar (1.9.0)
37
+ unicode-display_width (1.3.0)
39
38
 
40
39
  PLATFORMS
41
40
  ruby
data/README.md CHANGED
@@ -47,7 +47,6 @@ Prompt user with options and ask them to choose. Can answer using arrow keys, nu
47
47
 
48
48
  ```ruby
49
49
  Dev::UI.ask('What language/framework do you use?', options: %w(rails go ruby python))
50
- Dev::UI::InteractivePrompt.call(%w(rails go ruby python))
51
50
  ```
52
51
 
53
52
  ![Interactive Prompt](https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif)
@@ -150,3 +149,16 @@ end
150
149
  Output:
151
150
 
152
151
  ![Example Output](https://user-images.githubusercontent.com/3074765/33797758-7a54c7cc-dcdb-11e7-918e-a47c9689f068.gif)
152
+
153
+ ## Development
154
+
155
+ - Run tests using `bundle exec rake test`. All code should be tested.
156
+ - No code, outside of development and tests needs, should use dependencies. This is a self contained library
157
+
158
+ ## Contributing
159
+
160
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/dev-ui.
161
+
162
+ ## License
163
+
164
+ The code is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,19 @@
1
+ require 'dev/ui'
2
+ require 'rake/testtask'
3
+ require 'rubocop/rake_task'
4
+ require 'bundler/gem_tasks'
5
+
6
+ TEST_ROOT = File.expand_path('../test', __FILE__)
7
+
8
+ Rake::TestTask.new do |t|
9
+ t.libs += ["test"]
10
+ t.test_files = FileList[File.join(TEST_ROOT, '**', '*_test.rb')]
11
+ t.verbose = false
12
+ t.warning = false
13
+ end
14
+
15
+ RuboCop::RakeTask.new(:style) do |t|
16
+ t.options = ['--display-cop-names']
17
+ end
18
+
19
+ task default: [:test]
@@ -6,11 +6,11 @@ require "dev/ui/version"
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "dev-ui"
8
8
  spec.version = Dev::UI::VERSION
9
- spec.authors = ["Burke Libbey", "Julian Nadeau"]
10
- spec.email = ["burke.libbey@shopify.com", "julian.nadeau@shopify.com"]
9
+ spec.authors = ["Burke Libbey", "Julian Nadeau", "Lisa Ugray"]
10
+ spec.email = ["burke.libbey@shopify.com", "julian.nadeau@shopify.com", "lisa.ugray@shopify.com"]
11
11
 
12
- spec.summary = %q{Terminal UI framework}
13
- spec.description = %q{Terminal UI framework}
12
+ spec.summary = 'Terminal UI framework'
13
+ spec.description = 'Terminal UI framework'
14
14
  spec.homepage = "https://github.com/shopify/dev-ui"
15
15
  spec.license = "MIT"
16
16
 
data/dev.yml CHANGED
@@ -3,5 +3,12 @@ up:
3
3
  - bundler
4
4
 
5
5
  commands:
6
- test: bin/testunit
6
+ test:
7
+ run: |
8
+ if [ "$#" -eq 1 ] && [[ -f $1 ]];
9
+ then
10
+ rake test TEST=$1
11
+ else
12
+ rake test $@
13
+ fi
7
14
  style: "bundle exec rubocop -D"
@@ -5,7 +5,6 @@ module Dev
5
5
  autoload :Color, 'dev/ui/color'
6
6
  autoload :Box, 'dev/ui/box'
7
7
  autoload :Frame, 'dev/ui/frame'
8
- autoload :InteractivePrompt, 'dev/ui/interactive_prompt'
9
8
  autoload :Progress, 'dev/ui/progress'
10
9
  autoload :Prompt, 'dev/ui/prompt'
11
10
  autoload :Terminal, 'dev/ui/terminal'
@@ -140,7 +139,7 @@ module Dev
140
139
  yield
141
140
  ensure
142
141
  if file_descriptor = Dev::UI::StdoutRouter.duplicate_output_to
143
- file_descriptor.close
142
+ file_descriptor.close
144
143
  Dev::UI::StdoutRouter.duplicate_output_to = nil
145
144
  end
146
145
  end
@@ -37,14 +37,14 @@ module Dev
37
37
 
38
38
  SCAN_FUNCNAME = /\w+:/
39
39
  SCAN_GLYPH = /.}}/
40
- SCAN_BODY = %r{
40
+ SCAN_BODY = /
41
41
  .*?
42
42
  (
43
43
  #{BEGIN_EXPR} |
44
44
  #{END_EXPR} |
45
45
  \z
46
46
  )
47
- }mx
47
+ /mx
48
48
 
49
49
  DISCARD_BRACES = 0..-3
50
50
 
@@ -29,7 +29,7 @@ module Dev
29
29
  # * +:timing+ - How long did the frame content take? Invalid for blockless. Defaults to true for the block form
30
30
  #
31
31
  # ==== Example
32
- #
32
+ #
33
33
  # ===== Block Form (Assumes +Dev::UI::StdoutRouter.enable+ has been called)
34
34
  #
35
35
  # Dev::UI::Frame.open('Open') { puts 'hi' }
@@ -38,7 +38,7 @@ module Dev
38
38
  # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
39
39
  # ┃ hi
40
40
  # ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ (0.0s) ━━
41
- #
41
+ #
42
42
  # ===== Blockless Form
43
43
  #
44
44
  # Dev::UI::Frame.open('Open')
@@ -46,7 +46,7 @@ module Dev
46
46
  # Output:
47
47
  # ┏━━ Open ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
48
  #
49
- #
49
+ #
50
50
  def open(
51
51
  text,
52
52
  color: DEFAULT_FRAME_COLOR,
@@ -80,7 +80,7 @@ module Dev
80
80
  begin
81
81
  success = false
82
82
  success = yield
83
- rescue Exception
83
+ rescue
84
84
  closed = true
85
85
  t_diff = timing ? (Time.now.to_f - t_start) : nil
86
86
  close(failure_text, color: :red, elapsed: t_diff)
@@ -118,7 +118,7 @@ module Dev
118
118
  # Output:
119
119
  # ┗━━ Close ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
120
120
  #
121
- #
121
+ #
122
122
  def close(text, color: DEFAULT_FRAME_COLOR, elapsed: nil)
123
123
  color = Dev::UI.resolve_color(color)
124
124
 
@@ -188,7 +188,7 @@ module Dev
188
188
  pfx
189
189
  end
190
190
 
191
- # Override a color for a given thread.
191
+ # Override a color for a given thread.
192
192
  #
193
193
  # ==== Attributes
194
194
  #
@@ -202,7 +202,7 @@ module Dev
202
202
  Thread.current[:devui_frame_color_override] = prev
203
203
  end
204
204
 
205
- # The width of a prefix given the number of Frames in the stack
205
+ # The width of a prefix given the number of Frames in the stack
206
206
  #
207
207
  def prefix_width
208
208
  w = FrameStack.items.size
@@ -254,15 +254,15 @@ module Dev
254
254
  # Jumping around the line can cause some unwanted flashes
255
255
  o << Dev::UI::ANSI.hide_cursor
256
256
 
257
- if is_ci
258
- # In CI, we can't use absolute horizontal positions because of timestamps.
259
- # So we move around the line by offset from this cursor position.
260
- o << Dev::UI::ANSI.cursor_save
261
- else
262
- # Outside of CI, we reset to column 1 so that things like ^C don't
263
- # cause output misformatting.
264
- o << "\r"
265
- end
257
+ o << if is_ci
258
+ # In CI, we can't use absolute horizontal positions because of timestamps.
259
+ # So we move around the line by offset from this cursor position.
260
+ Dev::UI::ANSI.cursor_save
261
+ else
262
+ # Outside of CI, we reset to column 1 so that things like ^C don't
263
+ # cause output misformatting.
264
+ "\r"
265
+ end
266
266
 
267
267
  o << color.code
268
268
  o << Dev::UI::Box::Heavy::HORZ * termwidth # draw a full line
@@ -4,6 +4,9 @@ require 'readline'
4
4
  module Dev
5
5
  module UI
6
6
  module Prompt
7
+ autoload :InteractiveOptions, 'dev/ui/prompt/interactive_options'
8
+ private_constant :InteractiveOptions
9
+
7
10
  class << self
8
11
  # Ask a user a question with either free form answer or a set of answers
9
12
  # Do not use this method for yes/no questions. Use +confirm+
@@ -12,7 +15,7 @@ module Dev
12
15
  # * Handles free form answers (options are nil)
13
16
  # * Handles default answers for free form text
14
17
  # * Handles file auto completion for file input
15
- # * Handles interactively choosing answers using +InteractivePrompt+
18
+ # * Handles interactively choosing answers using +InteractiveOptions+
16
19
  #
17
20
  # https://user-images.githubusercontent.com/3074765/33799822-47f23302-dd01-11e7-82f3-9072a5a5f611.png
18
21
  #
@@ -22,7 +25,7 @@ module Dev
22
25
  #
23
26
  # ==== Options
24
27
  #
25
- # * +:options+ - Options to ask the user. Will use +InteractivePrompt+ to do so
28
+ # * +:options+ - Options to ask the user. Will use +InteractiveOptions+ to do so
26
29
  # * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
27
30
  # * +:is_file+ - Tells the input to use file auto-completion (tab completion)
28
31
  # * +:allow_empty+ - Allows the answer to be empty
@@ -62,8 +65,21 @@ module Dev
62
65
  puts_question(question)
63
66
  end
64
67
 
65
- return InteractivePrompt.call(options) if options
68
+ # Present the user with options
69
+ if options
70
+ resp = InteractiveOptions.call(options)
71
+
72
+ # Clear the line, and reset the question to include the answer
73
+ print(ANSI.previous_line + ANSI.end_of_line + ' ')
74
+ print(ANSI.cursor_save)
75
+ print(' ' * Dev::UI::Terminal.width)
76
+ print(ANSI.cursor_restore)
77
+ puts_question("#{question} (You chose: {{italic:#{resp}}})")
66
78
 
79
+ return resp
80
+ end
81
+
82
+ # Ask a free form question
67
83
  loop do
68
84
  line = readline(is_file: is_file)
69
85
 
@@ -83,12 +99,11 @@ module Dev
83
99
  #
84
100
  # ==== Example Usage:
85
101
  #
86
- # Free form question
102
+ # Confirmation question
87
103
  # Dev::UI::Prompt.confirm('Is the sky blue?')
88
104
  #
89
105
  def confirm(question)
90
- puts_question("#{question} {{yellow:(choose with ↓ ⏎)}}")
91
- InteractivePrompt.call(%w(yes no)) == 'yes'
106
+ ask(question, options: %w(yes no)) == 'yes'
92
107
  end
93
108
 
94
109
  private
@@ -0,0 +1,169 @@
1
+ require 'io/console'
2
+
3
+ module Dev
4
+ module UI
5
+ module Prompt
6
+ class InteractiveOptions
7
+ # Prompts the user with options
8
+ # Uses an interactive session to allow the user to pick an answer
9
+ # Can use arrows, y/n, numbers (1/2), and vim bindings to control
10
+ #
11
+ # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
12
+ #
13
+ # ==== Example Usage:
14
+ #
15
+ # Ask an interactive question
16
+ # Dev::UI::Prompt::InteractiveOptions.call(%w(rails go python))
17
+ #
18
+ def self.call(options)
19
+ list = new(options)
20
+ options[list.call - 1]
21
+ end
22
+
23
+ # Initializes a new +InteractiveOptions+
24
+ # Usually called from +self.call+
25
+ #
26
+ # ==== Example Usage:
27
+ #
28
+ # Dev::UI::Prompt::InteractiveOptions.new(%w(rails go python))
29
+ #
30
+ def initialize(options)
31
+ @options = options
32
+ @active = 1
33
+ @marker = '>'
34
+ @answer = nil
35
+ @state = :root
36
+ end
37
+
38
+ # Calls the +InteractiveOptions+ and asks the question
39
+ # Usually used from +self.call+
40
+ #
41
+ def call
42
+ Dev::UI.raw { print(ANSI.hide_cursor) }
43
+ while @answer.nil?
44
+ render_options
45
+ wait_for_user_input
46
+ reset_position
47
+ end
48
+ clear_output
49
+ @answer
50
+ ensure
51
+ Dev::UI.raw do
52
+ print(ANSI.show_cursor)
53
+ puts(ANSI.previous_line + ANSI.end_of_line)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def reset_position
60
+ # This will put us back at the beginning of the options
61
+ # When we redraw the options, they will be overwritten
62
+ Dev::UI.raw do
63
+ num_lines.times { print(ANSI.previous_line) }
64
+ print(ANSI.previous_line + ANSI.end_of_line + "\n")
65
+ end
66
+ end
67
+
68
+ def clear_output
69
+ Dev::UI.raw do
70
+ # Write over all lines with whitespace
71
+ num_lines.times { puts(' ' * Dev::UI::Terminal.width) }
72
+ end
73
+ reset_position
74
+ end
75
+
76
+ def num_lines
77
+ # @options will be an array of questions but each option can be multi-line
78
+ # so to get the # of lines, you need to join then split
79
+ joined_options = @options.join("\n")
80
+ joined_options.split("\n").reject(&:empty?).size
81
+ end
82
+
83
+ ESC = "\e"
84
+
85
+ def up
86
+ @active = @active - 1 >= 1 ? @active - 1 : @options.length
87
+ end
88
+
89
+ def down
90
+ @active = @active + 1 <= @options.length ? @active + 1 : 1
91
+ end
92
+
93
+ def select_n(n)
94
+ @active = n
95
+ @answer = n
96
+ end
97
+
98
+ def select_bool(char)
99
+ return unless (@options - %w(yes no)).empty?
100
+ opt = @options.detect { |o| o.start_with?(char) }
101
+ @active = @options.index(opt) + 1
102
+ @answer = @options.index(opt) + 1
103
+ end
104
+
105
+ # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
106
+ def wait_for_user_input
107
+ char = read_char
108
+ case @state
109
+ when :root
110
+ case char
111
+ when ESC ; @state = :esc
112
+ when 'k' ; up
113
+ when 'j' ; down
114
+ when ('1'..@options.size.to_s) ; select_n(char.to_i)
115
+ when 'y', 'n' ; select_bool(char)
116
+ when " ", "\r", "\n" ; @answer = @active # <enter>
117
+ when "\u0003" ; raise Interrupt # Ctrl-c
118
+ end
119
+ when :esc
120
+ case char
121
+ when '[' ; @state = :esc_bracket
122
+ else ; raise Interrupt # unhandled escape sequence.
123
+ end
124
+ when :esc_bracket
125
+ @state = :root
126
+ case char
127
+ when 'A' ; up
128
+ when 'B' ; down
129
+ else ; raise Interrupt # unhandled escape sequence.
130
+ end
131
+ end
132
+ end
133
+ # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
134
+
135
+ def read_char
136
+ raw_tty! { $stdin.getc.chr }
137
+ rescue IOError
138
+ "\e"
139
+ end
140
+
141
+ def raw_tty!
142
+ if ENV['TEST'] || !$stdin.tty?
143
+ yield
144
+ else
145
+ $stdin.raw { yield }
146
+ end
147
+ end
148
+
149
+ def render_options
150
+ @options.each_with_index do |choice, index|
151
+ num = index + 1
152
+ message = " #{num}."
153
+ message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
154
+
155
+ if num == @active
156
+ message = message.split("\n").map.with_index do |l, idx|
157
+ idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
158
+ end.join("\n")
159
+ end
160
+
161
+ Dev::UI.with_frame_color(:blue) do
162
+ puts Dev::UI.fmt(message)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -201,7 +201,7 @@ module Dev
201
201
 
202
202
  Dev::UI::Frame.open('Task Failed: ' + task.title, color: :red) do
203
203
  if e
204
- puts"#{e.class}: #{e.message}"
204
+ puts "#{e.class}: #{e.message}"
205
205
  puts "\tfrom #{e.backtrace.join("\n\tfrom ")}"
206
206
  end
207
207
 
@@ -8,7 +8,7 @@ module Dev
8
8
  # Otherwise will return 80
9
9
  #
10
10
  def self.width
11
- if console = IO.console
11
+ if console = IO.respond_to?(:console) && IO.console
12
12
  console.winsize[1]
13
13
  else
14
14
  80
@@ -1,5 +1,5 @@
1
1
  module Dev
2
2
  module UI
3
- VERSION = "0.1.1"
3
+ VERSION = "0.1.2"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,15 +1,16 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dev-ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Burke Libbey
8
8
  - Julian Nadeau
9
+ - Lisa Ugray
9
10
  autorequire:
10
11
  bindir: exe
11
12
  cert_chain: []
12
- date: 2017-12-11 00:00:00.000000000 Z
13
+ date: 2017-12-13 00:00:00.000000000 Z
13
14
  dependencies:
14
15
  - !ruby/object:Gem::Dependency
15
16
  name: bundler
@@ -57,6 +58,7 @@ description: Terminal UI framework
57
58
  email:
58
59
  - burke.libbey@shopify.com
59
60
  - julian.nadeau@shopify.com
61
+ - lisa.ugray@shopify.com
60
62
  executables: []
61
63
  extensions: []
62
64
  extra_rdoc_files: []
@@ -68,8 +70,8 @@ files:
68
70
  - Gemfile.lock
69
71
  - LICENSE.txt
70
72
  - README.md
73
+ - Rakefile
71
74
  - bin/console
72
- - bin/testunit
73
75
  - dev-ui.gemspec
74
76
  - dev.yml
75
77
  - lib/dev/ui.rb
@@ -79,9 +81,9 @@ files:
79
81
  - lib/dev/ui/formatter.rb
80
82
  - lib/dev/ui/frame.rb
81
83
  - lib/dev/ui/glyph.rb
82
- - lib/dev/ui/interactive_prompt.rb
83
84
  - lib/dev/ui/progress.rb
84
85
  - lib/dev/ui/prompt.rb
86
+ - lib/dev/ui/prompt/interactive_options.rb
85
87
  - lib/dev/ui/spinner.rb
86
88
  - lib/dev/ui/spinner/async.rb
87
89
  - lib/dev/ui/spinner/spin_group.rb
@@ -108,7 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
110
  version: '0'
109
111
  requirements: []
110
112
  rubyforge_project:
111
- rubygems_version: 2.6.14
113
+ rubygems_version: 2.5.2.1
112
114
  signing_key:
113
115
  specification_version: 4
114
116
  summary: Terminal UI framework
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env ruby --disable-gems
2
-
3
- root = File.expand_path('../..', __FILE__)
4
- DEV_TEST_ROOT = root + '/test'
5
-
6
- $LOAD_PATH.unshift(DEV_TEST_ROOT)
7
-
8
- def test_files
9
- Dir.glob(DEV_TEST_ROOT + "/**/*_test.rb")
10
- end
11
-
12
- if ARGV.empty?
13
- test_files.each { |f| require(f) }
14
- exit 0
15
- end
16
-
17
- # A list of files is presumed to be specified
18
- ARGV.each do |a|
19
- require a.sub(%r{^test/}, '').sub(%r{^misc/gatekeeper/test/}, '')
20
- end
@@ -1,150 +0,0 @@
1
- require 'io/console'
2
-
3
- module Dev
4
- module UI
5
- class InteractivePrompt
6
- # Prompts the user with options
7
- # Uses an interactive session to allow the user to pick an answer
8
- # Can use arrows, y/n, numbers (1/2), and vim bindings to control
9
- #
10
- # https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
11
- #
12
- # ==== Example Usage:
13
- #
14
- # Ask an interactive question
15
- # Dev::UI::InteractivePrompt.call(%w(rails go python))
16
- #
17
- def self.call(options)
18
- list = new(options)
19
- options[list.call - 1]
20
- end
21
-
22
- # Initializes a new +InteractivePrompt+
23
- # Usually called from +self.call+
24
- #
25
- # ==== Example Usage:
26
- #
27
- # Dev::UI::InteractivePrompt.new(%w(rails go python))
28
- #
29
- def initialize(options)
30
- @options = options
31
- @active = 1
32
- @marker = '>'
33
- @answer = nil
34
- @state = :root
35
- end
36
-
37
- # Calls the +InteractivePrompt+ and asks the question
38
- # Usually used from +self.call+
39
- #
40
- def call
41
- Dev::UI.raw { print(ANSI.hide_cursor) }
42
- while @answer.nil?
43
- render_options
44
- wait_for_user_input
45
-
46
- # This will put us back at the beginning of the options
47
- # When we redraw the options, they will be overwritten
48
- Dev::UI.raw do
49
- num_lines = @options.join("\n").split("\n").reject(&:empty?).size
50
- num_lines.times { print(ANSI.previous_line) }
51
- print(ANSI.previous_line + ANSI.end_of_line + "\n")
52
- end
53
- end
54
- render_options
55
- @answer
56
- ensure
57
- Dev::UI.raw do
58
- print(ANSI.show_cursor)
59
- puts(ANSI.previous_line + ANSI.end_of_line)
60
- end
61
- end
62
-
63
- private
64
-
65
- ESC = "\e"
66
-
67
- def up
68
- @active = @active - 1 >= 1 ? @active - 1 : @options.length
69
- end
70
-
71
- def down
72
- @active = @active + 1 <= @options.length ? @active + 1 : 1
73
- end
74
-
75
- def select_n(n)
76
- @active = n
77
- @answer = n
78
- end
79
-
80
- def select_bool(char)
81
- return unless (@options - %w(yes no)).empty?
82
- opt = @options.detect { |o| o.start_with?(char) }
83
- @active = @options.index(opt) + 1
84
- @answer = @options.index(opt) + 1
85
- end
86
-
87
- # rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
88
- def wait_for_user_input
89
- char = read_char
90
- case @state
91
- when :root
92
- case char
93
- when ESC ; @state = :esc
94
- when 'k' ; up
95
- when 'j' ; down
96
- when ('1'..@options.size.to_s) ; select_n(char.to_i)
97
- when 'y', 'n' ; select_bool(char)
98
- when " ", "\r", "\n" ; @answer = @active # <enter>
99
- when "\u0003" ; raise Interrupt # Ctrl-c
100
- end
101
- when :esc
102
- case char
103
- when '[' ; @state = :esc_bracket
104
- else ; raise Interrupt # unhandled escape sequence.
105
- end
106
- when :esc_bracket
107
- @state = :root
108
- case char
109
- when 'A' ; up
110
- when 'B' ; down
111
- else ; raise Interrupt # unhandled escape sequence.
112
- end
113
- end
114
- end
115
- # rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
116
-
117
- def read_char
118
- raw_tty! { $stdin.getc.chr }
119
- rescue IOError
120
- "\e"
121
- end
122
-
123
- def raw_tty!
124
- if ENV['TEST'] || !$stdin.tty?
125
- yield
126
- else
127
- $stdin.raw { yield }
128
- end
129
- end
130
-
131
- def render_options
132
- @options.each_with_index do |choice, index|
133
- num = index + 1
134
- message = " #{num}."
135
- message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
136
-
137
- if num == @active
138
- message = message.split("\n").map.with_index do |l, idx|
139
- idx == 0 ? "{{blue:> #{l.strip}}}" : "{{blue:>#{l.strip}}}"
140
- end.join("\n")
141
- end
142
-
143
- Dev::UI.with_frame_color(:blue) do
144
- puts Dev::UI.fmt(message)
145
- end
146
- end
147
- end
148
- end
149
- end
150
- end