cli-ui 1.2.0 โ 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/.dependabot/config.yml +8 -0
- data/.github/CODEOWNERS +1 -0
- data/.github/probots.yml +2 -0
- data/.gitignore +0 -1
- data/.rubocop.yml +23 -2
- data/.travis.yml +4 -2
- data/Gemfile.lock +56 -0
- data/README.md +50 -3
- data/Rakefile +1 -1
- data/cli-ui.gemspec +4 -4
- data/dev.yml +1 -1
- data/lib/cli/ui.rb +61 -20
- data/lib/cli/ui/ansi.rb +9 -3
- data/lib/cli/ui/color.rb +12 -7
- data/lib/cli/ui/formatter.rb +34 -21
- data/lib/cli/ui/frame.rb +108 -149
- data/lib/cli/ui/frame/frame_stack.rb +98 -0
- data/lib/cli/ui/frame/frame_style.rb +120 -0
- data/lib/cli/ui/frame/frame_style/box.rb +166 -0
- data/lib/cli/ui/frame/frame_style/bracket.rb +139 -0
- data/lib/cli/ui/glyph.rb +22 -17
- data/lib/cli/ui/os.rb +63 -0
- data/lib/cli/ui/printer.rb +47 -0
- data/lib/cli/ui/progress.rb +10 -8
- data/lib/cli/ui/prompt.rb +88 -17
- data/lib/cli/ui/prompt/interactive_options.rb +261 -50
- data/lib/cli/ui/prompt/options_handler.rb +7 -2
- data/lib/cli/ui/spinner.rb +23 -5
- data/lib/cli/ui/spinner/spin_group.rb +39 -11
- data/lib/cli/ui/stdout_router.rb +12 -7
- data/lib/cli/ui/terminal.rb +26 -16
- data/lib/cli/ui/truncater.rb +3 -3
- data/lib/cli/ui/version.rb +1 -1
- data/lib/cli/ui/widgets.rb +77 -0
- data/lib/cli/ui/widgets/base.rb +27 -0
- data/lib/cli/ui/widgets/status.rb +61 -0
- metadata +21 -24
- data/lib/cli/ui/box.rb +0 -15
data/lib/cli/ui/glyph.rb
CHANGED
@@ -5,6 +5,7 @@ module CLI
|
|
5
5
|
class Glyph
|
6
6
|
class InvalidGlyphHandle < ArgumentError
|
7
7
|
def initialize(handle)
|
8
|
+
super
|
8
9
|
@handle = handle
|
9
10
|
end
|
10
11
|
|
@@ -15,7 +16,7 @@ module CLI
|
|
15
16
|
end
|
16
17
|
end
|
17
18
|
|
18
|
-
attr_reader :handle, :codepoint, :color, :
|
19
|
+
attr_reader :handle, :codepoint, :color, :to_s, :fmt
|
19
20
|
|
20
21
|
# Creates a new glyph
|
21
22
|
#
|
@@ -23,35 +24,39 @@ module CLI
|
|
23
24
|
#
|
24
25
|
# * +handle+ - The handle in the +MAP+ constant
|
25
26
|
# * +codepoint+ - The codepoint used to create the glyph (e.g. +0x2717+ for a ballot X)
|
27
|
+
# * +plain+ - A fallback plain string to be used in case glyphs are disabled
|
26
28
|
# * +color+ - What color to output the glyph. Check +CLI::UI::Color+ for options.
|
27
29
|
#
|
28
|
-
def initialize(handle, codepoint, color)
|
30
|
+
def initialize(handle, codepoint, plain, color)
|
29
31
|
@handle = handle
|
30
32
|
@codepoint = codepoint
|
31
33
|
@color = color
|
32
|
-
@
|
34
|
+
@plain = plain
|
35
|
+
@char = Array(codepoint).pack('U*')
|
33
36
|
@to_s = color.code + char + Color::RESET.code
|
34
37
|
@fmt = "{{#{color.name}:#{char}}}"
|
35
38
|
|
36
39
|
MAP[handle] = self
|
37
40
|
end
|
38
41
|
|
42
|
+
# Fetches the actual character(s) to be displayed for a glyph, based on the current OS support
|
43
|
+
#
|
44
|
+
# ==== Returns
|
45
|
+
# Returns the glyph string
|
46
|
+
def char
|
47
|
+
CLI::UI::OS.current.supports_emoji? ? @char : @plain
|
48
|
+
end
|
49
|
+
|
39
50
|
# Mapping of glyphs to terminal output
|
40
51
|
MAP = {}
|
41
|
-
#
|
42
|
-
|
43
|
-
# BLUE
|
44
|
-
|
45
|
-
#
|
46
|
-
|
47
|
-
#
|
48
|
-
|
49
|
-
# RED BALLOT X (โ)
|
50
|
-
X = new('x', 0x2717, Color::RED)
|
51
|
-
# Bug emoji (๐)
|
52
|
-
BUG = new('b', 0x1f41b, Color::WHITE)
|
53
|
-
# RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (ยป)
|
54
|
-
CHEVRON = new('>', 0xbb, Color::YELLOW)
|
52
|
+
STAR = new('*', 0x2b51, '*', Color::YELLOW) # YELLOW SMALL STAR (โญ)
|
53
|
+
INFO = new('i', 0x1d4be, 'i', Color::BLUE) # BLUE MATHEMATICAL SCRIPT SMALL i (๐พ)
|
54
|
+
QUESTION = new('?', 0x003f, '?', Color::BLUE) # BLUE QUESTION MARK (?)
|
55
|
+
CHECK = new('v', 0x2713, 'โ', Color::GREEN) # GREEN CHECK MARK (โ)
|
56
|
+
X = new('x', 0x2717, 'X', Color::RED) # RED BALLOT X (โ)
|
57
|
+
BUG = new('b', 0x1f41b, '!', Color::WHITE) # Bug emoji (๐)
|
58
|
+
CHEVRON = new('>', 0xbb, 'ยป', Color::YELLOW) # RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK (ยป)
|
59
|
+
HOURGLASS = new('H', [0x231b, 0xfe0e], 'H', Color::BLUE) # HOURGLASS + VARIATION SELECTOR 15 (โ๏ธ)
|
55
60
|
|
56
61
|
# Looks up a glyph by name
|
57
62
|
#
|
data/lib/cli/ui/os.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
module CLI
|
2
|
+
module UI
|
3
|
+
module OS
|
4
|
+
# Determines which OS is currently running the UI, to make it easier to
|
5
|
+
# adapt its behaviour to the features of the OS.
|
6
|
+
def self.current
|
7
|
+
@current_os ||= case RUBY_PLATFORM
|
8
|
+
when /darwin/
|
9
|
+
Mac
|
10
|
+
when /linux/
|
11
|
+
Linux
|
12
|
+
when /mingw32/
|
13
|
+
Windows
|
14
|
+
else
|
15
|
+
raise "Could not determine OS from platform #{RUBY_PLATFORM}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class Mac
|
20
|
+
class << self
|
21
|
+
def supports_emoji?
|
22
|
+
true
|
23
|
+
end
|
24
|
+
|
25
|
+
def supports_color_prompt?
|
26
|
+
true
|
27
|
+
end
|
28
|
+
|
29
|
+
def supports_arrow_keys?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
def shift_cursor_on_line_reset?
|
34
|
+
false
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
class Linux < Mac
|
40
|
+
end
|
41
|
+
|
42
|
+
class Windows
|
43
|
+
class << self
|
44
|
+
def supports_emoji?
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
def supports_color_prompt?
|
49
|
+
false
|
50
|
+
end
|
51
|
+
|
52
|
+
def supports_arrow_keys?
|
53
|
+
false
|
54
|
+
end
|
55
|
+
|
56
|
+
def shift_cursor_on_line_reset?
|
57
|
+
true
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'cli/ui'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
class Printer
|
6
|
+
# Print a message to a stream with common utilities.
|
7
|
+
# Allows overriding the color, encoding, and target stream.
|
8
|
+
# By default, it formats the string using CLI:UI and rescues common stream errors.
|
9
|
+
#
|
10
|
+
# ==== Attributes
|
11
|
+
#
|
12
|
+
# * +msg+ - (required) the string to output. Can be frozen.
|
13
|
+
#
|
14
|
+
# ==== Options
|
15
|
+
#
|
16
|
+
# * +:frame_color+ - Override the frame color. Defaults to nil.
|
17
|
+
# * +:to+ - Target stream, like $stdout or $stderr. Can be anything with a puts method. Defaults to $stdout.
|
18
|
+
# * +:encoding+ - Force the output to be in a certain encoding. Defaults to UTF-8.
|
19
|
+
# * +:format+ - Whether to format the string using CLI::UI.fmt. Defaults to true.
|
20
|
+
# * +:graceful+ - Whether to gracefully ignore common I/O errors. Defaults to true.
|
21
|
+
#
|
22
|
+
# ==== Returns
|
23
|
+
# Returns whether the message was successfully printed,
|
24
|
+
# which can be useful if +:graceful+ is set to true.
|
25
|
+
#
|
26
|
+
# ==== Example
|
27
|
+
#
|
28
|
+
# CLI::UI::Printer.puts('{x} Ouch', stream: $stderr, color: :red)
|
29
|
+
#
|
30
|
+
def self.puts(msg, frame_color: nil, to: $stdout, encoding: Encoding::UTF_8, format: true, graceful: true)
|
31
|
+
msg = (+msg).force_encoding(encoding) if encoding
|
32
|
+
msg = CLI::UI.fmt(msg) if format
|
33
|
+
|
34
|
+
if frame_color
|
35
|
+
CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
|
36
|
+
else
|
37
|
+
to.puts(msg)
|
38
|
+
end
|
39
|
+
|
40
|
+
true
|
41
|
+
rescue Errno::EIO, Errno::EPIPE, IOError => e
|
42
|
+
raise(e) unless graceful
|
43
|
+
false
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
data/lib/cli/ui/progress.rb
CHANGED
@@ -26,11 +26,11 @@ module CLI
|
|
26
26
|
#
|
27
27
|
# Increase the percent by X
|
28
28
|
# CLI::UI::Progress.progress do |bar|
|
29
|
-
# bar.tick(percent:
|
29
|
+
# bar.tick(percent: 0.05)
|
30
30
|
# end
|
31
31
|
def self.progress(width: Terminal.width)
|
32
32
|
bar = Progress.new(width: width)
|
33
|
-
print
|
33
|
+
print(CLI::UI::ANSI.hide_cursor)
|
34
34
|
yield(bar)
|
35
35
|
ensure
|
36
36
|
puts bar.to_s
|
@@ -59,29 +59,31 @@ module CLI
|
|
59
59
|
# * +:percent+ - Increment progress by a specific percent amount
|
60
60
|
# * +:set_percent+ - Set progress to a specific percent
|
61
61
|
#
|
62
|
+
# *Note:* The +:percent+ and +:set_percent must be between 0.00 and 1.0
|
63
|
+
#
|
62
64
|
def tick(percent: 0.01, set_percent: nil)
|
63
65
|
raise ArgumentError, 'percent and set_percent cannot both be specified' if percent != 0.01 && set_percent
|
64
66
|
@percent_done += percent
|
65
67
|
@percent_done = set_percent if set_percent
|
66
68
|
@percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
|
67
69
|
|
68
|
-
print
|
69
|
-
print
|
70
|
+
print(to_s)
|
71
|
+
print(CLI::UI::ANSI.previous_line + "\n")
|
70
72
|
end
|
71
73
|
|
72
74
|
# Format the progress bar to be printed to terminal
|
73
75
|
#
|
74
76
|
def to_s
|
75
|
-
suffix = " #{(@percent_done * 100).
|
77
|
+
suffix = " #{(@percent_done * 100).floor}%".ljust(5)
|
76
78
|
workable_width = @max_width - Frame.prefix_width - suffix.size
|
77
79
|
filled = [(@percent_done * workable_width.to_f).ceil, 0].max
|
78
80
|
unfilled = [workable_width - filled, 0].max
|
79
81
|
|
80
|
-
CLI::UI.resolve_text
|
82
|
+
CLI::UI.resolve_text([
|
81
83
|
FILLED_BAR + ' ' * filled,
|
82
84
|
UNFILLED_BAR + ' ' * unfilled,
|
83
|
-
CLI::UI::Color::RESET.code + suffix
|
84
|
-
].join
|
85
|
+
CLI::UI::Color::RESET.code + suffix,
|
86
|
+
].join)
|
85
87
|
end
|
86
88
|
end
|
87
89
|
end
|
data/lib/cli/ui/prompt.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# coding: utf-8
|
1
2
|
require 'cli/ui'
|
2
3
|
require 'readline'
|
3
4
|
|
@@ -30,11 +31,16 @@ module CLI
|
|
30
31
|
# * +:default+ - The default answer to the question (e.g. they just press enter and don't input anything)
|
31
32
|
# * +:is_file+ - Tells the input to use file auto-completion (tab completion)
|
32
33
|
# * +:allow_empty+ - Allows the answer to be empty
|
34
|
+
# * +:multiple+ - Allow multiple options to be selected
|
35
|
+
# * +:filter_ui+ - Enable option filtering (default: true)
|
36
|
+
# * +:select_ui+ - Enable long-form option selection (default: true)
|
33
37
|
#
|
34
38
|
# Note:
|
35
|
-
# * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
|
39
|
+
# * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
|
40
|
+
# you cannot set options with either of these keywords
|
36
41
|
# * +:default+ conflicts with +:allow_empty:, you cannot set these together
|
37
42
|
# * +:options+ conflicts with providing a +Block+ , you may only set one
|
43
|
+
# * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
|
38
44
|
#
|
39
45
|
# ==== Block (optional)
|
40
46
|
#
|
@@ -44,7 +50,7 @@ module CLI
|
|
44
50
|
# ==== Return Value
|
45
51
|
#
|
46
52
|
# * If a +Block+ was not provided, the selected option or response to the free form question will be returned
|
47
|
-
# * If a +Block+ was provided, the
|
53
|
+
# * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
|
48
54
|
#
|
49
55
|
# ==== Example Usage:
|
50
56
|
#
|
@@ -71,18 +77,67 @@ module CLI
|
|
71
77
|
# handler.option('python') { |selection| selection }
|
72
78
|
# end
|
73
79
|
#
|
74
|
-
def ask(
|
75
|
-
|
80
|
+
def ask(
|
81
|
+
question,
|
82
|
+
options: nil,
|
83
|
+
default: nil,
|
84
|
+
is_file: nil,
|
85
|
+
allow_empty: true,
|
86
|
+
multiple: false,
|
87
|
+
filter_ui: true,
|
88
|
+
select_ui: true,
|
89
|
+
&options_proc
|
90
|
+
)
|
91
|
+
if (options || block_given?) && ((default && !multiple) || is_file)
|
76
92
|
raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
|
77
93
|
end
|
78
94
|
|
95
|
+
if options && multiple && default && !(default - options).empty?
|
96
|
+
raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
|
97
|
+
end
|
98
|
+
|
79
99
|
if options || block_given?
|
80
|
-
ask_interactive(
|
100
|
+
ask_interactive(
|
101
|
+
question,
|
102
|
+
options,
|
103
|
+
multiple: multiple,
|
104
|
+
default: default,
|
105
|
+
filter_ui: filter_ui,
|
106
|
+
select_ui: select_ui,
|
107
|
+
&options_proc
|
108
|
+
)
|
81
109
|
else
|
82
110
|
ask_free_form(question, default, is_file, allow_empty)
|
83
111
|
end
|
84
112
|
end
|
85
113
|
|
114
|
+
# Asks the user for a single-line answer, without displaying the characters while typing.
|
115
|
+
# Typically used for password prompts
|
116
|
+
#
|
117
|
+
# ==== Return Value
|
118
|
+
#
|
119
|
+
# The password, without a trailing newline.
|
120
|
+
# If the user simply presses "Enter" without typing any password, this will return an empty string.
|
121
|
+
def ask_password(question)
|
122
|
+
require 'io/console'
|
123
|
+
|
124
|
+
CLI::UI.with_frame_color(:blue) do
|
125
|
+
STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
|
126
|
+
|
127
|
+
# noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
|
128
|
+
# No fancy Readline integration (like echoing back) is required for a password prompt anyway.
|
129
|
+
password = STDIN.noecho do
|
130
|
+
# Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
|
131
|
+
# " 123 \n".chomp => " 123 "
|
132
|
+
STDIN.gets.chomp
|
133
|
+
end
|
134
|
+
|
135
|
+
STDOUT.puts # Complete the line
|
136
|
+
|
137
|
+
password
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
86
141
|
# Asks the user a yes/no question.
|
87
142
|
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
88
143
|
#
|
@@ -91,14 +146,18 @@ module CLI
|
|
91
146
|
# Confirmation question
|
92
147
|
# CLI::UI::Prompt.confirm('Is the sky blue?')
|
93
148
|
#
|
94
|
-
|
95
|
-
|
149
|
+
# CLI::UI::Prompt.confirm('Do a dangerous thing?', default: false)
|
150
|
+
#
|
151
|
+
def confirm(question, default: true)
|
152
|
+
ask_interactive(question, default ? %w(yes no) : %w(no yes), filter_ui: false) == 'yes'
|
96
153
|
end
|
97
154
|
|
98
155
|
private
|
99
156
|
|
100
157
|
def ask_free_form(question, default, is_file, allow_empty)
|
101
|
-
|
158
|
+
if default && !allow_empty
|
159
|
+
raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
|
160
|
+
end
|
102
161
|
|
103
162
|
if default
|
104
163
|
puts_question("#{question} (empty = #{default})")
|
@@ -121,7 +180,7 @@ module CLI
|
|
121
180
|
end
|
122
181
|
end
|
123
182
|
|
124
|
-
def ask_interactive(question, options = nil, multiple: false)
|
183
|
+
def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
|
125
184
|
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
|
126
185
|
|
127
186
|
options ||= if block_given?
|
@@ -130,15 +189,23 @@ module CLI
|
|
130
189
|
handler.options
|
131
190
|
end
|
132
191
|
|
133
|
-
raise(ArgumentError, 'insufficient options') if options.nil? || options.
|
134
|
-
|
192
|
+
raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
|
193
|
+
navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
|
194
|
+
"Choose with โ โ โ"
|
195
|
+
else
|
196
|
+
"Navigate up with 'k' and down with 'j', press Enter to select"
|
197
|
+
end
|
198
|
+
|
199
|
+
instructions = (multiple ? "Toggle options. " : "") + navigate_text
|
200
|
+
instructions += ", filter with 'f'" if filter_ui
|
201
|
+
instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
|
135
202
|
puts_question("#{question} {{yellow:(#{instructions})}}")
|
136
|
-
resp = interactive_prompt(options, multiple: multiple)
|
203
|
+
resp = interactive_prompt(options, multiple: multiple, default: default)
|
137
204
|
|
138
205
|
# Clear the line
|
139
|
-
print
|
206
|
+
print(ANSI.previous_line + ANSI.clear_to_end_of_line)
|
140
207
|
# Force StdoutRouter to prefix
|
141
|
-
print
|
208
|
+
print(ANSI.previous_line + "\n")
|
142
209
|
|
143
210
|
# reset the question to include the answer
|
144
211
|
resp_text = resp
|
@@ -159,8 +226,8 @@ module CLI
|
|
159
226
|
end
|
160
227
|
|
161
228
|
# Useful for stubbing in tests
|
162
|
-
def interactive_prompt(options, multiple: false)
|
163
|
-
InteractiveOptions.call(options, multiple: multiple)
|
229
|
+
def interactive_prompt(options, multiple: false, default: nil)
|
230
|
+
InteractiveOptions.call(options, multiple: multiple, default: default)
|
164
231
|
end
|
165
232
|
|
166
233
|
def write_default_over_empty_input(default)
|
@@ -195,10 +262,14 @@ module CLI
|
|
195
262
|
# thread to manage output, but the current strategy feels like a
|
196
263
|
# better tradeoff.
|
197
264
|
prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
|
198
|
-
prompt
|
265
|
+
# If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
|
266
|
+
# not change the colour here.
|
267
|
+
prompt = prefix + CLI::UI.fmt('{{blue:> }}')
|
268
|
+
prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
|
199
269
|
|
200
270
|
begin
|
201
271
|
line = Readline.readline(prompt, true)
|
272
|
+
print(CLI::UI::Color::RESET.code)
|
202
273
|
line.to_s.chomp
|
203
274
|
rescue Interrupt
|
204
275
|
CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
|
@@ -1,3 +1,4 @@
|
|
1
|
+
# coding: utf-8
|
1
2
|
require 'io/console'
|
2
3
|
|
3
4
|
module CLI
|
@@ -10,6 +11,9 @@ module CLI
|
|
10
11
|
# Prompts the user with options
|
11
12
|
# Uses an interactive session to allow the user to pick an answer
|
12
13
|
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
14
|
+
# For more than 9 options, hitting 'e', ':', or 'G' will enter select
|
15
|
+
# mode allowing the user to type in longer numbers
|
16
|
+
# Pressing 'f' or '/' will allow the user to filter the results
|
13
17
|
#
|
14
18
|
# https://user-images.githubusercontent.com/3074765/33797984-0ebb5e64-dcdf-11e7-9e7e-7204f279cece.gif
|
15
19
|
#
|
@@ -18,8 +22,8 @@ module CLI
|
|
18
22
|
# Ask an interactive question
|
19
23
|
# CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
|
20
24
|
#
|
21
|
-
def self.call(options, multiple: false)
|
22
|
-
list = new(options, multiple: multiple)
|
25
|
+
def self.call(options, multiple: false, default: nil)
|
26
|
+
list = new(options, multiple: multiple, default: default)
|
23
27
|
selected = list.call
|
24
28
|
if multiple
|
25
29
|
selected.map { |s| options[s - 1] }
|
@@ -35,23 +39,35 @@ module CLI
|
|
35
39
|
#
|
36
40
|
# CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
|
37
41
|
#
|
38
|
-
def initialize(options, multiple: false)
|
42
|
+
def initialize(options, multiple: false, default: nil)
|
39
43
|
@options = options
|
40
44
|
@active = 1
|
41
45
|
@marker = '>'
|
42
46
|
@answer = nil
|
43
47
|
@state = :root
|
44
48
|
@multiple = multiple
|
49
|
+
# Indicate that an extra line (the "metadata" line) is present and
|
50
|
+
# the terminal output should be drawn over when processing user input
|
51
|
+
@displaying_metadata = false
|
52
|
+
@filter = ''
|
45
53
|
# 0-indexed array representing if selected
|
46
54
|
# @options[0] is selected if @chosen[0]
|
47
|
-
|
55
|
+
if multiple
|
56
|
+
@chosen = if default
|
57
|
+
@options.map { |option| default.include?(option) }
|
58
|
+
else
|
59
|
+
Array.new(@options.size) { false }
|
60
|
+
end
|
61
|
+
end
|
48
62
|
@redraw = true
|
63
|
+
@presented_options = []
|
49
64
|
end
|
50
65
|
|
51
66
|
# Calls the +InteractiveOptions+ and asks the question
|
52
67
|
# Usually used from +self.call+
|
53
68
|
#
|
54
69
|
def call
|
70
|
+
calculate_option_line_lengths
|
55
71
|
CLI::UI.raw { print(ANSI.hide_cursor) }
|
56
72
|
while @answer.nil?
|
57
73
|
render_options
|
@@ -59,6 +75,7 @@ module CLI
|
|
59
75
|
reset_position
|
60
76
|
end
|
61
77
|
clear_output
|
78
|
+
|
62
79
|
@answer
|
63
80
|
ensure
|
64
81
|
CLI::UI.raw do
|
@@ -68,46 +85,96 @@ module CLI
|
|
68
85
|
|
69
86
|
private
|
70
87
|
|
71
|
-
def
|
88
|
+
def calculate_option_line_lengths
|
89
|
+
@terminal_width_at_calculation_time = CLI::UI::Terminal.width
|
90
|
+
# options will be an array of questions but each option can be multi-line
|
91
|
+
# so to get the # of lines, you need to join then split
|
92
|
+
|
93
|
+
# since lines may be longer than the terminal is wide, we need to
|
94
|
+
# determine how many extra lines would be taken up by them
|
95
|
+
max_width = (@terminal_width_at_calculation_time -
|
96
|
+
@options.count.to_s.size - # Width of the displayed number
|
97
|
+
5 - # Extra characters added during rendering
|
98
|
+
(@multiple ? 1 : 0) # Space for the checkbox, if rendered
|
99
|
+
).to_f
|
100
|
+
|
101
|
+
@option_lengths = @options.map do |text|
|
102
|
+
width = 1 if text.empty?
|
103
|
+
width ||= text
|
104
|
+
.split("\n")
|
105
|
+
.reject(&:empty?)
|
106
|
+
.map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
|
107
|
+
.reduce(&:+)
|
108
|
+
|
109
|
+
width
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def reset_position(number_of_lines = num_lines)
|
72
114
|
# This will put us back at the beginning of the options
|
73
115
|
# When we redraw the options, they will be overwritten
|
74
116
|
CLI::UI.raw do
|
75
|
-
|
117
|
+
number_of_lines.times { print(ANSI.previous_line) }
|
76
118
|
end
|
77
119
|
end
|
78
120
|
|
79
|
-
def clear_output
|
121
|
+
def clear_output(number_of_lines = num_lines)
|
80
122
|
CLI::UI.raw do
|
81
123
|
# Write over all lines with whitespace
|
82
|
-
|
124
|
+
number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
|
83
125
|
end
|
84
|
-
reset_position
|
126
|
+
reset_position(number_of_lines)
|
127
|
+
|
128
|
+
# Update if metadata is being displayed
|
129
|
+
# This must be done _after_ the output is cleared or it won't draw over
|
130
|
+
# the entire output
|
131
|
+
@displaying_metadata = display_metadata?
|
132
|
+
end
|
133
|
+
|
134
|
+
# Don't use this in place of +@displaying_metadata+, this updates too
|
135
|
+
# quickly to be useful when drawing to the screen.
|
136
|
+
def display_metadata?
|
137
|
+
filtering? || selecting? || has_filter?
|
85
138
|
end
|
86
139
|
|
87
140
|
def num_lines
|
88
|
-
|
89
|
-
# @options will be an array of questions but each option can be multi-line
|
90
|
-
# so to get the # of lines, you need to join then split
|
141
|
+
calculate_option_line_lengths if terminal_width_changed?
|
91
142
|
|
92
|
-
|
93
|
-
|
143
|
+
option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
|
144
|
+
# Handle continuation markers and "Done" option when multiple is true
|
145
|
+
next total_length + 1 if option_number.nil? || option_number.zero?
|
146
|
+
total_length + @option_lengths[option_number - 1]
|
147
|
+
end
|
148
|
+
|
149
|
+
option_length + (@displaying_metadata ? 1 : 0)
|
150
|
+
end
|
94
151
|
|
95
|
-
|
96
|
-
|
97
|
-
joined_options.split("\n").reject(&:empty?).size + empty_option_count
|
152
|
+
def terminal_width_changed?
|
153
|
+
@terminal_width_at_calculation_time != CLI::UI::Terminal.width
|
98
154
|
end
|
99
155
|
|
100
156
|
ESC = "\e"
|
157
|
+
BACKSPACE = "\u007F"
|
158
|
+
CTRL_C = "\u0003"
|
159
|
+
CTRL_D = "\u0004"
|
101
160
|
|
102
161
|
def up
|
103
|
-
|
104
|
-
|
162
|
+
active_index = @filtered_options.index { |_, num| num == @active } || 0
|
163
|
+
|
164
|
+
previous_visible = @filtered_options[active_index - 1]
|
165
|
+
previous_visible ||= @filtered_options.last
|
166
|
+
|
167
|
+
@active = previous_visible ? previous_visible.last : -1
|
105
168
|
@redraw = true
|
106
169
|
end
|
107
170
|
|
108
171
|
def down
|
109
|
-
|
110
|
-
|
172
|
+
active_index = @filtered_options.index { |_, num| num == @active } || 0
|
173
|
+
|
174
|
+
next_visible = @filtered_options[active_index + 1]
|
175
|
+
next_visible ||= @filtered_options.first
|
176
|
+
|
177
|
+
@active = next_visible ? next_visible.last : -1
|
111
178
|
@redraw = true
|
112
179
|
end
|
113
180
|
|
@@ -141,7 +208,36 @@ module CLI
|
|
141
208
|
@redraw = true
|
142
209
|
end
|
143
210
|
|
211
|
+
def build_selection(char)
|
212
|
+
@active = (@active.to_s + char).to_i
|
213
|
+
@redraw = true
|
214
|
+
end
|
215
|
+
|
216
|
+
def chop_selection
|
217
|
+
@active = @active.to_s.chop.to_i
|
218
|
+
@redraw = true
|
219
|
+
end
|
220
|
+
|
221
|
+
def update_search(char)
|
222
|
+
@redraw = true
|
223
|
+
|
224
|
+
# Control+D or Backspace on empty search closes search
|
225
|
+
if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE))
|
226
|
+
@filter = ''
|
227
|
+
@state = :root
|
228
|
+
return
|
229
|
+
end
|
230
|
+
|
231
|
+
if char == BACKSPACE
|
232
|
+
@filter.chop!
|
233
|
+
else
|
234
|
+
@filter += char
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
144
238
|
def select_current
|
239
|
+
# Prevent selection of invisible options
|
240
|
+
return unless presented_options.any? { |_, num| num == @active }
|
145
241
|
select_n(@active)
|
146
242
|
end
|
147
243
|
|
@@ -150,40 +246,93 @@ module CLI
|
|
150
246
|
wait_for_user_input until @redraw
|
151
247
|
end
|
152
248
|
|
153
|
-
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
249
|
+
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
|
154
250
|
def wait_for_user_input
|
155
251
|
char = read_char
|
252
|
+
@last_char = char
|
253
|
+
|
254
|
+
case char
|
255
|
+
when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
|
256
|
+
when CTRL_C ; raise Interrupt
|
257
|
+
end
|
258
|
+
|
259
|
+
max_digit = [@options.size, 9].min.to_s
|
156
260
|
case @state
|
157
261
|
when :root
|
158
262
|
case char
|
159
|
-
when
|
160
|
-
when
|
161
|
-
when '
|
162
|
-
when '
|
163
|
-
when '
|
164
|
-
when ('
|
165
|
-
when 'y', 'n'
|
166
|
-
when " ", "\r", "\n"
|
167
|
-
|
263
|
+
when ESC ; @state = :esc
|
264
|
+
when 'k' ; up
|
265
|
+
when 'j' ; down
|
266
|
+
when 'e', ':', 'G' ; start_line_select
|
267
|
+
when 'f', '/' ; start_filter
|
268
|
+
when ('0'..max_digit) ; select_n(char.to_i)
|
269
|
+
when 'y', 'n' ; select_bool(char)
|
270
|
+
when " ", "\r", "\n" ; select_current # <enter>
|
271
|
+
end
|
272
|
+
when :filter
|
273
|
+
case char
|
274
|
+
when ESC ; @state = :esc
|
275
|
+
when "\r", "\n" ; select_current
|
276
|
+
when "\b" ; update_search(BACKSPACE) # Happens on Windows
|
277
|
+
else ; update_search(char)
|
278
|
+
end
|
279
|
+
when :line_select
|
280
|
+
case char
|
281
|
+
when ESC ; @state = :esc
|
282
|
+
when 'k' ; up ; @state = :root
|
283
|
+
when 'j' ; down ; @state = :root
|
284
|
+
when 'e', ':', 'G', 'q' ; stop_line_select
|
285
|
+
when '0'..'9' ; build_selection(char)
|
286
|
+
when BACKSPACE ; chop_selection # Pop last input on backspace
|
287
|
+
when ' ', "\r", "\n" ; select_current
|
168
288
|
end
|
169
289
|
when :esc
|
170
290
|
case char
|
171
|
-
when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
|
172
291
|
when '[' ; @state = :esc_bracket
|
173
292
|
else ; raise Interrupt # unhandled escape sequence.
|
174
293
|
end
|
175
294
|
when :esc_bracket
|
176
|
-
@state = :root
|
295
|
+
@state = has_filter? ? :filter : :root
|
177
296
|
case char
|
178
|
-
when :timeout ; raise Interrupt # Timeout, use interrupt to simulate
|
179
297
|
when 'A' ; up
|
180
298
|
when 'B' ; down
|
299
|
+
when 'C' ; # Ignore right key
|
300
|
+
when 'D' ; # Ignore left key
|
181
301
|
else ; raise Interrupt # unhandled escape sequence.
|
182
302
|
end
|
183
303
|
end
|
184
304
|
end
|
185
305
|
# rubocop:enable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
186
306
|
|
307
|
+
def selecting?
|
308
|
+
@state == :line_select
|
309
|
+
end
|
310
|
+
|
311
|
+
def filtering?
|
312
|
+
@state == :filter
|
313
|
+
end
|
314
|
+
|
315
|
+
def has_filter?
|
316
|
+
!@filter.empty?
|
317
|
+
end
|
318
|
+
|
319
|
+
def start_filter
|
320
|
+
@state = :filter
|
321
|
+
@redraw = true
|
322
|
+
end
|
323
|
+
|
324
|
+
def start_line_select
|
325
|
+
@state = :line_select
|
326
|
+
@active = 0
|
327
|
+
@redraw = true
|
328
|
+
end
|
329
|
+
|
330
|
+
def stop_line_select
|
331
|
+
@state = :root
|
332
|
+
@active = 1 if @active.zero?
|
333
|
+
@redraw = true
|
334
|
+
end
|
335
|
+
|
187
336
|
def read_char
|
188
337
|
raw_tty! do
|
189
338
|
getc = $stdin.getc
|
@@ -205,9 +354,29 @@ module CLI
|
|
205
354
|
return @presented_options unless recalculate
|
206
355
|
|
207
356
|
@presented_options = @options.zip(1..Float::INFINITY)
|
208
|
-
|
357
|
+
if has_filter?
|
358
|
+
@presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
|
359
|
+
end
|
209
360
|
|
210
|
-
|
361
|
+
# Used for selection purposes
|
362
|
+
@presented_options.push([DONE, 0]) if @multiple
|
363
|
+
@filtered_options = @presented_options.dup
|
364
|
+
|
365
|
+
ensure_visible_is_active if has_filter?
|
366
|
+
|
367
|
+
# Must have more lines before the selection than we can display
|
368
|
+
if distance_from_start_to_selection > max_lines
|
369
|
+
@presented_options.shift(distance_from_start_to_selection - max_lines)
|
370
|
+
ensure_first_item_is_continuation_marker
|
371
|
+
end
|
372
|
+
|
373
|
+
# Must have more lines after the selection than we can display
|
374
|
+
if distance_from_selection_to_end > max_lines
|
375
|
+
@presented_options.pop(distance_from_selection_to_end - max_lines)
|
376
|
+
ensure_last_item_is_continuation_marker
|
377
|
+
end
|
378
|
+
|
379
|
+
while num_lines > max_lines
|
211
380
|
# try to keep the selection centered in the window:
|
212
381
|
if distance_from_selection_to_end > distance_from_start_to_selection
|
213
382
|
# selection is closer to top than bottom, so trim a row from the bottom
|
@@ -223,14 +392,22 @@ module CLI
|
|
223
392
|
@presented_options
|
224
393
|
end
|
225
394
|
|
395
|
+
def ensure_visible_is_active
|
396
|
+
unless presented_options.any? { |_, num| num == @active }
|
397
|
+
@active = presented_options.first&.last.to_i
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
226
401
|
def distance_from_selection_to_end
|
227
|
-
|
228
|
-
last_visible_option_number - @active
|
402
|
+
@presented_options.count - index_of_active_option
|
229
403
|
end
|
230
404
|
|
231
405
|
def distance_from_start_to_selection
|
232
|
-
|
233
|
-
|
406
|
+
index_of_active_option
|
407
|
+
end
|
408
|
+
|
409
|
+
def index_of_active_option
|
410
|
+
@presented_options.index { |_, num| num == @active }.to_i
|
234
411
|
end
|
235
412
|
|
236
413
|
def ensure_last_item_is_continuation_marker
|
@@ -241,15 +418,39 @@ module CLI
|
|
241
418
|
@presented_options.unshift(["...", nil]) if @presented_options.first.last
|
242
419
|
end
|
243
420
|
|
244
|
-
def
|
245
|
-
|
421
|
+
def max_lines
|
422
|
+
CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
|
246
423
|
end
|
247
424
|
|
248
425
|
def render_options
|
426
|
+
previously_displayed_lines = num_lines
|
427
|
+
|
428
|
+
@displaying_metadata = display_metadata?
|
429
|
+
|
430
|
+
options = presented_options(recalculate: true)
|
431
|
+
|
432
|
+
clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines
|
433
|
+
|
249
434
|
max_num_length = (@options.size + 1).to_s.length
|
250
435
|
|
251
|
-
|
252
|
-
|
436
|
+
metadata_text = if selecting?
|
437
|
+
select_text = @active
|
438
|
+
select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
|
439
|
+
"Select: #{select_text}"
|
440
|
+
elsif filtering? || has_filter?
|
441
|
+
filter_text = @filter
|
442
|
+
filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
|
443
|
+
"Filter: #{filter_text}"
|
444
|
+
end
|
445
|
+
|
446
|
+
if metadata_text
|
447
|
+
CLI::UI.with_frame_color(:blue) do
|
448
|
+
puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}")
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
options.each do |choice, num|
|
453
|
+
is_chosen = @multiple && num && @chosen[num - 1] && num != 0
|
253
454
|
|
254
455
|
padding = ' ' * (max_num_length - num.to_s.length)
|
255
456
|
message = " #{num}#{num ? '.' : ' '}#{padding}"
|
@@ -260,20 +461,30 @@ module CLI
|
|
260
461
|
format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
|
261
462
|
format = " #{format}"
|
262
463
|
|
263
|
-
message +=
|
264
|
-
message +=
|
464
|
+
message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
|
465
|
+
message += format_choice(format, choice)
|
265
466
|
|
266
467
|
if num == @active
|
267
|
-
|
268
|
-
|
269
|
-
|
468
|
+
|
469
|
+
color = filtering? || selecting? ? 'green' : 'blue'
|
470
|
+
message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
|
270
471
|
end
|
271
472
|
|
272
473
|
CLI::UI.with_frame_color(:blue) do
|
273
|
-
puts CLI::UI.fmt(message)
|
474
|
+
puts CLI::UI.fmt(message)
|
274
475
|
end
|
275
476
|
end
|
276
477
|
end
|
478
|
+
|
479
|
+
def format_choice(format, choice)
|
480
|
+
eol = CLI::UI::ANSI.clear_to_end_of_line
|
481
|
+
lines = choice.split("\n")
|
482
|
+
|
483
|
+
return eol if lines.empty? # Handle blank options
|
484
|
+
|
485
|
+
lines.map! { |l| format(format, l) + eol }
|
486
|
+
lines.join("\n")
|
487
|
+
end
|
277
488
|
end
|
278
489
|
end
|
279
490
|
end
|