sai 0.1.0 → 0.3.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 +4 -4
- data/.yardopts +1 -1
- data/CHANGELOG.md +32 -1
- data/README.md +32 -209
- data/docs/USAGE.md +303 -0
- data/lib/sai/ansi/sequence_processor.rb +380 -0
- data/lib/sai/ansi/sequenced_string.rb +475 -0
- data/lib/sai/ansi.rb +5 -5
- data/lib/sai/conversion/color_sequence.rb +31 -31
- data/lib/sai/conversion/rgb.rb +17 -17
- data/lib/sai/decorator.rb +256 -240
- data/lib/sai/mode_selector.rb +298 -0
- data/lib/sai/support.rb +99 -98
- data/lib/sai/terminal/capabilities.rb +22 -22
- data/lib/sai/terminal/color_mode.rb +7 -7
- data/lib/sai.rb +128 -77
- data/sig/manifest.yaml +3 -0
- data/sig/sai/ansi/sequence_processor.rbs +253 -0
- data/sig/sai/ansi/sequenced_string.rbs +380 -0
- data/sig/sai/ansi.rbs +5 -5
- data/sig/sai/conversion/color_sequence.rbs +21 -21
- data/sig/sai/conversion/rgb.rbs +17 -17
- data/sig/sai/decorator.rbs +111 -87
- data/sig/sai/mode_selector.rbs +319 -0
- data/sig/sai/support.rbs +50 -37
- data/sig/sai/terminal/capabilities.rbs +16 -16
- data/sig/sai/terminal/color_mode.rbs +7 -7
- data/sig/sai.rbs +109 -66
- metadata +12 -4
@@ -0,0 +1,380 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sai/ansi'
|
4
|
+
require 'strscan'
|
5
|
+
|
6
|
+
module Sai
|
7
|
+
module ANSI
|
8
|
+
# Extract ANSI sequence information from a string
|
9
|
+
#
|
10
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
11
|
+
# @since 0.3.0
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
class SequenceProcessor # rubocop:disable Metrics/ClassLength
|
15
|
+
# The pattern to extract ANSI sequences from a string
|
16
|
+
#
|
17
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
18
|
+
# @since 0.3.0
|
19
|
+
#
|
20
|
+
# @api private
|
21
|
+
#
|
22
|
+
# @return [Regexp] the pattern
|
23
|
+
SEQUENCE_PATTERN = /\e\[([0-9;]*)m/ #: Regexp
|
24
|
+
private_constant :SEQUENCE_PATTERN
|
25
|
+
|
26
|
+
# Matches the code portion of style sequences
|
27
|
+
#
|
28
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
29
|
+
# @since 0.3.0
|
30
|
+
#
|
31
|
+
# @api private
|
32
|
+
#
|
33
|
+
# @return [Regexp] the pattern
|
34
|
+
STYLE_CODE_PATTERN = /(?:[1-9]|2[1-9])/ #: Regexp
|
35
|
+
private_constant :STYLE_CODE_PATTERN
|
36
|
+
|
37
|
+
# Initialize a new instance of SequenceProcessor and parse the provided string
|
38
|
+
#
|
39
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
40
|
+
# @since 0.3.0
|
41
|
+
#
|
42
|
+
# @api private
|
43
|
+
#
|
44
|
+
# @param string [String] the string to parse
|
45
|
+
#
|
46
|
+
# @return [Array<Hash{Symbol => Object}>] the segments
|
47
|
+
# @rbs (String string) -> Array[Hash[Symbol, untyped]]
|
48
|
+
def self.process(string)
|
49
|
+
new(string).process
|
50
|
+
end
|
51
|
+
|
52
|
+
# Initialize a new instance of SequenceProcessor
|
53
|
+
#
|
54
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
55
|
+
# @since 0.3.0
|
56
|
+
#
|
57
|
+
# @api private
|
58
|
+
#
|
59
|
+
# @param string [String] the string to parse
|
60
|
+
#
|
61
|
+
# @return [SequenceProcessor] the new instance of SequenceProcessor
|
62
|
+
# @rbs (String string) -> void
|
63
|
+
def initialize(string)
|
64
|
+
@scanner = StringScanner.new(string)
|
65
|
+
@segments = [] #: Array[Hash[Symbol, untyped]]
|
66
|
+
@current_segment = blank_segment
|
67
|
+
@encoded_pos = 0
|
68
|
+
@stripped_pos = 0
|
69
|
+
end
|
70
|
+
|
71
|
+
# Parse a string and return a hash of segments
|
72
|
+
#
|
73
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
74
|
+
# @since 0.3.0
|
75
|
+
#
|
76
|
+
# @api private
|
77
|
+
#
|
78
|
+
# @return [Array<Hash{Symbol => Object}>] the segments
|
79
|
+
# @rbs () -> Array[Hash[Symbol, untyped]]
|
80
|
+
def process
|
81
|
+
consume_tokens
|
82
|
+
finalize_segment_if_text!
|
83
|
+
|
84
|
+
@segments
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
# Applies 24-bit truecolor (e.g., 38;2;R;G;B or 48;2;R;G;B)
|
90
|
+
#
|
91
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
92
|
+
# @since 0.3.0
|
93
|
+
#
|
94
|
+
# @api private
|
95
|
+
#
|
96
|
+
# @param codes_array [Array<Integer>]
|
97
|
+
# @param index [Integer]
|
98
|
+
#
|
99
|
+
# @return [Integer] the updated index (consumed 5 codes)
|
100
|
+
# @rbs (Array[Integer] codes_array, Integer index) -> Integer
|
101
|
+
def apply_24bit_color(codes_array, index)
|
102
|
+
base_code = codes_array[index]
|
103
|
+
r = codes_array[index + 2]
|
104
|
+
g = codes_array[index + 3]
|
105
|
+
b = codes_array[index + 4]
|
106
|
+
|
107
|
+
if base_code == 38
|
108
|
+
@current_segment[:foreground] = "38;2;#{r};#{g};#{b}"
|
109
|
+
else
|
110
|
+
@current_segment[:background] = "48;2;#{r};#{g};#{b}"
|
111
|
+
end
|
112
|
+
index + 5
|
113
|
+
end
|
114
|
+
|
115
|
+
# Applies 256-color mode (e.g., 38;5;160 or 48;5;21)
|
116
|
+
#
|
117
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
118
|
+
# @since 0.3.0
|
119
|
+
#
|
120
|
+
# @api private
|
121
|
+
#
|
122
|
+
# @param codes_array [Array<Integer>]
|
123
|
+
# @param index [Integer]
|
124
|
+
#
|
125
|
+
# @return [Integer] the updated index (consumed 3 codes)
|
126
|
+
# @rbs (Array[Integer] codes_array, Integer index) -> Integer
|
127
|
+
def apply_256_color(codes_array, index)
|
128
|
+
base_code = codes_array[index]
|
129
|
+
color_number = codes_array[index + 2]
|
130
|
+
if base_code == 38
|
131
|
+
@current_segment[:foreground] = "38;5;#{color_number}"
|
132
|
+
else
|
133
|
+
@current_segment[:background] = "48;5;#{color_number}"
|
134
|
+
end
|
135
|
+
index + 3 # consumed 3 codes total
|
136
|
+
end
|
137
|
+
|
138
|
+
# Applies the appropriate action for the provided ANSI sequence
|
139
|
+
#
|
140
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
141
|
+
# @since 0.3.0
|
142
|
+
#
|
143
|
+
# @api private
|
144
|
+
#
|
145
|
+
# @param sequence [String] an ANSI sequence (e.g. "\e[31m", "\e[0m")
|
146
|
+
#
|
147
|
+
# @return [void]
|
148
|
+
# @rbs (String sequence) -> void
|
149
|
+
def apply_ansi_sequence(sequence)
|
150
|
+
return reset_segment! if sequence == ANSI::RESET
|
151
|
+
|
152
|
+
codes = sequence.match(SEQUENCE_PATTERN)&.[](1)
|
153
|
+
return unless codes
|
154
|
+
|
155
|
+
apply_codes(codes)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Applies a basic color (FG or BG) in the range 30..37 (FG) or 40..47 (BG)
|
159
|
+
#
|
160
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
161
|
+
# @since 0.3.0
|
162
|
+
#
|
163
|
+
# @api private
|
164
|
+
#
|
165
|
+
# @param code [Integer] the numeric color code
|
166
|
+
#
|
167
|
+
# @return [void]
|
168
|
+
# @rbs (Integer code) -> void
|
169
|
+
def apply_basic_color(code)
|
170
|
+
if (30..37).cover?(code)
|
171
|
+
@current_segment[:foreground] = code.to_s
|
172
|
+
else # 40..47
|
173
|
+
@current_segment[:background] = code.to_s
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
# Parse all numeric codes in the provided string, applying them in order (just like a real ANSI terminal)
|
178
|
+
#
|
179
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
180
|
+
# @since 0.3.0
|
181
|
+
#
|
182
|
+
# @api private
|
183
|
+
#
|
184
|
+
# @param codes_string [String] e.g. "38;5;160;48;5;21;1"
|
185
|
+
#
|
186
|
+
# @return [void]
|
187
|
+
# @rbs (String codes_string) -> void
|
188
|
+
def apply_codes(codes_string)
|
189
|
+
codes_array = codes_string.split(';').map(&:to_i)
|
190
|
+
i = 0
|
191
|
+
i = apply_single_code(codes_array, i) while i < codes_array.size
|
192
|
+
end
|
193
|
+
|
194
|
+
# Applies a single code (or group) from the array. This might be:
|
195
|
+
# - 0 => reset
|
196
|
+
# - 30..37 => basic FG color
|
197
|
+
# - 40..47 => basic BG color
|
198
|
+
# - 38 or 48 => extended color sequence
|
199
|
+
# - otherwise => style code (bold, underline, etc.)
|
200
|
+
#
|
201
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
202
|
+
# @since 0.3.0
|
203
|
+
#
|
204
|
+
# @api private
|
205
|
+
#
|
206
|
+
# @param codes_array [Array<Integer>] the list of numeric codes
|
207
|
+
# @param index [Integer] the current index
|
208
|
+
#
|
209
|
+
# @return [Integer] the updated index after consuming needed codes
|
210
|
+
# @rbs (Array[Integer] codes_array, Integer index) -> Integer
|
211
|
+
def apply_single_code(codes_array, index) # rubocop:disable Metrics/MethodLength
|
212
|
+
code = codes_array[index]
|
213
|
+
return index + 1 if code.nil?
|
214
|
+
|
215
|
+
case code
|
216
|
+
when 0
|
217
|
+
reset_segment!
|
218
|
+
index + 1
|
219
|
+
when 30..37, 40..47
|
220
|
+
apply_basic_color(code)
|
221
|
+
index + 1
|
222
|
+
when 38, 48
|
223
|
+
parse_extended_color(codes_array, index)
|
224
|
+
else
|
225
|
+
apply_style_code(code)
|
226
|
+
index + 1
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
# Applies a single style code (e.g. 1=bold, 2=dim, 4=underline, etc.) if it matches
|
231
|
+
#
|
232
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
233
|
+
# @since 0.3.0
|
234
|
+
#
|
235
|
+
# @api private
|
236
|
+
#
|
237
|
+
# @param code [Integer] the numeric code to check
|
238
|
+
#
|
239
|
+
# @return [void]
|
240
|
+
# @rbs (Integer code) -> void
|
241
|
+
def apply_style_code(code)
|
242
|
+
# If it matches the existing style pattern, add it to @current_segment[:styles]
|
243
|
+
# Typically: 1..9, or 21..29, etc. (Your STYLE_CODE_PATTERN is /(?:[1-9]|2[1-9])/)
|
244
|
+
return unless code.to_s.match?(STYLE_CODE_PATTERN)
|
245
|
+
|
246
|
+
@current_segment[:styles] << code.to_s
|
247
|
+
end
|
248
|
+
|
249
|
+
# Creates and returns a fresh, blank segment
|
250
|
+
#
|
251
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
252
|
+
# @since 0.3.0
|
253
|
+
#
|
254
|
+
# @api private
|
255
|
+
#
|
256
|
+
# @return [Hash{Symbol => Object}] a new, empty segment
|
257
|
+
# @rbs () -> Hash[Symbol, untyped]
|
258
|
+
def blank_segment
|
259
|
+
{ text: +'', foreground: nil, background: nil, styles: [] }
|
260
|
+
end
|
261
|
+
|
262
|
+
# Scans the string for ANSI sequences or individual characters
|
263
|
+
#
|
264
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
265
|
+
# @since 0.3.0
|
266
|
+
#
|
267
|
+
# @api private
|
268
|
+
#
|
269
|
+
# @return [void]
|
270
|
+
# @rbs () -> void
|
271
|
+
def consume_tokens
|
272
|
+
handle_ansi_sequence || handle_character until @scanner.eos?
|
273
|
+
end
|
274
|
+
|
275
|
+
# Finalizes the current segment if any text is present, then resets it
|
276
|
+
#
|
277
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
278
|
+
# @since 0.3.0
|
279
|
+
#
|
280
|
+
# @api private
|
281
|
+
#
|
282
|
+
# @return [void]
|
283
|
+
# @rbs () -> void
|
284
|
+
def finalize_segment_if_text!
|
285
|
+
return if @current_segment[:text].empty?
|
286
|
+
|
287
|
+
seg_len = @current_segment[:text].length
|
288
|
+
segment = @current_segment.merge(
|
289
|
+
encoded_start: @encoded_pos - seg_len,
|
290
|
+
encoded_end: @encoded_pos,
|
291
|
+
stripped_start: @stripped_pos - seg_len,
|
292
|
+
stripped_end: @stripped_pos
|
293
|
+
)
|
294
|
+
|
295
|
+
@segments << segment
|
296
|
+
reset_segment!
|
297
|
+
end
|
298
|
+
|
299
|
+
# Attempts to capture an ANSI sequence from the scanner If found, finalizes
|
300
|
+
# the current text segment and applies the sequence
|
301
|
+
#
|
302
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
303
|
+
# @since 0.3.0
|
304
|
+
#
|
305
|
+
# @api private
|
306
|
+
#
|
307
|
+
# @return [Boolean] `true` if a sequence was found, `false` if otherwise
|
308
|
+
# @rbs () -> bool
|
309
|
+
def handle_ansi_sequence
|
310
|
+
sequence = @scanner.scan(SEQUENCE_PATTERN)
|
311
|
+
return false unless sequence
|
312
|
+
|
313
|
+
finalize_segment_if_text!
|
314
|
+
apply_ansi_sequence(sequence)
|
315
|
+
@encoded_pos += sequence.length
|
316
|
+
true
|
317
|
+
end
|
318
|
+
|
319
|
+
# Reads a single character from the scanner and appends it to the current segment
|
320
|
+
#
|
321
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
322
|
+
# @since 0.3.0
|
323
|
+
#
|
324
|
+
# @api private
|
325
|
+
#
|
326
|
+
# @return [void]
|
327
|
+
# @rbs () -> void
|
328
|
+
def handle_character
|
329
|
+
char = @scanner.getch
|
330
|
+
@current_segment[:text] << char
|
331
|
+
@encoded_pos += 1
|
332
|
+
@stripped_pos += 1
|
333
|
+
end
|
334
|
+
|
335
|
+
# Parse extended color codes from the array, e.g. 38;5;160 (256-color) or 38;2;R;G;B (24-bit),
|
336
|
+
# and apply them to foreground or background
|
337
|
+
#
|
338
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
339
|
+
# @since 0.3.0
|
340
|
+
#
|
341
|
+
# @api private
|
342
|
+
#
|
343
|
+
# @param codes_array [Array<Integer>] the array of codes
|
344
|
+
# @param index [Integer] the current position (where we saw 38 or 48)
|
345
|
+
#
|
346
|
+
# @return [Integer] the updated position in the codes array
|
347
|
+
# @rbs (Array[Integer] codes_array, Integer index) -> Integer
|
348
|
+
def parse_extended_color(codes_array, index)
|
349
|
+
mode_code = codes_array[index + 1]
|
350
|
+
|
351
|
+
return index + 1 unless mode_code
|
352
|
+
|
353
|
+
case mode_code
|
354
|
+
when 5
|
355
|
+
apply_256_color(codes_array, index)
|
356
|
+
when 2
|
357
|
+
apply_24bit_color(codes_array, index)
|
358
|
+
else
|
359
|
+
index + 1
|
360
|
+
end
|
361
|
+
end
|
362
|
+
|
363
|
+
# Resets the current segment to a fresh, blank state
|
364
|
+
#
|
365
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
366
|
+
# @since 0.3.0
|
367
|
+
#
|
368
|
+
# @api private
|
369
|
+
#
|
370
|
+
# @return [void]
|
371
|
+
# @rbs () -> void
|
372
|
+
def reset_segment!
|
373
|
+
@current_segment[:text] = +''
|
374
|
+
@current_segment[:foreground] = nil
|
375
|
+
@current_segment[:background] = nil
|
376
|
+
@current_segment[:styles] = []
|
377
|
+
end
|
378
|
+
end
|
379
|
+
end
|
380
|
+
end
|