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.
@@ -0,0 +1,475 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+ require 'sai/ansi'
5
+ require 'sai/ansi/sequence_processor'
6
+
7
+ module Sai
8
+ module ANSI
9
+ # A representation of a ANSI encoded string and its individual {SequencedString::Segment segments}
10
+ #
11
+ # @author {https://aaronmallen.me Aaron Allen}
12
+ # @since 0.3.0
13
+ #
14
+ # @api public
15
+ class SequencedString
16
+ # @rbs skip
17
+ extend Forwardable
18
+ include Enumerable #[Segment]
19
+
20
+ # @!method each
21
+ # Iterate over each segment
22
+ #
23
+ # @author {https://aaronmallen.me Aaron Allen}
24
+ # @since 0.3.0
25
+ #
26
+ # @api public
27
+ # @return [Enumerator] the Enumerator
28
+ #
29
+ # @!method map
30
+ # Map over segments
31
+ #
32
+ # @author {https://aaronmallen.me Aaron Allen}
33
+ # @since 0.3.0
34
+ #
35
+ # @api public
36
+ # @return [Array] the segments to map over
37
+ #
38
+ # @!method size
39
+ # Number of segments
40
+ #
41
+ # @author {https://aaronmallen.me Aaron Allen}
42
+ # @since 0.3.0
43
+ #
44
+ # @api public
45
+ #
46
+ # @return [Integer] the number of segments
47
+ def_delegators :@segments, :each, :empty?, :map, :size # steep:ignore NoMethod
48
+
49
+ # @rbs!
50
+ # def each: () { (Segment) -> void } -> SequencedString
51
+ # def empty?: () -> bool
52
+ # def map: () { (Segment) -> untyped } -> Array[untyped]
53
+ # def size: () -> Integer
54
+
55
+ # Initialize a new instance of SequencedString
56
+ #
57
+ # @author {https://aaronmallen.me Aaron Allen}
58
+ # @since 0.3.0
59
+ #
60
+ # @api private
61
+ #
62
+ # @param string [String] the sequenced string to Segment
63
+ #
64
+ # @return [SequencedString] the new instance of SequencedString
65
+ # @rbs (String string) -> void
66
+ def initialize(string)
67
+ @segments = ANSI::SequenceProcessor.process(string).map do |segment_options|
68
+ Segment.new(**segment_options) # steep:ignore InsufficientKeywordArguments
69
+ end
70
+ end
71
+
72
+ # Fetch a segment by index
73
+ #
74
+ # @author {https://aaronmallen.me Aaron Allen}
75
+ # @since 0.3.0
76
+ #
77
+ # @api public
78
+ #
79
+ # @example
80
+ # string = SequencedString.new("\e[31mred\e[0m")
81
+ # string[0] #=> #<SequencedString::Segment:0x00007f9b3b8b3e10>
82
+ #
83
+ # @param index [Integer] the index of the segment to fetch
84
+ #
85
+ # @return [Segment, nil] the segment at the index
86
+ # @rbs (Integer index) -> Segment?
87
+ def [](index)
88
+ @segments[index]
89
+ end
90
+
91
+ # Compare the SequencedString to another object
92
+ #
93
+ # @author {https://aaronmallen.me Aaron Allen}
94
+ # @since 0.3.0
95
+ #
96
+ # @api public
97
+ #
98
+ # @example
99
+ # string = "\e[31mred\e[0m"
100
+ # SequencedString.new(string) == string #=> true
101
+ #
102
+ # @param other [Object] the object to compare to
103
+ #
104
+ # @return [Boolean] `true` if the SequencedString is equal to the other object, `false` otherwise
105
+ # @rbs (untyped other) -> bool
106
+ def ==(other)
107
+ (other.is_a?(self.class) && to_s == other.to_s) ||
108
+ (other.is_a?(String) && to_s == self.class.new(other).to_s)
109
+ end
110
+
111
+ # Combine a sequenced string with another object
112
+ #
113
+ # @author {https://aaronmallen.me Aaron Allen}
114
+ # @since 0.3.0
115
+ #
116
+ # @api public
117
+ #
118
+ # @example
119
+ # sequenced_string = SequencedString.new("\e[31mred\e[0m")
120
+ # sequenced_string + " is a color" #=> "\e[31mred\e[0m is a color"
121
+ #
122
+ # @param other [Object] the object to combine with
123
+ #
124
+ # @return [SequencedString] the combined string
125
+ # @rbs (untyped other) -> SequencedString
126
+ def +(other)
127
+ string = to_s + other.to_s
128
+ self.class.new(string)
129
+ end
130
+
131
+ # Return just the raw text content with **no ANSI sequences**
132
+ #
133
+ # @author {https://aaronmallen.me Aaron Allen}
134
+ # @since 0.3.0
135
+ #
136
+ # @api public
137
+ #
138
+ # @example
139
+ # string = SequencedString.new("Normal \e[31mred\e[0m")
140
+ # string.stripped #=> "Normal red"
141
+ #
142
+ # @return [String] the concatenation of all segment text without color or style
143
+ def stripped
144
+ map(&:text).join
145
+ end
146
+
147
+ # Return the fully reconstructed string with **all ANSI sequences** (foreground, background, style)
148
+ #
149
+ # @author {https://aaronmallen.me Aaron Allen}
150
+ # @since 0.3.0
151
+ #
152
+ # @api public
153
+ #
154
+ # @example
155
+ # string = SequencedString.new("\e[31mred\e[0m")
156
+ # string.to_s #=> "\e[31mred\e[0m"
157
+ #
158
+ # @return [String]
159
+ def to_s
160
+ build_string
161
+ end
162
+ alias to_str to_s
163
+
164
+ # Return a string with everything except **background** color sequences removed
165
+ #
166
+ # @author {https://aaronmallen.me Aaron Allen}
167
+ # @since 0.3.0
168
+ #
169
+ # @api public
170
+ #
171
+ # @example Remove all background colors
172
+ # string = SequencedString.new("\e[41mBack\e[0m \e[1mBold\e[0m")
173
+ # string.without_background #=> "\e[1mBold\e[0m"
174
+ #
175
+ # @return [SequencedString] new instance with background colors removed
176
+ # @rbs () -> SequencedString
177
+ def without_background
178
+ self.class.new(build_string(skip_background: true))
179
+ end
180
+
181
+ # Return a string containing *style* sequences but **no foreground or background colors**
182
+ #
183
+ # @author {https://aaronmallen.me Aaron Allen}
184
+ # @since 0.3.0
185
+ #
186
+ # @api public
187
+ #
188
+ # @example Remove all colors
189
+ # string = SequencedString.new("\e[31mred\e[0m \e[1mbold\e[0m")
190
+ # string.without_color #=> "\e[1mbold\e[0m"
191
+ #
192
+ # @return [SequencedString] new instance with all colors removed
193
+ # @rbs () -> SequencedString
194
+ def without_color
195
+ self.class.new(build_string(skip_background: true, skip_foreground: true))
196
+ end
197
+
198
+ # Return a string with everything except **foreground** color sequences removed
199
+ #
200
+ # @author {https://aaronmallen.me Aaron Allen}
201
+ # @since 0.3.0
202
+ #
203
+ # @api public
204
+ #
205
+ # @example Remove all foreground colors
206
+ # string = SequencedString.new("\e[41mBack\e[0m \e[1mBold\e[0m")
207
+ # string.without_foreground #=> "\e[41mBack\e[0m \e[1mBold\e[0m"
208
+ #
209
+ # @return [SequencedString] new instance with foreground colors removed
210
+ # @rbs () -> SequencedString
211
+ def without_foreground
212
+ self.class.new(build_string(skip_foreground: true))
213
+ end
214
+
215
+ # Return a string with specified styles removed
216
+ #
217
+ # @author {https://aaronmallen.me Aaron Allen}
218
+ # @since 0.3.0
219
+ #
220
+ # @api public
221
+ #
222
+ # @example Remove all styles
223
+ # string = SequencedString.new("\e[31mred\e[0m \e[1mbold\e[0m")
224
+ # string.without_style #=> "\e[31mred\e[0m"
225
+ #
226
+ # @example Remove specific style
227
+ # string = SequencedString.new("\e[1;4mBold and Underlined\e[0m")
228
+ # string.without_style(:bold) #=> "\e[4mUnderlined\e[0m"
229
+ #
230
+ # @param styles [Array<Symbol>] specific styles to remove (default: all)
231
+ #
232
+ # @return [SequencedString] new instance with specified styles removed
233
+ def without_style(*styles)
234
+ skipped_styles = styles.empty? ? ANSI::STYLES.keys : styles.map(&:to_sym)
235
+ self.class.new(build_string(skip_styles: skipped_styles))
236
+ end
237
+
238
+ private
239
+
240
+ # Build the color sequences for a segment
241
+ #
242
+ # @author {https://aaronmallen.me Aaron Allen}
243
+ # @since 0.3.0
244
+ #
245
+ # @api private
246
+ #
247
+ # @param segment [Segment] the segment to build color sequences for
248
+ # @param skip_background [Boolean] whether to skip background colors
249
+ # @param skip_foreground [Boolean] whether to skip foreground colors
250
+ #
251
+ # @return [Array<String>] the color sequences
252
+ # @rbs (Segment segment, ?skip_background: bool, ?skip_foreground: bool) -> Array[String]
253
+ def build_color_sequences(segment, skip_background: false, skip_foreground: false)
254
+ [
255
+ (skip_foreground ? nil : segment.foreground),
256
+ (skip_background ? nil : segment.background)
257
+ ].compact
258
+ end
259
+
260
+ # Build a string with specified parts skipped
261
+ #
262
+ # @author {https://aaronmallen.me Aaron Allen}
263
+ # @since 0.3.0
264
+ #
265
+ # @api private
266
+ #
267
+ # @param skip_background [Boolean] whether to skip background colors
268
+ # @param skip_foreground [Boolean] whether to skip foreground colors
269
+ # @param skip_styles [Array<Symbol>] styles to skip
270
+ #
271
+ # @return [String] the built string
272
+ # @rbs (?skip_background: bool, ?skip_foreground: bool, ?skip_styles: Array[Symbol]) -> String
273
+ def build_string(skip_background: false, skip_foreground: false, skip_styles: [])
274
+ map do |segment|
275
+ color_sequences = build_color_sequences(segment, skip_background:, skip_foreground:)
276
+ style_sequences = build_style_sequences(segment, skip_styles: skip_styles)
277
+ sequences = color_sequences + style_sequences
278
+
279
+ out = sequences.empty? ? +'' : "\e[#{sequences.compact.join(';')}m"
280
+ out << segment.text
281
+ out << ANSI::RESET unless sequences.empty?
282
+ out
283
+ end.join
284
+ end
285
+
286
+ # Build the style sequences for a segment
287
+ #
288
+ # @author {https://aaronmallen.me Aaron Allen}
289
+ # @since 0.3.0
290
+ #
291
+ # @api private
292
+ #
293
+ # @param segment [Segment] the segment to build style sequences for
294
+ # @param skip_styles [Array<Symbol>] styles to skip
295
+ #
296
+ # @return [Array<String>] the style sequences
297
+ # @rbs (Segment segment, ?skip_styles: Array[Symbol]) -> Array[String]
298
+ def build_style_sequences(segment, skip_styles: [])
299
+ return [] if skip_styles.include?(:all)
300
+
301
+ segment.styles.filter_map do |style_code|
302
+ style_name = ANSI::STYLES.key(style_code.to_i)
303
+ style_code unless skip_styles.include?(style_name)
304
+ end
305
+ end
306
+
307
+ # A segment of an ANSI encoded string
308
+ #
309
+ # @author {https://aaronmallen.me Aaron Allen}
310
+ # @since 0.3.0
311
+ #
312
+ # @api public
313
+ class Segment
314
+ # The background color sequences for the Segment
315
+ #
316
+ # @author {https://aaronmallen.me Aaron Allen}
317
+ # @since 0.3.0
318
+ #
319
+ # @api public
320
+ #
321
+ # @return [String, nil] the background color sequences
322
+ attr_reader :background #: String?
323
+
324
+ # The foreground color sequences for the Segment
325
+ #
326
+ # @author {https://aaronmallen.me Aaron Allen}
327
+ # @since 0.3.0
328
+ #
329
+ # @api public
330
+ #
331
+ # @return [String, nil] the foreground color sequences
332
+ attr_reader :foreground #: String?
333
+
334
+ # The {Location} of the encoded string within the {SequencedString}
335
+ #
336
+ # @author {https://aaronmallen.me Aaron Allen}
337
+ # @since 0.3.0
338
+ #
339
+ # @api public
340
+ #
341
+ # @return [Location] the {Location}
342
+ attr_reader :encoded_location #: Location
343
+ alias encoded_loc encoded_location
344
+
345
+ # The {Location} of the encoded string without it's encoding within the {SequencedString}
346
+ #
347
+ # @author {https://aaronmallen.me Aaron Allen}
348
+ # @since 0.3.0
349
+ #
350
+ # @api public
351
+ #
352
+ # @return [Location] the {Location}
353
+ attr_reader :stripped_location #: Location
354
+ alias stripped_loc stripped_location
355
+
356
+ # The style sequences (bold, underline, etc...) for the segment
357
+ #
358
+ # @author {https://aaronmallen.me Aaron Allen}
359
+ # @since 0.3.0
360
+ #
361
+ # @api public
362
+ #
363
+ # @return [Array<String>] the style sequences
364
+ attr_reader :styles #: Array[String]
365
+
366
+ # The raw text of the Segment without any of its ANSI sequences
367
+ #
368
+ # @author {https://aaronmallen.me Aaron Allen}
369
+ # @since 0.3.0
370
+ #
371
+ # @api public
372
+ #
373
+ # @return [String]
374
+ attr_reader :text #: String
375
+
376
+ # Initialize a new instance of Segment
377
+ #
378
+ # @author {https://aaronmallen.me Aaron Allen}
379
+ # @since 0.3.0
380
+ #
381
+ # @api private
382
+ #
383
+ # @param options [Hash{Symbol => Object}] the options to initialize the Segment with
384
+ # @option options background [String, nil] the Segment {#background}
385
+ # @option options foreground [String, nil] the Segment {#foreground}
386
+ # @option options encoded_end [Integer] the {Location#end_position end_position} of the Segment
387
+ # {#encoded_location}
388
+ # @option options encoded_start [Integer] the {Location#start_position start_position} of the Segment
389
+ # {#encoded_location}
390
+ # @option options stripped_end [Integer] the {Location#end_position end_position} of the Segment
391
+ # {#stripped_location}
392
+ # @option options stripped_start [Integer] the {Location#start_position start_position} of the Segment
393
+ # {#stripped_location}
394
+ # @option options styles [Array<String>] the Segment {#styles}
395
+ # @option options text [String] the Segment {#text}
396
+ #
397
+ # @return [Segment] the new instance of Segment
398
+ # @rbs (
399
+ # ?background: String?,
400
+ # ?foreground: String?,
401
+ # encoded_end: Integer,
402
+ # encoded_start: Integer,
403
+ # stripped_end: Integer,
404
+ # stripped_start: Integer,
405
+ # ?styles: Array[String],
406
+ # text: String
407
+ # ) -> void
408
+ def initialize(**options)
409
+ @background = options.fetch(:background, nil)
410
+ @foreground = options.fetch(:foreground, nil)
411
+ @encoded_location = Location.new(
412
+ end_position: options.fetch(:encoded_end), #: Integer
413
+ start_position: options.fetch(:encoded_start) #: Integer
414
+ )
415
+ @stripped_location = Location.new(
416
+ end_position: options.fetch(:stripped_end), #: Integer
417
+ start_position: options.fetch(:stripped_start) #: Integer
418
+ )
419
+ @styles = options.fetch(:styles, [])
420
+ @text = options.fetch(:text)
421
+
422
+ freeze
423
+ end
424
+
425
+ # The location of the {Segment} within a {SequencedString}
426
+ #
427
+ # @author {https://aaronmallen.me Aaron Allen}
428
+ # @since 0.3.0
429
+ #
430
+ # @api public
431
+ class Location
432
+ # The ending position of the Location
433
+ #
434
+ # @author {https://aaronmallen.me Aaron Allen}
435
+ # @since 0.3.0
436
+ #
437
+ # @api public
438
+ #
439
+ # @return [Integer] the end position
440
+ attr_reader :end_position #: Integer
441
+ alias end_pos end_position
442
+
443
+ # The starting position of the Location
444
+ #
445
+ # @author {https://aaronmallen.me Aaron Allen}
446
+ # @since 0.3.0
447
+ #
448
+ # @api public
449
+ #
450
+ # @return [Integer] the start position
451
+ attr_reader :start_position #: Integer
452
+ alias start_pos start_position
453
+
454
+ # Initialize a new instance of Location
455
+ #
456
+ # @author {https://aaronmallen.me Aaron Allen}
457
+ # @since 0.3.0
458
+ #
459
+ # @api private
460
+ #
461
+ # @param end_position [Integer] the {#end_position} of the location
462
+ # @param start_position [Integer] the {#start_position} of the location
463
+ #
464
+ # @return [Location] the new instance of Location
465
+ # @rbs (end_position: Integer, start_position: Integer) -> void
466
+ def initialize(end_position:, start_position:)
467
+ @end_position = end_position
468
+ @start_position = start_position
469
+ freeze
470
+ end
471
+ end
472
+ end
473
+ end
474
+ end
475
+ end
data/lib/sai/ansi.rb CHANGED
@@ -4,14 +4,14 @@ module Sai
4
4
  # ANSI constants for encoding text styles and colors
