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.
- checksums.yaml +7 -0
- data/.yardopts +11 -0
- data/README.md +55 -0
- data/bin/bbcode +46 -0
- data/examples/24bit-colors.rb +20 -0
- data/examples/3bit-colors.rb +18 -0
- data/examples/8bit-colors.rb +32 -0
- data/examples/attributes.rb +14 -0
- data/examples/bbcode.rb +28 -0
- data/examples/info.rb +15 -0
- data/examples/key-codes.rb +22 -0
- data/lib/terminal/ansi/attributes.rb +209 -0
- data/lib/terminal/ansi/named_colors.rb +668 -0
- data/lib/terminal/ansi.rb +593 -0
- data/lib/terminal/detect.rb +151 -0
- data/lib/terminal/input.rb +187 -0
- data/lib/terminal/preload.rb +8 -0
- data/lib/terminal/rspec/helper.rb +33 -0
- data/lib/terminal/text/char_width.rb +2585 -0
- data/lib/terminal/text.rb +542 -0
- data/lib/terminal/version.rb +6 -0
- data/lib/terminal.rb +269 -0
- data/lib/terminal_rb.rb +3 -0
- metadata +68 -0
@@ -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
|