terminal_rb 0.19.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,135 +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
- str = str.encode(@encoding) if str.encoding != @encoding
33
- width = 0
34
- str.scan(@scan_width) do |sp, gc|
35
- next width += char_width(gc) if gc
36
- width += 1 if sp
37
- end
38
- 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
39
50
  end
40
51
 
41
- # Iterate each line of given text.
52
+ # Split text into lines with their display widths.
42
53
  #
43
- # @example Generate word-by-word wrapped text for a limited output width
44
- # Terminal::Text.each_line('This is a simple test 😀', limit: 6).to_a
45
- # # => ["This", "is a", "simple", "test", "😀"]
54
+ # @example
55
+ # Terminal::Text.lines_with_size('Hello World', width: 5)
56
+ # # => [["Hello", 5], ["World", 5]]
46
57
  #
47
- # @param text [#to_s, ...]
48
- # text objects to process
49
- # @param limit [#to_i, nil]
50
- # optionally limit line size
51
- # @param bbcode [true, false]
52
- # whether to interprete embedded BBCode (see {Ansi.bbcode})
53
- # @param ansi [true, false]
54
- # whether to keep embedded ANSI control codes
55
- # @param ignore_newline [true, false]
56
- # wheter to ignore embedded line breaks (`"\r\n"` or `"\n"`)
57
- # @yield [String] text line
58
- # @return [Enumerator] when no block given
59
- # @return [nil]
60
- # @raise ArgumentError when a `limit` less than `1` is given
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:)
97
+ end
98
+
99
+ # Format text with alignment, padding, and decorations.
100
+ #
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.
109
+ #
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
61
117
  def each_line(
62
- *text,
118
+ *str,
63
119
  limit: nil,
64
120
  bbcode: true,
65
121
  ansi: true,
66
122
  ignore_newline: false,
67
- &block
123
+ &
68
124
  )
69
- unless limit
70
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, Word)
71
- return block ? lines(snippets, &block) : to_enum(:lines, snippets)
72
- end
73
- limit = limit.to_i
74
- raise(ArgumentError, "invalid limit - #{limit}") if limit < 1
75
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, WordEx)
76
- return lim_lines(snippets, limit, &block) if block
77
- 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(&)
78
138
  end
79
139
  alias each each_line
80
140
 
81
- # Iterate each line and it's display width of given text.
82
- #
83
- # @example Generate word-by-word wrapped text for a limited output width
84
- # Terminal::Text.each_line_with_size('This is a simple test 😀', limit: 6).to_a
85
- # # => [["This", 4], ["is a", 4], ["simple", 6], ["test", 4], ["😀", 2]]
141
+ # @deprecated Use {.lines_with_size} and iterate over the result.
86
142
  #
87
- # @param (see each_line)
88
- # @yield [String, Integer] text line and it's display width
89
- # @return (see each_line)
90
- # @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
91
150
  def each_line_with_size(
92
- *text,
151
+ *str,
93
152
  limit: nil,
94
153
  bbcode: true,
95
154
  ansi: true,
96
155
  ignore_newline: false,
97
- &block
156
+ &
98
157
  )
99
- unless limit
100
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, Word)
101
- return block ? pairs(snippets, &block) : to_enum(:pairs, snippets)
102
- end
103
- limit = limit.to_i
104
- raise(ArgumentError, "invalid limit - #{limit}") if limit < 1
105
- snippets = as_snippets(text, bbcode, ansi, ignore_newline, WordEx)
106
- return lim_pairs(snippets, limit, &block) if block
107
- 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(&)
108
173
  end
109
174
  alias each_with_size each_line_with_size
110
175
 
111
- # Returns maximal width of a line of given text.
176
+ # Calculate the maximum display width of any line.
112
177
  #
113
- # @param (see each_line)
114
- # @return [Integer] width
115
- def max_line_width(*text, ignore_newline: false)
116
- return 0 if text.empty?
117
- ret = 0
118
- pairs(as_snippets(text, false, false, ignore_newline, Word)) do |_l, w|
119
- ret = w if w > ret
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
+ )
120
200
  end
121
- ret
122
- end
123
201
 
124
- private
202
+ Formatter.new(*str, bbcode:, ansi:, spaces:, eol:).max_line_width
203
+ end
125
204
 
205
+ # @private
126
206
  def char_width(char)
127
207
  ord = char.ord
128
208
  return @ctrlchar_width[ord] if ord < 0x20
129
209
  if char.size == 1
130
- return 1 if ord < 0xa1
131
- width = CharWidth[ord]
132
- return width < 0 ? @ambiguous_char_width : width
210
+ return ord < 0xa1 ? 1 : CharWidth[ord] || @ambiguous_char_width
133
211
  end
134
212
  sum = 0
135
213
  zwj = false
@@ -137,346 +215,13 @@ module Terminal
137
215
  next zwj = false if zwj
138
216
  ord = c.ord
139
217
  next zwj = true if ord == 0x200d # zero with joiner