5
5
  #
6
6
  # @author {https://aaronmallen.me Aaron Allen}
7
- # @since unreleased
7
+ # @since 0.1.0
8
8
  #
9
9
  # @api private
10
10
  module ANSI
11
11
  # ANSI color code mappings
12
12
  #
13
13
  # @author {https://aaronmallen.me Aaron Allen}
14
- # @since unreleased
14
+ # @since 0.1.0
15
15
  #
16
16
  # @api private
17
17
  #
@@ -30,7 +30,7 @@ module Sai
30
30
  # Standard ANSI color names and their RGB values
31
31
  #
32
32
  # @author {https://aaronmallen.me Aaron Allen}
33
- # @since unreleased
33
+ # @since 0.1.0
34
34
  #
35
35
  # @api private
36
36
  #
@@ -57,7 +57,7 @@ module Sai
57
57
  # ANSI escape sequence for resetting text formatting
58
58
  #
59
59
  # @author {https://aaronmallen.me Aaron Allen}
60
- # @since unreleased
60
+ # @since 0.1.0
61
61
  #
62
62
  # @api private
63
63
  #
@@ -67,7 +67,7 @@ module Sai
67
67
  # Standard ANSI style codes
68
68
  #
69
69
  # @author {https://aaronmallen.me Aaron Allen}
