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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: cd5d21387192d8ab8f7195f3e298674e1678e850
4
- data.tar.gz: 63524be481c12c133473aafbd6ce588e0e633d90
3
+ metadata.gz: ad712abce399f06334f0af18135d831f788ab217
4
+ data.tar.gz: dd3befbb4a7cf345965bdc10ebbaaa208a93a8db
5
5
  SHA512:
6
- metadata.gz: '0352484323c19b74b86edfff5cdef454f92e488113a06feef7bf27314b30dd077e88201cddac07c4c58fcb020a0e84290b24e97a4944152277867aae1246ec8f'
7
- data.tar.gz: 46d12fc29cee51ffb384151d8f75df27a4f88e5ed5d45788d04898ce5dd050df22d6ad4609debe2b17e07990d07269d362d8af326e5cda74316c22da8e77e5b7
6
+ metadata.gz: 843c4c640d52da02bded3f3ac79bc3b4a7d8e56227f3914a1b3289887bceb89b54d26b11d8cc55e2cf2ad871e04f408e9f5cc631a79ebc3bba4d89ec1fd15afd
7
+ data.tar.gz: 133dada99d103ea7449e6138f39d58929b09552895869317a3fba69844057a92eab3a3a0ecb920667a1c3357813c25bfd72af1b9d8ac6feccdb99697a26b928b
@@ -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: true)
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
 
@@ -145,10 +145,8 @@ module CLI
145
145
  cursor_up + control('1', 'G')
146
146
  end
147
147
 
148
- # Move to the end of the line
149
- #
150
- def self.end_of_line
151
- control("\033[", 'C')
148
+ def self.clear_to_end_of_line
149
+ control('', 'K')
152
150
  end
153
151
  end
154
152
  end
@@ -16,7 +16,8 @@ module CLI
16
16
  'red' => '31',
17
17
  'green' => '32',
18
18
  'yellow' => '33',
19
- 'blue' => '34',
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' => '34', # blue
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: true)
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
@@ -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
  #
@@ -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
@@ -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
- puts_question("#{question} {{yellow:(choose with ↑ ↓ ⏎)}}")
135
- resp = interactive_prompt(options)
136
-
137
- # Clear the line, and reset the question to include the answer
138
- print(ANSI.previous_line + ANSI.end_of_line + ' ')
139
- print(ANSI.cursor_save)
140
- print(' ' * CLI::UI::Terminal.width)
141
- print(ANSI.cursor_restore)
142
- puts_question("#{question} (You chose: {{italic:#{resp}}})")
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
- options[list.call - 1]
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
- wait_for_user_input
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 = @options.count(&:empty?)
84
- joined_options = @options.join("\n")
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
- @active = @active - 1 >= 1 ? @active - 1 : @options.length
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
- @active = @active + 1 <= @options.length ? @active + 1 : 1
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
- @active = n
100
- @answer = n
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" ; @answer = @active # <enter>
123
- when "\u0003" ; raise Interrupt # Ctrl-c
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
- @options.each_with_index do |choice, index|
163
- num = index + 1
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}.#{padding}"
166
- message += choice.split("\n").map { |l| " {{bold:#{l}}}" }.join("\n")
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
- inset + glyph(index) + CLI::UI::Color::RESET.code + ' ' + CLI::UI.resolve_text(title) + "\e[K"
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?) + move_from)
188
+ print(move_to + task.render(idx, idx.zero?, width: width) + move_from)
177
189
  end
178
190
  end
179
191
  end
@@ -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
@@ -1,5 +1,5 @@
1
1
  module CLI
2
2
  module UI
3
- VERSION = "1.1.4"
3
+ VERSION = "1.2.0"
4
4
  end
5
5
  end
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.1.4
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-05-15 00:00:00.000000000 Z
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: