rich-ruby 1.0.1 → 1.0.2

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.
data/lib/rich/style.rb CHANGED
@@ -1,433 +1,464 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "color"
4
-
5
- module Rich
6
- # Style attributes represented as bit flags
7
- module StyleAttribute
8
- BOLD = 1 << 0 # 1
9
- DIM = 1 << 1 # 2
10
- ITALIC = 1 << 2 # 4
11
- UNDERLINE = 1 << 3 # 8
12
- BLINK = 1 << 4 # 16
13
- BLINK2 = 1 << 5 # 32 (rapid blink)
14
- REVERSE = 1 << 6 # 64
15
- CONCEAL = 1 << 7 # 128
16
- STRIKE = 1 << 8 # 256
17
- UNDERLINE2 = 1 << 9 # 512 (double underline)
18
- FRAME = 1 << 10 # 1024
19
- ENCIRCLE = 1 << 11 # 2048
20
- OVERLINE = 1 << 12 # 4096
21
-
22
- ALL = {
23
- bold: BOLD,
24
- dim: DIM,
25
- italic: ITALIC,
26
- underline: UNDERLINE,
27
- blink: BLINK,
28
- blink2: BLINK2,
29
- reverse: REVERSE,
30
- conceal: CONCEAL,
31
- strike: STRIKE,
32
- underline2: UNDERLINE2,
33
- frame: FRAME,
34
- encircle: ENCIRCLE,
35
- overline: OVERLINE
36
- }.freeze
37
-
38
- NAMES = ALL.keys.freeze
39
- end
40
-
41
- # Represents a terminal style with colors and text attributes.
42
- # Styles are immutable and can be combined using the + operator.
43
- class Style
44
- # ANSI reset and attribute codes
45
- ANSI_CODES = {
46
- bold: "1",
47
- dim: "2",
48
- italic: "3",
49
- underline: "4",
50
- blink: "5",
51
- blink2: "6",
52
- reverse: "7",
53
- conceal: "8",
54
- strike: "9",
55
- underline2: "21",
56
- frame: "51",
57
- encircle: "52",
58
- overline: "53"
59
- }.freeze
60
-
61
- # Regex for parsing style definitions
62
- STYLE_REGEX = /
63
- (?<not>not\s+)?
64
- (?<attr>bold|dim|italic|underline2?|blink2?|reverse|conceal|strike|frame|encircle|overline)|
65
- (?<link>link\s+(?<url>\S+))|
66
- (?<on>on\s+)?(?<color>\S+)
67
- /x
68
-
69
- # @return [Color, nil] Foreground color
70
- attr_reader :color
71
-
72
- # @return [Color, nil] Background color
73
- attr_reader :bgcolor
74
-
75
- # @return [Integer] Attributes that are explicitly set
76
- attr_reader :set_attributes
77
-
78
- # @return [Integer] Attribute values (0 = off, 1 = on)
79
- attr_reader :attributes
80
-
81
- # @return [String, nil] Hyperlink URL
82
- attr_reader :link
83
-
84
- # @return [Hash, nil] Meta information
85
- attr_reader :meta
86
-
87
- # Cache for parsed styles
88
- @parse_cache = {}
89
- @parse_cache_mutex = Mutex.new
90
-
91
- # Create a new style
92
- # @param color [Color, String, nil] Foreground color
93
- # @param bgcolor [Color, String, nil] Background color
94
- # @param bold [Boolean, nil] Bold attribute
95
- # @param dim [Boolean, nil] Dim attribute
96
- # @param italic [Boolean, nil] Italic attribute
97
- # @param underline [Boolean, nil] Underline attribute
98
- # @param blink [Boolean, nil] Blink attribute
99
- # @param blink2 [Boolean, nil] Rapid blink attribute
100
- # @param reverse [Boolean, nil] Reverse video attribute
101
- # @param conceal [Boolean, nil] Conceal attribute
102
- # @param strike [Boolean, nil] Strikethrough attribute
103
- # @param underline2 [Boolean, nil] Double underline attribute
104
- # @param frame [Boolean, nil] Frame attribute
105
- # @param encircle [Boolean, nil] Encircle attribute
106
- # @param overline [Boolean, nil] Overline attribute
107
- # @param link [String, nil] Hyperlink URL
108
- # @param meta [Hash, nil] Meta information
109
- def initialize(
110
- color: nil,
111
- bgcolor: nil,
112
- bold: nil,
113
- dim: nil,
114
- italic: nil,
115
- underline: nil,
116
- blink: nil,
117
- blink2: nil,
118
- reverse: nil,
119
- conceal: nil,
120
- strike: nil,
121
- underline2: nil,
122
- frame: nil,
123
- encircle: nil,
124
- overline: nil,
125
- link: nil,
126
- meta: nil
127
- )
128
- @color = parse_color(color)
129
- @bgcolor = parse_color(bgcolor)
130
- @link = link&.freeze
131
- @meta = meta&.freeze
132
-
133
- # Build attribute masks
134
- @set_attributes = 0
135
- @attributes = 0
136
-
137
- attrs = {
138
- bold: bold, dim: dim, italic: italic, underline: underline,
139
- blink: blink, blink2: blink2, reverse: reverse, conceal: conceal,
140
- strike: strike, underline2: underline2, frame: frame,
141
- encircle: encircle, overline: overline
142
- }
143
-
144
- attrs.each do |name, value|
145
- next if value.nil?
146
-
147
- bit = StyleAttribute::ALL[name]
148
- @set_attributes |= bit
149
- @attributes |= bit if value
150
- end
151
-
152
- freeze
153
- end
154
-
155
- # Check if any attributes, colors, or link are set
156
- # @return [Boolean]
157
- def blank?
158
- @color.nil? && @bgcolor.nil? && @set_attributes == 0 && @link.nil?
159
- end
160
-
161
- # @return [Boolean] False if blank
162
- def present?
163
- !blank?
164
- end
165
-
166
- # Get a specific attribute value
167
- # @param name [Symbol] Attribute name
168
- # @return [Boolean, nil] Attribute value or nil if not set
169
- def [](name)
170
- bit = StyleAttribute::ALL[name]
171
- return nil unless bit
172
-
173
- return nil if (@set_attributes & bit) == 0
174
-
175
- (@attributes & bit) != 0
176
- end
177
-
178
- # Attribute accessor methods
179
- StyleAttribute::NAMES.each do |attr_name|
180
- define_method(attr_name) { self[attr_name] }
181
- define_method("#{attr_name}?") { self[attr_name] == true }
182
- end
183
-
184
- # Generate ANSI escape codes for this style
185
- # @param color_system [Symbol] Target color system
186
- # @return [String] ANSI escape sequence
187
- def render(color_system: ColorSystem::TRUECOLOR)
188
- codes = []
189
-
190
- # Add attribute codes
191
- StyleAttribute::NAMES.each do |name|
192
- value = self[name]
193
- next if value.nil?
194
-
195
- if value
196
- codes << ANSI_CODES[name]
197
- end
198
- end
199
-
200
- # Add color codes
201
- if @color
202
- target_color = @color.downgrade(color_system)
203
- codes.concat(target_color.ansi_codes(foreground: true))
204
- end
205
-
206
- if @bgcolor
207
- target_color = @bgcolor.downgrade(color_system)
208
- codes.concat(target_color.ansi_codes(foreground: false))
209
- end
210
-
211
- return "" if codes.empty?
212
-
213
- "\e[#{codes.join(';')}m"
214
- end
215
-
216
- # Generate the style definition string
217
- # @return [String]
218
- def to_s
219
- parts = []
220
-
221
- StyleAttribute::NAMES.each do |name|
222
- value = self[name]
223
- next if value.nil?
224
-
225
- parts << (value ? name.to_s : "not #{name}")
226
- end
227
-
228
- parts << @color.name if @color
229
- parts << "on #{@bgcolor.name}" if @bgcolor
230
- parts << "link #{@link}" if @link
231
-
232
- parts.join(" ")
233
- end
234
-
235
- def inspect
236
- attrs = []
237
- attrs << "color=#{@color.name}" if @color
238
- attrs << "bgcolor=#{@bgcolor.name}" if @bgcolor
239
-
240
- StyleAttribute::NAMES.each do |name|
241
- value = self[name]
242
- attrs << "#{name}=#{value}" unless value.nil?
243
- end
244
-
245
- attrs << "link=#{@link.inspect}" if @link
246
-
247
- "#<Rich::Style #{attrs.join(' ')}>"
248
- end
249
-
250
- # Combine two styles (right-hand style takes precedence)
251
- # @param other [Style] Style to combine with
252
- # @return [Style] Combined style
253
- def +(other)
254
- return self if other.nil? || other.blank?
255
- return other if blank?
256
-
257
- new_color = other.color || @color
258
- new_bgcolor = other.bgcolor || @bgcolor
259
- new_link = other.link || @link
260
- new_meta = @meta || other.meta ? (@meta || {}).merge(other.meta || {}) : nil
261
-
262
- # Merge attributes
263
- new_set = @set_attributes | other.set_attributes
264
- new_attrs = (@attributes & ~other.set_attributes) | (other.attributes & other.set_attributes)
265
-
266
- Style.combine(
267
- color: new_color,
268
- bgcolor: new_bgcolor,
269
- link: new_link,
270
- meta: new_meta,
271
- set_attributes: new_set,
272
- attributes: new_attrs
273
- )
274
- end
275
-
276
- # Get style with no colors
277
- # @return [Style]
278
- def without_color
279
- Style.combine(
280
- color: nil,
281
- bgcolor: nil,
282
- link: @link,
283
- meta: @meta,
284
- set_attributes: @set_attributes,
285
- attributes: @attributes
286
- )
287
- end
288
-
289
- # Get background-only style
290
- # @return [Style]
291
- def background_style
292
- Style.new(bgcolor: @bgcolor)
293
- end
294
-
295
- def ==(other)
296
- return false unless other.is_a?(Style)
297
-
298
- @color == other.color &&
299
- @bgcolor == other.bgcolor &&
300
- @set_attributes == other.set_attributes &&
301
- @attributes == other.attributes &&
302
- @link == other.link
303
- end
304
-
305
- alias eql? ==
306
-
307
- def hash
308
- [@color, @bgcolor, @set_attributes, @attributes, @link].hash
309
- end
310
-
311
- class << self
312
- # Parse a style definition string
313
- # @param style [String, Style, nil] Style definition
314
- # @return [Style]
315
- def parse(style)
316
- return null if style.nil? || (style.is_a?(String) && style.empty?)
317
- return style if style.is_a?(Style)
318
-
319
- style = style.to_s
320
-
321
- @parse_cache_mutex.synchronize do
322
- return @parse_cache[style] if @parse_cache.key?(style)
323
- end
324
-
325
- result = parse_uncached(style)
326
-
327
- @parse_cache_mutex.synchronize do
328
- @parse_cache[style] = result
329
- end
330
-
331
- result
332
- end
333
-
334
- # Create a null (empty) style
335
- # @return [Style]
336
- def null
337
- @null ||= new
338
- end
339
-
340
- # Create a combined style with explicit attributes (internal use)
341
- # @param color [Color, nil] Foreground color
342
- # @param bgcolor [Color, nil] Background color
343
- # @param link [String, nil] Hyperlink
344
- # @param meta [Hash, nil] Meta info
345
- # @param set_attributes [Integer] Set attributes bitmask
346
- # @param attributes [Integer] Attribute values bitmask
347
- # @return [Style]
348
- def combine(color:, bgcolor:, link:, meta:, set_attributes:, attributes:)
349
- style = allocate
350
- style.instance_variable_set(:@color, color)
351
- style.instance_variable_set(:@bgcolor, bgcolor)
352
- style.instance_variable_set(:@link, link&.freeze)
353
- style.instance_variable_set(:@meta, meta&.freeze)
354
- style.instance_variable_set(:@set_attributes, set_attributes)
355
- style.instance_variable_set(:@attributes, attributes)
356
- style.freeze
357
- style
358
- end
359
-
360
- # Create a style from just colors
361
- # @param color [Color, String, nil] Foreground color
362
- # @param bgcolor [Color, String, nil] Background color
363
- # @return [Style]
364
- def from_color(color: nil, bgcolor: nil)
365
- new(color: color, bgcolor: bgcolor)
366
- end
367
-
368
- # Create a style with meta information
369
- # @param meta [Hash] Meta data
370
- # @return [Style]
371
- def from_meta(meta)
372
- new(meta: meta)
373
- end
374
-
375
- # Normalize a style definition
376
- # @param style [String] Style definition
377
- # @return [String] Normalized style definition
378
- def normalize(style)
379
- parse(style).to_s
380
- end
381
-
382
- private
383
-
384
- def parse_uncached(style_str)
385
- attrs = {}
386
- color = nil
387
- bgcolor = nil
388
- link = nil
389
-
390
- style_str.scan(STYLE_REGEX) do
391
- match = Regexp.last_match
392
-
393
- if match[:attr]
394
- attr_name = match[:attr].to_sym
395
- attrs[attr_name] = match[:not].nil?
396
- elsif match[:link]
397
- link = match[:url]
398
- elsif match[:color]
399
- color_name = match[:color]
400
- begin
401
- parsed_color = Color.parse(color_name)
402
- if match[:on]
403
- bgcolor = parsed_color
404
- else
405
- color = parsed_color
406
- end
407
- rescue ColorParseError
408
- # Ignore invalid colors
409
- end
410
- end
411
- end
412
-
413
- new(
414
- color: color,
415
- bgcolor: bgcolor,
416
- link: link,
417
- **attrs
418
- )
419
- end
420
- end
421
-
422
- private
423
-
424
- def parse_color(color)
425
- return nil if color.nil?
426
- return color if color.is_a?(Color)
427
-
428
- Color.parse(color)
429
- rescue ColorParseError
430
- nil
431
- end
432
- end
433
- end
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color"
4
+
5
+ module Rich
6
+ # Style attributes represented as bit flags
7
+ module StyleAttribute
8
+ BOLD = 1 << 0 # 1
9
+ DIM = 1 << 1 # 2
10
+ ITALIC = 1 << 2 # 4
11
+ UNDERLINE = 1 << 3 # 8
12
+ BLINK = 1 << 4 # 16
13
+ BLINK2 = 1 << 5 # 32 (rapid blink)
14
+ REVERSE = 1 << 6 # 64
15
+ CONCEAL = 1 << 7 # 128
16
+ STRIKE = 1 << 8 # 256
17
+ UNDERLINE2 = 1 << 9 # 512 (double underline)
18
+ FRAME = 1 << 10 # 1024
19
+ ENCIRCLE = 1 << 11 # 2048
20
+ OVERLINE = 1 << 12 # 4096
21
+
22
+ ALL = {
23
+ bold: BOLD,
24
+ dim: DIM,
25
+ italic: ITALIC,
26
+ underline: UNDERLINE,
27
+ blink: BLINK,
28
+ blink2: BLINK2,
29
+ reverse: REVERSE,
30
+ conceal: CONCEAL,
31
+ strike: STRIKE,
32
+ underline2: UNDERLINE2,
33
+ frame: FRAME,
34
+ encircle: ENCIRCLE,
35
+ overline: OVERLINE
36
+ }.freeze
37
+
38
+ NAMES = ALL.keys.freeze
39
+ end
40
+
41
+ # Represents a terminal style with colors and text attributes.
42
+ # Styles are immutable and can be combined using the + operator.
43
+ class Style
44
+ # ANSI reset and attribute codes
45
+ ANSI_CODES = {
46
+ bold: "1",
47
+ dim: "2",
48
+ italic: "3",
49
+ underline: "4",
50
+ blink: "5",
51
+ blink2: "6",
52
+ reverse: "7",
53
+ conceal: "8",
54
+ strike: "9",
55
+ underline2: "21",
56
+ frame: "51",
57
+ encircle: "52",
58
+ overline: "53"
59
+ }.freeze
60
+
61
+ # Single-letter attribute aliases (expanded before parsing). Mirrors the
62
+ # shorthands documented in the cheat-sheet, e.g. [u] == [underline].
63
+ ATTRIBUTE_ALIASES = {
64
+ "b" => "bold",
65
+ "i" => "italic",
66
+ "u" => "underline",
67
+ "s" => "strike",
68
+ "d" => "dim",
69
+ "r" => "reverse",
70
+ "o" => "overline"
71
+ }.freeze
72
+
73
+ # Regex for parsing style definitions
74
+ STYLE_REGEX = /
75
+ (?<not>not\s+)?
76
+ (?<attr>bold|dim|italic|underline2?|blink2?|reverse|conceal|strike|frame|encircle|overline)|
77
+ (?<link>link\s+(?<url>\S+))|
78
+ (?<on>on\s+)?(?<color>\S+)
79
+ /x
80
+
81
+ # @return [Color, nil] Foreground color
82
+ attr_reader :color
83
+
84
+ # @return [Color, nil] Background color
85
+ attr_reader :bgcolor
86
+
87
+ # @return [Integer] Attributes that are explicitly set
88
+ attr_reader :set_attributes
89
+
90
+ # @return [Integer] Attribute values (0 = off, 1 = on)
91
+ attr_reader :attributes
92
+
93
+ # @return [String, nil] Hyperlink URL
94
+ attr_reader :link
95
+
96
+ # @return [Hash, nil] Meta information
97
+ attr_reader :meta
98
+
99
+ # Cache for parsed styles
100
+ @parse_cache = {}
101
+ @parse_cache_mutex = Mutex.new
102
+
103
+ # Create a new style
104
+ # @param color [Color, String, nil] Foreground color
105
+ # @param bgcolor [Color, String, nil] Background color
106
+ # @param bold [Boolean, nil] Bold attribute
107
+ # @param dim [Boolean, nil] Dim attribute
108
+ # @param italic [Boolean, nil] Italic attribute
109
+ # @param underline [Boolean, nil] Underline attribute
110
+ # @param blink [Boolean, nil] Blink attribute
111
+ # @param blink2 [Boolean, nil] Rapid blink attribute
112
+ # @param reverse [Boolean, nil] Reverse video attribute
113
+ # @param conceal [Boolean, nil] Conceal attribute
114
+ # @param strike [Boolean, nil] Strikethrough attribute
115
+ # @param underline2 [Boolean, nil] Double underline attribute
116
+ # @param frame [Boolean, nil] Frame attribute
117
+ # @param encircle [Boolean, nil] Encircle attribute
118
+ # @param overline [Boolean, nil] Overline attribute
119
+ # @param link [String, nil] Hyperlink URL
120
+ # @param meta [Hash, nil] Meta information
121
+ def initialize(
122
+ color: nil,
123
+ bgcolor: nil,
124
+ bold: nil,
125
+ dim: nil,
126
+ italic: nil,
127
+ underline: nil,
128
+ blink: nil,
129
+ blink2: nil,
130
+ reverse: nil,
131
+ conceal: nil,
132
+ strike: nil,
133
+ underline2: nil,
134
+ frame: nil,
135
+ encircle: nil,
136
+ overline: nil,
137
+ link: nil,
138
+ meta: nil
139
+ )
140
+ @color = parse_color(color)
141
+ @bgcolor = parse_color(bgcolor)
142
+ @link = link&.freeze
143
+ @meta = meta&.freeze
144
+
145
+ # Build attribute masks
146
+ @set_attributes = 0
147
+ @attributes = 0
148
+
149
+ attrs = {
150
+ bold: bold, dim: dim, italic: italic, underline: underline,
151
+ blink: blink, blink2: blink2, reverse: reverse, conceal: conceal,
152
+ strike: strike, underline2: underline2, frame: frame,
153
+ encircle: encircle, overline: overline
154
+ }
155
+
156
+ attrs.each do |name, value|
157
+ next if value.nil?
158
+
159
+ bit = StyleAttribute::ALL[name]
160
+ @set_attributes |= bit
161
+ @attributes |= bit if value
162
+ end
163
+
164
+ # Per-color-system render cache. freeze is shallow, so this Hash stays
165
+ # mutable even though the Style is frozen (Style is immutable, so the
166
+ # rendered escape for a given color system never changes).
167
+ @render_cache = {}
168
+
169
+ freeze
170
+ end
171
+
172
+ # Check if any attributes, colors, link, or meta are set
173
+ # @return [Boolean]
174
+ def blank?
175
+ @color.nil? && @bgcolor.nil? && @set_attributes == 0 && @link.nil? &&
176
+ (@meta.nil? || @meta.empty?)
177
+ end
178
+
179
+ # @return [Boolean] False if blank
180
+ def present?
181
+ !blank?
182
+ end
183
+
184
+ # Get a specific attribute value
185
+ # @param name [Symbol] Attribute name
186
+ # @return [Boolean, nil] Attribute value or nil if not set
187
+ def [](name)
188
+ bit = StyleAttribute::ALL[name]
189
+ return nil unless bit
190
+
191
+ return nil if (@set_attributes & bit) == 0
192
+
193
+ (@attributes & bit) != 0
194
+ end
195
+
196
+ # Attribute accessor methods
197
+ StyleAttribute::NAMES.each do |attr_name|
198
+ define_method(attr_name) { self[attr_name] }
199
+ define_method("#{attr_name}?") { self[attr_name] == true }
200
+ end
201
+
202
+ # Generate ANSI escape codes for this style
203
+ # @param color_system [Symbol] Target color system
204
+ # @return [String] ANSI escape sequence
205
+ def render(color_system: ColorSystem::TRUECOLOR)
206
+ cache = @render_cache
207
+ return cache[color_system] if cache&.key?(color_system)
208
+
209
+ result = compute_render(color_system)
210
+ cache[color_system] = result if cache
211
+ result
212
+ end
213
+
214
+ # Generate the style definition string
215
+ # @return [String]
216
+ def to_s
217
+ parts = []
218
+
219
+ StyleAttribute::NAMES.each do |name|
220
+ value = self[name]
221
+ next if value.nil?
222
+
223
+ parts << (value ? name.to_s : "not #{name}")
224
+ end
225
+
226
+ parts << @color.name if @color
227
+ parts << "on #{@bgcolor.name}" if @bgcolor
228
+ parts << "link #{@link}" if @link
229
+
230
+ parts.join(" ")
231
+ end
232
+
233
+ def inspect
234
+ attrs = []
235
+ attrs << "color=#{@color.name}" if @color
236
+ attrs << "bgcolor=#{@bgcolor.name}" if @bgcolor
237
+
238
+ StyleAttribute::NAMES.each do |name|
239
+ value = self[name]
240
+ attrs << "#{name}=#{value}" unless value.nil?
241
+ end
242
+
243
+ attrs << "link=#{@link.inspect}" if @link
244
+
245
+ "#<Rich::Style #{attrs.join(' ')}>"
246
+ end
247
+
248
+ # Combine two styles (right-hand style takes precedence)
249
+ # @param other [Style] Style to combine with
250
+ # @return [Style] Combined style
251
+ def +(other)
252
+ return self if other.nil? || other.blank?
253
+ return other if blank?
254
+
255
+ new_color = other.color || @color
256
+ new_bgcolor = other.bgcolor || @bgcolor
257
+ new_link = other.link || @link
258
+ new_meta = @meta || other.meta ? (@meta || {}).merge(other.meta || {}) : nil
259
+
260
+ # Merge attributes
261
+ new_set = @set_attributes | other.set_attributes
262
+ new_attrs = (@attributes & ~other.set_attributes) | (other.attributes & other.set_attributes)
263
+
264
+ Style.combine(
265
+ color: new_color,
266
+ bgcolor: new_bgcolor,
267
+ link: new_link,
268
+ meta: new_meta,
269
+ set_attributes: new_set,
270
+ attributes: new_attrs
271
+ )
272
+ end
273
+
274
+ # Get style with no colors
275
+ # @return [Style]
276
+ def without_color
277
+ Style.combine(
278
+ color: nil,
279
+ bgcolor: nil,
280
+ link: @link,
281
+ meta: @meta,
282
+ set_attributes: @set_attributes,
283
+ attributes: @attributes
284
+ )
285
+ end
286
+
287
+ # Get background-only style
288
+ # @return [Style]
289
+ def background_style
290
+ Style.new(bgcolor: @bgcolor)
291
+ end
292
+
293
+ def ==(other)
294
+ return false unless other.is_a?(Style)
295
+
296
+ @color == other.color &&
297
+ @bgcolor == other.bgcolor &&
298
+ @set_attributes == other.set_attributes &&
299
+ @attributes == other.attributes &&
300
+ @link == other.link
301
+ end
302
+
303
+ alias eql? ==
304
+
305
+ def hash
306
+ [@color, @bgcolor, @set_attributes, @attributes, @link].hash
307
+ end
308
+
309
+ class << self
310
+ # Parse a style definition string
311
+ # @param style [String, Style, nil] Style definition
312
+ # @return [Style]
313
+ def parse(style)
314
+ return null if style.nil? || (style.is_a?(String) && style.empty?)
315
+ return style if style.is_a?(Style)
316
+
317
+ style = style.to_s
318
+
319
+ @parse_cache_mutex.synchronize do
320
+ return @parse_cache[style] if @parse_cache.key?(style)
321
+ end
322
+
323
+ result = parse_uncached(style)
324
+
325
+ @parse_cache_mutex.synchronize do
326
+ @parse_cache[style] = result
327
+ end
328
+
329
+ result
330
+ end
331
+
332
+ # Create a null (empty) style
333
+ # @return [Style]
334
+ def null
335
+ @null ||= new
336
+ end
337
+
338
+ # Create a combined style with explicit attributes (internal use)
339
+ # @param color [Color, nil] Foreground color
340
+ # @param bgcolor [Color, nil] Background color
341
+ # @param link [String, nil] Hyperlink
342
+ # @param meta [Hash, nil] Meta info
343
+ # @param set_attributes [Integer] Set attributes bitmask
344
+ # @param attributes [Integer] Attribute values bitmask
345
+ # @return [Style]
346
+ def combine(color:, bgcolor:, link:, meta:, set_attributes:, attributes:)
347
+ style = allocate
348
+ style.instance_variable_set(:@color, color)
349
+ style.instance_variable_set(:@bgcolor, bgcolor)
350
+ style.instance_variable_set(:@link, link&.freeze)
351
+ style.instance_variable_set(:@meta, meta&.freeze)
352
+ style.instance_variable_set(:@set_attributes, set_attributes)
353
+ style.instance_variable_set(:@attributes, attributes)
354
+ style.instance_variable_set(:@render_cache, {})
355
+ style.freeze
356
+ style
357
+ end
358
+
359
+ # Create a style from just colors
360
+ # @param color [Color, String, nil] Foreground color
361
+ # @param bgcolor [Color, String, nil] Background color
362
+ # @return [Style]
363
+ def from_color(color: nil, bgcolor: nil)
364
+ new(color: color, bgcolor: bgcolor)
365
+ end
366
+
367
+ # Create a style with meta information
368
+ # @param meta [Hash] Meta data
369
+ # @return [Style]
370
+ def from_meta(meta)
371
+ new(meta: meta)
372
+ end
373
+
374
+ # Normalize a style definition
375
+ # @param style [String] Style definition
376
+ # @return [String] Normalized style definition
377
+ def normalize(style)
378
+ parse(style).to_s
379
+ end
380
+
381
+ private
382
+
383
+ def parse_uncached(style_str)
384
+ attrs = {}
385
+ color = nil
386
+ bgcolor = nil
387
+ link = nil
388
+
389
+ # Expand single-letter attribute aliases (e.g. "u" -> "underline")
390
+ # token-by-token so color names like "blue" are never misread.
391
+ style_str = style_str.split(/\s+/).map { |tok| ATTRIBUTE_ALIASES[tok] || tok }.join(" ")
392
+
393
+ style_str.scan(STYLE_REGEX) do
394
+ match = Regexp.last_match
395
+
396
+ if match[:attr]
397
+ attr_name = match[:attr].to_sym
398
+ attrs[attr_name] = match[:not].nil?
399
+ elsif match[:link]
400
+ link = match[:url]
401
+ elsif match[:color]
402
+ color_name = match[:color]
403
+ begin
404
+ parsed_color = Color.parse(color_name)
405
+ if match[:on]
406
+ bgcolor = parsed_color
407
+ else
408
+ color = parsed_color
409
+ end
410
+ rescue ColorParseError
411
+ # Ignore invalid colors
412
+ end
413
+ end
414
+ end
415
+
416
+ new(
417
+ color: color,
418
+ bgcolor: bgcolor,
419
+ link: link,
420
+ **attrs
421
+ )
422
+ end
423
+ end
424
+
425
+ private
426
+
427
+ # Build the ANSI escape sequence for the given color system (uncached).
428
+ def compute_render(color_system)
429
+ codes = []
430
+
431
+ # Add attribute codes
432
+ StyleAttribute::NAMES.each do |name|
433
+ value = self[name]
434
+ next if value.nil?
435
+
436
+ codes << ANSI_CODES[name] if value
437
+ end
438
+
439
+ # Add color codes
440
+ if @color
441
+ target_color = @color.downgrade(color_system)
442
+ codes.concat(target_color.ansi_codes(foreground: true))
443
+ end
444
+
445
+ if @bgcolor
446
+ target_color = @bgcolor.downgrade(color_system)
447
+ codes.concat(target_color.ansi_codes(foreground: false))
448
+ end
449
+
450
+ return "" if codes.empty?
451
+
452
+ "\e[#{codes.join(';')}m"
453
+ end
454
+
455
+ def parse_color(color)
456
+ return nil if color.nil?
457
+ return color if color.is_a?(Color)
458
+
459
+ Color.parse(color)
460
+ rescue ColorParseError
461
+ nil
462
+ end
463
+ end
464
+ end