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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class HSL
|
|
6
|
+
# Gradient generation in HSL color space.
|
|
7
|
+
module Gradient
|
|
8
|
+
# Linear gradient interpolation in HSL color space.
|
|
9
|
+
#
|
|
10
|
+
# Creates smooth color transitions by interpolating hue, saturation, and
|
|
11
|
+
# lightness separately. HSL gradients often produce more visually pleasing
|
|
12
|
+
# results than RGB for transitions across the color wheel.
|
|
13
|
+
#
|
|
14
|
+
# ## HSL Interpolation
|
|
15
|
+
#
|
|
16
|
+
# HSL gradients interpolate through the color wheel (hue), which can create
|
|
17
|
+
# smoother transitions through vivid colors. The hue component uses shortest-arc
|
|
18
|
+
# interpolation via the existing blend method.
|
|
19
|
+
#
|
|
20
|
+
# ## Examples
|
|
21
|
+
#
|
|
22
|
+
# # Rainbow gradient
|
|
23
|
+
# gradient = Unmagic::Color::HSL::Gradient::Linear.build(
|
|
24
|
+
# [
|
|
25
|
+
# ["hsl(0, 100%, 50%)", 0.0], # Red
|
|
26
|
+
# ["hsl(120, 100%, 50%)", 0.5], # Green
|
|
27
|
+
# ["hsl(240, 100%, 50%)", 1.0] # Blue
|
|
28
|
+
# ],
|
|
29
|
+
# direction: "to right"
|
|
30
|
+
# )
|
|
31
|
+
# bitmap = gradient.rasterize(width: 100)
|
|
32
|
+
#
|
|
33
|
+
# # Simple two-color gradient
|
|
34
|
+
# gradient = Unmagic::Color::HSL::Gradient::Linear.build(
|
|
35
|
+
# ["hsl(0, 100%, 50%)", "hsl(240, 100%, 50%)"],
|
|
36
|
+
# direction: "to bottom"
|
|
37
|
+
# )
|
|
38
|
+
# bitmap = gradient.rasterize(width: 1, height: 50)
|
|
39
|
+
#
|
|
40
|
+
# # Angled gradient with color objects
|
|
41
|
+
# gradient = Unmagic::Color::HSL::Gradient::Linear.build(
|
|
42
|
+
# [
|
|
43
|
+
# Unmagic::Color::HSL.new(hue: 0, saturation: 100, lightness: 50),
|
|
44
|
+
# Unmagic::Color::HSL.new(hue: 240, saturation: 100, lightness: 50)
|
|
45
|
+
# ],
|
|
46
|
+
# direction: "45deg"
|
|
47
|
+
# )
|
|
48
|
+
# bitmap = gradient.rasterize(width: 100, height: 100)
|
|
49
|
+
class Linear < Unmagic::Color::Gradient::Base
|
|
50
|
+
class << self
|
|
51
|
+
# Get the HSL color class.
|
|
52
|
+
#
|
|
53
|
+
# @return [Class] Unmagic::Color::HSL
|
|
54
|
+
def color_class
|
|
55
|
+
Unmagic::Color::HSL
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Rasterize the gradient to a bitmap.
|
|
60
|
+
#
|
|
61
|
+
# Generates a bitmap containing the gradient with support for angled directions.
|
|
62
|
+
# Hue interpolation uses shortest-arc blending for smooth color wheel transitions.
|
|
63
|
+
#
|
|
64
|
+
# @param width [Integer] Width of the bitmap (default 1)
|
|
65
|
+
# @param height [Integer] Height of the bitmap (default 1)
|
|
66
|
+
# @return [Bitmap] A bitmap with the specified dimensions
|
|
67
|
+
#
|
|
68
|
+
# @raise [Error] If width or height is less than 1
|
|
69
|
+
def rasterize(width: 1, height: 1)
|
|
70
|
+
raise self.class::Error, "width must be at least 1" if width < 1
|
|
71
|
+
raise self.class::Error, "height must be at least 1" if height < 1
|
|
72
|
+
|
|
73
|
+
# Get the angle from the direction's "to" component
|
|
74
|
+
degrees = @direction.to.value
|
|
75
|
+
|
|
76
|
+
# Generate pixels row by row
|
|
77
|
+
pixels = Array.new(height) do |y|
|
|
78
|
+
Array.new(width) do |x|
|
|
79
|
+
position = calculate_position(x, y, width, height, degrees)
|
|
80
|
+
color_at_position(position)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Unmagic::Color::Gradient::Bitmap.new(
|
|
85
|
+
width: width,
|
|
86
|
+
height: height,
|
|
87
|
+
pixels: pixels,
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def validate_color_types(stops)
|
|
94
|
+
stops.each_with_index do |stop, i|
|
|
95
|
+
unless stop.color.is_a?(Unmagic::Color::HSL)
|
|
96
|
+
raise self.class::Error, "stops[#{i}].color must be an HSL color"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Calculate the position (0-1) of a pixel in the gradient.
|
|
102
|
+
#
|
|
103
|
+
# @param x [Integer] X coordinate
|
|
104
|
+
# @param y [Integer] Y coordinate
|
|
105
|
+
# @param width [Integer] Bitmap width
|
|
106
|
+
# @param height [Integer] Bitmap height
|
|
107
|
+
# @param degrees [Float] Gradient angle in degrees
|
|
108
|
+
# @return [Float] Position along gradient (0.0 to 1.0)
|
|
109
|
+
def calculate_position(x, y, width, height, degrees)
|
|
110
|
+
# Normalize coordinates to 0-1 range
|
|
111
|
+
nx = width > 1 ? x / (width - 1).to_f : 0.5
|
|
112
|
+
ny = height > 1 ? y / (height - 1).to_f : 0.5
|
|
113
|
+
|
|
114
|
+
# Calculate position based on angle
|
|
115
|
+
angle_rad = degrees * Math::PI / 180.0
|
|
116
|
+
|
|
117
|
+
# The gradient line goes in the direction of the angle
|
|
118
|
+
# 0° = to top (upward)
|
|
119
|
+
# 90° = to right
|
|
120
|
+
# 180° = to bottom (downward)
|
|
121
|
+
# 270° = to left
|
|
122
|
+
dx = Math.sin(angle_rad)
|
|
123
|
+
dy = Math.cos(angle_rad)
|
|
124
|
+
|
|
125
|
+
# Calculate position by projecting pixel onto gradient direction
|
|
126
|
+
# We want position 0 at the start and position 1 at the end
|
|
127
|
+
position = (nx - 0.5) * dx + (0.5 - ny) * dy + 0.5
|
|
128
|
+
|
|
129
|
+
# Clamp to valid range
|
|
130
|
+
position.clamp(0.0, 1.0)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Get the color at a specific position in the gradient.
|
|
134
|
+
#
|
|
135
|
+
# @param position [Float] Position along gradient (0.0 to 1.0)
|
|
136
|
+
# @return [Color] The interpolated color at this position
|
|
137
|
+
def color_at_position(position)
|
|
138
|
+
start_stop, end_stop = find_bracket_stops(position)
|
|
139
|
+
|
|
140
|
+
if start_stop.position == end_stop.position
|
|
141
|
+
start_stop.color
|
|
142
|
+
else
|
|
143
|
+
segment_length = end_stop.position - start_stop.position
|
|
144
|
+
blend_amount = (position - start_stop.position) / segment_length
|
|
145
|
+
start_stop.color.blend(end_stop.color, blend_amount)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/unmagic/color/hsl.rb
CHANGED
|
@@ -75,31 +75,37 @@ module Unmagic
|
|
|
75
75
|
# Error raised when parsing HSL color strings fails
|
|
76
76
|
class ParseError < Color::Error; end
|
|
77
77
|
|
|
78
|
-
attr_reader :hue, :saturation, :lightness
|
|
78
|
+
attr_reader :hue, :saturation, :lightness, :alpha
|
|
79
79
|
|
|
80
80
|
# Create a new HSL color.
|
|
81
81
|
#
|
|
82
82
|
# @param hue [Numeric] Hue in degrees (0-360), wraps around if outside range
|
|
83
83
|
# @param saturation [Numeric] Saturation percentage (0-100), clamped to range
|
|
84
84
|
# @param lightness [Numeric] Lightness percentage (0-100), clamped to range
|
|
85
|
+
# @param alpha [Numeric, Color::Alpha, nil] Alpha channel (0-100%), defaults to 100 (fully opaque)
|
|
85
86
|
#
|
|
86
87
|
# @example Create a pure red
|
|
87
88
|
# HSL.new(hue: 0, saturation: 100, lightness: 50)
|
|
88
89
|
#
|
|
90
|
+
# @example Create a semi-transparent blue
|
|
91
|
+
# HSL.new(hue: 240, saturation: 40, lightness: 80, alpha: 50)
|
|
92
|
+
#
|
|
89
93
|
# @example Create a pastel blue
|
|
90
94
|
# HSL.new(hue: 240, saturation: 40, lightness: 80)
|
|
91
|
-
def initialize(hue:, saturation:, lightness:)
|
|
95
|
+
def initialize(hue:, saturation:, lightness:, alpha: nil)
|
|
92
96
|
super()
|
|
93
97
|
@hue = Color::Hue.new(value: hue)
|
|
94
|
-
@saturation = Color::Saturation.new(saturation)
|
|
95
|
-
@lightness = Color::Lightness.new(lightness)
|
|
98
|
+
@saturation = Color::Saturation.new(value: saturation)
|
|
99
|
+
@lightness = Color::Lightness.new(value: lightness)
|
|
100
|
+
@alpha = Color::Alpha.build(alpha) || Color::Alpha::DEFAULT
|
|
96
101
|
end
|
|
97
102
|
|
|
98
103
|
class << self
|
|
99
104
|
# Parse an HSL color from a string.
|
|
100
105
|
#
|
|
101
106
|
# Accepts formats:
|
|
102
|
-
# -
|
|
107
|
+
# - Legacy: "hsl(120, 100%, 50%)" or "hsla(120, 100%, 50%, 0.5)"
|
|
108
|
+
# - Modern: "hsl(120 100% 50% / 0.5)" or "hsl(120 100% 50% / 50%)"
|
|
103
109
|
# - Raw values: "120, 100%, 50%" or "120, 100, 50"
|
|
104
110
|
# - Percentages optional for saturation and lightness
|
|
105
111
|
#
|
|
@@ -110,18 +116,51 @@ module Unmagic
|
|
|
110
116
|
# @example Parse CSS format
|
|
111
117
|
# HSL.parse("hsl(120, 100%, 50%)")
|
|
112
118
|
#
|
|
119
|
+
# @example Parse with alpha
|
|
120
|
+
# HSL.parse("hsl(120 100% 50% / 0.5)")
|
|
121
|
+
#
|
|
113
122
|
# @example Parse without function wrapper
|
|
114
123
|
# HSL.parse("240, 50%, 75%")
|
|
115
124
|
def parse(input)
|
|
116
125
|
raise ParseError, "Input must be a string" unless input.is_a?(::String)
|
|
117
126
|
|
|
118
|
-
# Remove hsl() wrapper if present
|
|
119
|
-
clean = input.gsub(/^
|
|
127
|
+
# Remove hsl() or hsla() wrapper if present
|
|
128
|
+
clean = input.gsub(/^hsla?\s*\(\s*|\s*\)$/, "").strip
|
|
129
|
+
|
|
130
|
+
# Check for modern format with slash (space-separated with / for alpha)
|
|
131
|
+
# Example: "120 100% 50% / 0.5"
|
|
132
|
+
# Note: Modern format is only used WITH the hsl() wrapper
|
|
133
|
+
alpha = nil
|
|
134
|
+
has_slash = clean.include?("/")
|
|
135
|
+
if has_slash
|
|
136
|
+
parts = clean.split("/").map(&:strip)
|
|
137
|
+
raise ParseError, "Invalid format with /: expected 'H S% L% / alpha'" unless parts.length == 2
|
|
138
|
+
|
|
139
|
+
clean = parts[0]
|
|
140
|
+
alpha = Color::Alpha.parse(parts[1])
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Split HSL values
|
|
144
|
+
# - Comma-separated: legacy format (with or without hsl() wrapper)
|
|
145
|
+
# - Space-separated: only valid WITH hsl() wrapper (modern format)
|
|
146
|
+
parts = if clean.include?(",")
|
|
147
|
+
# Legacy comma-separated format
|
|
148
|
+
clean.split(/\s*,\s*/)
|
|
149
|
+
elsif has_slash || input.match?(/^hsla?\s*\(/)
|
|
150
|
+
# Modern space-separated format (only with hsl() wrapper or slash)
|
|
151
|
+
clean.split(/\s+/)
|
|
152
|
+
else
|
|
153
|
+
# No commas and no hsl() wrapper - invalid
|
|
154
|
+
raise ParseError, "Space-separated values require hsl() wrapper, use commas for raw values"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
unless [3, 4].include?(parts.length)
|
|
158
|
+
raise ParseError, "Expected 3 or 4 HSL values, got #{parts.length}"
|
|
159
|
+
end
|
|
120
160
|
|
|
121
|
-
#
|
|
122
|
-
parts
|
|
123
|
-
|
|
124
|
-
raise ParseError, "Expected 3 HSL values, got #{parts.length}"
|
|
161
|
+
# Parse alpha from 4th value if present (legacy format)
|
|
162
|
+
if parts.length == 4 && alpha.nil?
|
|
163
|
+
alpha = Color::Alpha.parse(parts[3])
|
|
125
164
|
end
|
|
126
165
|
|
|
127
166
|
# Check if hue is numeric
|
|
@@ -159,7 +198,36 @@ module Unmagic
|
|
|
159
198
|
raise ParseError, "Lightness must be between 0 and 100, got #{l}"
|
|
160
199
|
end
|
|
161
200
|
|
|
162
|
-
new(hue: h, saturation: s, lightness: l)
|
|
201
|
+
new(hue: h, saturation: s, lightness: l, alpha: alpha)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Build an HSL color from a string, positional values, or keyword arguments.
|
|
205
|
+
#
|
|
206
|
+
# @param args [String, Numeric] Either a color string or 3 component values
|
|
207
|
+
# @option kwargs [Numeric] :hue Hue in degrees (0-360)
|
|
208
|
+
# @option kwargs [Numeric] :saturation Saturation percentage (0-100)
|
|
209
|
+
# @option kwargs [Numeric] :lightness Lightness percentage (0-100)
|
|
210
|
+
# @return [HSL] The constructed HSL color
|
|
211
|
+
#
|
|
212
|
+
# @example From string
|
|
213
|
+
# HSL.build("hsl(120, 100%, 50%)")
|
|
214
|
+
#
|
|
215
|
+
# @example From positional values
|
|
216
|
+
# HSL.build(120, 100, 50)
|
|
217
|
+
#
|
|
218
|
+
# @example From keyword arguments
|
|
219
|
+
# HSL.build(hue: 120, saturation: 100, lightness: 50)
|
|
220
|
+
def build(*args, **kwargs)
|
|
221
|
+
if kwargs.any?
|
|
222
|
+
new(**kwargs)
|
|
223
|
+
elsif args.length == 1
|
|
224
|
+
parse(args[0])
|
|
225
|
+
elsif args.length == 3
|
|
226
|
+
values = args.map { |v| v.is_a?(::String) ? v.to_f : v }
|
|
227
|
+
new(hue: values[0], saturation: values[1], lightness: values[2])
|
|
228
|
+
else
|
|
229
|
+
raise ArgumentError, "Expected 1 or 3 arguments, got #{args.length}"
|
|
230
|
+
end
|
|
163
231
|
end
|
|
164
232
|
|
|
165
233
|
# Generate a deterministic HSL color from an integer seed.
|
|
@@ -211,7 +279,7 @@ module Unmagic
|
|
|
211
279
|
def to_rgb
|
|
212
280
|
rgb = hsl_to_rgb
|
|
213
281
|
require_relative "rgb"
|
|
214
|
-
Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2])
|
|
282
|
+
Unmagic::Color::RGB.new(red: rgb[0], green: rgb[1], blue: rgb[2], alpha: @alpha)
|
|
215
283
|
end
|
|
216
284
|
|
|
217
285
|
# Convert to OKLCH color space.
|
|
@@ -223,6 +291,15 @@ module Unmagic
|
|
|
223
291
|
to_rgb.to_oklch
|
|
224
292
|
end
|
|
225
293
|
|
|
294
|
+
# Convert to hex string.
|
|
295
|
+
#
|
|
296
|
+
# Converts via RGB as an intermediate step.
|
|
297
|
+
#
|
|
298
|
+
# @return [String] The color as a hex string (e.g., "#ff5733")
|
|
299
|
+
def to_hex
|
|
300
|
+
to_rgb.to_hex
|
|
301
|
+
end
|
|
302
|
+
|
|
226
303
|
# Calculate the relative luminance.
|
|
227
304
|
#
|
|
228
305
|
# Converts to RGB first, then calculates luminance.
|
|
@@ -254,7 +331,12 @@ module Unmagic
|
|
|
254
331
|
new_saturation = @saturation.value * (1 - amount) + other_hsl.saturation.value * amount
|
|
255
332
|
new_lightness = @lightness.value * (1 - amount) + other_hsl.lightness.value * amount
|
|
256
333
|
|
|
257
|
-
Unmagic::Color::HSL.new(
|
|
334
|
+
Unmagic::Color::HSL.new(
|
|
335
|
+
hue: new_hue,
|
|
336
|
+
saturation: new_saturation,
|
|
337
|
+
lightness: new_lightness,
|
|
338
|
+
alpha: @alpha.value * (1 - amount) + other_hsl.alpha.value * amount,
|
|
339
|
+
)
|
|
258
340
|
end
|
|
259
341
|
|
|
260
342
|
# Create a lighter version by increasing lightness.
|
|
@@ -271,7 +353,7 @@ module Unmagic
|
|
|
271
353
|
def lighten(amount = 0.1)
|
|
272
354
|
amount = amount.to_f.clamp(0, 1)
|
|
273
355
|
new_lightness = @lightness.value + (100 - @lightness.value) * amount
|
|
274
|
-
Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
|
|
356
|
+
Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
|
|
275
357
|
end
|
|
276
358
|
|
|
277
359
|
# Create a darker version by decreasing lightness.
|
|
@@ -288,7 +370,7 @@ module Unmagic
|
|
|
288
370
|
def darken(amount = 0.1)
|
|
289
371
|
amount = amount.to_f.clamp(0, 1)
|
|
290
372
|
new_lightness = @lightness.value * (1 - amount)
|
|
291
|
-
Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
|
|
373
|
+
Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
|
|
292
374
|
end
|
|
293
375
|
|
|
294
376
|
# Check if two HSL colors are equal.
|
|
@@ -358,7 +440,7 @@ module Unmagic
|
|
|
358
440
|
end
|
|
359
441
|
|
|
360
442
|
# Create new HSL color with computed values
|
|
361
|
-
color = self.class.
|
|
443
|
+
color = self.class.build(hue: @hue.value, saturation: new_saturation, lightness: new_lightness, alpha: @alpha.value)
|
|
362
444
|
colors << color
|
|
363
445
|
end
|
|
364
446
|
|
|
@@ -367,16 +449,58 @@ module Unmagic
|
|
|
367
449
|
|
|
368
450
|
# Convert to string representation.
|
|
369
451
|
#
|
|
370
|
-
# Returns the CSS hsl() function format.
|
|
452
|
+
# Returns the CSS hsl() function format. If alpha is less than 100%,
|
|
453
|
+
# includes alpha value using modern CSS syntax with / separator.
|
|
371
454
|
#
|
|
372
|
-
# @return [String] HSL string like "hsl(240, 80%, 50%)"
|
|
455
|
+
# @return [String] HSL string like "hsl(240, 80%, 50%)" or "hsl(240, 80%, 50% / 0.5)"
|
|
373
456
|
#
|
|
374
|
-
# @example
|
|
457
|
+
# @example Fully opaque
|
|
375
458
|
# color = HSL.new(hue: 240, saturation: 80, lightness: 50)
|
|
376
459
|
# color.to_s
|
|
377
460
|
# # => "hsl(240, 80.0%, 50.0%)"
|
|
461
|
+
#
|
|
462
|
+
# @example Semi-transparent
|
|
463
|
+
# color = HSL.new(hue: 240, saturation: 80, lightness: 50, alpha: 50)
|
|
464
|
+
# color.to_s
|
|
465
|
+
# # => "hsl(240, 80.0%, 50.0% / 0.5)"
|
|
378
466
|
def to_s
|
|
379
|
-
|
|
467
|
+
if @alpha.value < 100
|
|
468
|
+
"hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}% / #{@alpha.to_css})"
|
|
469
|
+
else
|
|
470
|
+
"hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Convert to ANSI SGR color code.
|
|
475
|
+
#
|
|
476
|
+
# Converts to RGB first, then generates the ANSI code.
|
|
477
|
+
#
|
|
478
|
+
# @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
|
|
479
|
+
# @param mode [Symbol] Color format mode (:truecolor, :palette256, :palette16)
|
|
480
|
+
# @return [String] ANSI SGR code like "31" or "38;2;255;0;0"
|
|
481
|
+
#
|
|
482
|
+
# @example
|
|
483
|
+
# color = HSL.new(hue: 0, saturation: 100, lightness: 50)
|
|
484
|
+
# color.to_ansi
|
|
485
|
+
# # => "31"
|
|
486
|
+
def to_ansi(layer: :foreground, mode: :truecolor)
|
|
487
|
+
to_rgb.to_ansi(layer: layer, mode: mode)
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Pretty print support with colored swatch in class name.
|
|
491
|
+
#
|
|
492
|
+
# Outputs standard Ruby object format with a colored block character
|
|
493
|
+
# embedded in the class name area.
|
|
494
|
+
#
|
|
495
|
+
# @param pp [PrettyPrint] The pretty printer instance
|
|
496
|
+
#
|
|
497
|
+
# @example
|
|
498
|
+
# hsl = HSL.new(hue: 9, saturation: 100, lightness: 60)
|
|
499
|
+
# pp hsl
|
|
500
|
+
# # Outputs: #<Unmagic::Color::HSL[█] @hue=9 @saturation=100 @lightness=60>
|
|
501
|
+
# # (with colored █ block)
|
|
502
|
+
def pretty_print(pp)
|
|
503
|
+
pp.text("#<#{self.class.name}[\x1b[#{to_ansi(mode: :truecolor)}m█\x1b[0m] @hue=#{@hue.value.round} @saturation=#{@saturation.value.round} @lightness=#{@lightness.value.round}>")
|
|
380
504
|
end
|
|
381
505
|
|
|
382
506
|
private
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
class OKLCH
|
|
6
|
+
# Gradient generation in OKLCH color space.
|
|
7
|
+
module Gradient
|
|
8
|
+
# Linear gradient interpolation in OKLCH color space.
|
|
9
|
+
#
|
|
10
|
+
# Creates perceptually uniform color transitions by interpolating lightness,
|
|
11
|
+
# chroma, and hue in the OKLCH color space. OKLCH gradients maintain consistent
|
|
12
|
+
# perceived brightness across the gradient.
|
|
13
|
+
#
|
|
14
|
+
# ## OKLCH Interpolation
|
|
15
|
+
#
|
|
16
|
+
# OKLCH is a perceptually uniform color space, meaning equal steps in OKLCH
|
|
17
|
+
# values produce equal perceived differences in color. This makes OKLCH gradients
|
|
18
|
+
# ideal for UI design where consistent visual weight is important.
|
|
19
|
+
#
|
|
20
|
+
# ## Examples
|
|
21
|
+
#
|
|
22
|
+
# # Perceptually uniform gradient
|
|
23
|
+
# gradient = Unmagic::Color::OKLCH::Gradient::Linear.build(
|
|
24
|
+
# [
|
|
25
|
+
# ["oklch(0.5 0.15 30)", 0.0],
|
|
26
|
+
# ["oklch(0.7 0.15 240)", 1.0]
|
|
27
|
+
# ],
|
|
28
|
+
# direction: "to right"
|
|
29
|
+
# )
|
|
30
|
+
# bitmap = gradient.rasterize(width: 100)
|
|
31
|
+
#
|
|
32
|
+
# # Simple two-color gradient
|
|
33
|
+
# gradient = Unmagic::Color::OKLCH::Gradient::Linear.build(
|
|
34
|
+
# ["oklch(0.3 0.15 30)", "oklch(0.7 0.15 240)"],
|
|
35
|
+
# direction: "to bottom"
|
|
36
|
+
# )
|
|
37
|
+
# bitmap = gradient.rasterize(width: 1, height: 50)
|
|
38
|
+
#
|
|
39
|
+
# # Angled gradient with color objects
|
|
40
|
+
# gradient = Unmagic::Color::OKLCH::Gradient::Linear.build(
|
|
41
|
+
# [
|
|
42
|
+
# Unmagic::Color::OKLCH.new(lightness: 0.3, chroma: 0.15, hue: 30),
|
|
43
|
+
# Unmagic::Color::OKLCH.new(lightness: 0.7, chroma: 0.15, hue: 240)
|
|
44
|
+
# ],
|
|
45
|
+
# direction: "45deg"
|
|
46
|
+
# )
|
|
47
|
+
# bitmap = gradient.rasterize(width: 100, height: 100)
|
|
48
|
+
class Linear < Unmagic::Color::Gradient::Base
|
|
49
|
+
class << self
|
|
50
|
+
# Get the OKLCH color class.
|
|
51
|
+
#
|
|
52
|
+
# @return [Class] Unmagic::Color::OKLCH
|
|
53
|
+
def color_class
|
|
54
|
+
Unmagic::Color::OKLCH
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Rasterize the gradient to a bitmap.
|
|
59
|
+
#
|
|
60
|
+
# Generates a bitmap containing the gradient with support for angled directions.
|
|
61
|
+
# Colors are interpolated in perceptually uniform OKLCH space.
|
|
62
|
+
#
|
|
63
|
+
# @param width [Integer] Width of the bitmap (default 1)
|
|
64
|
+
# @param height [Integer] Height of the bitmap (default 1)
|
|
65
|
+
# @return [Bitmap] A bitmap with the specified dimensions
|
|
66
|
+
#
|
|
67
|
+
# @raise [Error] If width or height is less than 1
|
|
68
|
+
def rasterize(width: 1, height: 1)
|
|
69
|
+
raise self.class::Error, "width must be at least 1" if width < 1
|
|
70
|
+
raise self.class::Error, "height must be at least 1" if height < 1
|
|
71
|
+
|
|
72
|
+
# Get the angle from the direction's "to" component
|
|
73
|
+
degrees = @direction.to.value
|
|
74
|
+
|
|
75
|
+
# Generate pixels row by row
|
|
76
|
+
pixels = Array.new(height) do |y|
|
|
77
|
+
Array.new(width) do |x|
|
|
78
|
+
position = calculate_position(x, y, width, height, degrees)
|
|
79
|
+
color_at_position(position)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Unmagic::Color::Gradient::Bitmap.new(
|
|
84
|
+
width: width,
|
|
85
|
+
height: height,
|
|
86
|
+
pixels: pixels,
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def validate_color_types(stops)
|
|
93
|
+
stops.each_with_index do |stop, i|
|
|
94
|
+
unless stop.color.is_a?(Unmagic::Color::OKLCH)
|
|
95
|
+
raise self.class::Error, "stops[#{i}].color must be an OKLCH color"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Calculate the position (0-1) of a pixel in the gradient.
|
|
101
|
+
#
|
|
102
|
+
# @param x [Integer] X coordinate
|
|
103
|
+
# @param y [Integer] Y coordinate
|
|
104
|
+
# @param width [Integer] Bitmap width
|
|
105
|
+
# @param height [Integer] Bitmap height
|
|
106
|
+
# @param degrees [Float] Gradient angle in degrees
|
|
107
|
+
# @return [Float] Position along gradient (0.0 to 1.0)
|
|
108
|
+
def calculate_position(x, y, width, height, degrees)
|
|
109
|
+
# Normalize coordinates to 0-1 range
|
|
110
|
+
nx = width > 1 ? x / (width - 1).to_f : 0.5
|
|
111
|
+
ny = height > 1 ? y / (height - 1).to_f : 0.5
|
|
112
|
+
|
|
113
|
+
# Calculate position based on angle
|
|
114
|
+
angle_rad = degrees * Math::PI / 180.0
|
|
115
|
+
|
|
116
|
+
# The gradient line goes in the direction of the angle
|
|
117
|
+
# 0° = to top (upward)
|
|
118
|
+
# 90° = to right
|
|
119
|
+
# 180° = to bottom (downward)
|
|
120
|
+
# 270° = to left
|
|
121
|
+
dx = Math.sin(angle_rad)
|
|
122
|
+
dy = Math.cos(angle_rad)
|
|
123
|
+
|
|
124
|
+
# Calculate position by projecting pixel onto gradient direction
|
|
125
|
+
# We want position 0 at the start and position 1 at the end
|
|
126
|
+
position = (nx - 0.5) * dx + (0.5 - ny) * dy + 0.5
|
|
127
|
+
|
|
128
|
+
# Clamp to valid range
|
|
129
|
+
position.clamp(0.0, 1.0)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Get the color at a specific position in the gradient.
|
|
133
|
+
#
|
|
134
|
+
# @param position [Float] Position along gradient (0.0 to 1.0)
|
|
135
|
+
# @return [Color] The interpolated color at this position
|
|
136
|
+
def color_at_position(position)
|
|
137
|
+
start_stop, end_stop = find_bracket_stops(position)
|
|
138
|
+
|
|
139
|
+
if start_stop.position == end_stop.position
|
|
140
|
+
start_stop.color
|
|
141
|
+
else
|
|
142
|
+
segment_length = end_stop.position - start_stop.position
|
|
143
|
+
blend_amount = (position - start_stop.position) / segment_length
|
|
144
|
+
start_stop.color.blend(end_stop.color, blend_amount)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|