cli-ui 1.1.4 → 1.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/cli/ui.rb +28 -4
- data/lib/cli/ui/ansi.rb +2 -4
- data/lib/cli/ui/formatter.rb +5 -4
- data/lib/cli/ui/glyph.rb +4 -0
- data/lib/cli/ui/progress.rb +1 -3
- data/lib/cli/ui/prompt.rb +27 -14
- data/lib/cli/ui/prompt/interactive_options.rb +118 -20
- data/lib/cli/ui/spinner/spin_group.rb +18 -6
- data/lib/cli/ui/terminal.rb +12 -0
- data/lib/cli/ui/truncater.rb +102 -0
- data/lib/cli/ui/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ad712abce399f06334f0af18135d831f788ab217
|
4
|
+
data.tar.gz: dd3befbb4a7cf345965bdc10ebbaaa208a93a8db
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 843c4c640d52da02bded3f3ac79bc3b4a7d8e56227f3914a1b3289887bceb89b54d26b11d8cc55e2cf2ad871e04f408e9f5cc631a79ebc3bba4d89ec1fd15afd
|
7
|
+
data.tar.gz: 133dada99d103ea7449e6138f39d58929b09552895869317a3fba69844057a92eab3a3a0ecb920667a1c3357813c25bfd72af1b9d8ac6feccdb99697a26b928b
|
data/lib/cli/ui.rb
CHANGED
@@ -8,6 +8,7 @@ module CLI
|
|
8
8
|
autoload :Progress, 'cli/ui/progress'
|
9
9
|
autoload :Prompt, 'cli/ui/prompt'
|
10
10
|
autoload :Terminal, 'cli/ui/terminal'
|
11
|
+
autoload :Truncater, 'cli/ui/truncater'
|
11
12
|
autoload :Formatter, 'cli/ui/formatter'
|
12
13
|
autoload :Spinner, 'cli/ui/spinner'
|
13
14
|
|
@@ -68,10 +69,13 @@ module CLI
|
|
68
69
|
# ==== Attributes
|
69
70
|
#
|
70
71
|
# * +input+ - input to format
|
72
|
+
# * +truncate_to+ - number of characters to truncate the string to (or nil)
|
71
73
|
#
|
72
|
-
def self.resolve_text(input)
|
74
|
+
def self.resolve_text(input, truncate_to: nil)
|
73
75
|
return input if input.nil?
|
74
|
-
CLI::UI::Formatter.new(input).format
|
76
|
+
formatted = CLI::UI::Formatter.new(input).format
|
77
|
+
return formatted unless truncate_to
|
78
|
+
return CLI::UI::Truncater.call(formatted, truncate_to)
|
75
79
|
end
|
76
80
|
|
77
81
|
# Conviencence Method to format text using +CLI::UI::Formatter.format+
|
@@ -86,9 +90,9 @@ module CLI
|
|
86
90
|
#
|
87
91
|
# ==== Options
|
88
92
|
#
|
89
|
-
# * +enable_color+ - should color be used? default to true
|
93
|
+
# * +enable_color+ - should color be used? default to true unless output is redirected.
|
90
94
|
#
|
91
|
-
def self.fmt(input, enable_color:
|
95
|
+
def self.fmt(input, enable_color: enable_color?)
|
92
96
|
CLI::UI::Formatter.new(input).format(enable_color: enable_color)
|
93
97
|
end
|
94
98
|
|
@@ -157,6 +161,26 @@ module CLI
|
|
157
161
|
ensure
|
158
162
|
Thread.current[:no_cliui_frame_inset] = prev
|
159
163
|
end
|
164
|
+
|
165
|
+
# Check whether colour is enabled in Formatter output. By default, colour
|
166
|
+
# is enabled when STDOUT is a TTY; that is, when output has not been
|
167
|
+
# redirected to another program or to a file.
|
168
|
+
#
|
169
|
+
def self.enable_color?
|
170
|
+
@enable_color
|
171
|
+
end
|
172
|
+
|
173
|
+
# Turn colour output in Formatter on or off.
|
174
|
+
#
|
175
|
+
# ==== Attributes
|
176
|
+
#
|
177
|
+
# * +bool+ - true or false; enable or disable colour.
|
178
|
+
#
|
179
|
+
def self.enable_color=(bool)
|
180
|
+
@enable_color = !!bool
|
181
|
+
end
|
182
|
+
|
183
|
+
self.enable_color = $stdout.tty?
|
160
184
|
end
|
161
185
|
end
|
162
186
|
|
data/lib/cli/ui/ansi.rb
CHANGED
data/lib/cli/ui/formatter.rb
CHANGED
@@ -16,7 +16,8 @@ module CLI
|
|
16
16
|
'red' => '31',
|
17
17
|
'green' => '32',
|
18
18
|
'yellow' => '33',
|
19
|
-
|
19
|
+
# default blue is low-contrast against black in some default terminal color scheme
|
20
|
+
'blue' => '94', # 9x = high-intensity fg color x
|
20
21
|
'magenta' => '35',
|
21
22
|
'cyan' => '36',
|
22
23
|
'bold' => '1',
|
@@ -28,7 +29,7 @@ module CLI
|
|
28
29
|
'error' => '31', # red
|
29
30
|
'success' => '32', # success
|
30
31
|
'warning' => '33', # yellow
|
31
|
-
'info' => '
|
32
|
+
'info' => '94', # bright blue
|
32
33
|
'command' => '36', # cyan
|
33
34
|
}.freeze
|
34
35
|
|
@@ -78,9 +79,9 @@ module CLI
|
|
78
79
|
#
|
79
80
|
# ===== Options
|
80
81
|
#
|
81
|
-
# * +:enable_color+ - enable color output? Default is true
|
82
|
+
# * +:enable_color+ - enable color output? Default is true unless output is redirected
|
82
83
|
#
|
83
|
-
def format(sgr_map = SGR_MAP, enable_color:
|
84
|
+
def format(sgr_map = SGR_MAP, enable_color: CLI::UI.enable_color?)
|
84
85
|
@nodes = []
|
85
86
|
stack = parse_body(StringScanner.new(@text))
|
86
87
|
prev_fmt = nil
|
data/lib/cli/ui/glyph.rb
CHANGED
@@ -48,6 +48,10 @@ module CLI
|
|
48
48
|
CHECK = new('v', 0x2713, Color::GREEN)
|
49
49
|
# RED BALLOT X (✗)
|
50
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)
|
51
55
|
|
52
56
|
# Looks up a glyph by name
|
53
57
|
#
|
data/lib/cli/ui/progress.rb
CHANGED
@@ -36,7 +36,6 @@ module CLI
|
|
36
36
|
puts bar.to_s
|
37
37
|
CLI::UI.raw do
|
38
38
|
print(ANSI.show_cursor)
|
39
|
-
puts(ANSI.previous_line + ANSI.end_of_line)
|
40
39
|
end
|
41
40
|
end
|
42
41
|
|
@@ -67,8 +66,7 @@ module CLI
|
|
67
66
|
@percent_done = [@percent_done, 1.0].min # Make sure we can't go above 1.0
|
68
67
|
|
69
68
|
print to_s
|
70
|
-
print CLI::UI::ANSI.previous_line
|
71
|
-
print CLI::UI::ANSI.end_of_line + "\n"
|
69
|
+
print CLI::UI::ANSI.previous_line + "\n"
|
72
70
|
end
|
73
71
|
|
74
72
|
# Format the progress bar to be printed to terminal
|
data/lib/cli/ui/prompt.rb
CHANGED
@@ -71,13 +71,13 @@ module CLI
|
|
71
71
|
# handler.option('python') { |selection| selection }
|
72
72
|
# end
|
73
73
|
#
|
74
|
-
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, &options_proc)
|
74
|
+
def ask(question, options: nil, default: nil, is_file: nil, allow_empty: true, multiple: false, &options_proc)
|
75
75
|
if ((options || block_given?) && (default || is_file))
|
76
76
|
raise(ArgumentError, 'conflicting arguments: options provided with default or is_file')
|
77
77
|
end
|
78
78
|
|
79
79
|
if options || block_given?
|
80
|
-
ask_interactive(question, options, &options_proc)
|
80
|
+
ask_interactive(question, options, multiple: multiple, &options_proc)
|
81
81
|
else
|
82
82
|
ask_free_form(question, default, is_file, allow_empty)
|
83
83
|
end
|
@@ -121,7 +121,7 @@ module CLI
|
|
121
121
|
end
|
122
122
|
end
|
123
123
|
|
124
|
-
def ask_interactive(question, options = nil)
|
124
|
+
def ask_interactive(question, options = nil, multiple: false)
|
125
125
|
raise(ArgumentError, 'conflicting arguments: options and block given') if options && block_given?
|
126
126
|
|
127
127
|
options ||= if block_given?
|
@@ -131,23 +131,36 @@ module CLI
|
|
131
131
|
end
|
132
132
|
|
133
133
|
raise(ArgumentError, 'insufficient options') if options.nil? || options.size < 2
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
print
|
140
|
-
|
141
|
-
print
|
142
|
-
|
134
|
+
instructions = (multiple ? "Toggle options. " : "") + "Choose with ↑ ↓ ⏎"
|
135
|
+
puts_question("#{question} {{yellow:(#{instructions})}}")
|
136
|
+
resp = interactive_prompt(options, multiple: multiple)
|
137
|
+
|
138
|
+
# Clear the line
|
139
|
+
print ANSI.previous_line + ANSI.clear_to_end_of_line
|
140
|
+
# Force StdoutRouter to prefix
|
141
|
+
print ANSI.previous_line + "\n"
|
142
|
+
|
143
|
+
# reset the question to include the answer
|
144
|
+
resp_text = resp
|
145
|
+
if multiple
|
146
|
+
resp_text = case resp.size
|
147
|
+
when 0
|
148
|
+
"<nothing>"
|
149
|
+
when 1..2
|
150
|
+
resp.join(" and ")
|
151
|
+
else
|
152
|
+
"#{resp.size} items"
|
153
|
+
end
|
154
|
+
end
|
155
|
+
puts_question("#{question} (You chose: {{italic:#{resp_text}}})")
|
143
156
|
|
144
157
|
return handler.call(resp) if block_given?
|
145
158
|
resp
|
146
159
|
end
|
147
160
|
|
148
161
|
# Useful for stubbing in tests
|
149
|
-
def interactive_prompt(options)
|
150
|
-
InteractiveOptions.call(options)
|
162
|
+
def interactive_prompt(options, multiple: false)
|
163
|
+
InteractiveOptions.call(options, multiple: multiple)
|
151
164
|
end
|
152
165
|
|
153
166
|
def write_default_over_empty_input(default)
|
@@ -4,6 +4,9 @@ module CLI
|
|
4
4
|
module UI
|
5
5
|
module Prompt
|
6
6
|
class InteractiveOptions
|
7
|
+
DONE = "Done"
|
8
|
+
CHECKBOX_ICON = { false => "☐", true => "☑" }
|
9
|
+
|
7
10
|
# Prompts the user with options
|
8
11
|
# Uses an interactive session to allow the user to pick an answer
|
9
12
|
# Can use arrows, y/n, numbers (1/2), and vim bindings to control
|
@@ -15,9 +18,14 @@ module CLI
|
|
15
18
|
# Ask an interactive question
|
16
19
|
# CLI::UI::Prompt::InteractiveOptions.call(%w(rails go python))
|
17
20
|
#
|
18
|
-
def self.call(options)
|
19
|
-
list = new(options)
|
20
|
-
|
21
|
+
def self.call(options, multiple: false)
|
22
|
+
list = new(options, multiple: multiple)
|
23
|
+
selected = list.call
|
24
|
+
if multiple
|
25
|
+
selected.map { |s| options[s - 1] }
|
26
|
+
else
|
27
|
+
options[selected - 1]
|
28
|
+
end
|
21
29
|
end
|
22
30
|
|
23
31
|
# Initializes a new +InteractiveOptions+
|
@@ -27,12 +35,17 @@ module CLI
|
|
27
35
|
#
|
28
36
|
# CLI::UI::Prompt::InteractiveOptions.new(%w(rails go python))
|
29
37
|
#
|
30
|
-
def initialize(options)
|
38
|
+
def initialize(options, multiple: false)
|
31
39
|
@options = options
|
32
40
|
@active = 1
|
33
41
|
@marker = '>'
|
34
42
|
@answer = nil
|
35
43
|
@state = :root
|
44
|
+
@multiple = multiple
|
45
|
+
# 0-indexed array representing if selected
|
46
|
+
# @options[0] is selected if @chosen[0]
|
47
|
+
@chosen = Array.new(@options.size) { false } if multiple
|
48
|
+
@redraw = true
|
36
49
|
end
|
37
50
|
|
38
51
|
# Calls the +InteractiveOptions+ and asks the question
|
@@ -42,7 +55,7 @@ module CLI
|
|
42
55
|
CLI::UI.raw { print(ANSI.hide_cursor) }
|
43
56
|
while @answer.nil?
|
44
57
|
render_options
|
45
|
-
|
58
|
+
process_input_until_redraw_required
|
46
59
|
reset_position
|
47
60
|
end
|
48
61
|
clear_output
|
@@ -50,7 +63,6 @@ module CLI
|
|
50
63
|
ensure
|
51
64
|
CLI::UI.raw do
|
52
65
|
print(ANSI.show_cursor)
|
53
|
-
puts(ANSI.previous_line + ANSI.end_of_line)
|
54
66
|
end
|
55
67
|
end
|
56
68
|
|
@@ -61,7 +73,6 @@ module CLI
|
|
61
73
|
# When we redraw the options, they will be overwritten
|
62
74
|
CLI::UI.raw do
|
63
75
|
num_lines.times { print(ANSI.previous_line) }
|
64
|
-
print(ANSI.previous_line + ANSI.end_of_line + "\n")
|
65
76
|
end
|
66
77
|
end
|
67
78
|
|
@@ -74,30 +85,52 @@ module CLI
|
|
74
85
|
end
|
75
86
|
|
76
87
|
def num_lines
|
88
|
+
options = presented_options.map(&:first)
|
77
89
|
# @options will be an array of questions but each option can be multi-line
|
78
90
|
# so to get the # of lines, you need to join then split
|
79
91
|
|
80
92
|
# empty_option_count is needed since empty option titles are omitted
|
81
93
|
# from the line count when reject(&:empty?) is called
|
82
94
|
|
83
|
-
empty_option_count =
|
84
|
-
joined_options =
|
95
|
+
empty_option_count = options.count(&:empty?)
|
96
|
+
joined_options = options.join("\n")
|
85
97
|
joined_options.split("\n").reject(&:empty?).size + empty_option_count
|
86
98
|
end
|
87
99
|
|
88
100
|
ESC = "\e"
|
89
101
|
|
90
102
|
def up
|
91
|
-
|
103
|
+
min_pos = @multiple ? 0 : 1
|
104
|
+
@active = @active - 1 >= min_pos ? @active - 1 : @options.length
|
105
|
+
@redraw = true
|
92
106
|
end
|
93
107
|
|
94
108
|
def down
|
95
|
-
|
109
|
+
min_pos = @multiple ? 0 : 1
|
110
|
+
@active = @active + 1 <= @options.length ? @active + 1 : min_pos
|
111
|
+
@redraw = true
|
96
112
|
end
|
97
113
|
|
114
|
+
# n is 1-indexed selection
|
115
|
+
# n == 0 if "Done" was selected in @multiple mode
|
98
116
|
def select_n(n)
|
99
|
-
@
|
100
|
-
|
117
|
+
if @multiple
|
118
|
+
if n == 0
|
119
|
+
@answer = []
|
120
|
+
@chosen.each_with_index do |selected, i|
|
121
|
+
@answer << i + 1 if selected
|
122
|
+
end
|
123
|
+
else
|
124
|
+
@active = n
|
125
|
+
@chosen[n - 1] = !@chosen[n - 1]
|
126
|
+
end
|
127
|
+
elsif n == 0
|
128
|
+
# Ignore pressing "0" when not in multiple mode
|
129
|
+
else
|
130
|
+
@active = n
|
131
|
+
@answer = n
|
132
|
+
end
|
133
|
+
@redraw = true
|
101
134
|
end
|
102
135
|
|
103
136
|
def select_bool(char)
|
@@ -105,6 +138,16 @@ module CLI
|
|
105
138
|
opt = @options.detect { |o| o.start_with?(char) }
|
106
139
|
@active = @options.index(opt) + 1
|
107
140
|
@answer = @options.index(opt) + 1
|
141
|
+
@redraw = true
|
142
|
+
end
|
143
|
+
|
144
|
+
def select_current
|
145
|
+
select_n(@active)
|
146
|
+
end
|
147
|
+
|
148
|
+
def process_input_until_redraw_required
|
149
|
+
@redraw = false
|
150
|
+
wait_for_user_input until @redraw
|
108
151
|
end
|
109
152
|
|
110
153
|
# rubocop:disable Style/WhenThen,Layout/SpaceBeforeSemicolon
|
@@ -117,10 +160,11 @@ module CLI
|
|
117
160
|
when ESC ; @state = :esc
|
118
161
|
when 'k' ; up
|
119
162
|
when 'j' ; down
|
163
|
+
when '0' ; select_n(char.to_i)
|
120
164
|
when ('1'..@options.size.to_s) ; select_n(char.to_i)
|
121
165
|
when 'y', 'n' ; select_bool(char)
|
122
|
-
when " ", "\r", "\n" ;
|
123
|
-
when "\u0003" ; raise Interrupt
|
166
|
+
when " ", "\r", "\n" ; select_current # <enter>
|
167
|
+
when "\u0003" ; raise Interrupt # Ctrl-c
|
124
168
|
end
|
125
169
|
when :esc
|
126
170
|
case char
|
@@ -157,13 +201,67 @@ module CLI
|
|
157
201
|
end
|
158
202
|
end
|
159
203
|
|
204
|
+
def presented_options(recalculate: false)
|
205
|
+
return @presented_options unless recalculate
|
206
|
+
|
207
|
+
@presented_options = @options.zip(1..Float::INFINITY)
|
208
|
+
@presented_options.unshift([DONE, 0]) if @multiple
|
209
|
+
|
210
|
+
while num_lines > max_options
|
211
|
+
# try to keep the selection centered in the window:
|
212
|
+
if distance_from_selection_to_end > distance_from_start_to_selection
|
213
|
+
# selection is closer to top than bottom, so trim a row from the bottom
|
214
|
+
ensure_last_item_is_continuation_marker
|
215
|
+
@presented_options.delete_at(-2)
|
216
|
+
else
|
217
|
+
# selection is closer to bottom than top, so trim a row from the top
|
218
|
+
ensure_first_item_is_continuation_marker
|
219
|
+
@presented_options.delete_at(1)
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
@presented_options
|
224
|
+
end
|
225
|
+
|
226
|
+
def distance_from_selection_to_end
|
227
|
+
last_visible_option_number = @presented_options[-1].last || @presented_options[-2].last
|
228
|
+
last_visible_option_number - @active
|
229
|
+
end
|
230
|
+
|
231
|
+
def distance_from_start_to_selection
|
232
|
+
first_visible_option_number = @presented_options[0].last || @presented_options[1].last
|
233
|
+
@active - first_visible_option_number
|
234
|
+
end
|
235
|
+
|
236
|
+
def ensure_last_item_is_continuation_marker
|
237
|
+
@presented_options.push(["...", nil]) if @presented_options.last.last
|
238
|
+
end
|
239
|
+
|
240
|
+
def ensure_first_item_is_continuation_marker
|
241
|
+
@presented_options.unshift(["...", nil]) if @presented_options.first.last
|
242
|
+
end
|
243
|
+
|
244
|
+
def max_options
|
245
|
+
@max_options ||= CLI::UI::Terminal.height - 2 # Keeps a one line question visible
|
246
|
+
end
|
247
|
+
|
160
248
|
def render_options
|
161
249
|
max_num_length = (@options.size + 1).to_s.length
|
162
|
-
|
163
|
-
|
250
|
+
|
251
|
+
presented_options(recalculate: true).each do |choice, num|
|
252
|
+
is_chosen = @multiple && num && @chosen[num - 1]
|
253
|
+
|
164
254
|
padding = ' ' * (max_num_length - num.to_s.length)
|
165
|
-
message = " #{num}
|
166
|
-
|
255
|
+
message = " #{num}#{num ? '.' : ' '}#{padding}"
|
256
|
+
|
257
|
+
format = "%s"
|
258
|
+
# If multiple, bold only selected. If not multiple, bold everything
|
259
|
+
format = "{{bold:#{format}}}" if !@multiple || is_chosen
|
260
|
+
format = "{{cyan:#{format}}}" if @multiple && is_chosen && num != @active
|
261
|
+
format = " #{format}"
|
262
|
+
|
263
|
+
message += sprintf(format, CHECKBOX_ICON[is_chosen]) if @multiple && num && num > 0
|
264
|
+
message += choice.split("\n").map { |l| sprintf(format, l) }.join("\n")
|
167
265
|
|
168
266
|
if num == @active
|
169
267
|
message = message.split("\n").map.with_index do |l, idx|
|
@@ -172,7 +270,7 @@ module CLI
|
|
172
270
|
end
|
173
271
|
|
174
272
|
CLI::UI.with_frame_color(:blue) do
|
175
|
-
puts CLI::UI.fmt(message)
|
273
|
+
puts CLI::UI.fmt(message) + CLI::UI::ANSI.clear_to_end_of_line
|
176
274
|
end
|
177
275
|
end
|
178
276
|
end
|
@@ -81,9 +81,10 @@ module CLI
|
|
81
81
|
#
|
82
82
|
# * +index+ - index of the task
|
83
83
|
# * +force+ - force rerender of the task
|
84
|
+
# * +width+ - current terminal width to format for
|
84
85
|
#
|
85
|
-
def render(index, force = true)
|
86
|
-
return full_render(index) if force || @force_full_render
|
86
|
+
def render(index, force = true, width: CLI::UI::Terminal.width)
|
87
|
+
return full_render(index, width) if force || @force_full_render
|
87
88
|
partial_render(index)
|
88
89
|
ensure
|
89
90
|
@force_full_render = false
|
@@ -102,8 +103,17 @@ module CLI
|
|
102
103
|
|
103
104
|
private
|
104
105
|
|
105
|
-
def full_render(index)
|
106
|
-
|
106
|
+
def full_render(index, terminal_width)
|
107
|
+
prefix = inset +
|
108
|
+
glyph(index) +
|
109
|
+
CLI::UI::Color::RESET.code +
|
110
|
+
' '
|
111
|
+
|
112
|
+
truncation_width = terminal_width - CLI::UI::ANSI.printing_width(prefix)
|
113
|
+
|
114
|
+
prefix +
|
115
|
+
CLI::UI.resolve_text(title, truncate_to: truncation_width) +
|
116
|
+
"\e[K"
|
107
117
|
end
|
108
118
|
|
109
119
|
def partial_render(index)
|
@@ -158,6 +168,8 @@ module CLI
|
|
158
168
|
loop do
|
159
169
|
all_done = true
|
160
170
|
|
171
|
+
width = CLI::UI::Terminal.width
|
172
|
+
|
161
173
|
@m.synchronize do
|
162
174
|
CLI::UI.raw do
|
163
175
|
@tasks.each.with_index do |task, int_index|
|
@@ -166,14 +178,14 @@ module CLI
|
|
166
178
|
all_done = false unless task_done
|
167
179
|
|
168
180
|
if nat_index > @consumed_lines
|
169
|
-
print(task.render(idx, true) + "\n")
|
181
|
+
print(task.render(idx, true, width: width) + "\n")
|
170
182
|
@consumed_lines += 1
|
171
183
|
else
|
172
184
|
offset = @consumed_lines - int_index
|
173
185
|
move_to = CLI::UI::ANSI.cursor_up(offset) + "\r"
|
174
186
|
move_from = "\r" + CLI::UI::ANSI.cursor_down(offset)
|
175
187
|
|
176
|
-
print(move_to + task.render(idx, idx.zero
|
188
|
+
print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
|
177
189
|
end
|
178
190
|
end
|
179
191
|
end
|
data/lib/cli/ui/terminal.rb
CHANGED
@@ -5,6 +5,7 @@ module CLI
|
|
5
5
|
module UI
|
6
6
|
module Terminal
|
7
7
|
DEFAULT_WIDTH = 80
|
8
|
+
DEFAULT_HEIGHT = 24
|
8
9
|
|
9
10
|
# Returns the width of the terminal, if possible
|
10
11
|
# Otherwise will return 80
|
@@ -19,6 +20,17 @@ module CLI
|
|
19
20
|
rescue Errno::EIO
|
20
21
|
DEFAULT_WIDTH
|
21
22
|
end
|
23
|
+
|
24
|
+
def self.height
|
25
|
+
if console = IO.respond_to?(:console) && IO.console
|
26
|
+
height = console.winsize[0]
|
27
|
+
height.zero? ? DEFAULT_HEIGHT : height
|
28
|
+
else
|
29
|
+
DEFAULT_HEIGHT
|
30
|
+
end
|
31
|
+
rescue Errno::EIO
|
32
|
+
DEFAULT_HEIGHT
|
33
|
+
end
|
22
34
|
end
|
23
35
|
end
|
24
36
|
end
|
@@ -0,0 +1,102 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'cli/ui'
|
4
|
+
|
5
|
+
module CLI
|
6
|
+
module UI
|
7
|
+
# Truncater truncates a string to a provided printable width.
|
8
|
+
module Truncater
|
9
|
+
PARSE_ROOT = :root
|
10
|
+
PARSE_ANSI = :ansi
|
11
|
+
PARSE_ESC = :esc
|
12
|
+
PARSE_ZWJ = :zwj
|
13
|
+
|
14
|
+
ESC = 0x1b
|
15
|
+
LEFT_SQUARE_BRACKET = 0x5b
|
16
|
+
ZWJ = 0x200d # emojipedia.org/emoji-zwj-sequences
|
17
|
+
SEMICOLON = 0x3b
|
18
|
+
|
19
|
+
# EMOJI_RANGE in particular is super inaccurate. This is best-effort.
|
20
|
+
# If you need this to be more accurate, we'll almost certainly accept a
|
21
|
+
# PR improving it.
|
22
|
+
EMOJI_RANGE = 0x1f300..0x1f5ff
|
23
|
+
NUMERIC_RANGE = 0x30..0x39
|
24
|
+
LC_ALPHA_RANGE = 0x40..0x5a
|
25
|
+
UC_ALPHA_RANGE = 0x60..0x71
|
26
|
+
|
27
|
+
TRUNCATED = "\x1b[0m…"
|
28
|
+
|
29
|
+
class << self
|
30
|
+
def call(text, printing_width)
|
31
|
+
return text if text.size <= printing_width
|
32
|
+
|
33
|
+
width = 0
|
34
|
+
mode = PARSE_ROOT
|
35
|
+
truncation_index = nil
|
36
|
+
|
37
|
+
codepoints = text.codepoints
|
38
|
+
codepoints.each.with_index do |cp, index|
|
39
|
+
case mode
|
40
|
+
when PARSE_ROOT
|
41
|
+
case cp
|
42
|
+
when ESC # non-printable, followed by some more non-printables.
|
43
|
+
mode = PARSE_ESC
|
44
|
+
when ZWJ # non-printable, followed by another non-printable.
|
45
|
+
mode = PARSE_ZWJ
|
46
|
+
else
|
47
|
+
width += width(cp)
|
48
|
+
if width >= printing_width
|
49
|
+
truncation_index ||= index
|
50
|
+
# it looks like we could break here but we still want the
|
51
|
+
# width calculation for the rest of the characters.
|
52
|
+
end
|
53
|
+
end
|
54
|
+
when PARSE_ESC
|
55
|
+
case cp
|
56
|
+
when LEFT_SQUARE_BRACKET
|
57
|
+
mode = PARSE_ANSI
|
58
|
+
else
|
59
|
+
mode = PARSE_ROOT
|
60
|
+
end
|
61
|
+
when PARSE_ANSI
|
62
|
+
# ANSI escape codes preeeetty much have the format of:
|
63
|
+
# \x1b[0-9;]+[A-Za-z]
|
64
|
+
case cp
|
65
|
+
when NUMERIC_RANGE, SEMICOLON
|
66
|
+
when LC_ALPHA_RANGE, UC_ALPHA_RANGE
|
67
|
+
mode = PARSE_ROOT
|
68
|
+
else
|
69
|
+
# unexpected. let's just go back to the root state I guess?
|
70
|
+
mode = PARSE_ROOT
|
71
|
+
end
|
72
|
+
when PARSE_ZWJ
|
73
|
+
# consume any character and consider it as having no width
|
74
|
+
# width(x+ZWJ+y) = width(x).
|
75
|
+
mode = PARSE_ROOT
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Without the `width <= printing_width` check, we truncate
|
80
|
+
# "foo\x1b[0m" for a width of 3, but it should not be truncated.
|
81
|
+
# It's specifically for the case where we decided "Yes, this is the
|
82
|
+
# point at which we'd have to add a truncation!" but it's actually
|
83
|
+
# the end of the string.
|
84
|
+
return text if !truncation_index || width <= printing_width
|
85
|
+
|
86
|
+
codepoints[0...truncation_index].pack("U*") + TRUNCATED
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def width(printable_codepoint)
|
92
|
+
case printable_codepoint
|
93
|
+
when EMOJI_RANGE
|
94
|
+
2
|
95
|
+
else
|
96
|
+
1
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
data/lib/cli/ui/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: cli-ui
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Burke Libbey
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: exe
|
12
12
|
cert_chain: []
|
13
|
-
date: 2018-
|
13
|
+
date: 2018-11-09 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: bundler
|
@@ -89,6 +89,7 @@ files:
|
|
89
89
|
- lib/cli/ui/spinner/spin_group.rb
|
90
90
|
- lib/cli/ui/stdout_router.rb
|
91
91
|
- lib/cli/ui/terminal.rb
|
92
|
+
- lib/cli/ui/truncater.rb
|
92
93
|
- lib/cli/ui/version.rb
|
93
94
|
homepage: https://github.com/shopify/cli-ui
|
94
95
|
licenses:
|