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/color.rb ADDED
@@ -0,0 +1,628 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "color_triplet"
4
+ require_relative "_palettes"
5
+
6
+ module Rich
7
+ # Color systems supported by terminals
8
+ module ColorSystem
9
+ STANDARD = :standard # 16 colors (4-bit)
10
+ EIGHT_BIT = :eight_bit # 256 colors (8-bit)
11
+ TRUECOLOR = :truecolor # 16 million colors (24-bit)
12
+ WINDOWS = :windows # Windows Console legacy colors
13
+
14
+ ALL = [STANDARD, EIGHT_BIT, TRUECOLOR, WINDOWS].freeze
15
+
16
+ class << self
17
+ # @param system [Symbol] Color system
18
+ # @return [Boolean] Whether the color system is valid
19
+ def valid?(system)
20
+ ALL.include?(system)
21
+ end
22
+ end
23
+ end
24
+
25
+ # Types of color values
26
+ module ColorType
27
+ DEFAULT = :default # Terminal default color
28
+ STANDARD = :standard # 16 ANSI colors (0-15)
29
+ EIGHT_BIT = :eight_bit # 256 colors (0-255)
30
+ TRUECOLOR = :truecolor # RGB color
31
+ WINDOWS = :windows # Windows console color
32
+
33
+ ALL = [DEFAULT, STANDARD, EIGHT_BIT, TRUECOLOR, WINDOWS].freeze
34
+ end
35
+
36
+ # ANSI color name to number mapping
37
+ ANSI_COLOR_NAMES = {
38
+ "black" => 0,
39
+ "red" => 1,
40
+ "green" => 2,
41
+ "yellow" => 3,
42
+ "blue" => 4,
43
+ "magenta" => 5,
44
+ "cyan" => 6,
45
+ "white" => 7,
46
+ "bright_black" => 8,
47
+ "bright_red" => 9,
48
+ "bright_green" => 10,
49
+ "bright_yellow" => 11,
50
+ "bright_blue" => 12,
51
+ "bright_magenta" => 13,
52
+ "bright_cyan" => 14,
53
+ "bright_white" => 15,
54
+ "grey0" => 16,
55
+ "gray0" => 16,
56
+ "navy_blue" => 17,
57
+ "dark_blue" => 18,
58
+ "blue3" => 20,
59
+ "blue1" => 21,
60
+ "dark_green" => 22,
61
+ "deep_sky_blue4" => 25,
62
+ "dodger_blue3" => 26,
63
+ "dodger_blue2" => 27,
64
+ "green4" => 28,
65
+ "spring_green4" => 29,
66
+ "turquoise4" => 30,
67
+ "deep_sky_blue3" => 32,
68
+ "dodger_blue1" => 33,
69
+ "green3" => 40,
70
+ "spring_green3" => 41,
71
+ "dark_cyan" => 36,
72
+ "light_sea_green" => 37,
73
+ "deep_sky_blue2" => 38,
74
+ "deep_sky_blue1" => 39,
75
+ "spring_green2" => 47,
76
+ "cyan3" => 43,
77
+ "dark_turquoise" => 44,
78
+ "turquoise2" => 45,
79
+ "green1" => 46,
80
+ "spring_green1" => 48,
81
+ "medium_spring_green" => 49,
82
+ "cyan2" => 50,
83
+ "cyan1" => 51,
84
+ "dark_red" => 88,
85
+ "deep_pink4" => 125,
86
+ "purple4" => 55,
87
+ "purple3" => 56,
88
+ "blue_violet" => 57,
89
+ "orange4" => 94,
90
+ "grey37" => 59,
91
+ "gray37" => 59,
92
+ "medium_purple4" => 60,
93
+ "slate_blue3" => 62,
94
+ "royal_blue1" => 63,
95
+ "chartreuse4" => 64,
96
+ "dark_sea_green4" => 71,
97
+ "pale_turquoise4" => 66,
98
+ "steel_blue" => 67,
99
+ "steel_blue3" => 68,
100
+ "cornflower_blue" => 69,
101
+ "chartreuse3" => 76,
102
+ "cadet_blue" => 73,
103
+ "sky_blue3" => 74,
104
+ "steel_blue1" => 81,
105
+ "pale_green3" => 114,
106
+ "sea_green3" => 78,
107
+ "aquamarine3" => 79,
108
+ "medium_turquoise" => 80,
109
+ "chartreuse2" => 112,
110
+ "sea_green2" => 83,
111
+ "sea_green1" => 85,
112
+ "aquamarine1" => 122,
113
+ "dark_slate_gray2" => 87,
114
+ "dark_magenta" => 91,
115
+ "dark_violet" => 128,
116
+ "purple" => 129,
117
+ "light_pink4" => 95,
118
+ "plum4" => 96,
119
+ "medium_purple3" => 98,
120
+ "slate_blue1" => 99,
121
+ "yellow4" => 106,
122
+ "wheat4" => 101,
123
+ "grey53" => 102,
124
+ "gray53" => 102,
125
+ "light_slate_grey" => 103,
126
+ "light_slate_gray" => 103,
127
+ "medium_purple" => 104,
128
+ "light_slate_blue" => 105,
129
+ "dark_olive_green3" => 149,
130
+ "dark_sea_green" => 108,
131
+ "light_sky_blue3" => 110,
132
+ "sky_blue2" => 111,
133
+ "dark_sea_green3" => 150,
134
+ "dark_slate_gray3" => 116,
135
+ "sky_blue1" => 117,
136
+ "chartreuse1" => 118,
137
+ "light_green" => 120,
138
+ "pale_green1" => 156,
139
+ "dark_slate_gray1" => 123,
140
+ "red3" => 160,
141
+ "medium_violet_red" => 126,
142
+ "magenta3" => 164,
143
+ "dark_orange3" => 166,
144
+ "indian_red" => 167,
145
+ "hot_pink3" => 168,
146
+ "medium_orchid3" => 133,
147
+ "medium_orchid" => 134,
148
+ "medium_purple2" => 140,
149
+ "dark_goldenrod" => 136,
150
+ "light_salmon3" => 173,
151
+ "rosy_brown" => 138,
152
+ "grey63" => 139,
153
+ "gray63" => 139,
154
+ "medium_purple1" => 141,
155
+ "gold3" => 178,
156
+ "dark_khaki" => 143,
157
+ "navajo_white3" => 144,
158
+ "grey69" => 145,
159
+ "gray69" => 145,
160
+ "light_steel_blue3" => 146,
161
+ "light_steel_blue" => 147,
162
+ "yellow3" => 184,
163
+ "dark_sea_green2" => 157,
164
+ "light_cyan3" => 152,
165
+ "light_sky_blue1" => 153,
166
+ "green_yellow" => 154,
167
+ "dark_olive_green2" => 155,
168
+ "dark_sea_green1" => 193,
169
+ "pale_turquoise1" => 159,
170
+ "deep_pink3" => 162,
171
+ "magenta2" => 200,
172
+ "hot_pink2" => 169,
173
+ "orchid" => 170,
174
+ "medium_orchid1" => 207,
175
+ "orange3" => 172,
176
+ "light_pink3" => 174,
177
+ "pink3" => 175,
178
+ "plum3" => 176,
179
+ "violet" => 177,
180
+ "light_goldenrod3" => 179,
181
+ "tan" => 180,
182
+ "misty_rose3" => 181,
183
+ "thistle3" => 182,
184
+ "plum2" => 183,
185
+ "khaki3" => 185,
186
+ "light_goldenrod2" => 222,
187
+ "light_yellow3" => 187,
188
+ "grey84" => 188,
189
+ "gray84" => 188,
190
+ "light_steel_blue1" => 189,
191
+ "yellow2" => 190,
192
+ "dark_olive_green1" => 192,
193
+ "honeydew2" => 194,
194
+ "light_cyan1" => 195,
195
+ "red1" => 196,
196
+ "deep_pink2" => 197,
197
+ "deep_pink1" => 199,
198
+ "magenta1" => 201,
199
+ "orange_red1" => 202,
200
+ "indian_red1" => 204,
201
+ "hot_pink" => 206,
202
+ "dark_orange" => 208,
203
+ "salmon1" => 209,
204
+ "light_coral" => 210,
205
+ "pale_violet_red1" => 211,
206
+ "orchid2" => 212,
207
+ "orchid1" => 213,
208
+ "orange1" => 214,
209
+ "sandy_brown" => 215,
210
+ "light_salmon1" => 216,
211
+ "light_pink1" => 217,
212
+ "pink1" => 218,
213
+ "plum1" => 219,
214
+ "gold1" => 220,
215
+ "navajo_white1" => 223,
216
+ "misty_rose1" => 224,
217
+ "thistle1" => 225,
218
+ "yellow1" => 226,
219
+ "light_goldenrod1" => 227,
220
+ "khaki1" => 228,
221
+ "wheat1" => 229,
222
+ "cornsilk1" => 230,
223
+ "grey100" => 231,
224
+ "gray100" => 231,
225
+ "grey3" => 232,
226
+ "gray3" => 232,
227
+ "grey7" => 233,
228
+ "gray7" => 233,
229
+ "grey11" => 234,
230
+ "gray11" => 234,
231
+ "grey15" => 235,
232
+ "gray15" => 235,
233
+ "grey19" => 236,
234
+ "gray19" => 236,
235
+ "grey23" => 237,
236
+ "gray23" => 237,
237
+ "grey27" => 238,
238
+ "gray27" => 238,
239
+ "grey30" => 239,
240
+ "gray30" => 239,
241
+ "grey35" => 240,
242
+ "gray35" => 240,
243
+ "grey39" => 241,
244
+ "gray39" => 241,
245
+ "grey42" => 242,
246
+ "gray42" => 242,
247
+ "grey46" => 243,
248
+ "gray46" => 243,
249
+ "grey50" => 244,
250
+ "gray50" => 244,
251
+ "grey54" => 245,
252
+ "gray54" => 245,
253
+ "grey58" => 246,
254
+ "gray58" => 246,
255
+ "grey62" => 247,
256
+ "gray62" => 247,
257
+ "grey66" => 248,
258
+ "gray66" => 248,
259
+ "grey70" => 249,
260
+ "gray70" => 249,
261
+ "grey74" => 250,
262
+ "gray74" => 250,
263
+ "grey78" => 251,
264
+ "gray78" => 251,
265
+ "grey82" => 252,
266
+ "gray82" => 252,
267
+ "grey85" => 253,
268
+ "gray85" => 253,
269
+ "grey89" => 254,
270
+ "gray89" => 254,
271
+ "grey93" => 255,
272
+ "gray93" => 255
273
+ }.freeze
274
+
275
+ # Reverse mapping from number to canonical name
276
+ COLOR_NUMBER_TO_NAME = ANSI_COLOR_NAMES.each_with_object({}) do |(name, number), hash|
277
+ hash[number] ||= name
278
+ end.freeze
279
+
280
+ # Error raised when a color definition cannot be parsed
281
+ class ColorParseError < StandardError
282
+ end
283
+
284
+ # Represents a terminal color.
285
+ # Supports default colors, standard ANSI colors (0-15), 8-bit colors (0-255),
286
+ # and true color (24-bit RGB).
287
+ class Color
288
+ # Regex for parsing color definitions
289
+ COLOR_REGEX = /\A
290
+ (?:\#(?<hex>[0-9a-fA-F]{6}))|
291
+ (?:color\((?<color8>\d{1,3})\))|
292
+ (?:rgb\((?<rgb>[\d\s,]+)\))
293
+ \z/x
294
+
295
+ # @return [String] Original color name or definition
296
+ attr_reader :name
297
+
298
+ # @return [Symbol] Color type (see ColorType)
299
+ attr_reader :type
300
+
301
+ # @return [Integer, nil] Color number for standard/8-bit colors
302
+ attr_reader :number
303
+
304
+ # @return [ColorTriplet, nil] RGB triplet for truecolor
305
+ attr_reader :triplet
306
+
307
+ # Cache for parsed colors
308
+ @parse_cache = {}
309
+ @parse_cache_mutex = Mutex.new
310
+
311
+ # Cache for downgraded colors
312
+ @downgrade_cache = {}
313
+ @downgrade_cache_mutex = Mutex.new
314
+
315
+ # Create a new color
316
+ # @param name [String] Color name or definition
317
+ # @param type [Symbol] Color type (see ColorType)
318
+ # @param number [Integer, nil] Color number
319
+ # @param triplet [ColorTriplet, nil] RGB triplet
320
+ def initialize(name, type:, number: nil, triplet: nil)
321
+ @name = name.freeze
322
+ @type = type
323
+ @number = number
324
+ @triplet = triplet
325
+ freeze
326
+ end
327
+
328
+ # @return [Symbol] Native color system for this color
329
+ def system
330
+ case @type
331
+ when ColorType::DEFAULT
332
+ ColorSystem::STANDARD
333
+ when ColorType::STANDARD
334
+ ColorSystem::STANDARD
335
+ when ColorType::EIGHT_BIT
336
+ ColorSystem::EIGHT_BIT
337
+ when ColorType::TRUECOLOR
338
+ ColorSystem::TRUECOLOR
339
+ when ColorType::WINDOWS
340
+ ColorSystem::WINDOWS
341
+ end
342
+ end
343
+
344
+ # @return [Boolean] True if color is system-defined (may vary by terminal)
345
+ def system_defined?
346
+ [ColorType::DEFAULT, ColorType::STANDARD].include?(@type)
347
+ end
348
+
349
+ # @return [Boolean] True if this is the default color
350
+ def default?
351
+ @type == ColorType::DEFAULT
352
+ end
353
+
354
+ # Get the RGB triplet for this color
355
+ # @param theme [TerminalTheme, nil] Terminal theme for system colors
356
+ # @param foreground [Boolean] True for foreground, false for background
357
+ # @return [ColorTriplet] RGB color value
358
+ def get_truecolor(theme: nil, foreground: true)
359
+ case @type
360
+ when ColorType::TRUECOLOR
361
+ @triplet
362
+ when ColorType::EIGHT_BIT
363
+ Palettes.get_eight_bit(@number)
364
+ when ColorType::STANDARD
365
+ if theme
366
+ theme.ansi_colors[@number]
367
+ else
368
+ Palettes.get_standard(@number)
369
+ end
370
+ when ColorType::WINDOWS
371
+ Palettes.get_windows(@number)
372
+ when ColorType::DEFAULT
373
+ if theme
374
+ foreground ? theme.foreground : theme.background
375
+ else
376
+ foreground ? ColorTriplet.new(255, 255, 255) : ColorTriplet.new(0, 0, 0)
377
+ end
378
+ end
379
+ end
380
+
381
+ # Get ANSI escape codes for this color
382
+ # @param foreground [Boolean] True for foreground, false for background
383
+ # @return [Array<String>] ANSI code components
384
+ def ansi_codes(foreground: true)
385
+ case @type
386
+ when ColorType::DEFAULT
387
+ [foreground ? "39" : "49"]
388
+ when ColorType::STANDARD
389
+ base = foreground ? 30 : 40
390
+ offset = @number < 8 ? @number : (@number - 8 + 60)
391
+ [(base + (@number < 8 ? @number : @number - 8 + 60)).to_s]
392
+ when ColorType::EIGHT_BIT
393
+ [foreground ? "38" : "48", "5", @number.to_s]
394
+ when ColorType::TRUECOLOR
395
+ [foreground ? "38" : "48", "2", @triplet.red.to_s, @triplet.green.to_s, @triplet.blue.to_s]
396
+ when ColorType::WINDOWS
397
+ base = @number < 8 ? (foreground ? 30 : 40) : (foreground ? 90 : 100)
398
+ [(base + @number % 8).to_s]
399
+ else
400
+ []
401
+ end
402
+ end
403
+
404
+ # Downgrade color to a simpler color system
405
+ # @param target_system [Symbol] Target color system
406
+ # @return [Color] Downgraded color (may be self if no downgrade needed)
407
+ def downgrade(target_system)
408
+ return self if @type == ColorType::DEFAULT
409
+ return self if target_system == system
410
+
411
+ cache_key = [@type, @number, @triplet&.to_a, target_system]
412
+
413
+ self.class.instance_variable_get(:@downgrade_cache_mutex).synchronize do
414
+ cache = self.class.instance_variable_get(:@downgrade_cache)
415
+ return cache[cache_key] if cache.key?(cache_key)
416
+
417
+ result = compute_downgrade(target_system)
418
+ cache[cache_key] = result
419
+ result
420
+ end
421
+ end
422
+
423
+ # Check equality with another color
424
+ def ==(other)
425
+ return false unless other.is_a?(Color)
426
+
427
+ @type == other.type && @number == other.number && @triplet == other.triplet
428
+ end
429
+
430
+ alias eql? ==
431
+
432
+ def hash
433
+ [@type, @number, @triplet].hash
434
+ end
435
+
436
+ def to_s
437
+ @name
438
+ end
439
+
440
+ def inspect
441
+ case @type
442
+ when ColorType::DEFAULT
443
+ "#<Rich::Color default>"
444
+ when ColorType::TRUECOLOR
445
+ "#<Rich::Color #{@name} (#{@triplet.hex})>"
446
+ else
447
+ "#<Rich::Color #{@name} (#{@type}:#{@number})>"
448
+ end
449
+ end
450
+
451
+ class << self
452
+ # Parse a color definition string
453
+ # @param color [String] Color definition
454
+ # @return [Color] Parsed color
455
+ # @raise [ColorParseError] If color cannot be parsed
456
+ def parse(color)
457
+ return color if color.is_a?(Color)
458
+
459
+ @parse_cache_mutex.synchronize do
460
+ return @parse_cache[color] if @parse_cache.key?(color)
461
+ end
462
+
463
+ result = parse_uncached(color)
464
+
465
+ @parse_cache_mutex.synchronize do
466
+ @parse_cache[color] = result
467
+ end
468
+
469
+ result
470
+ end
471
+
472
+ # Create a default color
473
+ # @return [Color]
474
+ def default
475
+ @default ||= new("default", type: ColorType::DEFAULT)
476
+ end
477
+
478
+ # Create a color from ANSI number
479
+ # @param number [Integer] Color number (0-255)
480
+ # @return [Color]
481
+ def from_ansi(number)
482
+ number = number.clamp(0, 255)
483
+ type = number < 16 ? ColorType::STANDARD : ColorType::EIGHT_BIT
484
+ name = COLOR_NUMBER_TO_NAME[number] || "color(#{number})"
485
+ new(name, type: type, number: number)
486
+ end
487
+
488
+ # Create a color from RGB triplet
489
+ # @param triplet [ColorTriplet] RGB triplet
490
+ # @return [Color]
491
+ def from_triplet(triplet)
492
+ new(triplet.hex, type: ColorType::TRUECOLOR, triplet: triplet)
493
+ end
494
+
495
+ # Create a color from RGB values
496
+ # @param red [Integer] Red (0-255)
497
+ # @param green [Integer] Green (0-255)
498
+ # @param blue [Integer] Blue (0-255)
499
+ # @return [Color]
500
+ def from_rgb(red, green, blue)
501
+ from_triplet(ColorTriplet.new(red, green, blue))
502
+ end
503
+
504
+ private
505
+
506
+ def parse_uncached(color)
507
+ original = color
508
+ color = color.to_s.strip.downcase
509
+
510
+ return default if color == "default"
511
+
512
+ # Check named colors
513
+ if ANSI_COLOR_NAMES.key?(color)
514
+ number = ANSI_COLOR_NAMES[color]
515
+ type = number < 16 ? ColorType::STANDARD : ColorType::EIGHT_BIT
516
+ return new(color, type: type, number: number)
517
+ end
518
+
519
+ # Try regex patterns
520
+ match = COLOR_REGEX.match(color)
521
+ raise ColorParseError, "'#{original}' is not a valid color" unless match
522
+
523
+ if match[:hex]
524
+ triplet = ColorTriplet.from_hex(match[:hex])
525
+ return new(color, type: ColorType::TRUECOLOR, triplet: triplet)
526
+ end
527
+
528
+ if match[:color8]
529
+ number = match[:color8].to_i
530
+ raise ColorParseError, "Color number must be <= 255 in '#{original}'" if number > 255
531
+
532
+ type = number < 16 ? ColorType::STANDARD : ColorType::EIGHT_BIT
533
+ return new(color, type: type, number: number)
534
+ end
535
+
536
+ if match[:rgb]
537
+ parts = match[:rgb].split(",").map(&:strip).map(&:to_i)
538
+ raise ColorParseError, "Expected 3 RGB components in '#{original}'" unless parts.length == 3
539
+ raise ColorParseError, "Color components must be <= 255 in '#{original}'" if parts.any? { |p| p > 255 }
540
+
541
+ triplet = ColorTriplet.new(*parts)
542
+ return new(color, type: ColorType::TRUECOLOR, triplet: triplet)
543
+ end
544
+
545
+ raise ColorParseError, "'#{original}' is not a valid color"
546
+ end
547
+ end
548
+
549
+ private
550
+
551
+ def compute_downgrade(target_system)
552
+ triplet = get_truecolor
553
+
554
+ case target_system
555
+ when ColorSystem::EIGHT_BIT
556
+ return self if @type == ColorType::EIGHT_BIT
557
+
558
+ # Convert to grayscale if low saturation
559
+ r, g, b = triplet.normalized
560
+ _h, l, s = rgb_to_hls(r, g, b)
561
+
562
+ if s < 0.15
563
+ gray = (l * 25.0).round
564
+ color_number = if gray == 0
565
+ 16
566
+ elsif gray == 25
567
+ 231
568
+ else
569
+ 231 + gray
570
+ end
571
+ return Color.new(@name, type: ColorType::EIGHT_BIT, number: color_number)
572
+ end
573
+
574
+ # Map to 6x6x6 color cube
575
+ six_red = triplet.red < 95 ? triplet.red / 95.0 : 1 + (triplet.red - 95) / 40.0
576
+ six_green = triplet.green < 95 ? triplet.green / 95.0 : 1 + (triplet.green - 95) / 40.0
577
+ six_blue = triplet.blue < 95 ? triplet.blue / 95.0 : 1 + (triplet.blue - 95) / 40.0
578
+
579
+ color_number = 16 + 36 * six_red.round + 6 * six_green.round + six_blue.round
580
+ Color.new(@name, type: ColorType::EIGHT_BIT, number: color_number.to_i)
581
+
582
+ when ColorSystem::STANDARD
583
+ number = Palettes.match_standard(triplet)
584
+ Color.new(@name, type: ColorType::STANDARD, number: number)
585
+
586
+ when ColorSystem::WINDOWS
587
+ if @type == ColorType::EIGHT_BIT && @number < 16
588
+ return Color.new(@name, type: ColorType::WINDOWS, number: @number)
589
+ end
590
+
591
+ number = Palettes.match_windows(triplet)
592
+ Color.new(@name, type: ColorType::WINDOWS, number: number)
593
+
594
+ else
595
+ self
596
+ end
597
+ end
598
+
599
+ def rgb_to_hls(r, g, b)
600
+ max_c = [r, g, b].max
601
+ min_c = [r, g, b].min
602
+ l = (max_c + min_c) / 2.0
603
+
604
+ return [0.0, l, 0.0] if max_c == min_c
605
+
606
+ s = if l <= 0.5
607
+ (max_c - min_c) / (max_c + min_c)
608
+ else
609
+ (max_c - min_c) / (2.0 - max_c - min_c)
610
+ end
611
+
612
+ rc = (max_c - r) / (max_c - min_c)
613
+ gc = (max_c - g) / (max_c - min_c)
614
+ bc = (max_c - b) / (max_c - min_c)
615
+
616
+ h = if r == max_c
617
+ bc - gc
618
+ elsif g == max_c
619
+ 2.0 + rc - bc
620
+ else
621
+ 4.0 + gc - rc
622
+ end
623
+
624
+ h = (h / 6.0) % 1.0
625
+ [h, l, s]
626
+ end
627
+ end
628
+ end