cli-ui 1.2.2 → 1.5.1
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 +4 -4
- data/README.md +45 -1
- data/lib/cli/ui.rb +75 -29
- data/lib/cli/ui/ansi.rb +10 -6
- data/lib/cli/ui/color.rb +12 -7
- data/lib/cli/ui/formatter.rb +34 -21
- data/lib/cli/ui/frame.rb +111 -152
- 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 +23 -17
- data/lib/cli/ui/os.rb +67 -0
- data/lib/cli/ui/printer.rb +59 -0
- data/lib/cli/ui/progress.rb +9 -7
- data/lib/cli/ui/prompt.rb +97 -21
- data/lib/cli/ui/prompt/interactive_options.rb +75 -61
- 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 +34 -12
- data/lib/cli/ui/stdout_router.rb +13 -8
- data/lib/cli/ui/terminal.rb +26 -16
- data/lib/cli/ui/truncater.rb +4 -4
- 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
- data/lib/cli/ui/wrap.rb +56 -0
- metadata +17 -16
- data/.gitignore +0 -15
- data/.rubocop.yml +0 -17
- data/.travis.yml +0 -5
- data/Gemfile +0 -16
- data/Rakefile +0 -20
- data/bin/console +0 -14
- data/cli-ui.gemspec +0 -27
- data/dev.yml +0 -14
- data/lib/cli/ui/box.rb +0 -15
data/lib/cli/ui/os.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'rbconfig'
|
2
|
+
|
3
|
+
module CLI
|
4
|
+
module UI
|
5
|
+
module OS
|
6
|
+
# Determines which OS is currently running the UI, to make it easier to
|
7
|
+
# adapt its behaviour to the features of the OS.
|
8
|
+
def self.current
|
9
|
+
@current_os ||= case RbConfig::CONFIG['host_os']
|
10
|
+
when /darwin/
|
11
|
+
Mac
|
12
|
+
when /linux/
|
13
|
+
Linux
|
14
|
+
else
|
15
|
+
if RUBY_PLATFORM !~ /cygwin/ && ENV['OS'] == 'Windows_NT'
|
16
|
+
Windows
|
17
|
+
else
|
18
|
+
raise "Could not determine OS from host_os #{RbConfig::CONFIG["host_os"]}"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
class Mac
|
24
|
+
class << self
|
25
|
+
def supports_emoji?
|
26
|
+
true
|
27
|
+
end
|
28
|
+
|
29
|
+
def supports_color_prompt?
|
30
|
+
true
|
31
|
+
end
|
32
|
+
|
33
|
+
def supports_arrow_keys?
|
34
|
+
true
|
35
|
+
end
|
36
|
+
|
37
|
+
def shift_cursor_on_line_reset?
|
38
|
+
false
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
class Linux < Mac
|
44
|
+
end
|
45
|
+
|
46
|
+
class Windows
|
47
|
+
class << self
|
48
|
+
def supports_emoji?
|
49
|
+
false
|
50
|
+
end
|
51
|
+
|
52
|
+
def supports_color_prompt?
|
53
|
+
false
|
54
|
+
end
|
55
|
+
|
56
|
+
def supports_arrow_keys?
|
57
|
+
false
|
58
|
+
end
|
59
|
+
|
60
|
+
def shift_cursor_on_line_reset?
|
61
|
+
true
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,59 @@
|
|
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
|
+
# * +:wrap+ - Whether to wrap text at word boundaries to terminal width. Defaults to true.
|
22
|
+
#
|
23
|
+
# ==== Returns
|
24
|
+
# Returns whether the message was successfully printed,
|
25
|
+
# which can be useful if +:graceful+ is set to true.
|
26
|
+
#
|
27
|
+
# ==== Example
|
28
|
+
#
|
29
|
+
# CLI::UI::Printer.puts('{{x}} Ouch', to: $stderr)
|
30
|
+
#
|
31
|
+
def self.puts(
|
32
|
+
msg,
|
33
|
+
frame_color:
|
34
|
+
nil,
|
35
|
+
to:
|
36
|
+
$stdout,
|
37
|
+
encoding: Encoding::UTF_8,
|
38
|
+
format: true,
|
39
|
+
graceful: true,
|
40
|
+
wrap: true
|
41
|
+
)
|
42
|
+
msg = (+msg).force_encoding(encoding) if encoding
|
43
|
+
msg = CLI::UI.fmt(msg) if format
|
44
|
+
msg = CLI::UI.wrap(msg) if wrap
|
45
|
+
|
46
|
+
if frame_color
|
47
|
+
CLI::UI::Frame.with_frame_color_override(frame_color) { to.puts(msg) }
|
48
|
+
else
|
49
|
+
to.puts(msg)
|
50
|
+
end
|
51
|
+
|
52
|
+
true
|
53
|
+
rescue Errno::EIO, Errno::EPIPE, IOError => e
|
54
|
+
raise(e) unless graceful
|
55
|
+
false
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
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,14 +59,16 @@ 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
|
@@ -77,11 +79,11 @@ module CLI
|
|
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
@@ -2,6 +2,21 @@
|
|
2
2
|
require 'cli/ui'
|
3
3
|
require 'readline'
|
4
4
|
|
5
|
+
module Readline
|
6
|
+
unless const_defined?(:FILENAME_COMPLETION_PROC)
|
7
|
+
FILENAME_COMPLETION_PROC = proc do |input|
|
8
|
+
directory = input[-1] == '/' ? input : File.dirname(input)
|
9
|
+
filename = input[-1] == '/' ? '' : File.basename(input)
|
10
|
+
|
11
|
+
(Dir.entries(directory).select do |fp|
|
12
|
+
fp.start_with?(filename)
|
13
|
+
end - (input[-1] == '.' ? [] : ['.', '..'])).map do |fp|
|
14
|
+
File.join(directory, fp).gsub(/\A\.\//, '')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
5
20
|
module CLI
|
6
21
|
module UI
|
7
22
|
module Prompt
|
@@ -36,7 +51,8 @@ module CLI
|
|
36
51
|
# * +:select_ui+ - Enable long-form option selection (default: true)
|
37
52
|
#
|
38
53
|
# Note:
|
39
|
-
# * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
|
54
|
+
# * +:options+ or providing a +Block+ conflicts with +:default+ and +:is_file+,
|
55
|
+
# you cannot set options with either of these keywords
|
40
56
|
# * +:default+ conflicts with +:allow_empty:, you cannot set these together
|
41
57
|
# * +:options+ conflicts with providing a +Block+ , you may only set one
|
42
58
|
# * +:multiple+ can only be used with +:options+ or a +Block+; it is ignored, otherwise.
|
@@ -49,7 +65,7 @@ module CLI
|
|
49
65
|
# ==== Return Value
|
50
66
|
#
|
51
67
|
# * If a +Block+ was not provided, the selected option or response to the free form question will be returned
|
52
|
-
# * If a +Block+ was provided, the
|
68
|
+
# * If a +Block+ was provided, the evaluated value of the +Block+ will be returned
|
53
69
|
#
|
54
70
|
# ==== Example Usage:
|
55
71
|
#
|
@@ -76,18 +92,67 @@ module CLI
|
|
76
92
|
# handler.option('python') { |selection| selection }
|
77
93
|
# end
|
78
94
|
#
|
79
|
-
def ask(
|
80
|
-
|
95
|
+
def ask(
|
96
|
+
question,
|
97
|
+
options: nil,
|
98
|
+
default: nil,
|
99
|
+
is_file: nil,
|
100
|
+
allow_empty: true,
|
101
|
+
multiple: false,
|
102
|
+
filter_ui: true,
|
103
|
+
select_ui: true,
|
104
|
+
&options_proc
|
105
|
+
)
|
106
|
+
if (options || block_given?) && ((default && !multiple) || is_file)
|
81
107
|
raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
|
82
108
|
end
|
83
109
|
|
110
|
+
if options && multiple && default && !(default - options).empty?
|
111
|
+
raise(ArgumentError, 'conflicting arguments: default should only include elements present in options')
|
112
|
+
end
|
113
|
+
|
84
114
|
if options || block_given?
|
85
|
-
ask_interactive(
|
115
|
+
ask_interactive(
|
116
|
+
question,
|
117
|
+
options,
|
118
|
+
multiple: multiple,
|
119
|
+
default: default,
|
120
|
+
filter_ui: filter_ui,
|
121
|
+
select_ui: select_ui,
|
122
|
+
&options_proc
|
123
|
+
)
|
86
124
|
else
|
87
125
|
ask_free_form(question, default, is_file, allow_empty)
|
88
126
|
end
|
89
127
|
end
|
90
128
|
|
129
|
+
# Asks the user for a single-line answer, without displaying the characters while typing.
|
130
|
+
# Typically used for password prompts
|
131
|
+
#
|
132
|
+
# ==== Return Value
|
133
|
+
#
|
134
|
+
# The password, without a trailing newline.
|
135
|
+
# If the user simply presses "Enter" without typing any password, this will return an empty string.
|
136
|
+
def ask_password(question)
|
137
|
+
require 'io/console'
|
138
|
+
|
139
|
+
CLI::UI.with_frame_color(:blue) do
|
140
|
+
STDOUT.print(CLI::UI.fmt('{{?}} ' + question)) # Do not use puts_question to avoid the new line.
|
141
|
+
|
142
|
+
# noecho interacts poorly with Readline under system Ruby, so do a manual `gets` here.
|
143
|
+
# No fancy Readline integration (like echoing back) is required for a password prompt anyway.
|
144
|
+
password = STDIN.noecho do
|
145
|
+
# Chomp will remove the one new line character added by `gets`, without touching potential extra spaces:
|
146
|
+
# " 123 \n".chomp => " 123 "
|
147
|
+
STDIN.gets.chomp
|
148
|
+
end
|
149
|
+
|
150
|
+
STDOUT.puts # Complete the line
|
151
|
+
|
152
|
+
password
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
91
156
|
# Asks the user a yes/no question.
|
92
157
|
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
93
158
|
#
|
@@ -105,7 +170,9 @@ module CLI
|
|
105
170
|
private
|
106
171
|
|
107
172
|
def ask_free_form(question, default, is_file, allow_empty)
|
108
|
-
|
173
|
+
if default && !allow_empty
|
174
|
+
raise(ArgumentError, 'conflicting arguments: default enabled but allow_empty is false')
|
175
|
+
end
|
109
176
|
|
110
177
|
if default
|
111
178
|
puts_question("#{question} (empty = #{default})")
|
@@ -128,7 +195,7 @@ module CLI
|
|
128
195
|
end
|
129
196
|
end
|
130
197
|
|
131
|
-
def ask_interactive(question, options = nil, multiple: false, filter_ui: true, select_ui: true)
|
198
|
+
def ask_interactive(question, options = nil, multiple: false, default: nil, filter_ui: true, select_ui: true)
|
132
199
|
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
|
133
200
|
|
134
201
|
options ||= if block_given?
|
@@ -137,26 +204,32 @@ module CLI
|
|
137
204
|
handler.options
|
138
205
|
end
|
139
206
|
|
140
|
-
raise(ArgumentError, 'insufficient options') if options.nil? || options.
|
141
|
-
|
207
|
+
raise(ArgumentError, 'insufficient options') if options.nil? || options.empty?
|
208
|
+
navigate_text = if CLI::UI::OS.current.supports_arrow_keys?
|
209
|
+
'Choose with ↑ ↓ ⏎'
|
210
|
+
else
|
211
|
+
"Navigate up with 'k' and down with 'j', press Enter to select"
|
212
|
+
end
|
213
|
+
|
214
|
+
instructions = (multiple ? 'Toggle options. ' : '') + navigate_text
|
142
215
|
instructions += ", filter with 'f'" if filter_ui
|
143
|
-
instructions += ", enter option with 'e'" if select_ui
|
216
|
+
instructions += ", enter option with 'e'" if select_ui && (options.size > 9)
|
144
217
|
puts_question("#{question} {{yellow:(#{instructions})}}")
|
145
|
-
resp = interactive_prompt(options, multiple: multiple)
|
218
|
+
resp = interactive_prompt(options, multiple: multiple, default: default)
|
146
219
|
|
147
220
|
# Clear the line
|
148
|
-
print
|
221
|
+
print(ANSI.previous_line + ANSI.clear_to_end_of_line)
|
149
222
|
# Force StdoutRouter to prefix
|
150
|
-
print
|
223
|
+
print(ANSI.previous_line + "\n")
|
151
224
|
|
152
225
|
# reset the question to include the answer
|
153
226
|
resp_text = resp
|
154
227
|
if multiple
|
155
228
|
resp_text = case resp.size
|
156
229
|
when 0
|
157
|
-
|
230
|
+
'<nothing>'
|
158
231
|
when 1..2
|
159
|
-
resp.join(
|
232
|
+
resp.join(' and ')
|
160
233
|
else
|
161
234
|
"#{resp.size} items"
|
162
235
|
end
|
@@ -168,8 +241,8 @@ module CLI
|
|
168
241
|
end
|
169
242
|
|
170
243
|
# Useful for stubbing in tests
|
171
|
-
def interactive_prompt(options, multiple: false)
|
172
|
-
InteractiveOptions.call(options, multiple: multiple)
|
244
|
+
def interactive_prompt(options, multiple: false, default: nil)
|
245
|
+
InteractiveOptions.call(options, multiple: multiple, default: default)
|
173
246
|
end
|
174
247
|
|
175
248
|
def write_default_over_empty_input(default)
|
@@ -193,10 +266,10 @@ module CLI
|
|
193
266
|
def readline(is_file: false)
|
194
267
|
if is_file
|
195
268
|
Readline.completion_proc = Readline::FILENAME_COMPLETION_PROC
|
196
|
-
Readline.completion_append_character =
|
269
|
+
Readline.completion_append_character = ''
|
197
270
|
else
|
198
271
|
Readline.completion_proc = proc { |*| nil }
|
199
|
-
Readline.completion_append_character =
|
272
|
+
Readline.completion_append_character = ' '
|
200
273
|
end
|
201
274
|
|
202
275
|
# because Readline is a C library, CLI::UI's hooks into $stdout don't
|
@@ -204,11 +277,14 @@ module CLI
|
|
204
277
|
# thread to manage output, but the current strategy feels like a
|
205
278
|
# better tradeoff.
|
206
279
|
prefix = CLI::UI.with_frame_color(:blue) { CLI::UI::Frame.prefix }
|
207
|
-
prompt
|
280
|
+
# If a prompt is interrupted on Windows it locks the colour of the terminal from that point on, so we should
|
281
|
+
# not change the colour here.
|
282
|
+
prompt = prefix + CLI::UI.fmt('{{blue:> }}')
|
283
|
+
prompt += CLI::UI::Color::YELLOW.code if CLI::UI::OS.current.supports_color_prompt?
|
208
284
|
|
209
285
|
begin
|
210
286
|
line = Readline.readline(prompt, true)
|
211
|
-
print
|
287
|
+
print(CLI::UI::Color::RESET.code)
|
212
288
|
line.to_s.chomp
|
213
289
|
rescue Interrupt
|
214
290
|
CLI::UI.raw { STDERR.puts('^C' + CLI::UI::Color::RESET.code) }
|
@@ -5,8 +5,8 @@ module CLI
|
|
5
5
|
module UI
|
6
6
|
module Prompt
|
7
7
|
class InteractiveOptions
|
8
|
-
DONE =
|
9
|
-
CHECKBOX_ICON = { false =>
|
8
|
+
DONE = 'Done'
|
9
|
+
CHECKBOX_ICON = { false => '☐', true => '☑' }
|
10
10
|
|
11
11
|
# Prompts the user with options
|
12
12
|
# Uses an interactive session to allow the user to pick an answer
|
@@ -22,8 +22,8 @@ module CLI
|
|
22
22
|
# Ask an interactive question
|
23
23
|
# CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
|
24
24
|
#
|
25
|
-
def self.call(options, multiple: false)
|
26
|
-
list = new(options, multiple: multiple)
|
25
|
+
def self.call(options, multiple: false, default: nil)
|
26
|
+
list = new(options, multiple: multiple, default: default)
|
27
27
|
selected = list.call
|
28
28
|
if multiple
|
29
29
|
selected.map { |s| options[s - 1] }
|
@@ -39,7 +39,7 @@ module CLI
|
|
39
39
|
#
|
40
40
|
# CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
|
41
41
|
#
|
42
|
-
def initialize(options, multiple: false)
|
42
|
+
def initialize(options, multiple: false, default: nil)
|
43
43
|
@options = options
|
44
44
|
@active = 1
|
45
45
|
@marker = '>'
|
@@ -52,7 +52,13 @@ module CLI
|
|
52
52
|
@filter = ''
|
53
53
|
# 0-indexed array representing if selected
|
54
54
|
# @options[0] is selected if @chosen[0]
|
55
|
-
|
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
|
56
62
|
@redraw = true
|
57
63
|
@presented_options = []
|
58
64
|
end
|
@@ -95,16 +101,16 @@ module CLI
|
|
95
101
|
@option_lengths = @options.map do |text|
|
96
102
|
width = 1 if text.empty?
|
97
103
|
width ||= text
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
104
|
+
.split("\n")
|
105
|
+
.reject(&:empty?)
|
106
|
+
.map { |l| (CLI::UI.fmt(l, enable_color: false).length / max_width).ceil }
|
107
|
+
.reduce(&:+)
|
102
108
|
|
103
109
|
width
|
104
110
|
end
|
105
111
|
end
|
106
112
|
|
107
|
-
def reset_position(number_of_lines=num_lines)
|
113
|
+
def reset_position(number_of_lines = num_lines)
|
108
114
|
# This will put us back at the beginning of the options
|
109
115
|
# When we redraw the options, they will be overwritten
|
110
116
|
CLI::UI.raw do
|
@@ -112,12 +118,12 @@ module CLI
|
|
112
118
|
end
|
113
119
|
end
|
114
120
|
|
115
|
-
def clear_output(number_of_lines=num_lines)
|
121
|
+
def clear_output(number_of_lines = num_lines)
|
116
122
|
CLI::UI.raw do
|
117
123
|
# Write over all lines with whitespace
|
118
124
|
number_of_lines.times { puts(' ' * CLI::UI::Terminal.width) }
|
119
125
|
end
|
120
|
-
reset_position
|
126
|
+
reset_position(number_of_lines)
|
121
127
|
|
122
128
|
# Update if metadata is being displayed
|
123
129
|
# This must be done _after_ the output is cleared or it won't draw over
|
@@ -128,7 +134,7 @@ module CLI
|
|
128
134
|
# Don't use this in place of +@displaying_metadata+, this updates too
|
129
135
|
# quickly to be useful when drawing to the screen.
|
130
136
|
def display_metadata?
|
131
|
-
filtering?
|
137
|
+
filtering? || selecting? || has_filter?
|
132
138
|
end
|
133
139
|
|
134
140
|
def num_lines
|
@@ -136,7 +142,7 @@ module CLI
|
|
136
142
|
|
137
143
|
option_length = presented_options.reduce(0) do |total_length, (_, option_number)|
|
138
144
|
# Handle continuation markers and "Done" option when multiple is true
|
139
|
-
next total_length + 1 if option_number.nil?
|
145
|
+
next total_length + 1 if option_number.nil? || option_number.zero?
|
140
146
|
total_length + @option_lengths[option_number - 1]
|
141
147
|
end
|
142
148
|
|
@@ -153,7 +159,7 @@ module CLI
|
|
153
159
|
CTRL_D = "\u0004"
|
154
160
|
|
155
161
|
def up
|
156
|
-
active_index = @filtered_options.index { |_,num| num == @active } || 0
|
162
|
+
active_index = @filtered_options.index { |_, num| num == @active } || 0
|
157
163
|
|
158
164
|
previous_visible = @filtered_options[active_index - 1]
|
159
165
|
previous_visible ||= @filtered_options.last
|
@@ -163,7 +169,7 @@ module CLI
|
|
163
169
|
end
|
164
170
|
|
165
171
|
def down
|
166
|
-
active_index = @filtered_options.index { |_,num| num == @active } || 0
|
172
|
+
active_index = @filtered_options.index { |_, num| num == @active } || 0
|
167
173
|
|
168
174
|
next_visible = @filtered_options[active_index + 1]
|
169
175
|
next_visible ||= @filtered_options.first
|
@@ -216,7 +222,7 @@ module CLI
|
|
216
222
|
@redraw = true
|
217
223
|
|
218
224
|
# Control+D or Backspace on empty search closes search
|
219
|
-
if char == CTRL_D
|
225
|
+
if (char == CTRL_D) || (@filter.empty? && (char == BACKSPACE))
|
220
226
|
@filter = ''
|
221
227
|
@state = :root
|
222
228
|
return
|
@@ -231,7 +237,7 @@ module CLI
|
|
231
237
|
|
232
238
|
def select_current
|
233
239
|
# Prevent selection of invisible options
|
234
|
-
return unless presented_options.any? { |_,num| num == @active }
|
240
|
+
return unless presented_options.any? { |_, num| num == @active }
|
235
241
|
select_n(@active)
|
236
242
|
end
|
237
243
|
|
@@ -240,7 +246,7 @@ module CLI
|
|
240
246
|
wait_for_user_input until @redraw
|
241
247
|
end
|
242
248
|
|
243
|
-
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
249
|
+
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon,Style/Semicolon
|
244
250
|
def wait_for_user_input
|
245
251
|
char = read_char
|
246
252
|
@last_char = char
|
@@ -250,22 +256,24 @@ module CLI
|
|
250
256
|
when CTRL_C ; raise Interrupt
|
251
257
|
end
|
252
258
|
|
259
|
+
max_digit = [@options.size, 9].min.to_s
|
253
260
|
case @state
|
254
261
|
when :root
|
255
262
|
case char
|
256
|
-
when ESC
|
257
|
-
when 'k'
|
258
|
-
when 'j'
|
259
|
-
when 'e', ':', 'G'
|
260
|
-
when 'f', '/'
|
261
|
-
when ('0'
|
262
|
-
when 'y', 'n'
|
263
|
-
when
|
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>
|
264
271
|
end
|
265
272
|
when :filter
|
266
273
|
case char
|
267
274
|
when ESC ; @state = :esc
|
268
275
|
when "\r", "\n" ; select_current
|
276
|
+
when "\b" ; update_search(BACKSPACE) # Happens on Windows
|
269
277
|
else ; update_search(char)
|
270
278
|
end
|
271
279
|
when :line_select
|
@@ -273,9 +281,9 @@ module CLI
|
|
273
281
|
when ESC ; @state = :esc
|
274
282
|
when 'k' ; up ; @state = :root
|
275
283
|
when 'j' ; down ; @state = :root
|
276
|
-
when 'e',':','G','q' ; stop_line_select
|
284
|
+
when 'e', ':', 'G', 'q' ; stop_line_select
|
277
285
|
when '0'..'9' ; build_selection(char)
|
278
|
-
when BACKSPACE ; chop_selection
|
286
|
+
when BACKSPACE ; chop_selection # Pop last input on backspace
|
279
287
|
when ' ', "\r", "\n" ; select_current
|
280
288
|
end
|
281
289
|
when :esc
|
@@ -288,6 +296,8 @@ module CLI
|
|
288
296
|
case char
|
289
297
|
when 'A' ; up
|
290
298
|
when 'B' ; down
|
299
|
+
when 'C' ; # Ignore right key
|
300
|
+
when 'D' ; # Ignore left key
|
291
301
|
else ; raise Interrupt # unhandled escape sequence.
|
292
302
|
end
|
293
303
|
end
|
@@ -324,37 +334,41 @@ module CLI
|
|
324
334
|
end
|
325
335
|
|
326
336
|
def read_char
|
327
|
-
|
328
|
-
|
329
|
-
|
337
|
+
if $stdin.tty? && !ENV['TEST']
|
338
|
+
$stdin.getch # raw mode for tty
|
339
|
+
else
|
340
|
+
$stdin.getc
|
330
341
|
end
|
331
342
|
rescue IOError
|
332
343
|
"\e"
|
333
344
|
end
|
334
345
|
|
335
|
-
def raw_tty!
|
336
|
-
if ENV['TEST'] || !$stdin.tty?
|
337
|
-
yield
|
338
|
-
else
|
339
|
-
$stdin.raw { yield }
|
340
|
-
end
|
341
|
-
end
|
342
|
-
|
343
346
|
def presented_options(recalculate: false)
|
344
347
|
return @presented_options unless recalculate
|
345
348
|
|
346
349
|
@presented_options = @options.zip(1..Float::INFINITY)
|
347
350
|
if has_filter?
|
348
|
-
@presented_options.select! { |option,_| option.downcase.include?(@filter.downcase) }
|
351
|
+
@presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
|
349
352
|
end
|
350
353
|
|
351
354
|
# Used for selection purposes
|
355
|
+
@presented_options.push([DONE, 0]) if @multiple
|
352
356
|
@filtered_options = @presented_options.dup
|
353
357
|
|
354
|
-
@presented_options.unshift([DONE, 0]) if @multiple
|
355
|
-
|
356
358
|
ensure_visible_is_active if has_filter?
|
357
359
|
|
360
|
+
# Must have more lines before the selection than we can display
|
361
|
+
if distance_from_start_to_selection > max_lines
|
362
|
+
@presented_options.shift(distance_from_start_to_selection - max_lines)
|
363
|
+
ensure_first_item_is_continuation_marker
|
364
|
+
end
|
365
|
+
|
366
|
+
# Must have more lines after the selection than we can display
|
367
|
+
if distance_from_selection_to_end > max_lines
|
368
|
+
@presented_options.pop(distance_from_selection_to_end - max_lines)
|
369
|
+
ensure_last_item_is_continuation_marker
|
370
|
+
end
|
371
|
+
|
358
372
|
while num_lines > max_lines
|
359
373
|
# try to keep the selection centered in the window:
|
360
374
|
if distance_from_selection_to_end > distance_from_start_to_selection
|
@@ -386,15 +400,15 @@ module CLI
|
|
386
400
|
end
|
387
401
|
|
388
402
|
def index_of_active_option
|
389
|
-
@presented_options.index { |_,num| num == @active }.to_i
|
403
|
+
@presented_options.index { |_, num| num == @active }.to_i
|
390
404
|
end
|
391
405
|
|
392
406
|
def ensure_last_item_is_continuation_marker
|
393
|
-
@presented_options.push([
|
407
|
+
@presented_options.push(['...', nil]) if @presented_options.last.last
|
394
408
|
end
|
395
409
|
|
396
410
|
def ensure_first_item_is_continuation_marker
|
397
|
-
@presented_options.unshift([
|
411
|
+
@presented_options.unshift(['...', nil]) if @presented_options.first.last
|
398
412
|
end
|
399
413
|
|
400
414
|
def max_lines
|
@@ -413,14 +427,14 @@ module CLI
|
|
413
427
|
max_num_length = (@options.size + 1).to_s.length
|
414
428
|
|
415
429
|
metadata_text = if selecting?
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
430
|
+
select_text = @active
|
431
|
+
select_text = '{{info:e, q, or up/down anytime to exit}}' if @active == 0
|
432
|
+
"Select: #{select_text}"
|
433
|
+
elsif filtering? || has_filter?
|
434
|
+
filter_text = @filter
|
435
|
+
filter_text = '{{info:Ctrl-D anytime or Backspace now to exit}}' if @filter.empty?
|
436
|
+
"Filter: #{filter_text}"
|
437
|
+
end
|
424
438
|
|
425
439
|
if metadata_text
|
426
440
|
CLI::UI.with_frame_color(:blue) do
|
@@ -429,23 +443,23 @@ module CLI
|
|
429
443
|
end
|
430
444
|
|
431
445
|
options.each do |choice, num|
|
432
|
-
is_chosen = @multiple && num && @chosen[num - 1]
|
446
|
+
is_chosen = @multiple && num && @chosen[num - 1] && num != 0
|
433
447
|
|
434
448
|
padding = ' ' * (max_num_length - num.to_s.length)
|
435
|
-
message = " #{num}#{num ?
|
449
|
+
message = " #{num}#{num ? "." : " "}#{padding}"
|
436
450
|
|
437
|
-
format =
|
451
|
+
format = '%s'
|
438
452
|
# If multiple, bold only selected. If not multiple, bold everything
|
439
453
|
format = "{{bold:#{format}}}" if !@multiple || is_chosen
|
440
454
|
format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
|
441
455
|
format = " #{format}"
|
442
456
|
|
443
|
-
message +=
|
457
|
+
message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
|
444
458
|
message += format_choice(format, choice)
|
445
459
|
|
446
460
|
if num == @active
|
447
461
|
|
448
|
-
color =
|
462
|
+
color = filtering? || selecting? ? 'green' : 'blue'
|
449
463
|
message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
|
450
464
|
end
|
451
465
|
|
@@ -461,7 +475,7 @@ module CLI
|
|
461
475
|
|
462
476
|
return eol if lines.empty? # Handle blank options
|
463
477
|
|
464
|
-
lines.map! { |l|
|
478
|
+
lines.map! { |l| format(format, l) + eol }
|
465
479
|
lines.join("\n")
|
466
480
|
end
|
467
481
|
end
|