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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
module Console
|
|
6
|
+
# Renders the colorized ASCII art banner for the console.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# puts Unmagic::Color::Console::Banner.new.render
|
|
10
|
+
class Banner
|
|
11
|
+
# ASCII art lines for the banner
|
|
12
|
+
LINES = [
|
|
13
|
+
" ▄▄",
|
|
14
|
+
" ▀▀ ██",
|
|
15
|
+
" ██ ██ ████▄ ███▄███▄ ▀▀█▄ ▄████ ██ ▄████ ▄████ ▄███▄ ██ ▄███▄ ████▄",
|
|
16
|
+
" ██ ██ ██ ██ ██ ██ ██ ▄█▀██ ██ ██ ██ ██ ▀▀▀▀▀ ██ ██ ██ ██ ██ ██ ██ ▀▀",
|
|
17
|
+
" ▀██▀█ ██ ██ ██ ██ ██ ▀█▄██ ▀████ ██▄ ▀████ ▀████ ▀███▀ ██ ▀███▀ ██",
|
|
18
|
+
" ██",
|
|
19
|
+
" ▀▀▀",
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
# Gradient colors for the banner (magenta -> cyan -> green -> yellow -> red)
|
|
23
|
+
COLORS = ["#ff00ff", "#00ffff", "#00ff00", "#ffff00", "#ff0000"].freeze
|
|
24
|
+
|
|
25
|
+
# Render the banner with gradient coloring.
|
|
26
|
+
#
|
|
27
|
+
# @return [String] The colorized banner
|
|
28
|
+
def render
|
|
29
|
+
gradient = Gradient.linear(COLORS, direction: "left to right")
|
|
30
|
+
|
|
31
|
+
height = LINES.length
|
|
32
|
+
width = LINES.map(&:length).max
|
|
33
|
+
|
|
34
|
+
bitmap = gradient.rasterize(width: width, height: height)
|
|
35
|
+
|
|
36
|
+
LINES.each_with_index.map do |line, y|
|
|
37
|
+
line.chars.each_with_index.map do |char, x|
|
|
38
|
+
if char.strip.empty?
|
|
39
|
+
char
|
|
40
|
+
else
|
|
41
|
+
color = bitmap.pixels[y][x]
|
|
42
|
+
"\e[#{color.to_ansi}m#{char}\e[0m"
|
|
43
|
+
end
|
|
44
|
+
end.join
|
|
45
|
+
end.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [String] The rendered banner
|
|
49
|
+
def to_s
|
|
50
|
+
render
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "highlighter"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Color
|
|
7
|
+
# Console output utilities for rendering colors in terminals.
|
|
8
|
+
module Console
|
|
9
|
+
# Renders a comprehensive "profile card" for a color.
|
|
10
|
+
#
|
|
11
|
+
# Displays the color's values in all color spaces (RGB, HSL, OKLCH),
|
|
12
|
+
# harmony colors, and variations (shades, tints, tones).
|
|
13
|
+
#
|
|
14
|
+
# @example Basic usage
|
|
15
|
+
# card = Unmagic::Color::Console::Card.new("#FF5733")
|
|
16
|
+
# puts card.render
|
|
17
|
+
#
|
|
18
|
+
# @example With a color object
|
|
19
|
+
# color = Unmagic::Color.parse("rebeccapurple")
|
|
20
|
+
# puts Unmagic::Color::Console::Card.new(color)
|
|
21
|
+
class Card
|
|
22
|
+
# Width of the card in characters (excluding box borders)
|
|
23
|
+
WIDTH = 72
|
|
24
|
+
|
|
25
|
+
def initialize(color)
|
|
26
|
+
@color = Color.parse(color)
|
|
27
|
+
@highlighter = Highlighter.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Render the color profile card as a string.
|
|
31
|
+
#
|
|
32
|
+
# @return [String] The formatted card with ANSI color codes
|
|
33
|
+
def render
|
|
34
|
+
lines = []
|
|
35
|
+
lines << top_border
|
|
36
|
+
lines.concat(header_rows)
|
|
37
|
+
lines << separator
|
|
38
|
+
lines.concat(harmony_rows)
|
|
39
|
+
lines << separator
|
|
40
|
+
lines.concat(variation_rows)
|
|
41
|
+
lines << bottom_border
|
|
42
|
+
lines.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] The rendered card
|
|
46
|
+
def to_s
|
|
47
|
+
render
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# Box drawing characters
|
|
53
|
+
def top_border
|
|
54
|
+
"┌#{"─" * WIDTH}┐"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def bottom_border
|
|
58
|
+
"└#{"─" * WIDTH}┘"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def separator
|
|
62
|
+
"├#{"─" * WIDTH}┤"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def row(content)
|
|
66
|
+
visible_length = content.gsub(/\e\[[0-9;]*m/, "").length
|
|
67
|
+
padding = WIDTH - visible_length - 1
|
|
68
|
+
"│ #{content}#{" " * [padding, 0].max}│"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Generate a color swatch (colored block)
|
|
72
|
+
def swatch(color, width: 2)
|
|
73
|
+
"\e[#{color.to_ansi}m#{"█" * width}\e[0m"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Generate multiple swatches for an array of colors
|
|
77
|
+
def swatches(colors)
|
|
78
|
+
colors.map { |c| swatch(c) }.join(" ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Header with text on left, color swatch on right (same alignment as variations)
|
|
82
|
+
def header_rows
|
|
83
|
+
rgb = @color.to_rgb
|
|
84
|
+
hsl = @color.to_hsl
|
|
85
|
+
oklch = @color.to_oklch
|
|
86
|
+
|
|
87
|
+
left_width = 40 # Same as variation blocks
|
|
88
|
+
swatch_width = WIDTH - left_width - 2 # -2 for space before row end and right margin
|
|
89
|
+
|
|
90
|
+
color_block = swatch(@color, width: swatch_width)
|
|
91
|
+
|
|
92
|
+
[
|
|
93
|
+
row("#{rgb.to_hex.upcase.ljust(left_width)}#{color_block}"),
|
|
94
|
+
row("#{"rgb(#{rgb.red.value}, #{rgb.green.value}, #{rgb.blue.value})".ljust(left_width)}#{color_block}"),
|
|
95
|
+
row("#{"hsl(#{hsl.hue.value.round}, #{hsl.saturation.value.round}%, #{hsl.lightness.value.round}%)".ljust(left_width)}#{color_block}"),
|
|
96
|
+
row("#{"oklch(#{format("%.2f", oklch.lightness)} #{format("%.2f", oklch.chroma.value)} #{oklch.hue.value.round})".ljust(left_width)}#{color_block}"),
|
|
97
|
+
]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Calculate WCAG contrast ratio between two luminance values
|
|
101
|
+
def contrast_ratio(lum1, lum2)
|
|
102
|
+
lighter = [lum1, lum2].max
|
|
103
|
+
darker = [lum1, lum2].min
|
|
104
|
+
(lighter + 0.05) / (darker + 0.05)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Harmony color rows
|
|
108
|
+
def harmony_rows
|
|
109
|
+
rows = []
|
|
110
|
+
rows.concat(variation_block("Complementary", "complementary", @color.complementary))
|
|
111
|
+
rows.concat(variation_block("Analogous", "analogous", @color.analogous))
|
|
112
|
+
rows.concat(variation_block("Triadic", "triadic", @color.triadic))
|
|
113
|
+
rows.concat(variation_block("Split Complementary", "split_complementary", @color.split_complementary))
|
|
114
|
+
rows.concat(variation_block("Tetradic Square", "tetradic_square", @color.tetradic_square))
|
|
115
|
+
rows.concat(variation_block("Tetradic Rectangle", "tetradic_rectangle", @color.tetradic_rectangle, last: true))
|
|
116
|
+
rows
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Variation rows (shades, tints, tones, monochromatic)
|
|
120
|
+
def variation_rows
|
|
121
|
+
rows = []
|
|
122
|
+
rows.concat(variation_block("Shades", "shades", @color.shades))
|
|
123
|
+
rows.concat(variation_block("Tints", "tints", @color.tints))
|
|
124
|
+
rows.concat(variation_block("Tones", "tones", @color.tones))
|
|
125
|
+
rows.concat(variation_block("Monochromatic", "monochromatic", @color.monochromatic, last: true))
|
|
126
|
+
rows
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Format a variation block with name, code, swatches and hex values
|
|
130
|
+
def variation_block(name, method_name, colors, last: false)
|
|
131
|
+
colors_array = colors.is_a?(Array) ? colors : [colors]
|
|
132
|
+
hex_value = @color.to_rgb.to_hex.upcase
|
|
133
|
+
|
|
134
|
+
# Build the code snippet (dimmed)
|
|
135
|
+
code = "parse(\"#{hex_value}\").#{method_name}"
|
|
136
|
+
dim_code = @highlighter.comment(code)
|
|
137
|
+
|
|
138
|
+
# Calculate left column width for alignment (same as header)
|
|
139
|
+
left_width = 40
|
|
140
|
+
total_swatch_width = WIDTH - left_width - 2 # -2 for space and right margin
|
|
141
|
+
|
|
142
|
+
# Auto-balance swatch widths based on number of colors
|
|
143
|
+
swatch_width = total_swatch_width / colors_array.length
|
|
144
|
+
swatch_row = colors_array.map { |c| swatch(c, width: swatch_width) }.join
|
|
145
|
+
|
|
146
|
+
# Pad title and code to left column, swatches on right
|
|
147
|
+
title_padded = name.ljust(left_width)
|
|
148
|
+
code_padded = dim_code.ljust(left_width + dim_code.length - visible_length(dim_code))
|
|
149
|
+
|
|
150
|
+
rows = [
|
|
151
|
+
row("#{title_padded}#{swatch_row}"),
|
|
152
|
+
row("#{code_padded}#{swatch_row}"),
|
|
153
|
+
]
|
|
154
|
+
rows << row("") unless last
|
|
155
|
+
rows
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Calculate visible length (excluding ANSI codes)
|
|
159
|
+
def visible_length(str)
|
|
160
|
+
str.gsub(/\e\[[0-9;]*m/, "").length
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "highlighter"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Color
|
|
7
|
+
module Console
|
|
8
|
+
# Renders the help text for the console.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# puts Unmagic::Color::Console::Help.new.render
|
|
12
|
+
class Help
|
|
13
|
+
# Render the help text with syntax highlighting.
|
|
14
|
+
#
|
|
15
|
+
# @return [String] The formatted help text
|
|
16
|
+
def render
|
|
17
|
+
link = highlighter.link("https://github.com/unreasonable-magic/unmagic-color")
|
|
18
|
+
|
|
19
|
+
code = highlighter.highlight(<<~RUBY)
|
|
20
|
+
# Parse colors
|
|
21
|
+
parse("#ff5733")
|
|
22
|
+
rgb(255, 87, 51)
|
|
23
|
+
hsl(9, 100, 60)
|
|
24
|
+
oklch(0.65, 0.22, 30)
|
|
25
|
+
parse("rebeccapurple")
|
|
26
|
+
|
|
27
|
+
# Manipulate colors
|
|
28
|
+
color = parse("#ff5733")
|
|
29
|
+
color.lighten(0.1)
|
|
30
|
+
color.darken(0.1)
|
|
31
|
+
color.saturate(0.1)
|
|
32
|
+
color.desaturate(0.1)
|
|
33
|
+
color.rotate(30)
|
|
34
|
+
|
|
35
|
+
# Convert between formats
|
|
36
|
+
color.to_rgb
|
|
37
|
+
color.to_hsl
|
|
38
|
+
color.to_oklch
|
|
39
|
+
color.to_hex
|
|
40
|
+
color.to_css_oklch
|
|
41
|
+
|
|
42
|
+
# Create gradients
|
|
43
|
+
gradient(:linear, ["#FF0000", "#0000FF"]).rasterize(width: 10).pixels[0].map(&:to_hex)
|
|
44
|
+
|
|
45
|
+
# Helpers
|
|
46
|
+
rgb(255, 87, 51)
|
|
47
|
+
hsl(9, 100, 60)
|
|
48
|
+
oklch(0.65, 0.22, 30)
|
|
49
|
+
parse("#ff5733")
|
|
50
|
+
gradient(:linear, ["#FF0000", "#0000FF"])
|
|
51
|
+
percentage(50)
|
|
52
|
+
RUBY
|
|
53
|
+
|
|
54
|
+
"#{link}\n\n#{code}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @return [String] The rendered help text
|
|
58
|
+
def to_s
|
|
59
|
+
render
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def highlighter
|
|
65
|
+
@highlighter ||= Highlighter.new
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
module Console
|
|
6
|
+
# Simple syntax highlighter for Ruby code snippets.
|
|
7
|
+
#
|
|
8
|
+
# Highlights strings, numbers, symbols, and comments
|
|
9
|
+
# using ANSI color codes.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# hl = Unmagic::Color::Console::Highlighter.new
|
|
13
|
+
# puts hl.highlight('color.to_rgb.to_s')
|
|
14
|
+
# puts hl.comment('# This is a comment')
|
|
15
|
+
#
|
|
16
|
+
# @example With custom colors
|
|
17
|
+
# hl = Unmagic::Color::Console::Highlighter.new(colors: { string: "#00FF00" })
|
|
18
|
+
# puts hl.highlight('parse("#FF0000")')
|
|
19
|
+
class Highlighter
|
|
20
|
+
# Default colors for syntax highlighting
|
|
21
|
+
DEFAULT = {
|
|
22
|
+
string: "#00FF00",
|
|
23
|
+
number: "#FF00FF",
|
|
24
|
+
symbol: "#FFFF00",
|
|
25
|
+
comment: "#696969",
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# @param mode [Symbol] ANSI color mode (:truecolor, :palette256, :palette16)
|
|
29
|
+
# @param colors [Hash] Color overrides (keys: :string, :number, :symbol, :comment)
|
|
30
|
+
def initialize(mode: :palette16, colors: DEFAULT)
|
|
31
|
+
@mode = mode
|
|
32
|
+
@colors = DEFAULT.merge(colors)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Highlight a code snippet with syntax coloring.
|
|
36
|
+
#
|
|
37
|
+
# Supports multi-line input. Lines starting with # are treated as comments.
|
|
38
|
+
#
|
|
39
|
+
# @param code [String] Ruby code to highlight (single or multi-line)
|
|
40
|
+
# @return [String] Code with ANSI color codes
|
|
41
|
+
def highlight(code)
|
|
42
|
+
code.lines.map { |line| highlight_line(line.chomp) }.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def highlight_line(line)
|
|
48
|
+
# Don't highlight if already contains ANSI codes
|
|
49
|
+
return line if line.include?("\e[")
|
|
50
|
+
|
|
51
|
+
# Treat lines starting with # as comments
|
|
52
|
+
return comment(line) if line.start_with?("#")
|
|
53
|
+
|
|
54
|
+
# Empty lines pass through
|
|
55
|
+
return line if line.empty?
|
|
56
|
+
|
|
57
|
+
result = line
|
|
58
|
+
|
|
59
|
+
# Protect strings first by replacing with placeholders
|
|
60
|
+
strings = []
|
|
61
|
+
result = result.gsub(/(".*?")/) do
|
|
62
|
+
strings << Regexp.last_match(1)
|
|
63
|
+
"\x00STRING#{strings.length - 1}\x00"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Now highlight other elements (won't match inside strings)
|
|
67
|
+
result = result
|
|
68
|
+
.gsub(/\b(\d+\.?\d*%?)/) { colorize(Regexp.last_match(1), :number) }
|
|
69
|
+
.gsub(/(:[a-z_]+|[a-z_]+:)/) { colorize(Regexp.last_match(1), :symbol) }
|
|
70
|
+
|
|
71
|
+
# Restore and highlight strings
|
|
72
|
+
strings.each_with_index do |str, i|
|
|
73
|
+
result = result.gsub("\x00STRING#{i}\x00", colorize(str, :string))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
public
|
|
80
|
+
|
|
81
|
+
# Format text as a comment.
|
|
82
|
+
#
|
|
83
|
+
# @param text [String] Comment text
|
|
84
|
+
# @return [String] Gray-colored text
|
|
85
|
+
def comment(text)
|
|
86
|
+
colorize(text, :comment)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Colorize text with a specific color.
|
|
90
|
+
#
|
|
91
|
+
# @param text [String] Text to colorize
|
|
92
|
+
# @param key [Symbol] Color key from the colors hash
|
|
93
|
+
# @return [String] Text with ANSI color codes
|
|
94
|
+
def colorize(text, key)
|
|
95
|
+
color = Color.parse(@colors[key])
|
|
96
|
+
"\e[#{color.to_ansi(mode: @mode)}m#{text}\e[0m"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Format text as a clickable hyperlink.
|
|
100
|
+
#
|
|
101
|
+
# Uses ANSI palette16 blue with underline, plus OSC 8 hyperlink
|
|
102
|
+
# sequence for iTerm2 and other modern terminals.
|
|
103
|
+
#
|
|
104
|
+
# @param url [String] The URL to link to
|
|
105
|
+
# @param text [String] The display text (defaults to url)
|
|
106
|
+
# @return [String] Styled, clickable link
|
|
107
|
+
def link(url, text = url)
|
|
108
|
+
# ANSI palette16 blue (34) + underline (4)
|
|
109
|
+
"\e[4;34m\e]8;;#{url}\a#{text}\e]8;;\a\e[0m"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Unmagic
|
|
4
|
+
class Color
|
|
5
|
+
module Gradient
|
|
6
|
+
# Base class for gradient implementations.
|
|
7
|
+
#
|
|
8
|
+
# Provides shared functionality for all gradient types. Subclasses must
|
|
9
|
+
# override `color_class` and `validate_color_types` to specify their color space.
|
|
10
|
+
#
|
|
11
|
+
# ## Subclass Requirements
|
|
12
|
+
#
|
|
13
|
+
# Subclasses must implement:
|
|
14
|
+
# - `.color_class` - Returns the color class (RGB, HSL, or OKLCH)
|
|
15
|
+
# - `#validate_color_types(stops)` - Validates all stops have correct color type
|
|
16
|
+
# - `#rasterize` - Generates a Bitmap from the gradient
|
|
17
|
+
#
|
|
18
|
+
# ## Examples
|
|
19
|
+
#
|
|
20
|
+
# # Subclasses use this base class
|
|
21
|
+
# class RGB::Gradient::Linear < Gradient::Base
|
|
22
|
+
# def self.color_class
|
|
23
|
+
# Unmagic::Color::RGB
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# def validate_color_types(stops)
|
|
27
|
+
# # Validation logic...
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# def rasterize
|
|
31
|
+
# # Rasterization logic...
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
class Base
|
|
35
|
+
# Error raised for gradient base class issues.
|
|
36
|
+
class Error < Color::Error; end
|
|
37
|
+
|
|
38
|
+
attr_reader :stops, :direction
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Get the color class for this gradient type.
|
|
42
|
+
#
|
|
43
|
+
# Subclasses must override this to return their color class.
|
|
44
|
+
#
|
|
45
|
+
# @return [Class] The color class (RGB, HSL, or OKLCH)
|
|
46
|
+
# @raise [NotImplementedError] If not overridden by subclass
|
|
47
|
+
def color_class
|
|
48
|
+
raise NotImplementedError, "Subclasses must define color_class"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build a gradient from colors or color/position tuples.
|
|
52
|
+
#
|
|
53
|
+
# Convenience factory method that converts colors to Stop objects
|
|
54
|
+
# and creates a gradient. Accepts both color objects and strings
|
|
55
|
+
# (strings are parsed using the color class's parse method).
|
|
56
|
+
#
|
|
57
|
+
# Works like CSS linear-gradient - you can mix positioned and non-positioned colors.
|
|
58
|
+
# Non-positioned colors auto-balance between their surrounding positioned neighbors.
|
|
59
|
+
#
|
|
60
|
+
# @param colors_or_tuples [Array] Array of colors or [color, position] pairs (can be mixed)
|
|
61
|
+
# @param direction [String, Numeric, Degrees, Direction, nil] Optional gradient direction
|
|
62
|
+
# - Direction strings: "to top", "from left to right", "45deg", "90°"
|
|
63
|
+
# - Numeric degrees: 45, 90, 180
|
|
64
|
+
# - Degrees/Direction instances
|
|
65
|
+
# - Defaults to "to bottom" (180°) if omitted
|
|
66
|
+
# @return [Base] New gradient instance
|
|
67
|
+
#
|
|
68
|
+
# @example All auto-balanced positions
|
|
69
|
+
# RGB::Gradient::Linear.build(["#FF0000", "#00FF00", "#0000FF"])
|
|
70
|
+
# # Positions: 0.0, 0.5, 1.0
|
|
71
|
+
#
|
|
72
|
+
# @example All explicit positions
|
|
73
|
+
# RGB::Gradient::Linear.build([["#FF0000", 0.0], ["#00FF00", 0.3], ["#0000FF", 1.0]])
|
|
74
|
+
#
|
|
75
|
+
# @example Mixed positions (like CSS linear-gradient)
|
|
76
|
+
# RGB::Gradient::Linear.build(["#FF0000", ["#FFFF00", 0.3], "#00FF00", ["#0000FF", 0.9], "#FF00FF"])
|
|
77
|
+
# # Positions: 0.0, 0.3, 0.6, 0.9, 1.0
|
|
78
|
+
# # (red at start, yellow at 30%, green auto-balances at 60%, blue at 90%, purple at end)
|
|
79
|
+
#
|
|
80
|
+
# @example With direction keyword
|
|
81
|
+
# RGB::Gradient::Linear.build(["#FF0000", "#0000FF"], direction: "to right")
|
|
82
|
+
# RGB::Gradient::Linear.build(["#FF0000", "#0000FF"], direction: "from left to right")
|
|
83
|
+
#
|
|
84
|
+
# @example With numeric direction
|
|
85
|
+
# RGB::Gradient::Linear.build(["#FF0000", "#0000FF"], direction: 45)
|
|
86
|
+
# RGB::Gradient::Linear.build(["#FF0000", "#0000FF"], direction: "90deg")
|
|
87
|
+
def build(colors_or_tuples, direction: nil)
|
|
88
|
+
# Parse colors and detect which have explicit positions
|
|
89
|
+
parsed = colors_or_tuples.map do |item|
|
|
90
|
+
if item.is_a?(::Array)
|
|
91
|
+
# Explicit position tuple
|
|
92
|
+
color_or_string, position = item
|
|
93
|
+
color = if color_or_string.is_a?(::String)
|
|
94
|
+
# Use universal parser for strings (handles named colors, hex, rgb(), hsl(), etc.)
|
|
95
|
+
parsed_color = Unmagic::Color[color_or_string]
|
|
96
|
+
# Convert to the gradient's color space if needed
|
|
97
|
+
convert_to_color_space(parsed_color)
|
|
98
|
+
else
|
|
99
|
+
color_or_string
|
|
100
|
+
end
|
|
101
|
+
{ color: color, position: position }
|
|
102
|
+
else
|
|
103
|
+
# No position, will auto-balance
|
|
104
|
+
color = if item.is_a?(::String)
|
|
105
|
+
# Use universal parser for strings
|
|
106
|
+
parsed_color = Unmagic::Color[item]
|
|
107
|
+
# Convert to the gradient's color space if needed
|
|
108
|
+
convert_to_color_space(parsed_color)
|
|
109
|
+
else
|
|
110
|
+
item
|
|
111
|
+
end
|
|
112
|
+
{ color: color, position: nil }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Auto-balance positions for items without explicit positions
|
|
117
|
+
# Pass 1: Set first and last items if they don't have positions
|
|
118
|
+
unless parsed.first[:position]
|
|
119
|
+
parsed.first[:position] = 0.0
|
|
120
|
+
end
|
|
121
|
+
unless parsed.last[:position]
|
|
122
|
+
parsed.last[:position] = 1.0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Pass 2: Auto-balance middle items
|
|
126
|
+
parsed.each_with_index do |item, i|
|
|
127
|
+
next if item[:position] # Already has position
|
|
128
|
+
|
|
129
|
+
# Find previous positioned stop
|
|
130
|
+
prev_pos = nil
|
|
131
|
+
prev_index = nil
|
|
132
|
+
(i - 1).downto(0) do |j|
|
|
133
|
+
if parsed[j][:position]
|
|
134
|
+
prev_pos = parsed[j][:position]
|
|
135
|
+
prev_index = j
|
|
136
|
+
break
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Find next positioned stop
|
|
141
|
+
next_pos = nil
|
|
142
|
+
next_index = nil
|
|
143
|
+
((i + 1)...parsed.length).each do |j|
|
|
144
|
+
if parsed[j][:position]
|
|
145
|
+
next_pos = parsed[j][:position]
|
|
146
|
+
next_index = j
|
|
147
|
+
break
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Count items in this unpositioned group
|
|
152
|
+
group_size = next_index - prev_index - 1
|
|
153
|
+
group_index = i - prev_index - 1
|
|
154
|
+
|
|
155
|
+
# Evenly distribute within the range
|
|
156
|
+
item[:position] = prev_pos + (next_pos - prev_pos) * (group_index + 1) / (group_size + 1).to_f
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Create Stop objects
|
|
160
|
+
stops = parsed.map do |item|
|
|
161
|
+
Unmagic::Color::Gradient::Stop.new(color: item[:color], position: item[:position])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
new(stops, direction: direction)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
# Convert a color to this gradient's color space.
|
|
170
|
+
#
|
|
171
|
+
# @param color [Color] The color to convert
|
|
172
|
+
# @return [Color] The color in the gradient's color space
|
|
173
|
+
def convert_to_color_space(color)
|
|
174
|
+
target_class = color_class
|
|
175
|
+
return color if color.is_a?(target_class)
|
|
176
|
+
|
|
177
|
+
# Convert to the target color space
|
|
178
|
+
case target_class.name
|
|
179
|
+
when "Unmagic::Color::RGB"
|
|
180
|
+
color.to_rgb
|
|
181
|
+
when "Unmagic::Color::HSL"
|
|
182
|
+
color.to_hsl
|
|
183
|
+
when "Unmagic::Color::OKLCH"
|
|
184
|
+
color.to_oklch
|
|
185
|
+
else
|
|
186
|
+
color
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Create a new gradient.
|
|
192
|
+
#
|
|
193
|
+
# @param stops [Array<Stop>] Array of color stops
|
|
194
|
+
# @param direction [Direction, nil] Optional Direction instance (defaults to TOP_TO_BOTTOM)
|
|
195
|
+
#
|
|
196
|
+
# @raise [Error] If stops is not an array
|
|
197
|
+
# @raise [Error] If there are fewer than 2 stops
|
|
198
|
+
# @raise [Error] If any stop is not a Stop object
|
|
199
|
+
# @raise [Error] If stops are not sorted by position
|
|
200
|
+
def initialize(stops, direction: nil)
|
|
201
|
+
raise Error, "stops must be an array" unless stops.is_a?(Array)
|
|
202
|
+
raise Error, "must have at least 2 stops" if stops.length < 2
|
|
203
|
+
|
|
204
|
+
stops.each_with_index do |stop, i|
|
|
205
|
+
unless stop.is_a?(Unmagic::Color::Gradient::Stop)
|
|
206
|
+
raise Error, "stops[#{i}] must be a Stop object"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
validate_color_types(stops)
|
|
211
|
+
|
|
212
|
+
stops.each_cons(2) do |a, b|
|
|
213
|
+
if a.position > b.position
|
|
214
|
+
raise Error, "stops must be sorted by position"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@stops = stops
|
|
219
|
+
@direction = direction
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Validate that all stops have the correct color type.
|
|
225
|
+
#
|
|
226
|
+
# Subclasses must override this to check color types.
|
|
227
|
+
#
|
|
228
|
+
# @param stops [Array<Stop>] Array of stops to validate
|
|
229
|
+
# @raise [NotImplementedError] If not overridden by subclass
|
|
230
|
+
def validate_color_types(stops)
|
|
231
|
+
raise NotImplementedError, "Subclasses must implement validate_color_types"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Find the two stops that bracket a given position.
|
|
235
|
+
#
|
|
236
|
+
# Returns the start and end stops for the segment containing the position.
|
|
237
|
+
# Used during interpolation to find which colors to blend.
|
|
238
|
+
#
|
|
239
|
+
# @param position [Float] Position to find (0.0-1.0)
|
|
240
|
+
# @return [Array<Stop, Stop>] The [start_stop, end_stop] that bracket the position
|
|
241
|
+
def find_bracket_stops(position)
|
|
242
|
+
@stops.each_cons(2) do |start_stop, end_stop|
|
|
243
|
+
if position >= start_stop.position && position <= end_stop.position
|
|
244
|
+
return [start_stop, end_stop]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
[@stops[-2], @stops[-1]]
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|