140
- width = CharWidth[ord]
141
- sum += (width < 0 ? @ambiguous_char_width : width)
218
+ sum += CharWidth[ord] || @ambiguous_char_width
142
219
  end
143
220
  sum
144
221
  end
145
-
146
- def lim_pairs(snippets, limit)
147
- line = @empty.dup
148
- size = 0
149
- csi = nil
150
- snippets.each do |snippet|
151
- if snippet == :space
152
- next if size == 0
153
- next line << ' ' if (size += 1) <= limit
154
- yield(line, size - 1)
155
- line = "#{csi}"
156
- next size = 0
157
- end
158
-
159
- if snippet == :nl
160
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
161
- line = "#{csi}"
162
- next size = 0
163
- end
164
-
165
- if snippet == :hard_nl
166
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
167
- line = @empty.dup
168
- csi = nil
169
- next size = 0
170
- end
171
-
172
- if snippet == CsiEnd
173
- line << CsiEnd if csi
174
- next csi = nil
175
- end
176
-
177
- next line << (csi = snippet) if Csi === snippet
178
- next line << snippet if Osc === snippet
179
-
180
- # Word:
181
-
182
- if (ns = size + snippet.size) <= limit
183
- line << snippet
184
- next size = ns
185
- end
186
-
187
- if line[-1] == ' '
188
- line.chop!
189
- size -= 1
190
- end
191
- yield(line, size) if size != 0
192
-
193
- if snippet.size <= limit
194
- line = "#{csi}#{snippet}"
195
- next size = snippet.size
196
- end
197
-
198
- words = snippet.split(limit)
199
- if words[-1].size <= limit
200
- snippet = words.pop
201
- line = "#{csi}#{snippet}"
202
- size = snippet.size
203
- else
204
- line = "#{csi}"
205
- size = 0
206
- end
207
- words.each { yield("#{csi}#{_1}", _1.size) }
208
- end
209
- nil
210
- end
211
-
212
- def pairs(snippets)
213
- line = @empty.dup
214
- size = 0
215
- csi = nil
216
- snippets.each do |snippet|
217
- if snippet == :space
218
- next if size == 0
219
- line << ' '
220
- next size += 1
221
- end
222
-
223
- if snippet == :nl
224
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
225
- line = "#{csi}"
226
- next size = 0
227
- end
228
-
229
- if snippet == :hard_nl
230
- line[-1] == ' ' ? yield(line.chop, size - 1) : yield(line, size)
231
- line = @empty.dup
232
- csi = nil
233
- next size = 0
234
- end
235
-
236
- if snippet == CsiEnd
237
- line << CsiEnd if csi
238
- next csi = nil
239
- end
240
-
241
- next line << (csi = snippet) if Csi === snippet
242
- next line << snippet if Osc === snippet
243
-
244
- # Word:
245
- size += snippet.size
246
- line << snippet
247
- end
248
- nil
249
- end
250
-
251
- def lim_lines(snippets, limit)
252
- line = @empty.dup
253
- size = 0
254
- csi = nil
255
- snippets.each do |snippet|
256
- if snippet == :space
257
- next if size == 0
258
- next line << ' ' if (size += 1) <= limit
259
- yield(line)
260
- line = "#{csi}"
261
- next size = 0
262
- end
263
-
264
- if snippet == :nl
265
- yield(line[-1] == ' ' ? line.chop : line)
266
- line = "#{csi}"
267
- next size = 0
268
- end
269
-
270
- if snippet == :hard_nl
271
- yield(line[-1] == ' ' ? line.chop : line)
272
- line = @empty.dup
273
- csi = nil
274
- next size = 0
275
- end
276
-
277
- if snippet == CsiEnd
278
- line << CsiEnd if csi
279
- next csi = nil
280
- end
281
-
282
- next line << (csi = snippet) if Csi === snippet
283
- next line << snippet if Osc === snippet
284
-
285
- # Word:
286
-
287
- if (ns = size + snippet.size) <= limit
288
- line << snippet
289
- next size = ns
290
- end
291
-
292
- if line[-1] == ' '
293
- line.chop!
294
- size -= 1
295
- end
296
- yield(line) if size != 0
297
-
298
- if snippet.size <= limit
299
- line = "#{csi}#{snippet}"
300
- next size = snippet.size
301
- end
302
-
303
- words = snippet.split(limit)
304
- if words[-1].size <= limit
305
- snippet = words.pop
306
- line = "#{csi}#{snippet}"
307
- size = snippet.size
308
- else
309
- line = "#{csi}"
310
- size = 0
311
- end
312
- words.each { yield("#{csi}#{_1}") }
313
- end
314
- nil
315
- end
316
-
317
- def lines(snippets)
318
- line = @empty.dup
319
- size = 0
320
- csi = nil
321
- snippets.each do |snippet|
322
- if snippet == :space
323
- next if size == 0
324
- line << ' '
325
- next size += 1
326
- end
327
-
328
- if snippet == :nl
329
- yield(line[-1] == ' ' ? line.chop : line)
330
- line = "#{csi}"
331
- next size = 0
332
- end
333
-
334
- if snippet == :hard_nl
335
- yield(line[-1] == ' ' ? line.chop : line)
336
- line = @empty.dup
337
- csi = nil
338
- next size = 0
339
- end
340
-
341
- if snippet == CsiEnd
342
- line << CsiEnd if csi
343
- next csi = nil
344
- end
345
-
346
- next line << (csi = snippet) if Csi === snippet
347
- next line << snippet if Osc === snippet
348
-
349
- # Word:
350
- size += snippet.size
351
- line << snippet
352
- end
353
- nil
354
- end
355
-
356
- def as_snippets(text, bbcode, ansi, ignore_newline, word_class)
357
- ret = []
358
- last = nil
359
- to_s = bbcode ? ->(s) { Ansi.bbcode(s) } : :to_s.to_proc
360
- text.each do |txt|
361
- if (txt = to_s[txt]).empty?
362
- next ret[-1] = last = :hard_nl if Symbol === last
363
- next ret << (last = :hard_nl)
364
- end
365
-
366
- txt = txt.encode(@encoding) if txt.encoding != @encoding
367
-
368
- txt.scan(@scan_snippet) do |nl, csi, osc, space, gc|
369
- if gc
370
- next last.add(gc, char_width(gc)) if word_class === last
371
- next ret << (last = word_class.new(gc, char_width(gc)))
372
- end
373
-
374
- next Symbol === last ? nil : ret << (last = :space) if space
375
-
376
- if nl
377
- if ignore_newline # handle nl like space
378
- next Symbol === last ? nil : ret << (last = :space)
379
- end
380
- next last == :space ? ret[-1] = last = :nl : ret << (last = :nl)
381
- end
382
-
383
- next unless ansi
384
-
385
- next ret << (last = Osc.new(osc)) if osc
386
-
387
- if csi == "\e[m" || csi == "\e[0m"
388
- next last == CsiEnd ? nil : ret << (last = CsiEnd)
389
- end
390
-
391
- Csi === last ? last.add(csi) : ret << (last = Csi.new(csi))
392
- end
393
-
394
- Symbol === last ? ret[-1] = last = :hard_nl : ret << (last = :hard_nl)
395
- end
396
- ret
397
- end
398
- end
399
-
400
- class Osc
401
- attr_reader :to_str, :size
402
- alias to_s to_str
403
-
404
- def initialize(str)
405
- @to_str = str
406
- @size = 0
407
- end
408
- end
409
-
410
- class Csi < Osc
411
- def add(str) = (@to_str << str)
412
- end
413
-
414
- module CsiEnd
415
- class << self
416
- attr_reader :to_str, :size
417
- end
418
- @to_str = "\e[m"
419
- @size = 0
420
- end
421
-
422
- class Word
423
- attr_reader :to_str, :size
424
- alias to_s to_str
425
-
426
- def initialize(char, size)
427
- @to_str = char.dup
428
- @size = size
429
- end
430
-
431
- def add(char, size)
432
- @to_str << char
433
- @size += size
434
- end
435
- end
436
-
437
- class WordEx < Word
438
- attr_reader :chars
439
-
440
- def initialize(char, size)
441
- super
442
- @chars = [[char, size]]
443
- end
444
-
445
- def add(char, size)
446
- @chars << [char, size]
447
- super
448
- end
449
-
450
- def split(limit)
451
- chars = @chars.dup
452
- ret = [last = Word.new(*chars.shift)]
453
- chars.each do |c, s|
454
- next ret << (last = Word.new(c, s)) if last.size + s > limit
455
- last.add(c, s)
456
- end
457
- ret
458
- end
459
222
  end
