terminal_rb 0.20.0 → 1.0.3

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.
data/lib/terminal/text.rb CHANGED
@@ -1,132 +1,213 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'ansi'
4
+ require_relative 'text/formatter'
4
5
 
5
6
  module Terminal
6
- # Text helper functions.
7
+ # Unicode-aware text processing utilities for display width calculation,
8
+ # word-wrapping, and formatted text output.
9
+ #
10
+ # All methods correctly handle multi-byte Unicode characters, East Asian
11
+ # wide characters, emoji, combining marks, and ANSI escape codes.
12
+ #
13
+ # @see Terminal::Text::Formatter
14
+ #
15
+ # @example Measure display width
16
+ # Terminal::Text.width('Hello') # => 5
17
+ # Terminal::Text.width("\u{1F600}") # => 2 (emoji)
18
+ #
19
+ # @example Word-wrap text
20
+ # Terminal::Text.lines('Hello World, this is a test', width: 12)
21
+ # # => ["Hello World,", "this is a", "test"]
7
22
  #
8
23
  module Text
9
24
  class << self
10
- # Value for {width} of letters whose display width is not precisely
11
- # defined by the Unicode standard.
12
- # Defaults to one (1).
25
+ # Display width used for East Asian ambiguous-width characters.
13
26
  #
14
- # @see .width
27
+ # @example
28
+ # Terminal::Text.ambiguous_char_width # => 1
29
+ # Terminal::Text.ambiguous_char_width = 2 # for CJK terminals
15
30
  #
16
- # @return [Integer] value
31
+ # @return [Integer] default: +1+
17
32
  attr_accessor :ambiguous_char_width
18
33
 
19
- # Calculates the display width of the text representation of a given
20
- # argument.
21
- # It can optionally ignore embedded BBCode.
34
+ # Calculate the display width of a string in terminal columns.
22
35
  #
23
- # The Unicode standard defines the display width for most characters but
24
- # some are ambiguous. The function uses {ambiguous_char_width} for each of
25
- # these characters.
36
+ # @example
37
+ # Terminal::Text.width('Hello') # => 5
38
+ # Terminal::Text.width('[bold]Hi[/bold]') # => 2
39
+ # Terminal::Text.width('[bold]Hi[/bold]', bbcode: false) # => 14
26
40
  #
27
- # @param str [#to_s] str to process
28
- # @param bbcode [true|false] whether to interpret embedded BBCode
29
- # @return [Integer] display width
30
- def width(str, bbcode: true)
31
- return 0 if (str = bbcode ? Ansi.unbbcode(str) : str.to_s).empty?
32
- width = 0
33
- (str.encoding == @encoding ? str : str.encode(@encoding)).scan(
34
- @scan_width
35
- ) do |space, gc|
36
- next width += char_width(gc) if gc
37
- width += 1 if space
38
- end
39
- width
41
+ # @param str [#to_s] the string to measure
42
+ # @param bbcode (see Formatter#initialize)
43
+ # @param spaces (see Formatter#initialize)
44
+ # @return [Integer] display width in columns
45
+ def width(str, bbcode: true, spaces: true)
46
+ Formatter
47
+ .new(str, bbcode:, spaces:, ansi: false, eol: false)
48
+ .lines_with_size
49
+ .dig(0, -1) || 0
50
+ end
51
+
52
+ # Split text into lines with their display widths.
53
+ #
54
+ # @example
55
+ # Terminal::Text.lines_with_size('Hello World', width: 5)
56
+ # # => [["Hello", 5], ["World", 5]]
57
+ #
58
+ # @param (see Formatter#initialize)
59
+ # @param (see Formatter#lines_with_size)
60
+ # @return (see Formatter#lines_with_size)
61
+ def lines_with_size(
62
+ *str,
63
+ bbcode: true,
64
+ ansi: true,
65
+ spaces: true,
66
+ eol: true,
67
+ width: nil
68
+ )
69
+ Formatter.new(*str, bbcode:, ansi:, spaces:, eol:).lines_with_size(
70
+ width:
71
+ )
72
+ end
73
+
74
+ # Split text into lines as strings.
75
+ #
76
+ # @see Formatter#lines
77
+ #
78
+ # @example
79
+ # Terminal::Text.lines('Hello World', width: 5)
80
+ # # => ["Hello", "World"]
81
+ # @example With BBCode
82
+ # Terminal::Text.lines('[bold]Hello[/] World', width: 5)
83
+ # # => ["\e[1mHello\e[m", "World"]
84
+ #
85
+ # @param (see Formatter#initialize)
86
+ # @param (see Formatter#lines)
87
+ # @return (see Formatter#lines)
88
+ def lines(
89
+ *str,
90
+ bbcode: true,
91
+ ansi: true,
92
+ spaces: true,
93
+ eol: true,
94
+ width: nil
95
+ )
96
+ Formatter.new(*str, bbcode:, ansi:, spaces:, eol:).lines(width:)
40
97
  end
