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.
@@ -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