unmagic-color 0.1.0 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +86 -0
- data/README.md +201 -41
- data/data/css.jsonc +150 -0
- data/data/css.txt +148 -0
- data/data/x11.jsonc +660 -0
- data/data/x11.txt +753 -0
- data/lib/unmagic/color/console/banner.rb +55 -0
- data/lib/unmagic/color/console/card.rb +165 -0
- data/lib/unmagic/color/console/help.rb +70 -0
- data/lib/unmagic/color/console/highlighter.rb +114 -0
- data/lib/unmagic/color/gradient/base.rb +252 -0
- data/lib/unmagic/color/gradient/bitmap.rb +91 -0
- data/lib/unmagic/color/gradient/stop.rb +48 -0
- data/lib/unmagic/color/gradient.rb +154 -0
- data/lib/unmagic/color/harmony.rb +293 -0
- data/lib/unmagic/color/hsl/gradient/linear.rb +152 -0
- data/lib/unmagic/color/hsl.rb +145 -21
- data/lib/unmagic/color/oklch/gradient/linear.rb +151 -0
- data/lib/unmagic/color/oklch.rb +124 -12
- data/lib/unmagic/color/rgb/ansi.rb +227 -0
- data/lib/unmagic/color/rgb/gradient/linear.rb +165 -0
- data/lib/unmagic/color/rgb/hex.rb +20 -8
- data/lib/unmagic/color/rgb/named.rb +213 -43
- data/lib/unmagic/color/rgb.rb +325 -22
- data/lib/unmagic/color/units/degrees.rb +233 -0
- data/lib/unmagic/color/units/direction.rb +206 -0
- data/lib/unmagic/color/util/percentage.rb +150 -22
- data/lib/unmagic/color/version.rb +8 -0
- data/lib/unmagic/color.rb +95 -0
- metadata +23 -3
- data/data/rgb.txt +0 -164
data/lib/unmagic/color/rgb.rb
CHANGED
|
@@ -62,24 +62,29 @@ module Unmagic
|
|
|
62
62
|
# Error raised when parsing RGB color strings fails
|
|
63
63
|
class ParseError < Color::Error; end
|
|
64
64
|
|
|
65
|
-
attr_reader :red, :green, :blue
|
|
65
|
+
attr_reader :red, :green, :blue, :alpha
|
|
66
66
|
|
|
67
67
|
# Create a new RGB color.
|
|
68
68
|
#
|
|
69
69
|
# @param red [Integer] Red component (0-255), values outside this range are clamped
|
|
70
70
|
# @param green [Integer] Green component (0-255), values outside this range are clamped
|
|
71
71
|
# @param blue [Integer] Blue component (0-255), values outside this range are clamped
|
|
72
|
+
# @param alpha [Numeric, Color::Alpha, nil] Alpha channel (0-100%), defaults to 100 (fully opaque)
|
|
72
73
|
#
|
|
73
74
|
# @example Create a red color
|
|
74
75
|
# RGB.new(red: 255, green: 0, blue: 0)
|
|
75
76
|
#
|
|
77
|
+
# @example Create a semi-transparent red
|
|
78
|
+
# RGB.new(red: 255, green: 0, blue: 0, alpha: 50)
|
|
79
|
+
#
|
|
76
80
|
# @example Values are automatically clamped
|
|
77
81
|
# RGB.new(red: 300, green: -10, blue: 128)
|
|
78
|
-
def initialize(red:, green:, blue:)
|
|
82
|
+
def initialize(red:, green:, blue:, alpha: nil)
|
|
79
83
|
super()
|
|
80
84
|
@red = Color::Red.new(value: red)
|
|
81
85
|
@green = Color::Green.new(value: green)
|
|
82
86
|
@blue = Color::Blue.new(value: blue)
|
|
87
|
+
@alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT
|
|
83
88
|
end
|
|
84
89
|
|
|
85
90
|
class << self
|
|
@@ -107,6 +112,11 @@ module Unmagic
|
|
|
107
112
|
|
|
108
113
|
input = input.strip
|
|
109
114
|
|
|
115
|
+
# Check for ANSI format first (numeric with optional semicolons)
|
|
116
|
+
if input.match?(/\A\d+(?:;\d+)*\z/) && ANSI.valid?(input)
|
|
117
|
+
return ANSI.parse(input)
|
|
118
|
+
end
|
|
119
|
+
|
|
110
120
|
# Check if it looks like a hex color (starts with # or only contains hex digits)
|
|
111
121
|
if input.start_with?("#") || input.match?(/\A[0-9A-Fa-f]{3,6}\z/)
|
|
112
122
|
return Hex.parse(input)
|
|
@@ -116,6 +126,58 @@ module Unmagic
|
|
|
116
126
|
parse_rgb_format(input)
|
|
117
127
|
end
|
|
118
128
|
|
|
129
|
+
# Build an RGB color from an integer, string, positional values, or keyword arguments.
|
|
130
|
+
#
|
|
131
|
+
# @param args [Integer, String, Array<Integer>] Either an integer (0xRRGGBB), color string, or 3 component values
|
|
132
|
+
# @option kwargs [Integer] :red Red component (0-255)
|
|
133
|
+
# @option kwargs [Integer] :green Green component (0-255)
|
|
134
|
+
# @option kwargs [Integer] :blue Blue component (0-255)
|
|
135
|
+
# @return [RGB] The constructed RGB color
|
|
136
|
+
#
|
|
137
|
+
# @example From integer (packed RGB)
|
|
138
|
+
# RGB.build(0xDAA520) # goldenrod
|
|
139
|
+
# RGB.build(14329120) # same as 0xDAA520
|
|
140
|
+
#
|
|
141
|
+
# @example From string
|
|
142
|
+
# RGB.build("#FF8800")
|
|
143
|
+
#
|
|
144
|
+
# @example From positional values
|
|
145
|
+
# RGB.build(255, 128, 0)
|
|
146
|
+
#
|
|
147
|
+
# @example From keyword arguments
|
|
148
|
+
# RGB.build(red: 255, green: 128, blue: 0)
|
|
149
|
+
def build(*args, **kwargs)
|
|
150
|
+
# Handle keyword arguments
|
|
151
|
+
return new(**kwargs) if kwargs.any?
|
|
152
|
+
|
|
153
|
+
# Handle single argument
|
|
154
|
+
if args.length == 1
|
|
155
|
+
value = args[0]
|
|
156
|
+
|
|
157
|
+
# Integer: extract RGB components via bit operations
|
|
158
|
+
if value.is_a?(::Integer)
|
|
159
|
+
return new(
|
|
160
|
+
red: (value >> 16) & 0xFF,
|
|
161
|
+
green: (value >> 8) & 0xFF,
|
|
162
|
+
blue: value & 0xFF,
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# String: delegate to parse
|
|
167
|
+
return parse(value) if value.is_a?(::String)
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "Expected Integer or String, got #{value.class}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Handle three positional arguments (r, g, b)
|
|
173
|
+
if args.length == 3
|
|
174
|
+
values = args.map { |v| v.is_a?(::String) ? v.to_i : v }
|
|
175
|
+
return new(red: values[0], green: values[1], blue: values[2])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}"
|
|
179
|
+
end
|
|
180
|
+
|
|
119
181
|
# Generate a deterministic RGB color from an integer seed.
|
|
120
182
|
#
|
|
121
183
|
# This creates consistent, visually distinct colors from hash values or IDs.
|
|
@@ -169,33 +231,64 @@ module Unmagic
|
|
|
169
231
|
new(red: r, green: g, blue: b)
|
|
170
232
|
end
|
|
171
233
|
|
|
172
|
-
# Parse RGB format like "rgb(255, 128, 0)" or "255
|
|
234
|
+
# Parse RGB format like "rgb(255, 128, 0)" or "rgb(255 128 0 / 0.5)"
|
|
235
|
+
#
|
|
236
|
+
# Supports both legacy comma-separated format and modern space-separated
|
|
237
|
+
# format with optional alpha value.
|
|
173
238
|
#
|
|
174
239
|
# @param input [String] RGB string to parse
|
|
175
240
|
# @return [RGB] Parsed RGB color
|
|
176
241
|
# @raise [ParseError] If format is invalid
|
|
177
242
|
def parse_rgb_format(input)
|
|
178
|
-
# Remove rgb() wrapper if present
|
|
179
|
-
clean = input.gsub(/^
|
|
243
|
+
# Remove rgb() or rgba() wrapper if present
|
|
244
|
+
clean = input.gsub(/^rgba?\s*\(\s*|\s*\)$/, "").strip
|
|
245
|
+
|
|
246
|
+
# Check for modern format with slash (space-separated with / for alpha)
|
|
247
|
+
# Example: "255 128 0 / 0.5" or "255 128 0 / 50%"
|
|
248
|
+
if clean.include?("/")
|
|
249
|
+
parts = clean.split("/").map(&:strip)
|
|
250
|
+
raise ParseError, "Invalid format with /: expected 'R G B / alpha'" unless parts.length == 2
|
|
251
|
+
|
|
252
|
+
rgb_values = parts[0].split(/\s+/)
|
|
253
|
+
alpha_str = parts[1]
|
|
254
|
+
|
|
255
|
+
unless rgb_values.length == 3
|
|
256
|
+
raise ParseError, "Expected 3 RGB values before /, got #{rgb_values.length}"
|
|
257
|
+
end
|
|
180
258
|
|
|
181
|
-
|
|
259
|
+
alpha = Color::Alpha.parse(alpha_str)
|
|
260
|
+
r, g, b = parse_rgb_values(rgb_values)
|
|
261
|
+
|
|
262
|
+
return new(red: r, green: g, blue: b, alpha: alpha)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Legacy comma-separated format (with or without alpha)
|
|
266
|
+
# Example: "255, 128, 0" or "255, 128, 0, 0.5"
|
|
182
267
|
values = clean.split(/\s*,\s*/)
|
|
183
|
-
|
|
184
|
-
|
|
268
|
+
|
|
269
|
+
unless [3, 4].include?(values.length)
|
|
270
|
+
raise ParseError, "Expected 3 or 4 RGB values, got #{values.length}"
|
|
185
271
|
end
|
|
186
272
|
|
|
187
|
-
|
|
188
|
-
values.
|
|
273
|
+
r, g, b = parse_rgb_values(values[0..2])
|
|
274
|
+
alpha = values.length == 4 ? Color::Alpha.parse(values[3]) : nil
|
|
275
|
+
|
|
276
|
+
new(red: r, green: g, blue: b, alpha: alpha)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Parse RGB component values
|
|
280
|
+
#
|
|
281
|
+
# @param values [Array<String>] Array of 3 RGB value strings
|
|
282
|
+
# @return [Array<Integer>] Array of 3 integers (0-255)
|
|
283
|
+
# @raise [ParseError] If values are invalid
|
|
284
|
+
def parse_rgb_values(values)
|
|
285
|
+
values.map.with_index do |v, i|
|
|
189
286
|
unless v.match?(/\A-?\d+\z/)
|
|
190
287
|
component = ["red", "green", "blue"][i]
|
|
191
288
|
raise ParseError, "Invalid #{component} value: #{v.inspect} (must be a number)"
|
|
192
289
|
end
|
|
290
|
+
v.to_i
|
|
193
291
|
end
|
|
194
|
-
|
|
195
|
-
# Convert to integers (constructor will clamp)
|
|
196
|
-
parsed = values.map(&:to_i)
|
|
197
|
-
|
|
198
|
-
new(red: parsed[0], green: parsed[1], blue: parsed[2])
|
|
199
292
|
end
|
|
200
293
|
end
|
|
201
294
|
|
|
@@ -211,16 +304,27 @@ module Unmagic
|
|
|
211
304
|
# Convert to hexadecimal color string.
|
|
212
305
|
#
|
|
213
306
|
# Returns a lowercase hex string with hash prefix, always 6 characters
|
|
214
|
-
# (2 per component).
|
|
307
|
+
# (2 per component). If alpha is less than 100%, includes 8 characters
|
|
308
|
+
# with alpha as the last 2 hex digits.
|
|
215
309
|
#
|
|
216
|
-
# @return [String] Hex color string like "#ff5733"
|
|
310
|
+
# @return [String] Hex color string like "#ff5733" or "#ff5733 80" with alpha
|
|
217
311
|
#
|
|
218
|
-
# @example
|
|
312
|
+
# @example Fully opaque color
|
|
219
313
|
# rgb = RGB.new(red: 255, green: 87, blue: 51)
|
|
220
314
|
# rgb.to_hex
|
|
221
315
|
# # => "#ff5733"
|
|
316
|
+
#
|
|
317
|
+
# @example Semi-transparent color
|
|
318
|
+
# rgb = RGB.new(red: 255, green: 87, blue: 51, alpha: 50)
|
|
319
|
+
# rgb.to_hex
|
|
320
|
+
# # => "#ff573380"
|
|
222
321
|
def to_hex
|
|
223
|
-
|
|
322
|
+
if @alpha.value < 100
|
|
323
|
+
alpha_hex = (@alpha.to_ratio * 255).round.to_s(16).rjust(2, "0")
|
|
324
|
+
format("#%02x%02x%02x%s", @red.value, @green.value, @blue.value, alpha_hex)
|
|
325
|
+
else
|
|
326
|
+
format("#%02x%02x%02x", @red.value, @green.value, @blue.value)
|
|
327
|
+
end
|
|
224
328
|
end
|
|
225
329
|
|
|
226
330
|
# Convert to HSL color space.
|
|
@@ -265,7 +369,12 @@ module Unmagic
|
|
|
265
369
|
end
|
|
266
370
|
end
|
|
267
371
|
|
|
268
|
-
Unmagic::Color::HSL.new(
|
|
372
|
+
Unmagic::Color::HSL.new(
|
|
373
|
+
hue: (h * 360).round,
|
|
374
|
+
saturation: (s * 100).round,
|
|
375
|
+
lightness: (l * 100).round,
|
|
376
|
+
alpha: @alpha,
|
|
377
|
+
)
|
|
269
378
|
end
|
|
270
379
|
|
|
271
380
|
# Convert to OKLCH color space.
|
|
@@ -282,9 +391,9 @@ module Unmagic
|
|
|
282
391
|
l = luminance
|
|
283
392
|
# Approximate chroma from saturation and lightness
|
|
284
393
|
hsl = to_hsl
|
|
285
|
-
c =
|
|
394
|
+
c = hsl.saturation.to_ratio * 0.2 * (1 - (l - 0.5).abs * 2)
|
|
286
395
|
h = hsl.hue
|
|
287
|
-
Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h)
|
|
396
|
+
Unmagic::Color::OKLCH.new(lightness: l, chroma: c, hue: h, alpha: @alpha)
|
|
288
397
|
end
|
|
289
398
|
|
|
290
399
|
# Calculate the relative luminance.
|
|
@@ -339,6 +448,7 @@ module Unmagic
|
|
|
339
448
|
red: (@red.value * (1 - amount) + other_rgb.red.value * amount).round,
|
|
340
449
|
green: (@green.value * (1 - amount) + other_rgb.green.value * amount).round,
|
|
341
450
|
blue: (@blue.value * (1 - amount) + other_rgb.blue.value * amount).round,
|
|
451
|
+
alpha: @alpha.value * (1 - amount) + other_rgb.alpha.value * amount,
|
|
342
452
|
)
|
|
343
453
|
end
|
|
344
454
|
|
|
@@ -385,6 +495,199 @@ module Unmagic
|
|
|
385
495
|
def to_s
|
|
386
496
|
to_hex
|
|
387
497
|
end
|
|
498
|
+
|
|
499
|
+
# Convert to ANSI SGR color code.
|
|
500
|
+
#
|
|
501
|
+
# Returns an ANSI Select Graphic Rendition (SGR) parameter string for terminal output.
|
|
502
|
+
# Supports multiple color modes for different terminal capabilities.
|
|
503
|
+
#
|
|
504
|
+
# @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
|
|
505
|
+
# @param mode [Symbol] Color format mode:
|
|
506
|
+
# - :truecolor (default) - 24-bit RGB (38;2;R;G;B or 48;2;R;G;B)
|
|
507
|
+
# - :palette256 - 256-color palette (38;5;N or 48;5;N)
|
|
508
|
+
# - :palette16 - 16-color palette (30-37, 90-97, 40-47, 100-107)
|
|
509
|
+
# @return [String] ANSI SGR code
|
|
510
|
+
# @raise [ArgumentError] If layer or mode is invalid
|
|
511
|
+
#
|
|
512
|
+
# @example Default mode (truecolor)
|
|
513
|
+
# red = RGB.new(red: 255, green: 0, blue: 0)
|
|
514
|
+
# red.to_ansi
|
|
515
|
+
# # => "38;2;255;0;0"
|
|
516
|
+
#
|
|
517
|
+
# @example Background color
|
|
518
|
+
# red = RGB.new(red: 255, green: 0, blue: 0)
|
|
519
|
+
# red.to_ansi(layer: :background)
|
|
520
|
+
# # => "48;2;255;0;0"
|
|
521
|
+
#
|
|
522
|
+
# @example True color mode (explicit)
|
|
523
|
+
# custom = RGB.new(red: 100, green: 150, blue: 200)
|
|
524
|
+
# custom.to_ansi(mode: :truecolor)
|
|
525
|
+
# # => "38;2;100;150;200"
|
|
526
|
+
#
|
|
527
|
+
# @example 256-color palette mode
|
|
528
|
+
# custom = RGB.new(red: 100, green: 150, blue: 200)
|
|
529
|
+
# custom.to_ansi(mode: :palette256)
|
|
530
|
+
# # => "38;5;67"
|
|
531
|
+
#
|
|
532
|
+
# @example 16-color palette mode
|
|
533
|
+
# custom = RGB.new(red: 100, green: 150, blue: 200)
|
|
534
|
+
# custom.to_ansi(mode: :palette16)
|
|
535
|
+
# # => "34"
|
|
536
|
+
def to_ansi(layer: :foreground, mode: :truecolor)
|
|
537
|
+
raise ArgumentError, "layer must be :foreground or :background" unless [:foreground, :background].include?(layer)
|
|
538
|
+
raise ArgumentError, "mode must be :truecolor, :palette256, or :palette16" unless [:truecolor, :palette256, :palette16].include?(mode)
|
|
539
|
+
|
|
540
|
+
case mode
|
|
541
|
+
when :truecolor
|
|
542
|
+
to_ansi_truecolor(layer)
|
|
543
|
+
when :palette256
|
|
544
|
+
to_ansi_palette256(layer)
|
|
545
|
+
when :palette16
|
|
546
|
+
to_ansi_palette16(layer)
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
private
|
|
551
|
+
|
|
552
|
+
# Convert to ANSI true color format (24-bit RGB).
|
|
553
|
+
#
|
|
554
|
+
# @param layer [Symbol] Foreground or background layer
|
|
555
|
+
# @return [String] ANSI SGR code
|
|
556
|
+
def to_ansi_truecolor(layer)
|
|
557
|
+
prefix = layer == :foreground ? 38 : 48
|
|
558
|
+
"#{prefix};2;#{@red.value};#{@green.value};#{@blue.value}"
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# Convert to ANSI 256-color palette format.
|
|
562
|
+
#
|
|
563
|
+
# Finds the nearest color in the 256-color palette.
|
|
564
|
+
#
|
|
565
|
+
# @param layer [Symbol] Foreground or background layer
|
|
566
|
+
# @return [String] ANSI SGR code
|
|
567
|
+
def to_ansi_palette256(layer)
|
|
568
|
+
index = rgb_to_palette256
|
|
569
|
+
prefix = layer == :foreground ? 38 : 48
|
|
570
|
+
"#{prefix};5;#{index}"
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Convert to 16-color palette ANSI format.
|
|
574
|
+
#
|
|
575
|
+
# Finds the nearest of the 8 basic colors and uses bright variants.
|
|
576
|
+
#
|
|
577
|
+
# @param layer [Symbol] Foreground or background layer
|
|
578
|
+
# @return [String] ANSI SGR code
|
|
579
|
+
def to_ansi_palette16(layer)
|
|
580
|
+
index = rgb_to_palette16
|
|
581
|
+
prefix = layer == :foreground ? 90 : 100
|
|
582
|
+
(prefix + index).to_s
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Find the nearest color in the 256-color palette.
|
|
586
|
+
#
|
|
587
|
+
# @return [Integer] Palette index (0-255)
|
|
588
|
+
def rgb_to_palette256
|
|
589
|
+
r = @red.value
|
|
590
|
+
g = @green.value
|
|
591
|
+
b = @blue.value
|
|
592
|
+
|
|
593
|
+
# Check if it's grayscale (all components within small threshold)
|
|
594
|
+
if (r - g).abs < 10 && (r - b).abs < 10 && (g - b).abs < 10
|
|
595
|
+
# Use grayscale ramp (232-255)
|
|
596
|
+
gray = (r + g + b) / 3
|
|
597
|
+
if gray < 8
|
|
598
|
+
return 16 # Use first RGB cube entry for very dark
|
|
599
|
+
elsif gray > 238
|
|
600
|
+
return 231 # Use last RGB cube entry for very light
|
|
601
|
+
else
|
|
602
|
+
# Map to grayscale ramp: 232 + (0-23)
|
|
603
|
+
index = ((gray - 8) / 10.0).round
|
|
604
|
+
return 232 + index.clamp(0, 23)
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Find nearest in 6x6x6 RGB cube (16-231)
|
|
609
|
+
# Each component: 0, 95, 135, 175, 215, 255 (values 0-5)
|
|
610
|
+
r_index = rgb_to_cube_index(r)
|
|
611
|
+
g_index = rgb_to_cube_index(g)
|
|
612
|
+
b_index = rgb_to_cube_index(b)
|
|
613
|
+
|
|
614
|
+
16 + (r_index * 36) + (g_index * 6) + b_index
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
# Convert RGB component to 6x6x6 cube index.
|
|
618
|
+
#
|
|
619
|
+
# @param value [Integer] RGB component value (0-255)
|
|
620
|
+
# @return [Integer] Cube index (0-5)
|
|
621
|
+
def rgb_to_cube_index(value)
|
|
622
|
+
# Cube values: 0, 95, 135, 175, 215, 255
|
|
623
|
+
# Thresholds: 47.5, 115, 155, 195, 235
|
|
624
|
+
if value < 48
|
|
625
|
+
0
|
|
626
|
+
elsif value < 115
|
|
627
|
+
1
|
|
628
|
+
elsif value < 155
|
|
629
|
+
2
|
|
630
|
+
elsif value < 195
|
|
631
|
+
3
|
|
632
|
+
elsif value < 235
|
|
633
|
+
4
|
|
634
|
+
else
|
|
635
|
+
5
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Find the nearest color in the 16-color palette (0-7).
|
|
640
|
+
#
|
|
641
|
+
# @return [Integer] Color index (0-7)
|
|
642
|
+
def rgb_to_palette16
|
|
643
|
+
# Standard ANSI colors
|
|
644
|
+
colors = [
|
|
645
|
+
[0, 0, 0], # 0: black
|
|
646
|
+
[255, 0, 0], # 1: red
|
|
647
|
+
[0, 255, 0], # 2: green
|
|
648
|
+
[255, 255, 0], # 3: yellow
|
|
649
|
+
[0, 0, 255], # 4: blue
|
|
650
|
+
[255, 0, 255], # 5: magenta
|
|
651
|
+
[0, 255, 255], # 6: cyan
|
|
652
|
+
[255, 255, 255], # 7: white
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
r = @red.value
|
|
656
|
+
g = @green.value
|
|
657
|
+
b = @blue.value
|
|
658
|
+
|
|
659
|
+
min_distance = Float::INFINITY
|
|
660
|
+
nearest_index = 0
|
|
661
|
+
|
|
662
|
+
colors.each_with_index do |color, index|
|
|
663
|
+
cr, cg, cb = color
|
|
664
|
+
distance = ((r - cr)**2 + (g - cg)**2 + (b - cb)**2)
|
|
665
|
+
if distance < min_distance
|
|
666
|
+
min_distance = distance
|
|
667
|
+
nearest_index = index
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
nearest_index
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
public
|
|
675
|
+
|
|
676
|
+
# Pretty print support with colored swatch in class name.
|
|
677
|
+
#
|
|
678
|
+
# Outputs standard Ruby object format with a colored block character
|
|
679
|
+
# embedded in the class name area.
|
|
680
|
+
#
|
|
681
|
+
# @param pp [PrettyPrint] The pretty printer instance
|
|
682
|
+
#
|
|
683
|
+
# @example
|
|
684
|
+
# rgb = RGB.new(red: 255, green: 87, blue: 51)
|
|
685
|
+
# pp rgb
|
|
686
|
+
# # Outputs: #<Unmagic::Color::RGB[█] @red=255 @green=87 @blue=51>
|
|
687
|
+
# # (with colored █ block)
|
|
688
|
+
def pretty_print(pp)
|
|
689
|
+
pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @red=#{@red.value} @green=#{@green.value} @blue=#{@blue.value}>")
|
|
690
|
+
end
|
|
388
691
|
end
|
|
389
692
|
end
|
|
390
693
|
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
# Unit types for color manipulation.
|
|
6
|
+
module Units
|
|
7
|
+
# Represents an angle in degrees (0-360°).
|
|
8
|
+
#
|
|
9
|
+
# Supports numeric values, degree strings, and named direction keywords.
|
|
10
|
+
# Values are automatically wrapped to the 0-360 range.
|
|
11
|
+
#
|
|
12
|
+
# ## Supported Formats
|
|
13
|
+
#
|
|
14
|
+
# - Numeric: `225`, `45.5`
|
|
15
|
+
# - Degree strings: `"225deg"`, `"45.5deg"`, `"-45deg"`, `"225°"`, `"45.5°"`
|
|
16
|
+
# - Named directions: `"top"`, `"bottom left"`, `"north"`, `"southwest"`, etc.
|
|
17
|
+
#
|
|
18
|
+
# ## Named Direction Keywords
|
|
19
|
+
#
|
|
20
|
+
# - Cardinal: `"top"` (0°), `"right"` (90°), `"bottom"` (180°), `"left"` (270°)
|
|
21
|
+
# - Diagonal: `"top right"` (45°), `"bottom right"` (135°), `"bottom left"` (225°), `"top left"` (315°)
|
|
22
|
+
# - Aliases: `"north"`, `"south"`, `"east"`, `"west"`, `"northeast"`, etc.
|
|
23
|
+
#
|
|
24
|
+
# @example Numeric values
|
|
25
|
+
# Degrees.build(225) #=> 225.0°
|
|
26
|
+
# Degrees.build(45.5) #=> 45.5°
|
|
27
|
+
# Degrees.build(-45) #=> 315.0° (wrapped)
|
|
28
|
+
#
|
|
29
|
+
# @example Degree strings
|
|
30
|
+
# Degrees.build("225deg") #=> 225.0°
|
|
31
|
+
# Degrees.build("45.5deg") #=> 45.5°
|
|
32
|
+
# Degrees.build("225°") #=> 225.0°
|
|
33
|
+
#
|
|
34
|
+
# @example Named directions
|
|
35
|
+
# Degrees.build("top") #=> 0.0°
|
|
36
|
+
# Degrees.build("bottom left") #=> 225.0°
|
|
37
|
+
# Degrees.build("north") #=> 0.0° (alias for "top")
|
|
38
|
+
#
|
|
39
|
+
# @example Constants
|
|
40
|
+
# Degrees::TOP #=> 0.0°
|
|
41
|
+
# Degrees::BOTTOM_LEFT #=> 225.0°
|
|
42
|
+
#
|
|
43
|
+
# @example String output
|
|
44
|
+
# Degrees::TOP.to_s #=> "top"
|
|
45
|
+
# Degrees::TOP.to_css #=> "0.0deg"
|
|
46
|
+
# Degrees.new(value: 123).to_s #=> "123.0°"
|
|
47
|
+
class Degrees
|
|
48
|
+
include Comparable
|
|
49
|
+
|
|
50
|
+
# Error raised when parsing invalid degree values.
|
|
51
|
+
class ParseError < Color::Error; end
|
|
52
|
+
|
|
53
|
+
attr_reader :value, :name, :aliases
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# All predefined degree constants
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<Degrees>] All constant degree values
|
|
59
|
+
def all
|
|
60
|
+
all_by_name.values.uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Find a constant by name or alias
|
|
64
|
+
#
|
|
65
|
+
# @param search [String] Name or alias to search for
|
|
66
|
+
# @return [Degrees, nil] Matching constant or nil
|
|
67
|
+
def find_by_name(search)
|
|
68
|
+
normalized = search.strip.downcase
|
|
69
|
+
all_by_name.fetch(normalized)
|
|
70
|
+
rescue KeyError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Hash mapping all names and aliases to their constants
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash<String, Degrees>] Name/alias to constant mapping
|
|
79
|
+
def all_by_name
|
|
80
|
+
@all_by_name ||= begin
|
|
81
|
+
constants = [TOP, RIGHT, BOTTOM, LEFT, TOP_RIGHT, BOTTOM_RIGHT, BOTTOM_LEFT, TOP_LEFT]
|
|
82
|
+
hash = {}
|
|
83
|
+
constants.each do |constant|
|
|
84
|
+
hash[constant.name] = constant
|
|
85
|
+
constant.aliases.each { |alias_name| hash[alias_name] = constant }
|
|
86
|
+
end
|
|
87
|
+
hash
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
public
|
|
92
|
+
|
|
93
|
+
# Build a Degrees instance from various input formats.
|
|
94
|
+
#
|
|
95
|
+
# @param input [Numeric, String, Degrees] The angle to parse
|
|
96
|
+
# @return [Degrees] Normalized degrees instance
|
|
97
|
+
# @raise [ParseError] If input format is invalid
|
|
98
|
+
#
|
|
99
|
+
# @example From number
|
|
100
|
+
# Degrees.build(225)
|
|
101
|
+
#
|
|
102
|
+
# @example From degree string
|
|
103
|
+
# Degrees.build("225deg")
|
|
104
|
+
#
|
|
105
|
+
# @example From CSS direction
|
|
106
|
+
# Degrees.build("to left top")
|
|
107
|
+
def build(input)
|
|
108
|
+
case input
|
|
109
|
+
when Degrees
|
|
110
|
+
input
|
|
111
|
+
when ::Numeric
|
|
112
|
+
new(value: input)
|
|
113
|
+
when ::String
|
|
114
|
+
parse(input)
|
|
115
|
+
else
|
|
116
|
+
raise ParseError, "Expected Numeric, String, or Degrees, got #{input.class}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Parse a degrees string.
|
|
121
|
+
#
|
|
122
|
+
# @param input [String] The string to parse
|
|
123
|
+
# @return [Degrees] Parsed degrees instance
|
|
124
|
+
# @raise [ParseError] If format is invalid
|
|
125
|
+
def parse(input)
|
|
126
|
+
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
127
|
+
|
|
128
|
+
input = input.strip
|
|
129
|
+
|
|
130
|
+
# Try to find a named constant first
|
|
131
|
+
constant = find_by_name(input)
|
|
132
|
+
return constant if constant
|
|
133
|
+
|
|
134
|
+
# Remove "deg" or "°" suffix if present
|
|
135
|
+
input = input.sub(/deg\z/i, "").sub(/°\z/, "")
|
|
136
|
+
|
|
137
|
+
# Try parsing as number
|
|
138
|
+
if input.match?(/\A-?\d+(?:\.\d+)?\z/)
|
|
139
|
+
return new(value: input.to_f)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
raise ParseError, "Invalid degrees format: #{input.inspect}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Create a new Degrees instance.
|
|
147
|
+
#
|
|
148
|
+
# @param value [Numeric] Angle in degrees (wraps to 0-360 range)
|
|
149
|
+
# @param name [String, nil] Optional name for this degree (e.g., "top", "bottom")
|
|
150
|
+
# @param aliases [Array<String>] Optional aliases for this degree (e.g., ["north"])
|
|
151
|
+
def initialize(value:, name: nil, aliases: [])
|
|
152
|
+
@value = value.to_f % 360
|
|
153
|
+
@name = name
|
|
154
|
+
@aliases = aliases
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Convert to float value.
|
|
158
|
+
#
|
|
159
|
+
# @return [Float] Degrees value (0-360)
|
|
160
|
+
def to_f
|
|
161
|
+
@value
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Get the opposite direction (180 degrees away).
|
|
165
|
+
#
|
|
166
|
+
# @return [Degrees] Opposite degree
|
|
167
|
+
def opposite
|
|
168
|
+
opposite_value = (@value + 180) % 360
|
|
169
|
+
self.class.all.find { |d| d.value == opposite_value } || self.class.new(value: opposite_value)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Convert to CSS string format.
|
|
173
|
+
#
|
|
174
|
+
# @return [String] CSS degree string (e.g., "225.0deg")
|
|
175
|
+
def to_css
|
|
176
|
+
"#{@value}deg"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Convert to string representation.
|
|
180
|
+
#
|
|
181
|
+
# @return [String] Canonical string format that can be parsed back
|
|
182
|
+
def to_s
|
|
183
|
+
@name || "#{@value}°"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Compare two Degrees instances.
|
|
187
|
+
#
|
|
188
|
+
# @param other [Degrees, Numeric] Value to compare
|
|
189
|
+
# @return [Integer, nil] Comparison result
|
|
190
|
+
def <=>(other)
|
|
191
|
+
case other
|
|
192
|
+
when Degrees
|
|
193
|
+
@value <=> other.value
|
|
194
|
+
when ::Numeric
|
|
195
|
+
@value <=> other.to_f
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Check equality.
|
|
200
|
+
#
|
|
201
|
+
# @param other [Object] Value to compare
|
|
202
|
+
# @return [Boolean] true if values are equal
|
|
203
|
+
def ==(other)
|
|
204
|
+
case other
|
|
205
|
+
when Degrees
|
|
206
|
+
@value == other.value
|
|
207
|
+
when ::Numeric
|
|
208
|
+
@value == other.to_f
|
|
209
|
+
else
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Predefined degree constants
|
|
215
|
+
TOP = new(value: 0, name: "top", aliases: ["north"]).freeze
|
|
216
|
+
# Right direction (90°, east)
|
|
217
|
+
RIGHT = new(value: 90, name: "right", aliases: ["east"]).freeze
|
|
218
|
+
# Bottom direction (180°, south)
|
|
219
|
+
BOTTOM = new(value: 180, name: "bottom", aliases: ["south"]).freeze
|
|
220
|
+
# Left direction (270°, west)
|
|
221
|
+
LEFT = new(value: 270, name: "left", aliases: ["west"]).freeze
|
|
222
|
+
# Top-right diagonal direction (45°, northeast)
|
|
223
|
+
TOP_RIGHT = new(value: 45, name: "top right", aliases: ["topright", "northeast", "north east"]).freeze
|
|
224
|
+
# Bottom-right diagonal direction (135°, southeast)
|
|
225
|
+
BOTTOM_RIGHT = new(value: 135, name: "bottom right", aliases: ["bottomright", "southeast", "south east"]).freeze
|
|
226
|
+
# Bottom-left diagonal direction (225°, southwest)
|
|
227
|
+
BOTTOM_LEFT = new(value: 225, name: "bottom left", aliases: ["bottomleft", "southwest", "south west"]).freeze
|
|
228
|
+
# Top-left diagonal direction (315°, northwest)
|
|
229
|
+
TOP_LEFT = new(value: 315, name: "top left", aliases: ["topleft", "northwest", "north west"]).freeze
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|