70
- # @since unreleased
70
+ # @since 0.1.0
71
71
  #
72
72
  # @api private
73
73
  #
@@ -9,7 +9,7 @@ module Sai
9
9
  # ANSI escape sequence utilities
10
10
  #
11
11
  # @author {https://aaronmallen.me Aaron Allen}
12
- # @since unreleased
12
+ # @since 0.1.0
13
13
  #
14
14
  # @api private
15
15
  module ColorSequence
@@ -20,7 +20,7 @@ module Sai
20
20
  # Convert a color to the appropriate ANSI escape sequence
21
21
  #
22
22
  # @author {https://aaronmallen.me Aaron Allen}
23
- # @since unreleased
23
+ # @since 0.1.0
24
24
  #
25
25
  # @api private
26
26
  #
@@ -36,7 +36,7 @@ module Sai
36
36
 
37
37
  case mode
38
38
  when Terminal::ColorMode::TRUE_COLOR then true_color(rgb, style_type)
39
- when Terminal::ColorMode::BIT8 then bit8(rgb, style_type)
39
+ when Terminal::ColorMode::ADVANCED then advanced(rgb, style_type)
40
40
  when Terminal::ColorMode::ANSI then ansi(rgb, style_type)
41
41
  when Terminal::ColorMode::BASIC then basic(rgb, style_type)
42
42
  else
@@ -46,10 +46,33 @@ module Sai
46
46
 
47
47
  private
48
48
 
49
+ # Convert RGB values to an 8-bit color sequence
50
+ #
51
+ # @author {https://aaronmallen.me Aaron Allen}
52
+ # @since 0.1.0
53
+ #
54
+ # @api private
55
+ #
56
+ # @param rgb [Array<Integer>] the RGB components
57
+ # @param style_type [Symbol] the type of color (foreground or background)
58
+ #
59
+ # @return [String] the ANSI escape sequence
60
+ # @rbs (Array[Integer] rgb, style_type type) -> String
61
+ def advanced(rgb, style_type)
62
+ code = style_type == :background ? 48 : 38
63
+ color_code = if rgb.uniq.size == 1
64
+ RGB.to_grayscale_index(rgb)
65
+ else
66
+ RGB.to_color_cube_index(rgb)
67
+ end
68
+
69
+ "\e[#{code};5;#{color_code}m"
70
+ end
71
+
49
72
  # Convert RGB values to a 4-bit ANSI color sequence
50
73
  #
51
74
  # @author {https://aaronmallen.me Aaron Allen}
52
- # @since unreleased
75
+ # @since 0.1.0
53
76
  #
54
77
  # @api private
55
78
  #
@@ -72,7 +95,7 @@ module Sai
72
95
  # Convert a base color to a foreground or background sequence
73
96
  #
74
97
  # @author {https://aaronmallen.me Aaron Allen}
75
- # @since unreleased
98
+ # @since 0.1.0
76
99
  #
77
100
  # @api private
78
101
  #
@@ -88,7 +111,7 @@ module Sai
88
111
  # Convert RGB values to a 3-bit basic color sequence
89
112
  #
90
113
  # @author {https://aaronmallen.me Aaron Allen}
91
- # @since unreleased
114
+ # @since 0.1.0
92
115
  #
93
116
  # @api private
94
117
  #
@@ -104,33 +127,10 @@ module Sai
104
127
  "\e[#{code}m"
105
128
  end
106
129
 
107
- # Convert RGB values to an 8-bit color sequence
108
- #
109
- # @author {https://aaronmallen.me Aaron Allen}
110
- # @since unreleased
111
- #
112
- # @api private
113
- #
114
- # @param rgb [Array<Integer>] the RGB components
115
- # @param style_type [Symbol] the type of color (foreground or background)
116
- #
117
- # @return [String] the ANSI escape sequence
118
- # @rbs (Array[Integer] rgb, style_type type) -> String
119
- def bit8(rgb, style_type)
120
- code = style_type == :background ? 48 : 38
121
- color_code = if rgb.uniq.size == 1
122
- RGB.to_grayscale_index(rgb)
123
- else
124
- RGB.to_color_cube_index(rgb)
125
- end
126
-
127
- "\e[#{code};5;#{color_code}m"
128
- end
129
-
130
130
  # Convert RGB values to a true color (24-bit) sequence
131
131
  #
132
132
  # @author {https://aaronmallen.me Aaron Allen}
133
- # @since unreleased
133
+ # @since 0.1.0
134
134
  #
135
135
  # @api private
136
136
  #
@@ -147,7 +147,7 @@ module Sai
147
147
  # Validate a color style type
148
148
  #
149
149
  # @author {https://aaronmallen.me Aaron Allen}
150
- # @since unreleased
150
+ # @since 0.1.0
151
151
  #
152
152
  # @api private
153
153
  #