41
98
 
42
- # Iterate each line of given text.
99
+ # Format text with alignment, padding, and decorations.
43
100
  #
44
- # @example Generate word-by-word wrapped text for a limited output width
45
- # Terminal::Text.each_line('This is a simple test 😀', limit: 6).to_a
46
- # # => ["This", "is a", "simple", "test", "😀"]
101
+ # @see Formatter.format
102
+ #
103
+ # @param (see Formatter.format)
104
+ # @return (see Formatter.format)
105
+ # @raise (see Formatter.format)
106
+ def format(...) = Formatter.format(...)
107
+
108
+ # @deprecated Use {.lines} and iterate over the result.
47
109
  #
48
- # @param text [#to_s, ...]
49
- # text objects to process
50
- # @param limit [#to_i, nil]
51
- # optionally limit line size
52
- # @param bbcode [true, false]
53
- # whether to interprete embedded BBCode (see {Ansi.bbcode})
54
- # @param ansi [true, false]
55
- # whether to keep embedded ANSI control codes
56
- # @param ignore_newline [true, false]
57
- # wheter to ignore embedded line breaks (`"\r\n"` or `"\n"`)
58
- # @yield [String] text line
59
- # @return [Enumerator] when no block given
60
- # @return [nil]
61
- # @raise ArgumentError when a `limit` less than `1` is given
110
+ # @comment @param str [Array<#to_s>] text to process
111
+ # @comment @param limit [Integer, nil] maximum line width
112
+ # @comment @param bbcode [true, false] process BBCode markup
113
+ # @comment @param ansi [true, false] recognize ANSI escape codes
114
+ # @comment @param ignore_newline [true, false] treat newlines as spaces
115
+ # @comment @yield [String] each line
116
+ # @comment @return [Enumerator, Object] enumerator or block result
62
117
  def each_line(
63
- *text,
118
+ *str,
64
119
  limit: nil,
65
120
  bbcode: true,
66
121
  ansi: true,
67
122
  ignore_newline: false,
68
- &block
123
+ &
69
124
  )
70
- unless limit
71
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, Word)
72
- return block ? lines(snippets, &block) : to_enum(:lines, snippets)
73
- end
74
- limit = limit.to_i
75
- raise(ArgumentError, "invalid limit - #{limit}") if limit < 1
76
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, WordEx)
77
- return lim_lines(snippets, limit, &block) if block
78
- to_enum(:lim_lines, snippets, limit)
125
+ warn(
126
+ '[DEPRECATED] `each_line` is deprecated use `.lines.each` instead',
127
+ category: :deprecated,
128
+ uplevel: 1
129
+ )
130
+ lines(
131
+ *str,
132
+ bbcode:,
133
+ ansi:,
134
+ spaces: true,
135
+ eol: !ignore_newline,
136
+ width: limit
137
+ ).each(&)
79
138
  end
80
139
  alias each each_line
81
140
 
82
- # Iterate each line and it's display width of given text.
141
+ # @deprecated Use {.lines_with_size} and iterate over the result.
83
142
  #
84
- # @example Generate word-by-word wrapped text for a limited output width
85
- # Terminal::Text.each_line_with_size('This is a simple test 😀', limit: 6).to_a
86
- # # => [["This", 4], ["is a", 4], ["simple", 6], ["test", 4], ["😀", 2]]
87
- #
88
- # @param (see each_line)
89
- # @yield [String, Integer] text line and it's display width
90
- # @return (see each_line)
91
- # @raise (see each_line)
143
+ # @comment @param str [Array<to_s>] text to process
144
+ # @comment @param limit [Integer, nil] maximum line width
145
+ # @comment @param bbcode [true, false] process BBCode markup
146
+ # @comment @param ansi [true, false] recognize ANSI escape codes
147
+ # @comment @param ignore_newline [true, false] treat newlines as spaces
148
+ # @comment @yield [String, Integer] each line and its display width
149
+ # @comment @return [Enumerator, Object] enumerator or block result
92
150
  def each_line_with_size(
93
- *text,
151
+ *str,
94
152
  limit: nil,
95
153
  bbcode: true,
96
154
  ansi: true,
97
155
  ignore_newline: false,
98
- &block
156
+ &
99
157
  )