460
223
 
461
224
  @ambiguous_char_width = 1
462
- @empty = String.new(encoding: @encoding = Encoding::UTF_8).freeze
463
-
464
- @scan_snippet =
465
- /\G(?:
466
- (\r?\n)
467
- | (\e\[[\x30-\x3f]*[\x20-\x2f]*[a-zA-Z])
468
- | (\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
469
- | (\s+)
470
- | (\X)
471
- )/x
472
-
473
- @scan_width =
474
- /\G(?:
475
- (?:\e\[[\x30-\x3f]*[\x20-\x2f]*[a-zA-Z])
476
- | (?:\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
477
- | (\s+)
478
- | (\X)
479
- )/x
480
225
 
481
226
  @ctrlchar_width =
482
227
  Hash
@@ -521,7 +266,6 @@ module Terminal
521
266
  cw_file = "#{__dir__}/text/char_width.rb"
522
267
  autoload :CharWidth, cw_file
523
268
  autoload :UNICODE_VERSION, cw_file
524
-
525
- private_constant :Osc, :Csi, :CsiEnd, :Word, :WordEx, :CharWidth
269
+ private_constant :CharWidth
526
270
  end
527
271
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Terminal
4
4
  # The version number of the gem.
5
- VERSION = '0.19.0'
5
+ VERSION = '1.0.3'
6
6
  end