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.
@@ -0,0 +1,619 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminal
4
+ module Text
5
+ # Processes text with BBCode markup and ANSI escape codes into
6
+ # display-width-aware lines. Supports Unicode-aware word-wrapping,
7
+ # alignment, padding, and prefix/suffix decorations.
8
+ #
9
+ # @see Terminal::Text
10
+ #
11
+ # @example Basic word-wrapping
12
+ # fmt = Terminal::Text::Formatter.new('Hello World, this is a test')
13
+ # fmt.lines(width: 12)
14
+ # # => ["Hello World,", "this is a", "test"]
15
+ #
16
+ # @example Formatted output with alignment
17
+ # Terminal::Text::Formatter.format(
18
+ # 'Hello', align: :center, width: 20
19
+ # )
20
+ # # => [" Hello "]
21
+ class Formatter
22
+ # Parse text into lines, optionally with display widths.
23
+ #
24
+ # @see #lines
25
+ # @see #lines_with_size
26
+ #
27
+ # @example
28
+ # Terminal::Text::Formatter['Hello World', width: 5]
29
+ # # => ["Hello", "World"]
30
+ # @example With size information
31
+ # Terminal::Text::Formatter['Hello', with_size: true]
32
+ # # => [["Hello", 5]]
33
+ #
34
+ # @param (see #initialize)
35
+ # @param with_size [true, false] when +true+,
36
+ # return +[line, width]+ pairs instead of plain strings
37
+ # @param (see #lines_with_size)
38
+ # @return (see #lines_with_size)
39
+ def self.[](
40
+ *str,
41
+ with_size: false,
42
+ ansi: true,
43
+ bbcode: true,
44
+ spaces: true,
45
+ eol: true,
46
+ width: nil
47
+ )
48
+ ret = new(*str, ansi:, bbcode:, spaces:, eol:).lines_with_size(width:)
49
+ with_size ? ret : ret.map(&:first)
50
+ end
51
+
52
+ # Format text with alignment, padding, and decorations.
53
+ #
54
+ # @see #format
55
+ #
56
+ # @param (see #initialize)
57
+ # @param (see #format)
58
+ # @result (see #format)
59
+ # @raise (see #format)
60
+ def self.format(
61
+ *str,
62
+ ansi: true,
63
+ bbcode: true,
64
+ spaces: true,
65
+ eol: true,
66
+ align: nil,
67
+ width: nil,
68
+ height: nil,
69
+ padding: nil,
70
+ prefix: nil,
71
+ suffix: nil
72
+ )
73
+ new(*str, ansi:, bbcode:, spaces:, eol:).format(
74
+ align:,
75
+ width:,
76
+ height:,
77
+ padding:,
78
+ prefix:,
79
+ suffix:
80
+ )
81
+ end
82
+
83
+ # Create a new formatter by tokenizing the given text.
84
+ #
85
+ # @param str [Array<#to_s>] text to process
86
+ # @param ansi [true, false] recognize ANSI escape codes
87
+ # @param bbcode [true, false] process BBCode markup
88
+ # @param spaces [true, false] preserve whitespace;
89
+ # when +false+ leading/trailing spaces and multiple spaces are
90
+ # collapsed
91
+ # @param eol [true, false] preserve line endings;
92
+ # when +false+ newlines are treated as spaces
93
+ def initialize(*str, ansi: true, bbcode: true, spaces: true, eol: true)
94
+ @lex = []
95
+ return if str.empty?
96
+ ansi ? _generate_ansi(str, bbcode) : _generate(str, bbcode)
97
+ return if @lex.empty?
98
+ @lex = _ignore_whitespace unless spaces
99
+ @lex = _ignore_newline unless eol
100
+ end
101
+
102
+ # Word-wrap and return lines with their display widths.
103
+ #
104
+ # @example
105
+ # fmt = Terminal::Text::Formatter.new('Hello World')
106
+ # fmt.lines_with_size(width: 5)
107
+ # # => [["Hello", 5], ["World", 5]]
108
+ #
109
+ # @param width [Integer, nil] maximum line width in columns;
110
+ # +nil+ returns unwrapped lines
111
+ # @return [Array<String, Integer>] pairs of
112
+ # +[line_text, display_width]+
113
+ # @raise (see #format)
114
+ def lines_with_size(width: nil)
115
+ return [] if @lex.empty?
116
+ return @unlimited || _unlimited unless width
117
+ (w = width.to_i) > 0 and return _limited(w)
118
+ raise(ArgumentError, "invalid width - #{width.inspect}")
119
+ end
120
+
121
+ # Word-wrap and return lines as strings.
122
+ #
123
+ # @example
124
+ # fmt = Terminal::Text::Formatter.new('Hello World')
125
+ # fmt.lines(width: 5)
126
+ # # => ["Hello", "World"]
127
+ #
128
+ # @param width [Integer, nil] maximum line width in columns
129
+ # @return [Array<String>]
130
+ # @raise [ArgumentError] if +width+ is zero or negative
131
+ def lines(width: nil) = lines_with_size(width:).map(&:first)
132
+
133
+ # Format lines with alignment, padding, and decorations.
134
+ #
135
+ # @example Centered text with padding
136
+ # fmt = Terminal::Text::Formatter.new('Hi')
137
+ # fmt.format(align: :center, width: 10, padding: [0, 1])
138
+ # # => [" Hi "]
139
+ #
140
+ # @param align [Symbol, nil] text alignment:
141
+ # +:left+, +:right+, +:center+, or +nil+ (no fill)
142
+ # @param width [Integer, nil] output line width in columns
143
+ # @param height [Integer, nil] number of output lines;
144
+ # negative values take lines from the end
145
+ # @param padding [Integer, Array, nil] CSS-style padding
146
+ # - 1 value: all sides;
147
+ # - 2 values: [vertical, horizontal];
148
+ # - 3 values: [top, horizontal, bottom];
149
+ # - 4 values: [top, right, bottom, left]
150
+ # @param prefix [#to_s, nil] string prepended to each line
151
+ # @param suffix [#to_s, nil] string appended to each line
152
+ # @return [Array<String>] formatted output lines
153
+ # @raise [ArgumentError] if +width+ is zero or negative
154
+ def format(
155
+ align: nil,
156
+ width: nil,
157
+ height: nil,
158
+ padding: nil,
159
+ prefix: nil,
160
+ suffix: nil
161
+ )
162
+ top, right, bottom, left = _padding(padding)
163
+
164
+ if height
165
+ return [] if (height = height.to_i) == 0
166
+ if height < 0
167
+ tail = true
168
+ height = -height
169
+ end
170
+ height -= top
171
+ height -= -bottom
172
+ return [] if height < 1
173
+ end
174
+
175
+ if width
176
+ w = (width = width.to_i) - left - right
177
+ raise(ArgumentError, "invalid width - #{width.inspect}") if w < 1
178
+ lws = _limited(w)
179
+ lws = tail ? lws.last(height) : lws.take(height) if height
180
+ empty = "#{prefix}#{' ' * width}#{suffix}" if top + bottom > 0
181
+ else
182
+ lws = @unlimited || _unlimited
183
+ lws = tail ? lws.last(height) : lws.take(height) if height
184
+ w = lws.empty? ? 0 : lws.max_by(&:last)[-1]
185
+ if top + bottom > 0
186
+ empty = "#{prefix}#{' ' * (w + left + right)}#{suffix}"
187
+ end
188
+ end
189
+
190
+ ret = []
191
+ ret.fill(empty, 0, top) if top > 0
192
+ left = left < 1 ? prefix.to_s : "#{prefix}#{' ' * left}"
193
+ right = right < 1 ? suffix.to_s : "#{' ' * right}#{suffix}"
194
+
195
+ space = SPACE_CACHE.dup
196
+
197
+ case align
198
+ when :left
199
+ lws.each { |l, s| ret << "#{left}#{l}#{space[w - s]}#{right}" }
200
+ when :right
201
+ lws.each { |l, s| ret << "#{left}#{space[w - s]}#{l}#{right}" }
202
+ when :center
203
+ lws.each do |l, s|
204
+ ls = (s = w - s) / 2
205
+ next ret << "#{left}#{l}#{right}" if ls < 1
206
+ ret << "#{left}#{space[ls]}#{l}#{space[s - ls]}#{right}"
207
+ end
208
+ else
209
+ lws.each { |l, _| ret << "#{left}#{l}#{right}" }
210
+ end
211
+
212
+ return ret.fill(empty, ret.size, bottom) if bottom > 0
213
+ ret.empty? ? ret << '' : ret
214
+ end
215
+
216
+ # Whether the formatter has any content.
217
+ #
218
+ # @return [true, false]
219
+ def empty? = @lex.empty?
220
+
221
+ # Maximum display width of any line.
222
+ #
223
+ # @example
224
+ # fmt = Terminal::Text::Formatter.new("short\na longer line")
225
+ # fmt.max_line_width # => 13
226
+ #
227
+ # @return [Integer] widest line in columns, +0+ when empty
228
+ def max_line_width
229
+ @max_line_width ||=
230
+ (@lex.empty? ? 0 : (@unlimited || _unlimited).max_by(&:last)[-1])
231
+ end
232
+
233
+ # @private
234
+ # Convert to a single string with lines joined by newlines.
235
+ #
236
+ # @example
237
+ # Terminal::Text::Formatter.new('Hello World').to_s(width: 5)
238
+ # # => "Hello\nWorld"
239
+ #
240
+ # @param width [Integer, nil] maximum line width in columns
241
+ # @return [String]
242
+ def to_s(width: nil) = lines(width:).join("\n")
243
+
244
+ private
245
+
246
+ def _padding(value)
247
+ return Array.new(4, [0, value.to_i].max) unless value.respond_to?(:to_a)
248
+ value = value.to_a.take(4).map! { [0, it.to_i].max }
249
+ case value.size
250
+ when 0
251
+ value.fill(0, 0, 4)
252
+ when 1
253
+ value.fill(value[0], 0, 4)
254
+ when 2
255
+ value * 2
256
+ when 3
257
+ value << value[1]
258
+ else
259
+ value
260
+ end
261
+ end
262
+
263
+ def _limited(width)
264
+ ret = []
265
+ line = []
266
+ csi = []
267
+ size = 0
268
+
269
+ add_line = -> do
270
+ if size == 0 # Osc, Csi, CsiEnd only
271
+ ret << [+'', 0]
272
+ next line = [], size = 0
273
+ end
274
+
275
+ if csi.empty?
276
+ ret << [line.join, size]
277
+ next line = [], size = 0
278
+ end
279
+
280
+ if csi.size == 1 && line[-1].is_a?(Csi) # Csi at end
281
+ line.pop
282
+ ret << [line.join, size]
283
+ next line = [], size = 0
284
+ end
285
+
286
+ line << CsiEnd if line[-1] != CsiEnd
287
+ ret << [line.join, size]
288
+ line = csi.dup
289
+ size = 0
290
+ end
291
+
292
+ @lex.each do |current|
293
+ if current.is_a?(NewLine)
294
+ if (last = line[-1]).is_a?(Space) # ignore trailing space
295
+ line.pop
296
+ size -= last.size
297
+ end
298
+ add_line.call # if size > 0
299
+ next
300
+ end
301
+
302
+ # ignore leading space:
303
+ next if line.empty? && current.is_a?(Space) && current.size == 1
304
+
305
+ ns = size + current.size
306
+
307
+ # fits in width:
308
+ if ns < width
309
+ if current == CsiEnd
310
+ next if csi.empty? # useless
311
+
312
+ if csi.size == 1 && line[-1].is_a?(Csi) # Csi at end
313
+ line.pop
314
+ else
315
+ line << CsiEnd
316
+ end
317
+
318
+ next csi = []
319
+ end
320
+
321
+ csi << current if current.is_a?(Csi)
322
+ next line << current, size = ns
323
+ end
324
+
325
+ # fill-up the line:
326
+ if ns == width
327
+ # ignore trailing space:
328
+ next add_line.call if current.is_a?(Space)
329
+ line << current
330
+ size = width
331
+ next add_line.call
332
+ end
333
+
334
+ # exceeding the width:
335
+
336
+ # force flush:
337
+ if (last = line[-1]).is_a?(Space) # ignore trailing space
338
+ line.pop
339
+ size -= last.size
340
+ end
341
+ add_line.call if size > 0
342
+
343
+ next if current.is_a?(Space) # ignore trailing space
344
+
345
+ # handle Word...
346
+ splitted = current.split(width)
347
+ last = splitted.pop
348
+
349
+ if csi.empty?
350
+ splitted.each { ret << [it.to_str, it.size] }
351
+ else
352
+ csi_str = csi.join
353
+ splitted.each { ret << ["#{csi_str}#{it.to_str}\e[m", it.size] }
354
+ end
355
+
356
+ line = csi.dup
357
+ next size = 0 unless last
358
+ line << last
359
+ size = last.size
360
+ end
361
+
362
+ ret.pop if ret[-1] == ['', 0]
363
+ ret
364
+ end
365
+
366
+ def _unlimited
367
+ @unlimited = []
368
+ line = []
369
+ @lex.each do |current|
370
+ next line << current unless current.is_a?(NewLine)
371
+ @unlimited << [line.join.freeze, line.sum(&:size)]
372
+ line = []
373
+ end
374
+ @unlimited << [line.join.freeze, line.sum(&:size)] unless line.empty?
375
+ @unlimited.freeze
376
+ end
377
+
378
+ def _generate(text, bbcode)
379
+ bbcode = bbcode ? ->(s) { Ansi.unbbcode(s) } : :to_s.to_proc
380
+
381
+ text.each do |line|
382
+ next @lex << EOP if (line = bbcode[line]).empty?
383
+
384
+ line.encode!(ENCODING) if line.encoding != ENCODING
385
+
386
+ line.scan(SCAN_REGEX) do |newline, space, _, _, char|
387
+ if char
388
+ (last = @lex[-1]).is_a?(Word) and next last.add(char)
389
+ next @lex << Word.new(char)
390
+ end
391
+
392
+ if space
393
+ (last = @lex[-1]).is_a?(Space) and next last.inc
394
+ next @lex << Space.new
395
+ end
396
+
397
+ next @lex << EOL if newline
398
+ end
399
+
400
+ @lex[-1] == EOL ? @lex[-1] = EOP : @lex << EOP
401
+ end
402
+
403
+ nil
404
+ end
405
+
406
+ def _generate_ansi(text, bbcode)
407
+ bbcode = bbcode ? ->(s) { Ansi.bbcode(s) } : :to_s.to_proc
408
+
409
+ text.each do |line|
410
+ next @lex << EOP if (line = bbcode[line]).empty?
411
+
412
+ line.encode!(ENCODING) if line.encoding != ENCODING
413
+ csis = 0
414
+
415
+ line.scan(SCAN_REGEX) do |newline, space, csi, osc, char|
416
+ if char
417
+ (last = @lex[-1]).is_a?(Word) and next last.add(char)
418
+ next @lex << Word.new(char)
419
+ end
420
+
421
+ if space
422
+ (last = @lex[-1]).is_a?(Space) and next last.inc
423
+ next @lex << Space.new
424
+ end
425
+
426
+ next @lex << EOL if newline
427
+
428
+ next @lex << Osc.new(osc) if osc
429
+
430
+ # Handle csi...
431
+ if csi == "\e[m" || csi == "\e[0m"
432
+ next if csis == 0
433
+
434
+ if @lex[-1].is_a?(Csi)
435
+ @lex.pop
436
+ next csis = 0 if csis == 1
437
+ end
438
+
439
+ csis = 0
440
+ next @lex << CsiEnd
441
+ end
442
+
443
+ (last = @lex[-1]).is_a?(Csi) and next last.add(csi)
444
+
445
+ csis += 1
446
+ @lex << Csi.new(csi)
447
+ end
448
+
449
+ if (last = @lex[-1]) == EOL
450
+ @lex.pop
451
+ @lex << CsiEnd if csis != 0
452
+ next @lex << EOP
453
+ end
454
+
455
+ next @lex << EOP if csis == 0
456
+
457
+ if last.is_a?(Csi)
458
+ @lex.pop
459
+ @lex << CsiEnd if csis > 1
460
+ else
461
+ @lex << CsiEnd
462
+ end
463
+ @lex << EOP
464
+ end
465
+
466
+ nil
467
+ end
468
+
469
+ def _ignore_whitespace
470
+ ret = []
471
+ last = nil
472
+ space = Space.new.freeze
473
+
474
+ @lex.each do |current|
475
+ if current.is_a?(Space)
476
+ next if last.nil? || last.is_a?(NewLine) # skip leading space
477
+ next ret << (last = space)
478
+ end
479
+
480
+ if current.is_a?(NewLine) # remove trailing space
481
+ if last == space
482
+ ret.pop
483
+ elsif last == CsiEnd && ret[-2] == space
484
+ ret.delete_at(-2)
485
+ end
486
+ end
487
+
488
+ ret << (last = current)
489
+ end
490
+
491
+ ret
492
+ end
493
+
494
+ def _ignore_newline
495
+ last = nil
496
+
497
+ @lex.filter_map do |current|
498
+ if current == EOL
499
+ next if last.nil? || last == EOP || last.is_a?(Space)
500
+ next last = Space.new
501
+ end
502
+
503
+ if current.is_a?(Space) && last.is_a?(Space)
504
+ last.size = last.size == 1 ? current.size : last.size + current.size
505
+ next
506
+ end
507
+
508
+ last = current
509
+ end
510
+ end
511
+
512
+ # @private
513
+ class Space
514
+ attr_accessor :size
515
+ def to_str = (' ' * @size)
516
+ def initialize = (@size = 1)
517
+ def inc = (@size += 1)
518
+ def inspect = @size == 1 ? '<Space>' : "<Space #{@size}>"
519
+ end
520
+
521
+ # @private
522
+ class Word
523
+ def to_str = (@to_str ||= @chars.join)
524
+ def size = (@size ||= @sizes.sum)
525
+ def inspect = "<Word #{@sizes.sum}:#{@chars.join.inspect}>"
526
+
527
+ def initialize(char, size = Text.char_width(char))
528
+ @chars = [char]
529
+ @sizes = [size]
530
+ end
531
+
532
+ def add(char, size = Text.char_width(char))
533
+ @chars << char
534
+ @sizes << size
535
+ nil
536
+ end
537
+
538
+ def split(width)
539
+ return [self] if @chars.size == 1
540
+ ret = last = size = nil
541
+ @chars.each_with_index do |char, idx|
542
+ csize = @sizes[idx]
543
+ next ret = [last = Word.new(char, size = csize)] unless ret
544
+ next last.add(char, csize) if (size += csize) <= width
545
+ ret << (last = Word.new(char, size = csize))
546
+ end
547
+ ret
548
+ end
549
+ end
550
+
551
+ # @private
552
+ class Osc
553
+ attr_reader :to_str
554
+ def size = 0
555
+ def initialize(str) = (@to_str = str)
556
+ def inspect = "<Osc #{@to_str.inspect}>"
557
+ end
558
+
559
+ # @private
560
+ class Csi
561
+ attr_reader :to_str
562
+ def size = 0
563
+ def initialize(str) = (@to_str = str)
564
+ def add(str) = (@to_str << str unless @to_str.include?(str))
565
+ def inspect = "<Csi #{@to_str.inspect}>"
566
+ end
567
+
568
+ # @private
569
+ module CsiEnd
570
+ class << self
571
+ attr_reader :to_str, :size, :inspect
572
+ end
573
+ @to_str = "\e[m"
574
+ @size = 0
575
+ @inspect = '<CsiEnd>'
576
+ end
577
+
578
+ # @private
579
+ class NewLine
580
+ attr_reader :inspect
581
+ def initialize(inspect) = (@inspect = inspect)
582
+ end
583
+
584
+ EOL = NewLine.new '<EOL>'
585
+ EOP = NewLine.new '<EOP>'
586
+
587
+ ENCODING = Encoding::UTF_8
588
+
589
+ SPACE_CACHE =
590
+ Hash
591
+ .new { |h, s| h[s] = s < 1 ? '' : ' ' * s }
592
+ .compare_by_identity
593
+ .freeze
594
+
595
+ SCAN_REGEX =
596
+ /\G(?:
597
+ (\r?\n)
598
+ | (\s)
599
+ | (\e\[[\x30-\x3f]*[\x20-\x2f]*[a-zA-Z])
600
+ | (\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
601
+ | (\X)
602
+ )/x
603
+
604
+ private_constant(
605
+ :Space,
606
+ :Word,
607
+ :Osc,
608
+ :Csi,
609
+ :CsiEnd,
610
+ :NewLine,
611
+ :EOL,
612
+ :EOP,
613
+ :ENCODING,
614
+ :SPACE_CACHE,
615
+ :SCAN_REGEX
616
+ )
617
+ end
618
+ end
619
+ end