100
- unless limit
101
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, Word)
102
- return block ? pairs(snippets, &block) : to_enum(:pairs, snippets)
103
- end
104
- limit = limit.to_i
105
- raise(ArgumentError, "invalid limit - #{limit}") if limit < 1
106
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, WordEx)
107
- return lim_pairs(snippets, limit, &block) if block
108
- to_enum(:lim_pairs, snippets, limit)
158
+ warn(
159
+ '[DEPRECATED] `each_line_with_size` is deprecated ' \
160
+ '– use `.lines_width_size.each` instead',
161
+ category: :deprecated,
162
+ uplevel: 1
163
+ )
164
+
165
+ lines_with_size(
166
+ *str,
167
+ bbcode:,
168
+ ansi:,
169
+ spaces: true,
170
+ eol: !ignore_newline,
171
+ width: limit
172
+ ).each(&)
109
173
  end
110
174
  alias each_with_size each_line_with_size
111
175
 
112
- # Returns maximal width of a line of given text.
176
+ # Calculate the maximum display width of any line.
113
177
  #
114
- # @param (see each_line)
115
- # @return [Integer] width
116
- def max_line_width(*text, ignore_newline: false)
117
- return 0 if text.empty?
118
- max_width_of(as_snippets(text, true, true, ignore_newline, Word))
119
- end
178
+ # @example
179
+ # Terminal::Text.max_line_width("short\na longer line")
180
+ # # => 13
181
+ #
182
+ # @param (see Formatter#initialize)
183
+ # @return (see Formatter#max_line_width)
184
+ def max_line_width(
185
+ *str,
186
+ ansi: true,
187
+ bbcode: true,
188
+ spaces: true,
189
+ eol: true,
190
+ **opts
191
+ )
192
+ # backward compatibility:
193
+ if opts.key?(:ignore_newline)
194
+ eol = opts[:ignore_newline] ? false : true
195
+ warn(
196
+ "Parameter ':ignore_newline' will become obsolete - " \
197
+ "use 'eol: #{eol.inspect}' instead",
198
+ category: :deprecated
199
+ )
200
+ end
120
201
 
121
- private
202
+ Formatter.new(*str, bbcode:, ansi:, spaces:, eol:).max_line_width
203
+ end
122
204
 
205
+ # @private
123
206
  def char_width(char)
124
207
  ord = char.ord
125
208
  return @ctrlchar_width[ord] if ord < 0x20
126
209
  if char.size == 1
127
- return 1 if ord < 0xa1
128
- width = CharWidth[ord]
129
- return width < 0 ? @ambiguous_char_width : width
210
+ return ord < 0xa1 ? 1 : CharWidth[ord] || @ambiguous_char_width
130
211
  end
131
212
  sum = 0
132
213
  zwj = false
@@ -134,369 +215,13 @@ module Terminal
134
215
  next zwj = false if zwj
135
216
  ord = c.ord
136
217
  next zwj = true if ord == 0x200d # zero with joiner
137
- width = CharWidth[ord]
138
- sum += (width < 0 ? @ambiguous_char_width : width)
218
+ sum += CharWidth[ord] || @ambiguous_char_width
139
219
  end
140
220
  sum
141
221
  end
