cli-ui 1.2.0 โ 1.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.
- 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
|