terminal_rb 0.6.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.
@@ -0,0 +1,542 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ansi'
4
+
5
+ module Terminal
6
+ module Text
7
+ class << self
8
+ # Value for {width} of letters whose display width is not precisely
9
+ # defined by the Unicode standard.
10
+ # Defaults to one (1).
11
+ #
12
+ # @see .width
13
+ #
14
+ # @return [Integer] value
15
+ attr_accessor :ambiguous_char_width
16
+
17
+ # Calculates the display width of the text representation of a given
18
+ # argument.
19
+ # It can optionally ignore embedded BBCode.
20
+ #
21
+ # The Unicode standard defines the display width for most characters but
22
+ # some are ambiguous. The function uses {ambiguous_char_width} for each of
23
+ # these characters.
24
+ #
25
+ # @param [#to_s] str object to process
26
+ # @param [true|false] bbcode whether to interpret embedded BBCode
27
+ # @return [Integer] display width
28
+ def width(str, bbcode: true)
29
+ str = bbcode ? Ansi.unbbcode(str) : str.to_s
30
+ return 0 if str.empty?
31
+ str = str.encode(ENC) if str.encoding != ENC
32
+ width = 0
33
+ str.scan(WIDTH_SCANNER) do |sp, gc|
34
+ next width += __char_width(gc) if gc
35
+ width += 1 if sp
36
+ end
37
+ width
38
+ end
39
+
40
+ # Report each line of the text.
41
+ #
42
+ # @param (see Wrap#initialize)
43
+ # @param (see Wrap#each_line)
44
+ # @yield (see Wrap#each_line)
45
+ # @return (see Wrap#each_line)
46
+ # @raise (see Wrap#each_line)
47
+ def each_line(
48
+ *text,
49
+ bbcode: true,
50
+ ansi: true,
51
+ ignore_newline: false,
52
+ limit: nil,
53
+ with_size: false,
54
+ &block
55
+ )
56
+ Wrap.new(
57
+ *text,
58
+ bbcode: bbcode,
59
+ ansi: ansi,
60
+ ignore_newline: ignore_newline
61
+ ).each_line(limit: limit, with_size: with_size, &block)
62
+ end
63
+
64
+ # @!visibility private
65
+ # works for UTF-8 chars only!
66
+ def __char_width(char)
67
+ ord = char.ord
68
+ return CONTROL_CHAR_WIDTH[ord] || 2 if ord < 0x20
69
+ return 1 if ord < 0xa1
70
+ return @ambiguous_char_width if (size = CharWidth[ord]) == -1
71
+ return size if size != 1 || char.size < 2
72
+ sco = char[1].ord
73
+ # Halfwidth Dakuten Handakuten
74
+ sco == 0xff9e || sco == 0xff9f ? 2 : 1
75
+ end
76
+ end
77
+
78
+ # Helper class to calculate word-wise text wrapping in various conditions.
79
+ # Internally used by {Text.each_line}.
80
+ class Wrap
81
+ # Create a new instance which may support different conditions.
82
+ #
83
+ # @param [#to_s, ...] text
84
+ # text objects to process
85
+ # @param [true, false] bbcode
86
+ # whether to interprete embedded BBCode (see {Ansi.bbcode})
87
+ # @param [true, false] ansi
88
+ # whether to keep embedded ANSI control codes
89
+ # @param [true, false] ignore_newline
90
+ # wheter to ignore embedded line breaks (`"\r\n"` or `"\n"`)
91
+ def initialize(*text, bbcode: true, ansi: true, ignore_newline: false)
92
+ @parts = []
93
+ newline = ignore_newline ? :space : :nl
94
+ text.each do |txt|
95
+ txt = bbcode ? Ansi.bbcode(txt) : txt.to_s
96
+ next @parts << :hard_nl if txt.empty?
97
+ txt = txt.encode(ENC) if txt.encoding != ENC
98
+ word = nil
99
+ txt.scan(REGEXP) do |nl, csi, osc, space, gc|
100
+ next word ? word << gc : @parts << (word = Word.new(gc)) if gc
101
+ word = nil
102
+ next @parts << :space if space
103
+ next @parts << newline if nl
104
+ next unless ansi
105
+ next @parts << [:seq, osc] if osc
106
+ next @parts << :seq_end if csi == "\e[m" || csi == "\e[0m"
107
+ @parts << [:seq, csi]
108
+ end
109
+ @parts << :hard_nl
110
+ end
111
+ end
112
+
113
+ # Report each line of the text.
114
+ #
115
+ # @param [#to_i, nil] limit
116
+ # optionally limit line size
117
+ # @param [true, false] with_size
118
+ # whether to yield each line with it's display width
119
+ # @yield [String] line when `with_size` is false
120
+ # @yield [<String, Integer>] line and it's display width
121
+ # when `with_size` is true
122
+ # @return [Enumerator] when no block is given
123
+ # @return [nil] when block is given
124
+ # @raise ArgumentError when a `limit` less than `1` is given
125
+ def each_line(limit: nil, with_size: false, &block)
126
+ unless limit
127
+ return with_size ? pairs(&block) : lines(&block) if block_given?
128
+ return to_enum(with_size ? :pairs : :lines)
129
+ end
130
+ limit = limit.to_i
131
+ raise(ArgumentError, "invalid limit - #{limit}") if limit < 1
132
+ if block_given?
133
+ return with_size ? pairs_in(limit, &block) : lines_in(limit, &block)
134
+ end
135
+ to_enum(with_size ? :pairs_in : :lines_in, limit)
136
+ end
137
+ alias each each_line
138
+
139
+ # @!visibility private
140
+ def each_word(with_size: false, &block)
141
+ return with_size ? word_pairs(&block) : words(&block) if block_given?
142
+ to_enum(with_size ? :word_pairs : :words)
143
+ end
144
+
145
+ # @!visibility private
146
+ def to_str
147
+ str = EMPTY.dup
148
+ seq = EMPTY.dup
149
+ @parts.each do |part, opt|
150
+ next str << part if part.is_a?(Word)
151
+ next str << ' ' if part == :space
152
+
153
+ if part == :nl
154
+ str << "\n"
155
+ next str << seq.dup
156
+ end
157
+
158
+ if part == :seq
159
+ str << opt
160
+ next seq << opt
161
+ end
162
+
163
+ # :seq_end
164
+ next if seq.empty?
165
+ str << "\e[m"
166
+ seq.clear
167
+ end
168
+ str
169
+ end
170
+ alias to_s to_str
171
+
172
+ # @!visibility private
173
+ def to_a(limit: nil, with_size: false)
174
+ each_line(limit: limit, with_size: with_size).to_a
175
+ end
176
+
177
+ # @!visibility private
178
+ def to_ary = each_line.to_a
179
+
180
+ private
181
+
182
+ def words
183
+ @parts.each { yield(_1.to_s) if _1.is_a?(Word) }
184
+ nil
185
+ end
186
+
187
+ def word_pairs
188
+ @parts.each { yield(_1.to_s, _1.size) if _1.is_a?(Word) }
189
+ nil
190
+ end
191
+
192
+ def lines
193
+ current = EMPTY.dup
194
+ seq = EMPTY.dup
195
+ lws = nil
196
+ @parts.each do |part, opt|
197
+ if part == :space
198
+ next if lws
199
+ current << ' '
200
+ next lws = true
201
+ end
202
+
203
+ if part.is_a?(Word)
204
+ current << part
205
+ next lws = false
206
+ end
207
+
208
+ if part == :nl
209
+ yield(lws ? current.chop : current)
210
+ current = seq.dup
211
+ next lws = false
212
+ end
213
+
214
+ if part == :hard_nl
215
+ yield(lws ? current.chop : current)
216
+ seq.clear
217
+ current = EMPTY.dup
218
+ next lws = false
219
+ end
220
+
221
+ lws = false
222
+
223
+ if part == :seq
224
+ current << opt
225
+ next seq << opt
226
+ end
227
+
228
+ # :seq_end
229
+ current << "\e[m"
230
+ seq.clear
231
+ end
232
+ nil
233
+ end
234
+
235
+ def lines_in(limit)
236
+ current = EMPTY.dup
237
+ seq = EMPTY.dup
238
+ width = 0
239
+ lws = nil
240
+ @parts.each do |part, opt|
241
+ if part == :space
242
+ next if lws
243
+ if width.succ < limit
244
+ current << ' '
245
+ width += 1
246
+ next lws = true
247
+ end
248
+ yield(current)
249
+ current = seq.dup
250
+ width = 0
251
+ next lws = false
252
+ end
253
+
254
+ if part.is_a?(Word)
255
+ if (nw = width + part.size) <= limit
256
+ current << part
257
+ width = nw
258
+ next lws = false
259
+ end
260
+
261
+ yield(lws ? current.chop : current) if width > 0
262
+ current = seq.dup
263
+
264
+ if part.size < limit
265
+ current << part
266
+ width = part.size
267
+ next lws = false
268
+ end
269
+
270
+ if part.size == limit
271
+ yield(current + part)
272
+ current = seq.dup
273
+ width = 0
274
+ next lws = false
275
+ end
276
+
277
+ width = 0
278
+ part.chars.each do |c, w|
279
+ next current << c if (width += w) <= limit
280
+ yield(current)
281
+ current = seq.dup << c
282
+ width = w
283
+ end
284
+
285
+ next lws = false
286
+ end
287
+
288
+ if part == :nl
289
+ yield(lws ? current.chop : current)
290
+ current = seq.dup
291
+ width = 0
292
+ next lws = false
293
+ end
294
+
295
+ if part == :hard_nl
296
+ yield(lws ? current.chop : current) if width > 0
297
+ seq.clear
298
+ current = EMPTY.dup
299
+ width = 0
300
+ next lws = false
301
+ end
302
+
303
+ lws = false
304
+
305
+ if part == :seq
306
+ current << opt
307
+ next seq << opt
308
+ end
309
+
310
+ # :seq_end
311
+ next if seq.empty?
312
+ current << "\e[m"
313
+ seq.clear
314
+ end
315
+ nil
316
+ end
317
+
318
+ def pairs
319
+ current = EMPTY.dup
320
+ seq = EMPTY.dup
321
+ width = 0
322
+ lws = nil
323
+ @parts.each do |part, opt|
324
+ if part == :space
325
+ next if lws
326
+ current << ' '
327
+ width += 1
328
+ next lws = true
329
+ end
330
+
331
+ if part.is_a?(Word)
332
+ current << part
333
+ width += part.size
334
+ next lws = false
335
+ end
336
+
337
+ if part == :nl
338
+ lws ? yield(current.chop, width - 1) : yield(current, width)
339
+ current = seq.dup
340
+ width = 0
341
+ next lws = false
342
+ end
343
+
344
+ if part == :hard_nl
345
+ lws ? yield(current.chop, width - 1) : yield(current, width)
346
+ seq.clear
347
+ current = EMPTY.dup
348
+ width = 0
349
+ next lws = false
350
+ end
351
+
352
+ lws = false
353
+
354
+ if part == :seq
355
+ current << opt
356
+ next seq << opt
357
+ end
358
+
359
+ # :seq_end
360
+ next if seq.empty?
361
+ current << "\e[m"
362
+ seq.clear
363
+ end
364
+ nil
365
+ end
366
+
367
+ def pairs_in(limit)
368
+ current = EMPTY.dup
369
+ seq = EMPTY.dup
370
+ width = 0
371
+ lws = nil
372
+ @parts.each do |part, opt|
373
+ if part == :space
374
+ next if lws
375
+ if width.succ < limit
376
+ current << ' '
377
+ width += 1
378
+ next lws = true
379
+ end
380
+ yield(current, width)
381
+ current = seq.dup
382
+ width = 0
383
+ next lws = false
384
+ end
385
+
386
+ if part.is_a?(Word)
387
+ if (nw = width + part.size) <= limit
388
+ current << part
389
+ width = nw
390
+ next lws = false
391
+ end
392
+
393
+ if width > 0
394
+ lws ? yield(current.chop, width - 1) : yield(current, width)
395
+ end
396
+ current = seq.dup
397
+
398
+ if part.size < limit
399
+ current << part
400
+ width = part.size
401
+ next lws = false
402
+ end
403
+
404
+ if part.size == limit
405
+ yield(current + part, limit)
406
+ current = seq.dup
407
+ width = 0
408
+ next lws = false
409
+ end
410
+
411
+ width = 0
412
+ part.chars.each do |c, w|
413
+ next current << c if (width += w) <= limit
414
+ yield(current, width - w)
415
+ current = seq.dup << c
416
+ width = w
417
+ end
418
+
419
+ next lws = false
420
+ end
421
+
422
+ if part == :nl
423
+ lws ? yield(current.chop, width - 1) : yield(current, width)
424
+ current = seq.dup
425
+ width = 0
426
+ next lws = false
427
+ end
428
+
429
+ if part == :hard_nl
430
+ if width > 0
431
+ lws ? yield(current.chop, width - 1) : yield(current, width)
432
+ end
433
+ seq.clear
434
+ current = EMPTY.dup
435
+ width = 0
436
+ next lws = false
437
+ end
438
+
439
+ lws = false
440
+
441
+ if part == :seq
442
+ current << opt
443
+ next seq << opt
444
+ end
445
+
446
+ # :seq_end
447
+ next if seq.empty?
448
+ current << "\e[m"
449
+ seq.clear
450
+ end
451
+ nil
452
+ end
453
+
454
+ class Word
455
+ attr_reader :to_str, :size, :chars
456
+
457
+ def initialize(char)
458
+ @to_str = char.dup
459
+ @size = Text.__char_width(char)
460
+ @chars = [[char, @size]]
461
+ end
462
+
463
+ def <<(char)
464
+ @to_str << char
465
+ @chars << [char, cw = Text.__char_width(char)]
466
+ @size += cw
467
+ self
468
+ end
469
+ end
470
+ private_constant :Word
471
+
472
+ REGEXP =
473
+ /\G(?:
474
+ (\r?\n)
475
+ | (\e\[[\d;:\?]*[ABCDEFGHJKSTfminsuhl])
476
+ | (\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
477
+ | (\s+)
478
+ | (\X)
479
+ )/x
480
+ private_constant :REGEXP
481
+
482
+ ENC = Encoding::UTF_8
483
+ private_constant :ENC
484
+
485
+ EMPTY = String.new(encoding: ENC).freeze
486
+ private_constant :EMPTY
487
+ end
488
+
489
+ ENC = Encoding::UTF_8
490
+ private_constant :ENC
491
+
492
+ @ambiguous_char_width = 1
493
+
494
+ WIDTH_SCANNER =
495
+ /\G(?:
496
+ (?:\e\[[\d;:\?]*[ABCDEFGHJKSTfminsuhl])
497
+ | (?:\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
498
+ | (\s+)
499
+ | (\X)
500
+ )/x
501
+ private_constant :WIDTH_SCANNER
502
+
503
+ CONTROL_CHAR_WIDTH = {
504
+ 0x00 => 0,
505
+ 0x01 => 1,
506
+ 0x02 => 1,
507
+ 0x03 => 1,
508
+ 0x04 => 1,
509
+ 0x05 => 0,
510
+ 0x06 => 1,
511
+ 0x07 => 0,
512
+ 0x08 => 0,
513
+ 0x09 => 8,
514
+ 0x0a => 0,
515
+ 0x0b => 0,
516
+ 0x0c => 0,
517
+ 0x0d => 0,
518
+ 0x0e => 0,
519
+ 0x0f => 0,
520
+ 0x10 => 1,
521
+ 0x11 => 1,
522
+ 0x12 => 1,
523
+ 0x13 => 1,
524
+ 0x14 => 1,
525
+ 0x15 => 1,
526
+ 0x16 => 1,
527
+ 0x17 => 1,
528
+ 0x18 => 1,
529
+ 0x19 => 1,
530
+ 0x1a => 1,
531
+ 0x1b => 1,
532
+ 0x1c => 1,
533
+ 0x1d => 1,
534
+ 0x1e => 1,
535
+ 0x1f => 1
536
+ }.compare_by_identity.freeze
537
+ private_constant :CONTROL_CHAR_WIDTH
538
+
539
+ autoload :CharWidth, "#{__dir__}/text/char_width.rb"
540
+ private_constant :CharWidth
541
+ end
542
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminal
4
+ # The version number of the gem.
5
+ VERSION = '0.6.0'
6
+ end