142
-
143
- def lim_pairs(snippets, limit)
144
- line = @empty.dup
145
- size = 0
146
- csi = nil
147
- snippets.each do |snippet|
148
- if snippet == :space
149
- next if size == 0
150
- next line << ' ' if (size += 1) <= limit
151
- yield(line, size - 1)
152
- line = "#{csi}"
153
- next size = 0
154
- end
155
-
156
- if snippet == :nl
157
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
158
- line = "#{csi}"
159
- next size = 0
160
- end
161
-
162
- if snippet == :hard_nl
163
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
164
- line = @empty.dup
165
- csi = nil
166
- next size = 0
167
- end
168
-
169
- if snippet == CsiEnd
170
- line << CsiEnd if csi
171
- next csi = nil
172
- end
173
-
174
- next line << (csi = snippet) if Csi === snippet
175
- next line << snippet if Osc === snippet
176
-
177
- # Word:
178
-
179
- if (ns = size + snippet.size) <= limit
180
- line << snippet
181
- next size = ns
182
- end
183
-
184
- if line[-1] == ' '
185
- line.chop!
186
- size -= 1
187
- end
188
- yield(line, size) if size != 0
189
-
190
- if snippet.size <= limit
191
- line = "#{csi}#{snippet}"
192
- next size = snippet.size
193
- end
194
-
195
- words = snippet.split(limit)
196
- if words[-1].size <= limit
197
- snippet = words.pop
198
- line = "#{csi}#{snippet}"
199
- size = snippet.size
200
- else
201
- line = "#{csi}"
202
- size = 0
203
- end
204
- words.each { yield("#{csi}#{_1}", _1.size) }
205
- end
206
- nil
207
- end
208
-
209
- def max_width_of(snippets)
210
- lws = false
211
- ret = size = 0
212
- snippets.each do |snippet|
213
- if snippet == :space
214
- next if size == 0
215
- lws = true
216
- next size += 1
217
- end
218
-
219
- if snippet == :nl || snippet == :hard_nl
220
- size -= 1 if lws
221
- ret = size if ret < size
222
- lws = false
223
- next size = 0
224
- end
225
-
226
- lws = false
227
- size += snippet.size if Word === snippet
228
- end
229
- ret
230
- end
231
-
232
- def pairs(snippets)
233
- line = @empty.dup
234
- size = 0
235
- csi = nil
236
- snippets.each do |snippet|
237
- if snippet == :space
238
- next if size == 0
239
- line << ' '
240
- next size += 1
241
- end
242
-
243
- if snippet == :nl
244
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
245
- line = "#{csi}"
246
- next size = 0
247
- end
248
-
249
- if snippet == :hard_nl
250
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
251
- line = @empty.dup
252
- csi = nil
253
- next size = 0
254
- end
255
-
256
- if snippet == CsiEnd
257
- line << CsiEnd if csi
258
- next csi = nil
259
- end
260
-
261
- next line << (csi = snippet) if Csi === snippet
262
- next line << snippet if Osc === snippet
263
-
264
- # Word:
265
- size += snippet.size
266
- line << snippet
267
- end
268
- nil
269
- end
270
-
271
- def lim_lines(snippets, limit)
272
- line = @empty.dup
273
- size = 0
274
- csi = nil
275
- snippets.each do |snippet|
276
- if snippet == :space
277
- next if size == 0
278
- next line << ' ' if (size += 1) <= limit
279
- yield(line)
280
- line = "#{csi}"
281
- next size = 0
282
- end
283
-
284
- if snippet == :nl
285
- yield(line[-1] == ' ' ? line.chop : line)
286
- line = "#{csi}"
287
- next size = 0
288
- end
289
-
290
- if snippet == :hard_nl
291
- yield(line[-1] == ' ' ? line.chop : line)
292
- line = @empty.dup
293
- csi = nil
294
- next size = 0
295
- end
296
-
297
- if snippet == CsiEnd
298
- line << CsiEnd if csi
299
- next csi = nil
300
- end
301
-
302
- next line << (csi = snippet) if Csi === snippet
303
- next line << snippet if Osc === snippet
304
-
305
- # Word:
306
-
307
- if (ns = size + snippet.size) <= limit
308
- line << snippet
309
- next size = ns
310
- end
311
-
312
- if line[-1] == ' '
313
- line.chop!
314
- size -= 1
315
- end
316
- yield(line) if size != 0
317
-
318
- if snippet.size <= limit
319
- line = "#{csi}#{snippet}"
320
- next size = snippet.size
321
- end
322
-
323
- words = snippet.split(limit)
324
- if words[-1].size <= limit
325
- snippet = words.pop
326
- line = "#{csi}#{snippet}"
327
- size = snippet.size
328
- else
329
- line = "#{csi}"
330
- size = 0
331
- end
332
- words.each { yield("#{csi}#{_1}") }
333
- end
334
- nil
335
- end
336
-
337
- def lines(snippets)
338
- line = @empty.dup
339
- size = 0
340
- csi = nil
341
- snippets.each do |snippet|
342
- if snippet == :space
343
- next if size == 0
344
- line << ' '
345
- next size += 1
346
- end
347
-
348
- if snippet == :nl
349
- yield(line[-1] == ' ' ? line.chop : line)
350
- line = "#{csi}"
351
- next size = 0
352
- end
353
-
354
- if snippet == :hard_nl
355
- yield(line[-1] == ' ' ? line.chop : line)
356
- line = @empty.dup
357
- csi = nil
358
- next size = 0
359
- end
360
-
361
- if snippet == CsiEnd
362
- line << CsiEnd if csi
363
- next csi = nil
364
- end
365
-
366
- next line << (csi = snippet) if Csi === snippet
367
- next line << snippet if Osc === snippet
368
-
369
- # Word:
370
- size += snippet.size
371
- line << snippet
372
- end
373
- nil
374
- end
375
-
376
- def as_snippets(text, bbcode, ansi, ignore_newline, word_class)
377
- ret = []
378
- last = nil
379
- to_s = bbcode ? ->(s) { Ansi.bbcode(s) } : :to_s.to_proc
380
- text.each do |txt|
381
- if (txt = to_s[txt]).empty?
382
- next ret[-1] = last = :hard_nl if Symbol === last
383
- next ret << (last = :hard_nl)
384
- end
385
-
386
- (txt.encoding == @encoding ? txt : txt.encode(@encoding)).scan(
387
- @scan_snippet
388
- ) do |nl, csi, osc, space, gc|
389
- if gc
390
- next last.add(gc, char_width(gc)) if word_class === last
391
- next ret << (last = word_class.new(gc, char_width(gc)))
392
- end
393
-
394
- next Symbol === last ? nil : ret << (last = :space) if space
395
-
396
- if nl
397
- if ignore_newline # handle nl like space
398
- next Symbol === last ? nil : ret << (last = :space)
399
- end
400
- next last == :space ? ret[-1] = last = :nl : ret << (last = :nl)
401
- end
402
-
403
- next unless ansi
404
-
405
- next ret << (last = Osc.new(osc)) if osc
406
-
407
- if csi == "\e[m" || csi == "\e[0m"
408
- next last == CsiEnd ? nil : ret << (last = CsiEnd)
409
- end
410
-
411
- Csi === last ? last.add(csi) : ret << (last = Csi.new(csi))
412
- end
413
-
414
- Symbol === last ? ret[-1] = last = :hard_nl : ret << (last = :hard_nl)
415
- end
416
- ret
417
- end
418
- end
419
-
420
- class Osc
421
- attr_reader :to_str, :size
422
- alias to_s to_str
423
-
424
- def initialize(str)
425
- @to_str = str
426
- @size = 0
427
- end
428
- end
429
-
430
- class Csi < Osc
431
- def add(str) = (@to_str << str)
432
- end
433
-
434
- module CsiEnd
435
- class << self
436
- attr_reader :to_str, :size
437
- end
438
- @to_str = "\e[m"
439
- @size = 0
440
- end
441
-
442
- class Word
443
- attr_reader :to_str, :size
444
- alias to_s to_str
445
-
446
- def initialize(char, size)
447
- @to_str = char.dup
448
- @size = size
449
- end
450
-
451
- def add(char, size)
452
- @to_str << char
453
- @size += size
454
- end
455
- end
456
-
457
- class WordEx < Word
458
- attr_reader :chars
459
-
460
- def initialize(char, size)
461
- super
462
- @chars = [[char, size]]
463
- end
464
-
465
- def add(char, size)
466
- @chars << [char, size]
467
- super
468
- end
469
-
470
- def split(limit)
471
- chars = @chars.dup
472
- ret = [last = Word.new(*chars.shift)]
473
- chars.each do |c, s|
474
- next ret << (last = Word.new(c, s)) if last.size + s > limit
475
- last.add(c, s)
476
- end
477
- ret
478
- end
479
222
  end
480
223
 
481
224
  @ambiguous_char_width = 1
482
- @empty = String.new(encoding: @encoding = Encoding::UTF_8).freeze
483
-
484
- @scan_snippet =
485
- /\G(?:
486
- (\r?\n)
487
- | (\e\[[\x30-\x3f]*[\x20-\x2f]*[a-zA-Z])
488
- | (\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
489
- | (\s+)
490
- | (\X)
491
- )/x
492
-
493
- @scan_width =
494
- /\G(?:
495
- (?:\e\[[\x30-\x3f]*[\x20-\x2f]*[a-zA-Z])
496
- | (?:\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
497
- | (\s+)
498
- | (\X)
499
- )/x
500
225
 
501
226
  @ctrlchar_width =
502
227
  Hash
@@ -541,7 +266,6 @@ module Terminal
541
266
  cw_file = "#{__dir__}/text/char_width.rb"
542
267
  autoload :CharWidth, cw_file
543
268
  autoload :UNICODE_VERSION, cw_file
544
-
545
- private_constant :Osc, :Csi, :CsiEnd, :Word, :WordEx, :CharWidth
269
+ private_constant :CharWidth
546
270
  end
547
271
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Terminal
4
4
  # The version number of the gem.
5
- VERSION = '0.20.0'
5
+ VERSION = '1.0.3'
6
6
  end