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,593 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terminal
4
+ #
5
+ # Fast ANSI control code and BBCode processing.
6
+ #
7
+ module Ansi
8
+ class << self
9
+ # Supported attribute names.
10
+ #
11
+ # @see []
12
+ #
13
+ # @attribute [r] attributes
14
+ # @return [Array<Symbol>] all attribute names
15
+ def attributes = ATTRIBUTES_S.keys
16
+
17
+ # Supported 3/4-bit color names.
18
+ #
19
+ # @see []
20
+ #
21
+ # @attribute [r] colors
22
+ # @return [Array<Symbol>] all color names
23
+ def colors = COLORS_S.keys
24
+
25
+ # Supported basic 24-bit (Kitty compatible) color names.
26
+ #
27
+ # @see []
28
+ #
29
+ # @attribute [r] named_colors
30
+ # @return [Array<Symbol>] all basic named_colors names
31
+ def named_colors = NAMED_COLORS.keys.map!(&:to_sym)
32
+
33
+ #
34
+ # @!group ANSI control code generator functions
35
+ #
36
+
37
+ # Combine given ANSI {attributes}, {colors}, {named_colors} and color
38
+ # codes.
39
+ #
40
+ # Colors can specified by their name for ANSI 3-bit and 4-bit colors.
41
+ # For 8-bit ANSI colors use 2-digit hexadecimal values `00`...`ff`.
42
+ #
43
+ # To use RGB ANSI colors (24-bit colors) specify 3-digit or 6-digit
44
+ # hexadecimal values `000`...`fff` or `000000`...`ffffff`.
45
+ # This represent the `RRGGBB` values (or `RGB` for short version) like you
46
+ # may known from CSS color notation.
47
+ #
48
+ # To use a color as background color prefix the color attribute with `bg_`
49
+ # or `on_`.
50
+ # To use a color as underline color prefix the color attribute with `ul_`.
51
+ # To clarify that a color attribute have to be used as foreground
52
+ # color use the prefix `fg_`.
53
+ #
54
+ # @example Valid Foreground Color Attributes
55
+ # Terminal::Ansi[:yellow]
56
+ # Terminal::Ansi[:fg_fab]
57
+ # Terminal::Ansi[:fg_00aa00]
58
+ # Terminal::Ansi[:af]
59
+ # Terminal::Ansi[:fg_af]
60
+ # Terminal::Ansi['#fab']
61
+ # Terminal::Ansi['#00aa00']
62
+ # Terminal::Ansi['lightblue']
63
+ #
64
+ # @example Valid Background Color Attributes
65
+ # Terminal::Ansi[:bg_yellow]
66
+ # Terminal::Ansi[:bg_fab]
67
+ # Terminal::Ansi[:bg_00aa00]
68
+ # Terminal::Ansi[:bg_af]
69
+ # Terminal::Ansi['bg#00aa00']
70
+ # Terminal::Ansi['bg_lightblue']
71
+ #
72
+ # Terminal::Ansi[:on_yellow]
73
+ # Terminal::Ansi[:on_fab]
74
+ # Terminal::Ansi[:on_00aa00]
75
+ # Terminal::Ansi[:on_af]
76
+ # Terminal::Ansi['on#00aa00']
77
+ # Terminal::Ansi['on_lightblue']
78
+ #
79
+ # @example Valid Underline Color Attributes
80
+ # Terminal::Ansi[:underline, :ul_yellow]
81
+ # Terminal::Ansi[:underline, :ul_fab]
82
+ # Terminal::Ansi[:underline, :ul_00aa00]
83
+ # Terminal::Ansi[:underline, :ul_fa]
84
+ # Terminal::Ansi[:underline, :ul_bright_yellow]
85
+ # Terminal::Ansi[:underline, 'ul#00aa00']
86
+ # Terminal::Ansi['underline', 'ul_lightblue']
87
+ #
88
+ # @example Combined attributes:
89
+ # Terminal::Ansi[:bold, :italic, :bright_white, :on_0000cc]
90
+ #
91
+ # @see valid?
92
+ #
93
+ # @param attributes [Array<Symbol, String>] attribute names to be used
94
+ # @return [String] combined ANSI attributes
95
+ def [](*attributes)
96
+ return +'' if attributes.empty?
97
+ "\e[#{
98
+ attributes
99
+ .map do |arg|
100
+ case arg
101
+ when String
102
+ ATTRIBUTES[arg] || COLORS[arg] || _color(arg) || _invalid(arg)
103
+ when Symbol
104
+ ATTRIBUTES_S[arg] || COLORS_S[arg] || _color(arg) ||
105
+ _invalid(arg)
106
+ when (0..255)
107
+ "38;5;#{arg}"
108
+ when (256..511)
109
+ "48;5;#{arg - 256}"
110
+ when (512..767)
111
+ "58;5;#{arg - 512}"
112
+ else
113
+ _invalid(arg)
114
+ end
115
+ end
116
+ .join(';')
117
+ }m"
118
+ end
119
+
120
+ # Test if given String contains ANSI control codes.
121
+ #
122
+ # @param str [#to_s] object to be tested
123
+ # @return [true, false] whether if attributes are found
124
+ def ansi?(str) = TEST.match?(str.to_s)
125
+
126
+ # Decorate given argument with ANSI attributes and colors.
127
+ #
128
+ # @example
129
+ # Terminal::Ansi.decorate(
130
+ # 'Hello World!',
131
+ # :bold, :italic, :bright_white, :on_00c
132
+ # )
133
+ # # => "\e[1;3;97;48;2;0;0;204mHello World!\e[m"
134
+ #
135
+ # @see []
136
+ # @see undecorate
137
+ #
138
+ # @param str [#to_s] object to be decorated
139
+ # @param attributes [Array<Symbol, String>] attribute names to be used
140
+ # @param reset [true, false] whether to include reset code for ANSI attributes
141
+ # @return [String] `str` converted and decorated with the ANSI `attributes`
142
+ def decorate(str, *attributes, reset: true)
143
+ attributes = self[*attributes]
144
+ attributes.empty? ? "#{str}" : "#{attributes}#{str}#{"\e[m" if reset}"
145
+ end
146
+
147
+ # Remove ANSI functions, attributes and colors from given string.
148
+ #
149
+ # @example
150
+ # Terminal::Ansi.undecorate("\e[1;3;97;48;2;0;0;204mHello World!\e[m")
151
+ # # => "Hello World!"
152
+ #
153
+ # @see decorate
154
+ #
155
+ # @param str [#to_s] string to be modified
156
+ # @return [String] string without ANSI attributes
157
+ def undecorate(str) = str.to_s.gsub(TEST, '')
158
+
159
+ # Try to combine given ANSI attributes and colors.
160
+ # The attributes and colors have to be separated by given `separator``.
161
+ #
162
+ # @example Valid Attribute String
163
+ # Terminal::Ansi.try_convert('bold italic blink red on#00ff00')
164
+ # # => "\e[1;3;5;31;48;2;0;255;0m"
165
+ #
166
+ # @example Invalid Attribute String
167
+ # Terminal::Ansi.try_convert('cool bold on green')
168
+ # # => nil
169
+ #
170
+ # @see []
171
+ #
172
+ # @param attributes [#to_s] attributes separated by given `separator`
173
+ # @param separator [String] attribute separator char
174
+ # @return [String] combined ANSI attributes
175
+ # @return [nil] when string does not contain valid attributes
176
+ def try_convert(attributes, separator: ' ')
177
+ return unless attributes
178
+ return if (attributes = attributes.to_s.split(separator)).empty?
179
+ "\e[#{
180
+ attributes
181
+ .map! { ATTRIBUTES[_1] || COLORS[_1] || _color(_1) || return }
182
+ .join(';')
183
+ }m"
184
+ end
185
+
186
+ # Test if all given attributes are valid.
187
+ #
188
+ # @see []
189
+ #
190
+ # @param attributes [Array<Symbol, String>] attribute names to be used
191
+ # @return [true, false] whether if all given attributes are valid
192
+ def valid?(*attributes)
193
+ attributes.all? do |arg|
194
+ case arg
195
+ when String
196
+ ATTRIBUTES[arg] || COLORS[arg] || _color(arg)
197
+ when Symbol
198
+ ATTRIBUTES_S[arg] || COLORS_S[arg] || _color(arg)
199
+ when (0..767)
200
+ true
201
+ end
202
+ end
203
+ end
204
+
205
+ #
206
+ # @!endgroup
207
+ #
208
+ # @!group BBcode related functions
209
+ #
210
+
211
+ # Replace embedded BBCode-like attributes with ANSI control codes.
212
+ #
213
+ # @example
214
+ # Terminal::Ansi.bbcode "[b]Bold[/b] Text"
215
+ # # => "\e[1mBold\e[22m Text"
216
+ #
217
+ # @see unbbcode
218
+ # @see []
219
+ #
220
+ # @param str [#to_s] string to be modified
221
+ # @return [String] string with ANSI attributes
222
+ def bbcode(str)
223
+ str
224
+ .to_s
225
+ .gsub(BBCODE) do |match_str|
226
+ next match_str if (match = Regexp.last_match[1]).empty?
227
+ next "[#{match[1..]}]" if match[0] == '\\'
228
+ try_convert(match) || match_str
229
+ end
230
+ end
231
+
232
+ # Remove embedded BBCode-like attributes.
233
+ #
234
+ # @example
235
+ # Terminal::Ansi.unbbcode "[b]Bold[/b] Text"
236
+ # # => "Bold Text"
237
+ #
238
+ # @see bbcode
239
+ #
240
+ # @param str [#to_s] string to be modified
241
+ # @return [String] string without BBCode
242
+ def unbbcode(str)
243
+ str
244
+ .to_s
245
+ .gsub(BBCODE) do |match_str|
246
+ next match_str if (match = Regexp.last_match[1]).empty?
247
+ next "[#{match[1..]}]" if match[0] == '\\'
248
+ next match_str if (match = match.split).empty?
249
+ next if match.all? { ATTRIBUTES[_1] || COLORS[_1] || _color(_1) }
250
+ match_str
251
+ end
252
+ end
253
+
254
+ #
255
+ # @!endgroup
256
+ #
257
+ # @!group Other tool functions
258
+ #
259
+
260
+ # Remove any BBCode-like and/or ANSI attributes.
261
+ #
262
+ # @see undecorate
263
+ # @see unbbcode
264
+ #
265
+ # @param str [#to_s] string to be modified
266
+ # @return [String] string without BBCode and ANSI control codes.
267
+ def plain(str) = unbbcode(str).gsub(TEST, '')
268
+
269
+ # Create nice colored text.
270
+ #
271
+ # @param str [#to_s] string to enrich with color
272
+ # @param frequency [Float] color change frequency
273
+ # @param spread [Float] number of chars with same color
274
+ # @param seed [Float] start index on sinus curve
275
+ # @return [String] fancy text
276
+ def rainbow(str, frequency: 0.3, spread: 0.8, seed: 1.1)
277
+ pos = -1
278
+ str
279
+ .to_s
280
+ .chars
281
+ .map! do |char|
282
+ i = (seed + ((pos += 1) / spread)) * frequency
283
+ "\e[38;2;#{(Math.sin(i) * 255).abs.to_i};" \
284
+ "#{(Math.sin(i + PI2_THIRD) * 255).abs.to_i};" \
285
+ "#{(Math.sin(i + PI4_THIRD) * 255).abs.to_i}m#{char}"
286
+ end
287
+ .join << RESET
288
+ end
289
+
290
+ #
291
+ # @!endgroup
292
+ #
293
+ # @!group Cursor manipulation
294
+ #
295
+
296
+ # Move cursor given lines up.
297
+ #
298
+ # @param lines [Integer] number of lines to move
299
+ # @return [String] ANSI control code
300
+ def cursor_up(lines = 1) = "\e[#{lines}A"
301
+
302
+ # Move cursor given lines down.
303
+ #
304
+ # @param (see cursor_up)
305
+ # @return (see cursor_up)
306
+ def cursor_down(lines = 1) = "\e[#{lines}B"
307
+
308
+ # Move cursor given columns forward.
309
+ #
310
+ # @param columns [Integer] number of columns to move
311
+ # @return (see cursor_up)
312
+ def cursor_forward(columns = 1) = "\e[#{columns}C"
313
+
314
+ # Move cursor given columns back.
315
+ #
316
+ # @param (see forward)
317
+ # @return (see cursor_up)
318
+ def cursor_back(columns = 1) = "\e[#{columns}D"
319
+
320
+ # Move cursor to the beginning of the given next line.
321
+ #
322
+ # @param (see up)
323
+ # @return (see cursor_up)
324
+ def cursor_next_line(lines = 1) = "\e[#{lines}E"
325
+
326
+ # Move cursor to the beginning of the given previous line.
327
+ #
328
+ # @param (see up)
329
+ # @return (see cursor_up)
330
+ def cursor_prev_line(lines = 1) = "\e[#{lines}F"
331
+
332
+ # Move cursor to given column in the current row.
333
+ #
334
+ # @param column [Integer] column index
335
+ # @return (see cursor_up)
336
+ def cursor_column(column = 1) = "\e[#{column}G"
337
+
338
+ # Move to given row and column.
339
+ #
340
+ # @param row [Integer] row index
341
+ # @param column [Integer] column index
342
+ # @return (see cursor_up)
343
+ def cursor_pos(row, column = nil)
344
+ return column ? "\e[;#{column}H" : "\e[H" unless row
345
+ column ? "\e[#{row};#{column}H" : "\e[#{row}H"
346
+ end
347
+
348
+ # Show cursor.
349
+ #
350
+ # @return (see cursor_up)
351
+ def cursor_show = +CURSOR_SHOW
352
+
353
+ # Hide cursor.
354
+ #
355
+ # @return (see cursor_up)
356
+ def cursor_hide = +CURSOR_HIDE
357
+
358
+ # Save current cursor position.
359
+ #
360
+ # @return (see cursor_up)
361
+ def cursor_save_pos = +CURSOR_POS_SAVE
362
+
363
+ # Restore saved cursor position.
364
+ #
365
+ # @return (see cursor_up)
366
+ def cursor_restore_pos = +CURSOR_POS_RESTORE
367
+
368
+ #
369
+ # @!endgroup
370
+ #
371
+ # @!group Screen manipulation
372
+ #
373
+
374
+ # Erase screen part.
375
+ #
376
+ # @return (see cursor_up)
377
+ def screen_erase(part = :all)
378
+ "\e[#{
379
+ case part
380
+ when :below
381
+ # nop
382
+ when :above
383
+ '1'
384
+ when :scrollback
385
+ '3'
386
+ else # all
387
+ '2'
388
+ end
389
+ }J"
390
+ end
391
+
392
+ # Safe current screen.
393
+ #
394
+ # @return (see cursor_up)
395
+ def screen_save = +SCREEN_SAVE
396
+
397
+ # Restore current screen.
398
+ #
399
+ # @return (see cursor_up)
400
+ def screen_restore = +SCREEN_RESTORE
401
+
402
+ # Use alternative screen buffer.
403
+ #
404
+ # @return (see cursor_up)
405
+ def screen_alternate = +SCREEN_ALTERNATE
406
+
407
+ # Do not longer use alternative screen buffer.
408
+ #
409
+ # @return (see cursor_up)
410
+ def screen_alternate_off = +SCREEN_ALTERNATE_OFF
411
+
412
+ # Scroll window given lines up.
413
+ #
414
+ # @param lines [Integer] number of lines to scroll
415
+ # @return (see cursor_up)
416
+ def screen_scroll_up(lines = 1) = "\e[#{lines}S"
417
+
418
+ # Scroll window given lines down.
419
+ #
420
+ # @param (see scroll_up)
421
+ # @return (see cursor_up)
422
+ def screen_scroll_down(lines = 1) = "\e[#{lines}T"
423
+
424
+ #
425
+ # @!endgroup
426
+ #
427
+ # @!group Other ANSI control functions
428
+ #
429
+
430
+ # Erase part of line.
431
+ #
432
+ # @return (see cursor_up)
433
+ def line_erase(part = :all)
434
+ "\e[#{
435
+ case part
436
+ when :to_end
437
+ # nop
438
+ when :to_start
439
+ '1'
440
+ else # :all
441
+ '2'
442
+ end
443
+ }K"
444
+ end
445
+
446
+ # Set window title.
447
+ # This is not widely supported.
448
+ #
449
+ # @param [#to_s] title text
450
+ # @return (see cursor_up)
451
+ def title(title) = "\e]2;#{title}\a"
452
+
453
+ # Set tab title.
454
+ # This is not widely supported.
455
+ #
456
+ # @param (see title)
457
+ # @return (see cursor_up)
458
+ def tab_title(title) = "\e]0;#{title}\a"
459
+
460
+ # Create a hyperlink.
461
+ # This is not widely supported.
462
+ #
463
+ # @param [#to_s] url URL to link to
464
+ # @param [#to_s] text text to display for the link
465
+ # @return (see cursor_up)
466
+ def link(url, text) = "\e]8;;#{url}\a#{text}\e]8;;\a"
467
+
468
+ #
469
+ # @!endgroup
470
+ #
471
+
472
+ private
473
+
474
+ def _invalid(att)
475
+ raise(
476
+ ArgumentError,
477
+ "unknown ANSI attribute - #{att.inspect}",
478
+ caller(1)
479
+ )
480
+ end
481
+
482
+ def _color(str)
483
+ b, v = /\A(fg|bg|on|ul)?_?#?([[:xdigit:]]{1,6})\z/.match(str)&.captures
484
+ if v
485
+ return(
486
+ case v.size
487
+ when 1, 2
488
+ "#{COLOR_BASE[b]};5;#{v.hex}"
489
+ when 3
490
+ "#{COLOR_BASE[b]};2;#{(v[0] * 2).hex};#{
491
+ (v[1] * 2).hex
492
+ };#{(v[2] * 2).hex}"
493
+ when 6
494
+ "#{COLOR_BASE[b]};2;#{v[0, 2].hex};#{v[2, 2].hex};#{v[4, 2].hex}"
495
+ end
496
+ )
497
+ end
498
+ b, v = /\A(fg|bg|on|ul)?_?([a-z]{3,}[0-9]{0,3})\z/.match(str)&.captures
499
+ return unless v
500
+ name = NAMED_COLORS[v] and return "#{COLOR_BASE[b]};#{name}"
501
+ end
502
+ end
503
+
504
+ COLOR_BASE =
505
+ { 'bg' => '48', 'on' => '48', 'ul' => '58' }.tap { _1.default = '38' }
506
+ .freeze
507
+
508
+ TEST =
509
+ /
510
+ (?:\e\[[\d;:\?]*[ABCDEFGHJKSTfminsuhl])
511
+ |
512
+ (?:\e\]\d+(?:;[^\a\e]+)*(?:\a|\e\\))
513
+ /x
514
+
515
+ BBCODE = /(?:\[((?~[\[\]]))\])/
516
+
517
+ PI2_THIRD = 2.0 * Math::PI / 3.0
518
+ PI4_THIRD = 4.0 * Math::PI / 3.0
519
+
520
+ private_constant :COLOR_BASE, :TEST, :BBCODE, :PI2_THIRD, :PI4_THIRD
521
+
522
+ require_relative 'ansi/attributes'
523
+
524
+ autoload :NAMED_COLORS, "#{__dir__}/ansi/named_colors.rb"
525
+ private_constant :NAMED_COLORS
526
+
527
+ # @!visibility private
528
+ RESET = self[:reset].freeze
529
+
530
+ # @!visibility private
531
+ FULL_RESET = "\ec"
532
+
533
+ # @!visibility private
534
+ CURSOR_HOME = cursor_pos(nil, nil).freeze
535
+ # @!visibility private
536
+ CURSOR_FIRST_ROW = cursor_pos(1).freeze
537
+ # @!visibility private
538
+ CURSOR_FIRST_COLUMN = cursor_column(1).freeze
539
+
540
+ # @!visibility private
541
+ CURSOR_SHOW = "\e[?25h"
542
+ # @!visibility private
543
+ CURSOR_HIDE = "\e[?25l"
544
+
545
+ # CURSOR_POS_SAVE_SCO = "\e[s"
546
+ # CURSOR_POS_SAVE_DEC = "\e7"
547
+ # @!visibility private
548
+ CURSOR_POS_SAVE = "\e7"
549
+
550
+ # CURSOR_POS_RESTORE_SCO = "\e[u"
551
+ # CURSOR_POS_RESTORE_DEC = "\e8"
552
+ # @!visibility private
553
+ CURSOR_POS_RESTORE = "\e8"
554
+
555
+ # @!visibility private
556
+ SCREEN_ERASE = screen_erase.freeze
557
+ # @!visibility private
558
+ SCREEN_ERASE_BELOW = screen_erase(:below).freeze
559
+ # @!visibility private
560
+ SCREEN_ERASE_ABOVE = screen_erase(:above).freeze
561
+ # @!visibility private
562
+ SCREEN_ERASE_SCROLLBACK = screen_erase(:scrollback).freeze
563
+
564
+ # @!visibility private
565
+ SCREEN_SAVE = "\e[?47h"
566
+ # @!visibility private
567
+ SCREEN_RESTORE = "\e[?47l"
568
+ # @!visibility private
569
+ SCREEN_ALTERNATE = "\e[?1049h"
570
+ # @!visibility private
571
+ SCREEN_ALTERNATE_OFF = "\e[?1049l"
572
+
573
+ # @!visibility private
574
+ LINE_ERASE = line_erase.freeze
575
+ # @!visibility private
576
+ LINE_ERASE_TO_END = line_erase(:to_end).freeze
577
+ # @!visibility private
578
+ LINE_ERASE_TO_START = line_erase(:to_start).freeze
579
+ # @!visibility private
580
+ LINE_ERASE_PREV = "#{cursor_prev_line(nil)}#{LINE_ERASE}".freeze
581
+
582
+ # @comment seems not widely supported:
583
+ # @comment doubled!? def cursor_column(column = 1) = "\e[#{column}`"
584
+ # @comment doubled!? def cursor_row(row = 1) = "\e[#{row}d"
585
+ # @comment def cursor_column_rel(columns = 1) = "\e[#{columns}a"
586
+ # @comment def cursor_row_rel(rows = 1) = "\e[#{rows}e"
587
+ # @comment def cursor_tab(count = 1) = "\e[#{column}I"
588
+ # @comment def cursor_reverse_tab(count = 1) = "\e[#{count}Z"
589
+ # @comment def chars_delete(count = 1) = "\e[#{count}P"
590
+ # @comment def chars_erase(count = 1) = "\e[#{count}X"
591
+ # @comment def notify(title) = "\e]9;#{title}\a"
592
+ end
593
+ end