cli-ui 1.2.1 → 1.5.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 +4 -4
- data/.dependabot/config.yml +8 -0
- data/.github/CODEOWNERS +1 -0
- data/.github/probots.yml +2 -0
- data/.gitignore +0 -1
- data/.rubocop.yml +28 -4
- data/.travis.yml +5 -3
- data/Gemfile +3 -2
- data/Gemfile.lock +60 -0
- data/README.md +50 -3
- data/Rakefile +2 -2
- data/bin/console +3 -3
- data/cli-ui.gemspec +13 -13
- data/dev.yml +1 -1
- data/lib/cli/ui.rb +77 -31
- 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 +10 -8
- data/lib/cli/ui/prompt.rb +107 -21
- data/lib/cli/ui/prompt/interactive_options.rb +271 -67
- 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 +41 -13
- 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 +22 -24
- data/lib/cli/ui/box.rb +0 -15
@@ -1,15 +1,19 @@
|
|
1
|
+
# coding: utf-8
|
1
2
|
require 'io/console'
|
2
3
|
|
3
4
|
module CLI
|
4
5
|
module UI
|
5
6
|
module Prompt
|
6
7
|
class InteractiveOptions
|
7
|
-
DONE =
|
8
|
-
CHECKBOX_ICON = { false =>
|
8
|
+
DONE = 'Done'
|
9
|
+
CHECKBOX_ICON = { false => '☐', true => '☑' }
|
9
10
|
|
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
|
-
|
90
|
-
|
141
|
+
calculate_option_line_lengths if terminal_width_changed?
|
142
|
+
|
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
|
91
148
|
|
92
|
-
|
93
|
-
|
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,64 +246,130 @@ 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
|
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
|
|
187
|
-
def
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
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
|
194
328
|
end
|
195
329
|
|
196
|
-
def
|
197
|
-
|
198
|
-
|
330
|
+
def stop_line_select
|
331
|
+
@state = :root
|
332
|
+
@active = 1 if @active.zero?
|
333
|
+
@redraw = true
|
334
|
+
end
|
335
|
+
|
336
|
+
def read_char
|
337
|
+
if $stdin.tty? && !ENV['TEST']
|
338
|
+
$stdin.getch # raw mode for tty
|
199
339
|
else
|
200
|
-
$stdin.
|
340
|
+
$stdin.getc
|
201
341
|
end
|
342
|
+
rescue IOError
|
343
|
+
"\e"
|
202
344
|
end
|
203
345
|
|
204
346
|
def presented_options(recalculate: false)
|
205
347
|
return @presented_options unless recalculate
|
206
348
|
|
207
349
|
@presented_options = @options.zip(1..Float::INFINITY)
|
208
|
-
|
350
|
+
if has_filter?
|
351
|
+
@presented_options.select! { |option, _| option.downcase.include?(@filter.downcase) }
|
352
|
+
end
|
353
|
+
|
354
|
+
# Used for selection purposes
|
355
|
+
@presented_options.push([DONE, 0]) if @multiple
|
356
|
+
@filtered_options = @presented_options.dup
|
357
|
+
|
358
|
+
ensure_visible_is_active if has_filter?
|
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
|
209
371
|
|
210
|
-
while num_lines >
|
372
|
+
while num_lines > max_lines
|
211
373
|
# try to keep the selection centered in the window:
|
212
374
|
if distance_from_selection_to_end > distance_from_start_to_selection
|
213
375
|
# selection is closer to top than bottom, so trim a row from the bottom
|
@@ -223,57 +385,99 @@ module CLI
|
|
223
385
|
@presented_options
|
224
386
|
end
|
225
387
|
|
388
|
+
def ensure_visible_is_active
|
389
|
+
unless presented_options.any? { |_, num| num == @active }
|
390
|
+
@active = presented_options.first&.last.to_i
|
391
|
+
end
|
392
|
+
end
|
393
|
+
|
226
394
|
def distance_from_selection_to_end
|
227
|
-
|
228
|
-
last_visible_option_number - @active
|
395
|
+
@presented_options.count - index_of_active_option
|
229
396
|
end
|
230
397
|
|
231
398
|
def distance_from_start_to_selection
|
232
|
-
|
233
|
-
|
399
|
+
index_of_active_option
|
400
|
+
end
|
401
|
+
|
402
|
+
def index_of_active_option
|
403
|
+
@presented_options.index { |_, num| num == @active }.to_i
|
234
404
|
end
|
235
405
|
|
236
406
|
def ensure_last_item_is_continuation_marker
|
237
|
-
@presented_options.push([
|
407
|
+
@presented_options.push(['...', nil]) if @presented_options.last.last
|
238
408
|
end
|
239
409
|
|
240
410
|
def ensure_first_item_is_continuation_marker
|
241
|
-
@presented_options.unshift([
|
411
|
+
@presented_options.unshift(['...', nil]) if @presented_options.first.last
|
242
412
|
end
|
243
413
|
|
244
|
-
def
|
245
|
-
|
414
|
+
def max_lines
|
415
|
+
CLI::UI::Terminal.height - (@displaying_metadata ? 3 : 2) # Keeps a one line question visible
|
246
416
|
end
|
247
417
|
|
248
418
|
def render_options
|
419
|
+
previously_displayed_lines = num_lines
|
420
|
+
|
421
|
+
@displaying_metadata = display_metadata?
|
422
|
+
|
423
|
+
options = presented_options(recalculate: true)
|
424
|
+
|
425
|
+
clear_output(previously_displayed_lines) if previously_displayed_lines > num_lines
|
426
|
+
|
249
427
|
max_num_length = (@options.size + 1).to_s.length
|
250
428
|
|
251
|
-
|
252
|
-
|
429
|
+
metadata_text = if selecting?
|
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
|
438
|
+
|
439
|
+
if metadata_text
|
440
|
+
CLI::UI.with_frame_color(:blue) do
|
441
|
+
puts CLI::UI.fmt(" {{green:#{metadata_text}}}#{ANSI.clear_to_end_of_line}")
|
442
|
+
end
|
443
|
+
end
|
444
|
+
|
445
|
+
options.each do |choice, num|
|
446
|
+
is_chosen = @multiple && num && @chosen[num - 1] && num != 0
|
253
447
|
|
254
448
|
padding = ' ' * (max_num_length - num.to_s.length)
|
255
|
-
message = " #{num}#{num ?
|
449
|
+
message = " #{num}#{num ? "." : " "}#{padding}"
|
256
450
|
|
257
|
-
format =
|
451
|
+
format = '%s'
|
258
452
|
# If multiple, bold only selected. If not multiple, bold everything
|
259
453
|
format = "{{bold:#{format}}}" if !@multiple || is_chosen
|
260
454
|
format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
|
261
455
|
format = " #{format}"
|
262
456
|
|
263
|
-
message +=
|
264
|
-
message +=
|
457
|
+
message += format(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
|
458
|
+
message += format_choice(format, choice)
|
265
459
|
|
266
460
|
if num == @active
|
267
|
-
|
268
|
-
|
269
|
-
|
461
|
+
|
462
|
+
color = filtering? || selecting? ? 'green' : 'blue'
|
463
|
+
message = message.split("\n").map { |l| "{{#{color}:> #{l.strip}}}" }.join("\n")
|
270
464
|
end
|
271
465
|
|
272
466
|
CLI::UI.with_frame_color(:blue) do
|
273
|
-
puts CLI::UI.fmt(message)
|
467
|
+
puts CLI::UI.fmt(message)
|
274
468
|
end
|
275
469
|
end
|
276
470
|
end
|
471
|
+
|
472
|
+
def format_choice(format, choice)
|
473
|
+
eol = CLI::UI::ANSI.clear_to_end_of_line
|
474
|
+
lines = choice.split("\n")
|
475
|
+
|
476
|
+
return eol if lines.empty? # Handle blank options
|
477
|
+
|
478
|
+
lines.map! { |l| format(format, l) + eol }
|
479
|
+
lines.join("\n")
|
480
|
+
end
|
277
481
|
end
|
278
482
|
end
|
279
483
|
end
|