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,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "degrees"
|
|
4
|
+
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Color
|
|
7
|
+
module Units
|
|
8
|
+
class Degrees
|
|
9
|
+
# Represents a gradient direction as a from/to tuple of Degrees.
|
|
10
|
+
#
|
|
11
|
+
# Direction defines a gradient's angle by specifying where it starts (from)
|
|
12
|
+
# and where it ends (to). Supports CSS-style direction strings with flexible
|
|
13
|
+
# parsing that infers missing components.
|
|
14
|
+
#
|
|
15
|
+
# ## Supported Formats
|
|
16
|
+
#
|
|
17
|
+
# - Full directions: `"from left to right"`, `"from bottom left to top right"`
|
|
18
|
+
# - Implicit from: `"to top"` → infers `"from bottom to top"`
|
|
19
|
+
# - Implicit to: `"from bottom"` → infers `"from bottom to top"`
|
|
20
|
+
# - Without "from": `"left to right"` → `"from left to right"`
|
|
21
|
+
# - Mixed formats: `"from 45deg to top right"`, `"from south to 90deg"`, `"from 45° to 90°"`
|
|
22
|
+
# - Hash: `{ from: "north", to: "south" }`
|
|
23
|
+
#
|
|
24
|
+
# @example Parse direction strings
|
|
25
|
+
# Direction.parse("from left to right")
|
|
26
|
+
# Direction.parse("to top") # Infers from bottom
|
|
27
|
+
# Direction.parse("from bottom") # Infers to top
|
|
28
|
+
# Direction.parse("left to right") # Implicit "from"
|
|
29
|
+
# Direction.parse("from 45deg to 90deg") # Numeric degrees
|
|
30
|
+
# Direction.parse("from 45° to 90°") # With ° symbol
|
|
31
|
+
# Direction.parse("from south to top right") # Mixed
|
|
32
|
+
#
|
|
33
|
+
# @example Build from various inputs
|
|
34
|
+
# Direction.build("from left to right")
|
|
35
|
+
# Direction.build(from: "north", to: "south")
|
|
36
|
+
# Direction.build(from: 45, to: 90)
|
|
37
|
+
#
|
|
38
|
+
# @example Direct construction
|
|
39
|
+
# direction = Direction.new(from: Degrees::LEFT, to: Degrees::RIGHT)
|
|
40
|
+
# direction.from.value #=> 270.0
|
|
41
|
+
# direction.to.value #=> 90.0
|
|
42
|
+
#
|
|
43
|
+
# @example Constants
|
|
44
|
+
# Direction::LEFT_TO_RIGHT
|
|
45
|
+
# Direction::BOTTOM_LEFT_TO_TOP_RIGHT
|
|
46
|
+
#
|
|
47
|
+
# @example String output
|
|
48
|
+
# Direction::LEFT_TO_RIGHT.to_s #=> "from left to right"
|
|
49
|
+
# Direction::LEFT_TO_RIGHT.to_css #=> "from left to right"
|
|
50
|
+
class Direction
|
|
51
|
+
attr_reader :from, :to
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# All predefined direction constants
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Direction>] All constant directions
|
|
57
|
+
def all
|
|
58
|
+
all_constants
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Array of all predefined direction constants
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<Direction>] All constant directions
|
|
66
|
+
def all_constants
|
|
67
|
+
@all_constants ||= [
|
|
68
|
+
BOTTOM_TO_TOP,
|
|
69
|
+
LEFT_TO_RIGHT,
|
|
70
|
+
TOP_TO_BOTTOM,
|
|
71
|
+
RIGHT_TO_LEFT,
|
|
72
|
+
BOTTOM_LEFT_TO_TOP_RIGHT,
|
|
73
|
+
TOP_LEFT_TO_BOTTOM_RIGHT,
|
|
74
|
+
TOP_RIGHT_TO_BOTTOM_LEFT,
|
|
75
|
+
BOTTOM_RIGHT_TO_TOP_LEFT,
|
|
76
|
+
]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
public
|
|
80
|
+
|
|
81
|
+
# Check if a string looks like a direction keyword.
|
|
82
|
+
#
|
|
83
|
+
# @param input [String] The string to check
|
|
84
|
+
# @return [Boolean] true if the string appears to be a direction keyword
|
|
85
|
+
def matches?(input)
|
|
86
|
+
return false unless input.is_a?(::String)
|
|
87
|
+
|
|
88
|
+
normalized = input.strip.downcase
|
|
89
|
+
|
|
90
|
+
# Check if it contains "from" or "to" keywords
|
|
91
|
+
normalized.start_with?("to ") || normalized.start_with?("from ") || normalized.include?(" to ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Build a Direction from various input formats.
|
|
95
|
+
#
|
|
96
|
+
# @param input [String, Direction, Hash] Direction string, instance, or hash with :from and :to keys
|
|
97
|
+
# @return [Direction] Direction instance
|
|
98
|
+
def build(input)
|
|
99
|
+
return input if input.is_a?(Direction)
|
|
100
|
+
|
|
101
|
+
if input.is_a?(::Hash)
|
|
102
|
+
raise Degrees::ParseError, "Hash must have :from and :to keys" unless input.key?(:from) && input.key?(:to)
|
|
103
|
+
|
|
104
|
+
from_degree = Degrees.build(input[:from])
|
|
105
|
+
to_degree = Degrees.build(input[:to])
|
|
106
|
+
return new(from: from_degree, to: to_degree)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
parse(input)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Parse a direction string into a Direction instance.
|
|
113
|
+
#
|
|
114
|
+
# Supports mixed formats like:
|
|
115
|
+
# - "from 275deg to 45deg"
|
|
116
|
+
# - "from south to 90"
|
|
117
|
+
# - "from north to top right"
|
|
118
|
+
# - "to top" (infers from as opposite)
|
|
119
|
+
# - "from bottom" (infers to as opposite)
|
|
120
|
+
#
|
|
121
|
+
# @param input [String] Direction string
|
|
122
|
+
# @return [Direction] Parsed direction
|
|
123
|
+
# @raise [Degrees::ParseError] If direction is invalid
|
|
124
|
+
def parse(input)
|
|
125
|
+
# Normalize: strip, downcase, and collapse whitespace
|
|
126
|
+
normalized = input.strip.downcase.gsub(/\s+/, " ")
|
|
127
|
+
|
|
128
|
+
# Remove "from " prefix if present, then split on "to "
|
|
129
|
+
parts = normalized.delete_prefix("from ").split("to ", 2).map(&:strip)
|
|
130
|
+
left = parts[0]
|
|
131
|
+
right = parts[1]
|
|
132
|
+
|
|
133
|
+
if left.empty? && right
|
|
134
|
+
# Only right side specified: "to top"
|
|
135
|
+
to_degree = Degrees.build(right)
|
|
136
|
+
from_degree = to_degree.opposite
|
|
137
|
+
elsif right.nil?
|
|
138
|
+
# Only left side specified: "from bottom"
|
|
139
|
+
from_degree = Degrees.build(left)
|
|
140
|
+
to_degree = from_degree.opposite
|
|
141
|
+
else
|
|
142
|
+
# Both sides specified: "left to right" or "from left to right"
|
|
143
|
+
from_degree = Degrees.build(left)
|
|
144
|
+
to_degree = Degrees.build(right)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
new(from: from_degree, to: to_degree)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Create a new Direction instance.
|
|
152
|
+
#
|
|
153
|
+
# @param from [Degrees] Starting degree
|
|
154
|
+
# @param to [Degrees] Ending degree
|
|
155
|
+
def initialize(from:, to:)
|
|
156
|
+
@from = from
|
|
157
|
+
@to = to
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Convert to CSS string format.
|
|
161
|
+
#
|
|
162
|
+
# @return [String] CSS direction string (e.g., "from left to right")
|
|
163
|
+
def to_css
|
|
164
|
+
from_str = @from.name || @from.to_css
|
|
165
|
+
to_str = @to.name || @to.to_css
|
|
166
|
+
"from #{from_str} to #{to_str}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Convert to string representation.
|
|
170
|
+
#
|
|
171
|
+
# @return [String] Canonical string format that can be parsed back
|
|
172
|
+
def to_s
|
|
173
|
+
from_str = @from.name || @from.to_s
|
|
174
|
+
to_str = @to.name || @to.to_s
|
|
175
|
+
"from #{from_str} to #{to_str}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check equality.
|
|
179
|
+
#
|
|
180
|
+
# @param other [Object] Value to compare
|
|
181
|
+
# @return [Boolean] true if from and to are equal
|
|
182
|
+
def ==(other)
|
|
183
|
+
other.is_a?(Direction) && @from == other.from && @to == other.to
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Predefined direction constants
|
|
187
|
+
BOTTOM_TO_TOP = new(from: Degrees::BOTTOM, to: Degrees::TOP).freeze
|
|
188
|
+
# Left to right direction (horizontal)
|
|
189
|
+
LEFT_TO_RIGHT = new(from: Degrees::LEFT, to: Degrees::RIGHT).freeze
|
|
190
|
+
# Top to bottom direction (vertical, default)
|
|
191
|
+
TOP_TO_BOTTOM = new(from: Degrees::TOP, to: Degrees::BOTTOM).freeze
|
|
192
|
+
# Right to left direction (horizontal)
|
|
193
|
+
RIGHT_TO_LEFT = new(from: Degrees::RIGHT, to: Degrees::LEFT).freeze
|
|
194
|
+
# Bottom-left to top-right diagonal direction
|
|
195
|
+
BOTTOM_LEFT_TO_TOP_RIGHT = new(from: Degrees::BOTTOM_LEFT, to: Degrees::TOP_RIGHT).freeze
|
|
196
|
+
# Top-left to bottom-right diagonal direction
|
|
197
|
+
TOP_LEFT_TO_BOTTOM_RIGHT = new(from: Degrees::TOP_LEFT, to: Degrees::BOTTOM_RIGHT).freeze
|
|
198
|
+
# Top-right to bottom-left diagonal direction
|
|
199
|
+
TOP_RIGHT_TO_BOTTOM_LEFT = new(from: Degrees::TOP_RIGHT, to: Degrees::BOTTOM_LEFT).freeze
|
|
200
|
+
# Bottom-right to top-left diagonal direction
|
|
201
|
+
BOTTOM_RIGHT_TO_TOP_LEFT = new(from: Degrees::BOTTOM_RIGHT, to: Degrees::TOP_LEFT).freeze
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -7,19 +7,19 @@ module Unmagic
|
|
|
7
7
|
# Handles both direct percentage values and ratio calculations.
|
|
8
8
|
#
|
|
9
9
|
# @example Direct percentage value
|
|
10
|
-
# percentage = Percentage.
|
|
10
|
+
# percentage = Percentage.build(75.5)
|
|
11
11
|
# percentage.to_s
|
|
12
12
|
# #=> "75.5%"
|
|
13
13
|
# percentage.value
|
|
14
14
|
# #=> 75.5
|
|
15
15
|
#
|
|
16
16
|
# @example Calculated from ratio
|
|
17
|
-
# percentage = Percentage.
|
|
17
|
+
# percentage = Percentage.build(50, 100)
|
|
18
18
|
# percentage.to_s
|
|
19
19
|
# #=> "50.0%"
|
|
20
20
|
#
|
|
21
21
|
# @example Progress tracking
|
|
22
|
-
# percentage = Percentage.
|
|
22
|
+
# percentage = Percentage.build(current_item, total_items)
|
|
23
23
|
# percentage.to_s
|
|
24
24
|
# #=> "25.0%"
|
|
25
25
|
class Percentage
|
|
@@ -29,24 +29,130 @@ module Unmagic
|
|
|
29
29
|
|
|
30
30
|
# Create a new percentage
|
|
31
31
|
#
|
|
32
|
-
# @param
|
|
33
|
-
def initialize(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
# @param value [Numeric] The percentage value (0-100)
|
|
33
|
+
def initialize(value:)
|
|
34
|
+
@value = value.to_f.clamp(0.0, 100.0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Build a percentage from various input types.
|
|
39
|
+
#
|
|
40
|
+
# Handles both single value and numerator/denominator ratio inputs.
|
|
41
|
+
#
|
|
42
|
+
# @param args [Array<Numeric>] Either a single percentage value (0-100) or numerator and denominator
|
|
43
|
+
# @option kwargs [Numeric] :value The percentage value (0-100)
|
|
44
|
+
# @return [Percentage, nil] The created percentage or nil if passed nil
|
|
45
|
+
#
|
|
46
|
+
# @example Single value
|
|
47
|
+
# Percentage.build(75.5)
|
|
48
|
+
# #=> Percentage with value 75.5
|
|
49
|
+
#
|
|
50
|
+
# @example Ratio
|
|
51
|
+
# Percentage.build(50, 100)
|
|
52
|
+
# #=> Percentage with value 50.0
|
|
53
|
+
#
|
|
54
|
+
# @example Keyword argument
|
|
55
|
+
# Percentage.build(value: 75.5)
|
|
56
|
+
# #=> Percentage with value 75.5
|
|
57
|
+
#
|
|
58
|
+
# @example Pass through existing instance
|
|
59
|
+
# existing = Percentage.new(value: 50)
|
|
60
|
+
# Percentage.build(existing)
|
|
61
|
+
# #=> Returns the same instance
|
|
62
|
+
def build(*args, **kwargs)
|
|
63
|
+
return new(**kwargs) if kwargs.any?
|
|
64
|
+
|
|
65
|
+
case args.length
|
|
66
|
+
when 1
|
|
67
|
+
input = args[0]
|
|
68
|
+
return if input.nil?
|
|
69
|
+
return input if input.is_a?(self)
|
|
70
|
+
|
|
71
|
+
# Handle strings by parsing
|
|
72
|
+
return parse(input) if input.is_a?(::String)
|
|
73
|
+
|
|
74
|
+
# Handle Rational
|
|
75
|
+
if input.is_a?(Rational)
|
|
76
|
+
return new(value: input.to_f * 100)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Handle numeric values
|
|
80
|
+
# If value is <= 1.0, treat as ratio (multiply by 100)
|
|
81
|
+
# If value is > 1.0, treat as literal percentage value
|
|
82
|
+
value = input.to_f
|
|
83
|
+
if value <= 1.0
|
|
84
|
+
new(value: value * 100)
|
|
85
|
+
else
|
|
86
|
+
new(value: value)
|
|
87
|
+
end
|
|
88
|
+
when 2
|
|
89
|
+
numerator, denominator = args
|
|
90
|
+
value = if denominator.to_f.zero?
|
|
91
|
+
0.0
|
|
92
|
+
else
|
|
93
|
+
(numerator.to_f / denominator.to_f * 100.0)
|
|
94
|
+
end
|
|
95
|
+
new(value: value)
|
|
41
96
|
else
|
|
42
|
-
(
|
|
97
|
+
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 1..2)"
|
|
43
98
|
end
|
|
44
|
-
else
|
|
45
|
-
raise ArgumentError, "wrong number of arguments (given #{args.length}, expected 1..2)"
|
|
46
99
|
end
|
|
47
100
|
|
|
48
|
-
#
|
|
49
|
-
|
|
101
|
+
# Parse a percentage from string format.
|
|
102
|
+
#
|
|
103
|
+
# Handles multiple formats:
|
|
104
|
+
# - Explicit percentage: "50%" → 50.0
|
|
105
|
+
# - Fraction notation: "10/100" → 10.0
|
|
106
|
+
# - Bare decimal ≤ 1.0: "0.5" → 50.0 (treated as ratio)
|
|
107
|
+
# - Bare decimal > 1.0: "75" → 75.0 (treated as literal percentage)
|
|
108
|
+
#
|
|
109
|
+
# @param input [String] The string to parse
|
|
110
|
+
# @return [Percentage] The parsed percentage
|
|
111
|
+
# @raise [ArgumentError] If input is not a string or format is invalid
|
|
112
|
+
#
|
|
113
|
+
# @example Parse explicit percentage
|
|
114
|
+
# Percentage.parse("23.5%")
|
|
115
|
+
# #=> Percentage with value 23.5
|
|
116
|
+
#
|
|
117
|
+
# @example Parse ratio
|
|
118
|
+
# Percentage.parse("0.5")
|
|
119
|
+
# #=> Percentage with value 50.0
|
|
120
|
+
#
|
|
121
|
+
# @example Parse fraction
|
|
122
|
+
# Percentage.parse("1/4")
|
|
123
|
+
# #=> Percentage with value 25.0
|
|
124
|
+
def parse(input)
|
|
125
|
+
raise ArgumentError, "Input must be a string" unless input.is_a?(::String)
|
|
126
|
+
|
|
127
|
+
input = input.strip
|
|
128
|
+
|
|
129
|
+
# Handle explicit percentage format
|
|
130
|
+
if input.end_with?("%")
|
|
131
|
+
value = input.chomp("%").to_f
|
|
132
|
+
return new(value: value)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Handle fraction notation
|
|
136
|
+
if input.include?("/")
|
|
137
|
+
parts = input.split("/").map(&:strip)
|
|
138
|
+
raise ArgumentError, "Invalid fraction format" unless parts.length == 2
|
|
139
|
+
|
|
140
|
+
numerator = parts[0].to_f
|
|
141
|
+
denominator = parts[1].to_f
|
|
142
|
+
return build(numerator, denominator)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Handle bare numeric values
|
|
146
|
+
value = input.to_f
|
|
147
|
+
|
|
148
|
+
# If value is <= 1.0, treat as ratio (multiply by 100)
|
|
149
|
+
# If value is > 1.0, treat as literal percentage value
|
|
150
|
+
if value <= 1.0
|
|
151
|
+
new(value: value * 100)
|
|
152
|
+
else
|
|
153
|
+
new(value: value)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
50
156
|
end
|
|
51
157
|
|
|
52
158
|
# Format as percentage string with configurable decimal places
|
|
@@ -102,9 +208,9 @@ module Unmagic
|
|
|
102
208
|
def +(other)
|
|
103
209
|
case other
|
|
104
210
|
when Percentage
|
|
105
|
-
Percentage.new([value + other.value, 100.0].min)
|
|
211
|
+
Percentage.new(value: [value + other.value, 100.0].min)
|
|
106
212
|
when Numeric
|
|
107
|
-
Percentage.new([value + other.to_f, 100.0].min)
|
|
213
|
+
Percentage.new(value: [value + other.to_f, 100.0].min)
|
|
108
214
|
else
|
|
109
215
|
raise TypeError, "can't add #{other.class} to Percentage"
|
|
110
216
|
end
|
|
@@ -117,9 +223,9 @@ module Unmagic
|
|
|
117
223
|
def -(other)
|
|
118
224
|
case other
|
|
119
225
|
when Percentage
|
|
120
|
-
Percentage.new([value - other.value, 0.0].max)
|
|
226
|
+
Percentage.new(value: [value - other.value, 0.0].max)
|
|
121
227
|
when Numeric
|
|
122
|
-
Percentage.new([value - other.to_f, 0.0].max)
|
|
228
|
+
Percentage.new(value: [value - other.to_f, 0.0].max)
|
|
123
229
|
else
|
|
124
230
|
raise TypeError, "can't subtract #{other.class} from Percentage"
|
|
125
231
|
end
|
|
@@ -129,7 +235,7 @@ module Unmagic
|
|
|
129
235
|
#
|
|
130
236
|
# @return [Percentage] New percentage with absolute value
|
|
131
237
|
def abs
|
|
132
|
-
self.class.new(@value.abs)
|
|
238
|
+
self.class.new(value: @value.abs)
|
|
133
239
|
end
|
|
134
240
|
|
|
135
241
|
# Check if percentage is zero
|
|
@@ -138,6 +244,28 @@ module Unmagic
|
|
|
138
244
|
def zero?
|
|
139
245
|
@value.zero?
|
|
140
246
|
end
|
|
247
|
+
|
|
248
|
+
# Pretty print format for debugging
|
|
249
|
+
#
|
|
250
|
+
# @param pp [PP] The pretty printer
|
|
251
|
+
# @return [void]
|
|
252
|
+
def pretty_print(pp)
|
|
253
|
+
pp.group(1, "#<#{self.class}(", ")>") do
|
|
254
|
+
pp.text("\"#{self}\"")
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Constructor-style method for creating Percentage instances
|
|
260
|
+
#
|
|
261
|
+
# Handles both string parsing and numeric building.
|
|
262
|
+
#
|
|
263
|
+
# @param args [Array] Arguments to pass to Percentage.build
|
|
264
|
+
# @option kwargs [Numeric] :value The percentage value (0-100)
|
|
265
|
+
# @return [Percentage, nil] The created percentage
|
|
266
|
+
def Percentage(*args, **kwargs) # rubocop:disable Naming/MethodName
|
|
267
|
+
Percentage.build(*args, **kwargs)
|
|
141
268
|
end
|
|
269
|
+
module_function :Percentage
|
|
142
270
|
end
|
|
143
271
|
end
|
data/lib/unmagic/color.rb
CHANGED
|
@@ -37,18 +37,38 @@ module Unmagic
|
|
|
37
37
|
# darker = color.darken(0.1)
|
|
38
38
|
# mixed = color.blend(other_color, 0.5)
|
|
39
39
|
class Color
|
|
40
|
+
# Base error class for color-related errors.
|
|
40
41
|
# @private
|
|
41
42
|
class Error < StandardError; end
|
|
43
|
+
|
|
44
|
+
# Error raised when a color string cannot be parsed.
|
|
42
45
|
# @private
|
|
43
46
|
class ParseError < Error; end
|
|
44
47
|
|
|
48
|
+
# Path to the data directory containing color databases.
|
|
49
|
+
# @api private
|
|
50
|
+
DATA_PATH = File.join(__dir__, "..", "..", "data")
|
|
51
|
+
|
|
52
|
+
require_relative "color/version"
|
|
45
53
|
require_relative "color/rgb"
|
|
46
54
|
require_relative "color/rgb/hex"
|
|
47
55
|
require_relative "color/rgb/named"
|
|
56
|
+
require_relative "color/rgb/ansi"
|
|
48
57
|
require_relative "color/hsl"
|
|
49
58
|
require_relative "color/oklch"
|
|
50
59
|
require_relative "color/string/hash_function"
|
|
51
60
|
require_relative "color/util/percentage"
|
|
61
|
+
require_relative "color/units/degrees"
|
|
62
|
+
require_relative "color/gradient"
|
|
63
|
+
require_relative "color/gradient/stop"
|
|
64
|
+
require_relative "color/gradient/bitmap"
|
|
65
|
+
require_relative "color/gradient/base"
|
|
66
|
+
require_relative "color/rgb/gradient/linear"
|
|
67
|
+
require_relative "color/hsl/gradient/linear"
|
|
68
|
+
require_relative "color/oklch/gradient/linear"
|
|
69
|
+
require_relative "color/harmony"
|
|
70
|
+
|
|
71
|
+
include Harmony
|
|
52
72
|
|
|
53
73
|
class << self
|
|
54
74
|
# Parse a color string into the appropriate color space object.
|
|
@@ -92,6 +112,8 @@ module Unmagic
|
|
|
92
112
|
HSL.parse(input)
|
|
93
113
|
elsif input.start_with?("oklch")
|
|
94
114
|
OKLCH.parse(input)
|
|
115
|
+
elsif input.match?(/\A\d+(?:;\d+)*\z/) && RGB::ANSI.valid?(input)
|
|
116
|
+
RGB::ANSI.parse(input)
|
|
95
117
|
elsif RGB::Named.valid?(input)
|
|
96
118
|
RGB::Named.parse(input)
|
|
97
119
|
else
|
|
@@ -124,7 +146,10 @@ module Unmagic
|
|
|
124
146
|
super(value: value.to_i.clamp(0, 255))
|
|
125
147
|
end
|
|
126
148
|
|
|
149
|
+
# @return [Integer] Component value as integer
|
|
127
150
|
def to_i = value
|
|
151
|
+
|
|
152
|
+
# @return [Float] Component value as float
|
|
128
153
|
def to_f = value.to_f
|
|
129
154
|
|
|
130
155
|
# @param other [Component, Numeric] Value to compare
|
|
@@ -182,7 +207,10 @@ module Unmagic
|
|
|
182
207
|
super(value: value.to_f % 360)
|
|
183
208
|
end
|
|
184
209
|
|
|
210
|
+
# @return [Float] Hue value as float
|
|
185
211
|
def to_f = value
|
|
212
|
+
|
|
213
|
+
# @return [Float] Hue value in degrees
|
|
186
214
|
def degrees = value
|
|
187
215
|
|
|
188
216
|
# @param other [Hue, Numeric] Value to compare
|
|
@@ -281,6 +309,53 @@ module Unmagic
|
|
|
281
309
|
# Lightness percentage (0-100%)
|
|
282
310
|
class Lightness < Unmagic::Util::Percentage; end
|
|
283
311
|
|
|
312
|
+
# Alpha channel for transparency. Stored internally as percentage (0-100%)
|
|
313
|
+
# where 100% is fully opaque and 0% is fully transparent. Use to_css to
|
|
314
|
+
# output as ratio (0.0-1.0) for CSS formats.
|
|
315
|
+
#
|
|
316
|
+
# Inherits parse method from Percentage which handles:
|
|
317
|
+
# - Explicit percentage: "50%" → Alpha with value 50.0
|
|
318
|
+
# - Fraction notation: "1/2" → Alpha with value 50.0
|
|
319
|
+
# - Bare decimal ≤ 1.0: "0.5" → Alpha with value 50.0 (treated as CSS ratio)
|
|
320
|
+
# - Bare decimal > 1.0: "75" → Alpha with value 75.0
|
|
321
|
+
#
|
|
322
|
+
# @example Parse CSS ratio
|
|
323
|
+
# alpha = Unmagic::Color::Alpha.parse("0.5")
|
|
324
|
+
# alpha.value
|
|
325
|
+
# #=> 50.0
|
|
326
|
+
#
|
|
327
|
+
# @example Parse percentage
|
|
328
|
+
# alpha = Unmagic::Color::Alpha.parse("50%")
|
|
329
|
+
# alpha.value
|
|
330
|
+
# #=> 50.0
|
|
331
|
+
class Alpha < Unmagic::Util::Percentage
|
|
332
|
+
# Default alpha value (fully opaque)
|
|
333
|
+
DEFAULT = new(value: 100).freeze
|
|
334
|
+
|
|
335
|
+
# Convert to CSS output format (as ratio 0.0-1.0, not percentage).
|
|
336
|
+
#
|
|
337
|
+
# Returns "1" for fully opaque, "0" for fully transparent, and decimal
|
|
338
|
+
# values for semi-transparent colors (e.g., "0.5", "0.75").
|
|
339
|
+
#
|
|
340
|
+
# @return [String] The alpha value as a CSS ratio string
|
|
341
|
+
#
|
|
342
|
+
# @example Fully opaque
|
|
343
|
+
# Unmagic::Color::Alpha.new(value: 100).to_css
|
|
344
|
+
# #=> "1"
|
|
345
|
+
#
|
|
346
|
+
# @example Semi-transparent
|
|
347
|
+
# Unmagic::Color::Alpha.new(value: 50).to_css
|
|
348
|
+
# #=> "0.5"
|
|
349
|
+
#
|
|
350
|
+
# @example Fully transparent
|
|
351
|
+
# Unmagic::Color::Alpha.new(value: 0).to_css
|
|
352
|
+
# #=> "0"
|
|
353
|
+
def to_css
|
|
354
|
+
ratio = to_ratio
|
|
355
|
+
ratio == 1.0 ? "1" : ratio.to_s.sub(/\.?0+$/, "")
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
284
359
|
# Convert this color to RGB color space.
|
|
285
360
|
#
|
|
286
361
|
# RGB represents colors as a combination of Red, Green, and Blue light,
|
|
@@ -311,6 +386,26 @@ module Unmagic
|
|
|
311
386
|
raise NotImplementedError
|
|
312
387
|
end
|
|
313
388
|
|
|
389
|
+
# Convert this color to an ANSI SGR color code.
|
|
390
|
+
#
|
|
391
|
+
# Returns an ANSI Select Graphic Rendition (SGR) parameter string that can be used
|
|
392
|
+
# in terminal output. The returned string does not include the escape sequence prefix
|
|
393
|
+
# (\x1b[) or the trailing 'm'.
|
|
394
|
+
#
|
|
395
|
+
# @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
|
|
396
|
+
# @return [String] ANSI SGR code like "31" or "38;2;255;0;0"
|
|
397
|
+
#
|
|
398
|
+
# @example Output red text
|
|
399
|
+
# color = Unmagic::Color.parse("red")
|
|
400
|
+
# puts "\x1b[#{color.to_ansi}mHello\x1b[0m"
|
|
401
|
+
#
|
|
402
|
+
# @example Set background color
|
|
403
|
+
# color = Unmagic::Color.parse("#336699")
|
|
404
|
+
# puts "\x1b[#{color.to_ansi(layer: :background)}mText\x1b[0m"
|
|
405
|
+
def to_ansi(layer: :foreground)
|
|
406
|
+
raise NotImplementedError
|
|
407
|
+
end
|
|
408
|
+
|
|
314
409
|
# Calculate the perceptual luminance of this color.
|
|
315
410
|
#
|
|
316
411
|
# Luminance represents how bright the color appears to the human eye,
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unmagic-color
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keith Pitt
|
|
@@ -17,16 +17,36 @@ executables: []
|
|
|
17
17
|
extensions: []
|
|
18
18
|
extra_rdoc_files: []
|
|
19
19
|
files:
|
|
20
|
+
- CHANGELOG.md
|
|
20
21
|
- README.md
|
|
21
|
-
- data/
|
|
22
|
+
- data/css.jsonc
|
|
23
|
+
- data/css.txt
|
|
24
|
+
- data/x11.jsonc
|
|
25
|
+
- data/x11.txt
|
|
22
26
|
- lib/unmagic/color.rb
|
|
27
|
+
- lib/unmagic/color/console/banner.rb
|
|
28
|
+
- lib/unmagic/color/console/card.rb
|
|
29
|
+
- lib/unmagic/color/console/help.rb
|
|
30
|
+
- lib/unmagic/color/console/highlighter.rb
|
|
31
|
+
- lib/unmagic/color/gradient.rb
|
|
32
|
+
- lib/unmagic/color/gradient/base.rb
|
|
33
|
+
- lib/unmagic/color/gradient/bitmap.rb
|
|
34
|
+
- lib/unmagic/color/gradient/stop.rb
|
|
35
|
+
- lib/unmagic/color/harmony.rb
|
|
23
36
|
- lib/unmagic/color/hsl.rb
|
|
37
|
+
- lib/unmagic/color/hsl/gradient/linear.rb
|
|
24
38
|
- lib/unmagic/color/oklch.rb
|
|
39
|
+
- lib/unmagic/color/oklch/gradient/linear.rb
|
|
25
40
|
- lib/unmagic/color/rgb.rb
|
|
41
|
+
- lib/unmagic/color/rgb/ansi.rb
|
|
42
|
+
- lib/unmagic/color/rgb/gradient/linear.rb
|
|
26
43
|
- lib/unmagic/color/rgb/hex.rb
|
|
27
44
|
- lib/unmagic/color/rgb/named.rb
|
|
28
45
|
- lib/unmagic/color/string/hash_function.rb
|
|
46
|
+
- lib/unmagic/color/units/degrees.rb
|
|
47
|
+
- lib/unmagic/color/units/direction.rb
|
|
29
48
|
- lib/unmagic/color/util/percentage.rb
|
|
49
|
+
- lib/unmagic/color/version.rb
|
|
30
50
|
- lib/unmagic_color.rb
|
|
31
51
|
homepage: https://github.com/unreasonable-magic/unmagic-color
|
|
32
52
|
licenses:
|
|
@@ -49,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
49
69
|
- !ruby/object:Gem::Version
|
|
50
70
|
version: '0'
|
|
51
71
|
requirements: []
|
|
52
|
-
rubygems_version:
|
|
72
|
+
rubygems_version: 4.0.3
|
|
53
73
|
specification_version: 4
|
|
54
74
|
summary: Comprehensive color manipulation library
|
|
55
75
|
test_files: []
|