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 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: