unmagic-color 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -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
- # - CSS format: "hsl(120, 100%, 50%)"
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(/^hsl\s*\(\s*|\s*\)$/, "").strip
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
- # Split and parse values
122
- parts = clean.split(/\s*,\s*/)
123
- unless parts.length == 3
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.
@@ -254,7 +322,12 @@ module Unmagic
254
322
  new_saturation = @saturation.value * (1 - amount) + other_hsl.saturation.value * amount
255
323
  new_lightness = @lightness.value * (1 - amount) + other_hsl.lightness.value * amount
256
324
 
257
- Unmagic::Color::HSL.new(hue: new_hue, saturation: new_saturation, lightness: new_lightness)
325
+ Unmagic::Color::HSL.new(
326
+ hue: new_hue,
327
+ saturation: new_saturation,
328
+ lightness: new_lightness,
329
+ alpha: @alpha.value * (1 - amount) + other_hsl.alpha.value * amount,
330
+ )
258
331
  end
259
332
 
260
333
  # Create a lighter version by increasing lightness.
@@ -271,7 +344,7 @@ module Unmagic
271
344
  def lighten(amount = 0.1)
272
345
  amount = amount.to_f.clamp(0, 1)
273
346
  new_lightness = @lightness.value + (100 - @lightness.value) * amount
274
- Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
347
+ Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
275
348
  end
276
349
 
277
350
  # Create a darker version by decreasing lightness.
@@ -288,7 +361,7 @@ module Unmagic
288
361
  def darken(amount = 0.1)
289
362
  amount = amount.to_f.clamp(0, 1)
290
363
  new_lightness = @lightness.value * (1 - amount)
291
- Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness)
364
+ Unmagic::Color::HSL.new(hue: @hue.value, saturation: @saturation.value, lightness: new_lightness, alpha: @alpha.value)
292
365
  end
293
366
 
294
367
  # Check if two HSL colors are equal.
@@ -358,7 +431,7 @@ module Unmagic
358
431
  end
359
432
 
360
433
  # Create new HSL color with computed values
361
- color = self.class.new(hue: @hue.value, saturation: new_saturation, lightness: new_lightness)
434
+ color = self.class.build(hue: @hue.value, saturation: new_saturation, lightness: new_lightness, alpha: @alpha.value)
362
435
  colors << color
363
436
  end
364
437
 
@@ -367,16 +440,58 @@ module Unmagic
367
440
 
368
441
  # Convert to string representation.
369
442
  #
370
- # Returns the CSS hsl() function format.
443
+ # Returns the CSS hsl() function format. If alpha is less than 100%,
444
+ # includes alpha value using modern CSS syntax with / separator.
371
445
  #
372
- # @return [String] HSL string like "hsl(240, 80%, 50%)"
446
+ # @return [String] HSL string like "hsl(240, 80%, 50%)" or "hsl(240, 80%, 50% / 0.5)"
373
447
  #
374
- # @example
448
+ # @example Fully opaque
375
449
  # color = HSL.new(hue: 240, saturation: 80, lightness: 50)
376
450
  # color.to_s
377
451
  # # => "hsl(240, 80.0%, 50.0%)"
452
+ #
453
+ # @example Semi-transparent
454
+ # color = HSL.new(hue: 240, saturation: 80, lightness: 50, alpha: 50)
455
+ # color.to_s
456
+ # # => "hsl(240, 80.0%, 50.0% / 0.5)"
378
457
  def to_s
379
- "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
458
+ if @alpha.value < 100
459
+ "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}% / #{@alpha.to_css})"
460
+ else
461
+ "hsl(#{@hue.value.round}, #{@saturation.value}%, #{@lightness.value}%)"
462
+ end
463
+ end
464
+
465
+ # Convert to ANSI SGR color code.
466
+ #
467
+ # Converts to RGB first, then generates the ANSI code.
468
+ #
469
+ # @param layer [Symbol] Whether to generate foreground (:foreground) or background (:background) code
470
+ # @param mode [Symbol] Color format mode (:truecolor, :palette256, :palette16)
471
+ # @return [String] ANSI SGR code like "31" or "38;2;255;0;0"
472
+ #
473
+ # @example
474
+ # color = HSL.new(hue: 0, saturation: 100, lightness: 50)
475
+ # color.to_ansi
476
+ # # => "31"
477
+ def to_ansi(layer: :foreground, mode: :truecolor)
478
+ to_rgb.to_ansi(layer: layer, mode: mode)
479
+ end
480
+
481
+ # Pretty print support with colored swatch in class name.
482
+ #
483
+ # Outputs standard Ruby object format with a colored block character
484
+ # embedded in the class name area.
485
+ #
486
+ # @param pp [PrettyPrint] The pretty printer instance
487
+ #
488
+ # @example
489
+ # hsl = HSL.new(hue: 9, saturation: 100, lightness: 60)
490
+ # pp hsl
491
+ # # Outputs: #<Unmagic::Color::HSL[█] @hue=9 @saturation=100 @lightness=60>
492
+ # # (with colored █ block)
493
+ def pretty_print(pp)
494
+ 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
495
  end
381
496
 
382
497
  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