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.
@@ -1,220 +1,227 @@
1
- # frozen_string_literal: true
2
-
3
- module Rich
4
- # Represents an RGB color triplet with values from 0-255.
5
- # This is an immutable value object used for true color representation.
6
- class ColorTriplet
7
- # @return [Integer] Red component (0-255)
8
- attr_reader :red
9
-
10
- # @return [Integer] Green component (0-255)
11
- attr_reader :green
12
-
13
- # @return [Integer] Blue component (0-255)
14
- attr_reader :blue
15
-
16
- # Create a new color triplet
17
- # @param red [Integer] Red component (0-255)
18
- # @param green [Integer] Green component (0-255)
19
- # @param blue [Integer] Blue component (0-255)
20
- def initialize(red, green, blue)
21
- @red = clamp_component(red)
22
- @green = clamp_component(green)
23
- @blue = clamp_component(blue)
24
- freeze
25
- end
26
-
27
- # @return [String] Hexadecimal representation (e.g., "#ff0000")
28
- def hex
29
- format("%02x%02x%02x", @red, @green, @blue)
30
- end
31
-
32
- # @return [String] RGB string representation (e.g., "rgb(255, 0, 0)")
33
- def rgb
34
- "rgb(#{@red}, #{@green}, #{@blue})"
35
- end
36
-
37
- # @return [Array<Float>] Normalized components (0.0-1.0)
38
- def normalized
39
- [@red / 255.0, @green / 255.0, @blue / 255.0]
40
- end
41
-
42
- # @return [Array<Integer>] Components as array [red, green, blue]
43
- def to_a
44
- [@red, @green, @blue]
45
- end
46
-
47
- # @return [Hash] Components as hash
48
- def to_h
49
- { red: @red, green: @green, blue: @blue }
50
- end
51
-
52
- # Check equality with another triplet
53
- # @param other [ColorTriplet, Object] Object to compare
54
- # @return [Boolean]
55
- def ==(other)
56
- return false unless other.is_a?(ColorTriplet)
57
-
58
- @red == other.red && @green == other.green && @blue == other.blue
59
- end
60
-
61
- alias eql? ==
62
-
63
- # @return [Integer] Hash code for use in hash tables
64
- def hash
65
- [@red, @green, @blue].hash
66
- end
67
-
68
- # @return [String] String representation
69
- def to_s
70
- hex
71
- end
72
-
73
- # @return [String] Inspect representation
74
- def inspect
75
- "#<Rich::ColorTriplet #{hex} (#{@red}, #{@green}, #{@blue})>"
76
- end
77
-
78
- # Deconstruct for pattern matching
79
- # @return [Array<Integer>]
80
- def deconstruct
81
- to_a
82
- end
83
-
84
- # Deconstruct for pattern matching with keys
85
- # @param keys [Array<Symbol>]
86
- # @return [Hash]
87
- def deconstruct_keys(keys)
88
- to_h.slice(*(keys || [:red, :green, :blue]))
89
- end
90
-
91
- # Calculate the perceived luminance of the color
92
- # Uses the formula for relative luminance from WCAG 2.0
93
- # @return [Float] Luminance value (0.0-1.0)
94
- def luminance
95
- r, g, b = normalized.map do |c|
96
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055)**2.4
97
- end
98
- 0.2126 * r + 0.7152 * g + 0.0722 * b
99
- end
100
-
101
- # Check if this is a "dark" color based on luminance
102
- # @return [Boolean]
103
- def dark?
104
- luminance < 0.5
105
- end
106
-
107
- # Check if this is a "light" color based on luminance
108
- # @return [Boolean]
109
- def light?
110
- !dark?
111
- end
112
-
113
- # Blend this color with another
114
- # @param other [ColorTriplet] Color to blend with
115
- # @param factor [Float] Blend factor (0.0 = this color, 1.0 = other color)
116
- # @return [ColorTriplet] Blended color
117
- def blend(other, factor = 0.5)
118
- factor = [[factor, 0.0].max, 1.0].min
119
-
120
- new_r = (@red + (other.red - @red) * factor).round
121
- new_g = (@green + (other.green - @green) * factor).round
122
- new_b = (@blue + (other.blue - @blue) * factor).round
123
-
124
- ColorTriplet.new(new_r, new_g, new_b)
125
- end
126
-
127
- # Calculate color distance (Euclidean in RGB space)
128
- # @param other [ColorTriplet] Color to compare
129
- # @return [Float] Distance value
130
- def distance(other)
131
- dr = @red - other.red
132
- dg = @green - other.green
133
- db = @blue - other.blue
134
- Math.sqrt(dr * dr + dg * dg + db * db)
135
- end
136
-
137
- # Calculate weighted color distance (better perceptual accuracy)
138
- # Uses weighted Euclidean distance based on human color perception
139
- # @param other [ColorTriplet] Color to compare
140
- # @return [Float] Weighted distance value
141
- def weighted_distance(other)
142
- dr = @red - other.red
143
- dg = @green - other.green
144
- db = @blue - other.blue
145
-
146
- # Weighted by perceptual importance (red-green component is most important)
147
- r_mean = (@red + other.red) / 2.0
148
- weight_r = 2.0 + r_mean / 256.0
149
- weight_g = 4.0
150
- weight_b = 2.0 + (255.0 - r_mean) / 256.0
151
-
152
- Math.sqrt(weight_r * dr * dr + weight_g * dg * dg + weight_b * db * db)
153
- end
154
-
155
- class << self
156
- # Create from hex string
157
- # @param hex_str [String] Hex color string (e.g., "#ff0000" or "ff0000")
158
- # @return [ColorTriplet]
159
- def from_hex(hex_str)
160
- hex_str = hex_str.delete_prefix("#")
161
- raise ArgumentError, "Invalid hex color: #{hex_str}" unless hex_str.match?(/\A[0-9a-fA-F]{6}\z/)
162
-
163
- r = hex_str[0, 2].to_i(16)
164
- g = hex_str[2, 2].to_i(16)
165
- b = hex_str[4, 2].to_i(16)
166
-
167
- new(r, g, b)
168
- end
169
-
170
- # Create from normalized values (0.0-1.0)
171
- # @param r [Float] Red component (0.0-1.0)
172
- # @param g [Float] Green component (0.0-1.0)
173
- # @param b [Float] Blue component (0.0-1.0)
174
- # @return [ColorTriplet]
175
- def from_normalized(r, g, b)
176
- new(
177
- (r * 255).round,
178
- (g * 255).round,
179
- (b * 255).round
180
- )
181
- end
182
-
183
- # Create from HSL values
184
- # @param h [Float] Hue (0-360)
185
- # @param s [Float] Saturation (0-100)
186
- # @param l [Float] Lightness (0-100)
187
- # @return [ColorTriplet]
188
- def from_hsl(h, s, l)
189
- h = h % 360
190
- s = s / 100.0
191
- l = l / 100.0
192
-
193
- c = (1 - (2 * l - 1).abs) * s
194
- x = c * (1 - ((h / 60.0) % 2 - 1).abs)
195
- m = l - c / 2.0
196
-
197
- r, g, b = case (h / 60).floor
198
- when 0 then [c, x, 0]
199
- when 1 then [x, c, 0]
200
- when 2 then [0, c, x]
201
- when 3 then [0, x, c]
202
- when 4 then [x, 0, c]
203
- else [c, 0, x]
204
- end
205
-
206
- new(
207
- ((r + m) * 255).round,
208
- ((g + m) * 255).round,
209
- ((b + m) * 255).round
210
- )
211
- end
212
- end
213
-
214
- private
215
-
216
- def clamp_component(value)
217
- [[value.to_i, 0].max, 255].min
218
- end
219
- end
220
- end
1
+ # frozen_string_literal: true
2
+
3
+ module Rich
4
+ # Represents an RGB color triplet with values from 0-255.
5
+ # This is an immutable value object used for true color representation.
6
+ class ColorTriplet
7
+ # @return [Integer] Red component (0-255)
8
+ attr_reader :red
9
+
10
+ # @return [Integer] Green component (0-255)
11
+ attr_reader :green
12
+
13
+ # @return [Integer] Blue component (0-255)
14
+ attr_reader :blue
15
+
16
+ # Create a new color triplet
17
+ # @param red [Integer] Red component (0-255)
18
+ # @param green [Integer] Green component (0-255)
19
+ # @param blue [Integer] Blue component (0-255)
20
+ def initialize(red, green, blue)
21
+ @red = clamp_component(red)
22
+ @green = clamp_component(green)
23
+ @blue = clamp_component(blue)
24
+ freeze
25
+ end
26
+
27
+ # @return [String] Hexadecimal representation without a leading '#'
28
+ # (e.g., "ff0000"). Use {#hex_with_hash} for the "#ff0000" form.
29
+ def hex
30
+ format("%02x%02x%02x", @red, @green, @blue)
31
+ end
32
+
33
+ # @return [String] Hexadecimal representation with a leading '#'
34
+ # (e.g., "#ff0000")
35
+ def hex_with_hash
36
+ "##{hex}"
37
+ end
38
+
39
+ # @return [String] RGB string representation (e.g., "rgb(255, 0, 0)")
40
+ def rgb
41
+ "rgb(#{@red}, #{@green}, #{@blue})"
42
+ end
43
+
44
+ # @return [Array<Float>] Normalized components (0.0-1.0)
45
+ def normalized
46
+ [@red / 255.0, @green / 255.0, @blue / 255.0]
47
+ end
48
+
49
+ # @return [Array<Integer>] Components as array [red, green, blue]
50
+ def to_a
51
+ [@red, @green, @blue]
52
+ end
53
+
54
+ # @return [Hash] Components as hash
55
+ def to_h
56
+ { red: @red, green: @green, blue: @blue }
57
+ end
58
+
59
+ # Check equality with another triplet
60
+ # @param other [ColorTriplet, Object] Object to compare
61
+ # @return [Boolean]
62
+ def ==(other)
63
+ return false unless other.is_a?(ColorTriplet)
64
+
65
+ @red == other.red && @green == other.green && @blue == other.blue
66
+ end
67
+
68
+ alias eql? ==
69
+
70
+ # @return [Integer] Hash code for use in hash tables
71
+ def hash
72
+ [@red, @green, @blue].hash
73
+ end
74
+
75
+ # @return [String] String representation
76
+ def to_s
77
+ hex
78
+ end
79
+
80
+ # @return [String] Inspect representation
81
+ def inspect
82
+ "#<Rich::ColorTriplet #{hex} (#{@red}, #{@green}, #{@blue})>"
83
+ end
84
+
85
+ # Deconstruct for pattern matching
86
+ # @return [Array<Integer>]
87
+ def deconstruct
88
+ to_a
89
+ end
90
+
91
+ # Deconstruct for pattern matching with keys
92
+ # @param keys [Array<Symbol>]
93
+ # @return [Hash]
94
+ def deconstruct_keys(keys)
95
+ to_h.slice(*(keys || [:red, :green, :blue]))
96
+ end
97
+
98
+ # Calculate the perceived luminance of the color
99
+ # Uses the formula for relative luminance from WCAG 2.0
100
+ # @return [Float] Luminance value (0.0-1.0)
101
+ def luminance
102
+ r, g, b = normalized.map do |c|
103
+ c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055)**2.4
104
+ end
105
+ 0.2126 * r + 0.7152 * g + 0.0722 * b
106
+ end
107
+
108
+ # Check if this is a "dark" color based on luminance
109
+ # @return [Boolean]
110
+ def dark?
111
+ luminance < 0.5
112
+ end
113
+
114
+ # Check if this is a "light" color based on luminance
115
+ # @return [Boolean]
116
+ def light?
117
+ !dark?
118
+ end
119
+
120
+ # Blend this color with another
121
+ # @param other [ColorTriplet] Color to blend with
122
+ # @param factor [Float] Blend factor (0.0 = this color, 1.0 = other color)
123
+ # @return [ColorTriplet] Blended color
124
+ def blend(other, factor = 0.5)
125
+ factor = [[factor, 0.0].max, 1.0].min
126
+
127
+ new_r = (@red + (other.red - @red) * factor).round
128
+ new_g = (@green + (other.green - @green) * factor).round
129
+ new_b = (@blue + (other.blue - @blue) * factor).round
130
+
131
+ ColorTriplet.new(new_r, new_g, new_b)
132
+ end
133
+
134
+ # Calculate color distance (Euclidean in RGB space)
135
+ # @param other [ColorTriplet] Color to compare
136
+ # @return [Float] Distance value
137
+ def distance(other)
138
+ dr = @red - other.red
139
+ dg = @green - other.green
140
+ db = @blue - other.blue
141
+ Math.sqrt(dr * dr + dg * dg + db * db)
142
+ end
143
+
144
+ # Calculate weighted color distance (better perceptual accuracy)
145
+ # Uses weighted Euclidean distance based on human color perception
146
+ # @param other [ColorTriplet] Color to compare
147
+ # @return [Float] Weighted distance value
148
+ def weighted_distance(other)
149
+ dr = @red - other.red
150
+ dg = @green - other.green
151
+ db = @blue - other.blue
152
+
153
+ # Weighted by perceptual importance (red-green component is most important)
154
+ r_mean = (@red + other.red) / 2.0
155
+ weight_r = 2.0 + r_mean / 256.0
156
+ weight_g = 4.0
157
+ weight_b = 2.0 + (255.0 - r_mean) / 256.0
158
+
159
+ Math.sqrt(weight_r * dr * dr + weight_g * dg * dg + weight_b * db * db)
160
+ end
161
+
162
+ class << self
163
+ # Create from hex string
164
+ # @param hex_str [String] Hex color string (e.g., "#ff0000" or "ff0000")
165
+ # @return [ColorTriplet]
166
+ def from_hex(hex_str)
167
+ hex_str = hex_str.delete_prefix("#")
168
+ raise ArgumentError, "Invalid hex color: #{hex_str}" unless hex_str.match?(/\A[0-9a-fA-F]{6}\z/)
169
+
170
+ r = hex_str[0, 2].to_i(16)
171
+ g = hex_str[2, 2].to_i(16)
172
+ b = hex_str[4, 2].to_i(16)
173
+
174
+ new(r, g, b)
175
+ end
176
+
177
+ # Create from normalized values (0.0-1.0)
178
+ # @param r [Float] Red component (0.0-1.0)
179
+ # @param g [Float] Green component (0.0-1.0)
180
+ # @param b [Float] Blue component (0.0-1.0)
181
+ # @return [ColorTriplet]
182
+ def from_normalized(r, g, b)
183
+ new(
184
+ (r * 255).round,
185
+ (g * 255).round,
186
+ (b * 255).round
187
+ )
188
+ end
189
+
190
+ # Create from HSL values
191
+ # @param h [Float] Hue (0-360)
192
+ # @param s [Float] Saturation (0-100)
193
+ # @param l [Float] Lightness (0-100)
194
+ # @return [ColorTriplet]
195
+ def from_hsl(h, s, l)
196
+ h = h % 360
197
+ s = s / 100.0
198
+ l = l / 100.0
199
+
200
+ c = (1 - (2 * l - 1).abs) * s
201
+ x = c * (1 - ((h / 60.0) % 2 - 1).abs)
202
+ m = l - c / 2.0
203
+
204
+ r, g, b = case (h / 60).floor
205
+ when 0 then [c, x, 0]
206
+ when 1 then [x, c, 0]
207
+ when 2 then [0, c, x]
208
+ when 3 then [0, x, c]
209
+ when 4 then [x, 0, c]
210
+ else [c, 0, x]
211
+ end
212
+
213
+ new(
214
+ ((r + m) * 255).round,
215
+ ((g + m) * 255).round,
216
+ ((b + m) * 255).round
217
+ )
218
+ end
219
+ end
220
+
221
+ private
222
+
223
+ def clamp_component(value)
224
+ [[value.to_i, 0].max, 255].min
225
+ end
226
+ end
227
+ end