cli-ui 1.1.4 → 1.2.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/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:
|