rich-ruby 1.0.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.
data/lib/rich/style.rb ADDED
@@ -0,0 +1,433 @@
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