sai 0.1.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.
- checksums.yaml +7 -0
- data/.yardopts +11 -0
- data/CHANGELOG.md +18 -0
- data/LICENSE +21 -0
- data/README.md +275 -0
- data/lib/sai/ansi.rb +94 -0
- data/lib/sai/conversion/color_sequence.rb +167 -0
- data/lib/sai/conversion/rgb.rb +322 -0
- data/lib/sai/decorator.rb +856 -0
- data/lib/sai/support.rb +115 -0
- data/lib/sai/terminal/capabilities.rb +121 -0
- data/lib/sai/terminal/color_mode.rb +63 -0
- data/lib/sai.rb +185 -0
- data/sig/sai/ansi.rbs +51 -0
- data/sig/sai/conversion/color_sequence.rbs +114 -0
- data/sig/sai/conversion/rgb.rbs +243 -0
- data/sig/sai/decorator.rbs +246 -0
- data/sig/sai/support.rbs +108 -0
- data/sig/sai/terminal/capabilities.rbs +81 -0
- data/sig/sai/terminal/color_mode.rbs +63 -0
- data/sig/sai.rbs +207 -0
- metadata +67 -0
@@ -0,0 +1,322 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'sai/ansi'
|
4
|
+
|
5
|
+
module Sai
|
6
|
+
module Conversion
|
7
|
+
# RGB color conversion utilities
|
8
|
+
#
|
9
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
10
|
+
# @since unreleased
|
11
|
+
#
|
12
|
+
# @api private
|
13
|
+
module RGB
|
14
|
+
class << self
|
15
|
+
# Get closest ANSI color for RGB values
|
16
|
+
#
|
17
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
18
|
+
# @since unreleased
|
19
|
+
#
|
20
|
+
# @api private
|
21
|
+
#
|
22
|
+
# @param red [Float] the red component (0-1)
|
23
|
+
# @param green [Float] the green component (0-1)
|
24
|
+
# @param blue [Float] the blue component (0-1)
|
25
|
+
#
|
26
|
+
# @return [Symbol] the closest ANSI color name
|
27
|
+
# @rbs (Float red, Float green, Float blue) -> Symbol
|
28
|
+
def closest_ansi_color(red, green, blue)
|
29
|
+
return :black if dark?(red, green, blue)
|
30
|
+
return :white if grayscale?(red, green, blue)
|
31
|
+
return primary_color(red, green, blue) if primary?(red, green, blue)
|
32
|
+
return secondary_color(red, green, blue) if secondary?(red, green, blue)
|
33
|
+
|
34
|
+
:white
|
35
|
+
end
|
36
|
+
|
37
|
+
# Determine if a color is dark
|
38
|
+
#
|
39
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
40
|
+
# @since unreleased
|
41
|
+
#
|
42
|
+
# @api private
|
43
|
+
#
|
44
|
+
# @param red [Float] the red component (0-1)
|
45
|
+
# @param green [Float] the green component (0-1)
|
46
|
+
# @param blue [Float] the blue component (0-1)
|
47
|
+
#
|
48
|
+
# @return [Boolean] true if color is dark
|
49
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
50
|
+
def dark?(red, green, blue)
|
51
|
+
[red, green, blue].max < 0.3
|
52
|
+
end
|
53
|
+
|
54
|
+
# Determine if a color is grayscale
|
55
|
+
#
|
56
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
57
|
+
# @since unreleased
|
58
|
+
#
|
59
|
+
# @api private
|
60
|
+
#
|
61
|
+
# @param red [Float] the red component (0-1)
|
62
|
+
# @param green [Float] the green component (0-1)
|
63
|
+
# @param blue [Float] the blue component (0-1)
|
64
|
+
#
|
65
|
+
# @return [Boolean] true if color is grayscale
|
66
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
67
|
+
def grayscale?(red, green, blue)
|
68
|
+
red == green && green == blue
|
69
|
+
end
|
70
|
+
|
71
|
+
# Convert a color value to RGB components
|
72
|
+
#
|
73
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
74
|
+
# @since unreleased
|
75
|
+
#
|
76
|
+
# @api private
|
77
|
+
#
|
78
|
+
# @param color [String, Array<Integer>] the color to convert
|
79
|
+
#
|
80
|
+
# @raise [ArgumentError] if the color format is invalid
|
81
|
+
# @return [Array<Integer>] the RGB components
|
82
|
+
# @rbs (Array[Integer] | String | Symbol color) -> Array[Integer]
|
83
|
+
def resolve(color)
|
84
|
+
case color
|
85
|
+
when Array then validate_rgb(color)
|
86
|
+
when /^#?([A-Fa-f0-9]{6})$/
|
87
|
+
hex_to_rgb(
|
88
|
+
Regexp.last_match(1) #: String
|
89
|
+
)
|
90
|
+
when String, Symbol then named_to_rgb(color.to_s.downcase)
|
91
|
+
else
|
92
|
+
raise ArgumentError, "Invalid color format: #{color}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
# Convert RGB values to 256-color cube index
|
97
|
+
#
|
98
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
99
|
+
# @since unreleased
|
100
|
+
#
|
101
|
+
# @api private
|
102
|
+
#
|
103
|
+
# @param rgb [Array<Integer>] RGB values (0-255)
|
104
|
+
#
|
105
|
+
# @return [Integer] the color cube index
|
106
|
+
# @rbs (Array[Integer] rgb) -> Integer
|
107
|
+
def to_color_cube_index(rgb)
|
108
|
+
r, g, b = rgb.map { |c| ((c / 255.0) * 5).round } #: [Integer, Integer, Integer]
|
109
|
+
16 + (r * 36) + (g * 6) + b
|
110
|
+
end
|
111
|
+
|
112
|
+
# Convert RGB values to grayscale index
|
113
|
+
#
|
114
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
115
|
+
# @since unreleased
|
116
|
+
#
|
117
|
+
# @api private
|
118
|
+
#
|
119
|
+
# @param rgb [Array<Integer>] RGB values
|
120
|
+
#
|
121
|
+
# @return [Integer] the grayscale index
|
122
|
+
# @rbs (Array[Integer] rgb) -> Integer
|
123
|
+
def to_grayscale_index(rgb)
|
124
|
+
232 + ((rgb[0] / 255.0) * 23).round
|
125
|
+
end
|
126
|
+
|
127
|
+
private
|
128
|
+
|
129
|
+
# Check if RGB values represent cyan
|
130
|
+
#
|
131
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
132
|
+
# @since unreleased
|
133
|
+
#
|
134
|
+
# @api private
|
135
|
+
#
|
136
|
+
# @param red [Float] the red component (0-1)
|
137
|
+
# @param green [Float] the green component (0-1)
|
138
|
+
# @param blue [Float] the blue component (0-1)
|
139
|
+
#
|
140
|
+
# @return [Boolean] true if color is cyan
|
141
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
142
|
+
def cyan?(red, green, blue)
|
143
|
+
green > red && blue > red
|
144
|
+
end
|
145
|
+
|
146
|
+
# Convert a hex string to RGB values
|
147
|
+
#
|
148
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
149
|
+
# @since unreleased
|
150
|
+
#
|
151
|
+
# @api private
|
152
|
+
#
|
153
|
+
# @param hex [String] the hex color code
|
154
|
+
#
|
155
|
+
# @return [Array<Integer>] the RGB components
|
156
|
+
# @rbs (String hex) -> Array[Integer]
|
157
|
+
def hex_to_rgb(hex)
|
158
|
+
hex = hex.delete_prefix('#') #: String
|
159
|
+
[
|
160
|
+
hex[0..1].to_i(16), # steep:ignore UnexpectedPositionalArgument
|
161
|
+
hex[2..3].to_i(16), # steep:ignore UnexpectedPositionalArgument
|
162
|
+
hex[4..5].to_i(16) # steep:ignore UnexpectedPositionalArgument
|
163
|
+
]
|
164
|
+
end
|
165
|
+
|
166
|
+
# Check if RGB values represent magenta
|
167
|
+
#
|
168
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
169
|
+
# @since unreleased
|
170
|
+
#
|
171
|
+
# @api private
|
172
|
+
#
|
173
|
+
# @param red [Float] the red component (0-1)
|
174
|
+
# @param green [Float] the green component (0-1)
|
175
|
+
# @param blue [Float] the blue component (0-1)
|
176
|
+
#
|
177
|
+
# @return [Boolean] true if color is magenta
|
178
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
179
|
+
def magenta?(red, green, blue)
|
180
|
+
red > green && blue > green
|
181
|
+
end
|
182
|
+
|
183
|
+
# Convert a named color to RGB values
|
184
|
+
#
|
185
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
186
|
+
# @since unreleased
|
187
|
+
#
|
188
|
+
# @api private
|
189
|
+
#
|
190
|
+
# @param color_name [String] the color name
|
191
|
+
#
|
192
|
+
# @raise [ArgumentError] if the color name is unknown
|
193
|
+
# @return [Array<Integer>] the RGB components
|
194
|
+
# @rbs (String color_name) -> Array[Integer]
|
195
|
+
def named_to_rgb(color_name)
|
196
|
+
ANSI::COLOR_NAMES.fetch(color_name.to_sym) do
|
197
|
+
raise ArgumentError, "Unknown color name: #{color_name}"
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# Determine if RGB values represent a primary color
|
202
|
+
#
|
203
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
204
|
+
# @since unreleased
|
205
|
+
#
|
206
|
+
# @api private
|
207
|
+
#
|
208
|
+
# @param red [Float] the red component (0-1)
|
209
|
+
# @param green [Float] the green component (0-1)
|
210
|
+
# @param blue [Float] the blue component (0-1)
|
211
|
+
#
|
212
|
+
# @return [Boolean] true if color is primary
|
213
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
214
|
+
def primary?(red, green, blue)
|
215
|
+
max = [red, green, blue].max
|
216
|
+
mid = [red, green, blue].sort[1]
|
217
|
+
(max - mid) > 0.3
|
218
|
+
end
|
219
|
+
|
220
|
+
# Get the closest primary color
|
221
|
+
#
|
222
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
223
|
+
# @since unreleased
|
224
|
+
#
|
225
|
+
# @api private
|
226
|
+
#
|
227
|
+
# @param red [Float] the red component (0-1)
|
228
|
+
# @param green [Float] the green component (0-1)
|
229
|
+
# @param blue [Float] the blue component (0-1)
|
230
|
+
#
|
231
|
+
# @return [Symbol] the primary color name
|
232
|
+
# @rbs (Float red, Float green, Float blue) -> Symbol
|
233
|
+
def primary_color(red, green, blue)
|
234
|
+
max = [red, green, blue].max
|
235
|
+
case max
|
236
|
+
when red then :red
|
237
|
+
when green then :green
|
238
|
+
else :blue
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
# Determine if RGB values represent a secondary color
|
243
|
+
#
|
244
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
245
|
+
# @since unreleased
|
246
|
+
#
|
247
|
+
# @api private
|
248
|
+
#
|
249
|
+
# @param red [Float] the red component (0-1)
|
250
|
+
# @param green [Float] the green component (0-1)
|
251
|
+
# @param blue [Float] the blue component (0-1)
|
252
|
+
#
|
253
|
+
# @return [Boolean] true if color is secondary
|
254
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
255
|
+
def secondary?(red, green, blue)
|
256
|
+
return true if yellow?(red, green, blue)
|
257
|
+
return true if magenta?(red, green, blue)
|
258
|
+
return true if cyan?(red, green, blue)
|
259
|
+
|
260
|
+
false
|
261
|
+
end
|
262
|
+
|
263
|
+
# Get the closest secondary color
|
264
|
+
#
|
265
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
266
|
+
# @since unreleased
|
267
|
+
#
|
268
|
+
# @api private
|
269
|
+
#
|
270
|
+
# @param red [Float] the red component (0-1)
|
271
|
+
# @param green [Float] the green component (0-1)
|
272
|
+
# @param blue [Float] the blue component (0-1)
|
273
|
+
#
|
274
|
+
# @return [Symbol] the secondary color name
|
275
|
+
# @rbs (Float red, Float green, Float blue) -> Symbol
|
276
|
+
def secondary_color(red, green, blue)
|
277
|
+
return :yellow if yellow?(red, green, blue)
|
278
|
+
return :magenta if magenta?(red, green, blue)
|
279
|
+
return :cyan if cyan?(red, green, blue)
|
280
|
+
|
281
|
+
:white
|
282
|
+
end
|
283
|
+
|
284
|
+
# Validate RGB values
|
285
|
+
#
|
286
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
287
|
+
# @since unreleased
|
288
|
+
#
|
289
|
+
# @api private
|
290
|
+
#
|
291
|
+
# @param color [Array<Integer>] the RGB components to validate
|
292
|
+
# @return [Array<Integer>] the validated RGB components
|
293
|
+
# @raise [ArgumentError] if the RGB values are invalid
|
294
|
+
# @rbs (Array[Integer] color) -> Array[Integer]
|
295
|
+
def validate_rgb(color)
|
296
|
+
unless color.size == 3 && color.all? { |c| c.is_a?(Integer) && c.between?(0, 255) }
|
297
|
+
raise ArgumentError, "Invalid RGB values: #{color}"
|
298
|
+
end
|
299
|
+
|
300
|
+
color
|
301
|
+
end
|
302
|
+
|
303
|
+
# Check if RGB values represent yellow
|
304
|
+
#
|
305
|
+
# @author {https://aaronmallen.me Aaron Allen}
|
306
|
+
# @since unreleased
|
307
|
+
#
|
308
|
+
# @api private
|
309
|
+
#
|
310
|
+
# @param red [Float] the red component (0-1)
|
311
|
+
# @param green [Float] the green component (0-1)
|
312
|
+
# @param blue [Float] the blue component (0-1)
|
313
|
+
#
|
314
|
+
# @return [Boolean] true if color is yellow
|
315
|
+
# @rbs (Float red, Float green, Float blue) -> bool
|
316
|
+
def yellow?(red, green, blue)
|
317
|
+
red > blue && green > blue && (red - green).abs < 0.3
|
318
|
+
end
|
319
|
+
end
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|