// =============================================================================
// CSS Color Parser
// =============================================================================
//
// Parses CSS color values and resolves them to RGBA.
// Supports:
// - Named colors (148 CSS named colors)
// - Hex colors (#rgb, #rrggbb, #rgba, #rrggbbaa)
// - rgb() and rgba() functional notation
// - hsl() and hsla() functional notation
// - transparent, currentColor, inherit keywords
///|
/// Result of parsing a CSS color value
pub(all) enum CssColorValue {
Resolved(@types.Color) // Fully resolved RGBA color
CurrentColor // Needs parent's color to resolve
Inherit // Inherit from parent
Invalid // Invalid or unsupported as a solid CSS color
}
///|
pub fn CssColorValue::is_resolved(self : CssColorValue) -> Bool {
match self {
Resolved(_) => true
_ => false
}
}
///|
pub fn CssColorValue::get_color(self : CssColorValue) -> @types.Color? {
match self {
Resolved(c) => Some(c)
_ => None
}
}
///|
pub fn CssColorValue::or_default(
self : CssColorValue,
default : @types.Color,
) -> @types.Color {
match self {
Resolved(c) => c
_ => default
}
}
///|
pub impl Show for CssColorValue with fn output(self, logger) {
match self {
Resolved(c) => c.output(logger)
CurrentColor => logger.write_string("currentColor")
Inherit => logger.write_string("inherit")
Invalid => logger.write_string("invalid")
}
}
///|
/// Resolve CSS var() function for colors - extracts fallback value if present
/// This is the legacy version without context - only uses fallback values
fn resolve_color_var_fallback(value : String) -> String {
let v = value.trim()
if !v.has_prefix("var(") || !v.has_suffix(")") {
return v.to_owned()
}
// Extract content inside var(...)
let inner = view_to_string(v.view(start_offset=4, end_offset=v.length() - 1))
// Find the comma separating variable name from fallback
let mut paren_depth = 0
let mut comma_pos = -1
for i = 0; i < inner.length(); i = i + 1 {
let c = inner[i].to_int().unsafe_to_char()
if c == '(' {
paren_depth += 1
} else if c == ')' {
paren_depth -= 1
} else if c == ',' && paren_depth == 0 {
comma_pos = i
break
}
}
if comma_pos > 0 {
// Has fallback value
let fallback = inner
.unsafe_substring(start=comma_pos + 1, end=inner.length())
.trim()
// Recursively resolve if fallback also contains var()
return resolve_color_var_fallback(fallback.to_owned())
}
// No fallback, return original
v.to_owned()
}
///|
/// Parse a CSS color value (legacy - uses fallback only for var())
pub fn parse_color(value : String) -> CssColorValue {
// First resolve any var() functions
let v = resolve_color_var_fallback(value).trim().to_lower()
parse_color_value(v.to_owned())
}
///|
/// Parse a CSS color value with context for proper var() resolution
pub fn parse_color_with_ctx(
value : String,
ctx : ComputeContext,
) -> CssColorValue {
// First resolve any var() functions using context
let resolved = if value.contains("var(") {
resolve_all_vars(value, ctx)
} else {
value
}
parse_color_value_with_ctx(resolved, ctx)
}
///|
fn parse_color_value_with_ctx(
value : String,
ctx : ComputeContext,
) -> CssColorValue {
let v = value.trim().to_lower()
if v.has_prefix("light-dark(") {
match select_light_dark_arg(value, ctx) {
Some(selected) => {
let resolved = if selected.contains("var(") {
resolve_all_vars(selected, ctx)
} else {
selected
}
return parse_color_value_with_ctx(resolved, ctx)
}
None => ()
}
}
parse_color_value(v.to_owned())
}
///|
fn select_light_dark_arg(value : String, ctx : ComputeContext) -> String? {
let trimmed = value.trim().to_owned()
let lower = trimmed.to_lower()
if !lower.has_prefix("light-dark(") || !trimmed.has_suffix(")") {
return None
}
let inner = trimmed.unsafe_substring(start=11, end=trimmed.length() - 1)
let args = split_css_top_level(inner, ',')
if args.length() < 2 {
return None
}
let use_dark = match
ctx.custom_properties.get(internal_effective_color_scheme_key) {
Some("dark") => true
_ => false
}
if use_dark {
Some(args[1])
} else {
Some(args[0])
}
}
///|
/// Internal function to parse color value (after var() resolution)
fn parse_color_value(v : String) -> CssColorValue {
// Keywords
if v == "transparent" {
return Resolved(@types.Color::transparent())
}
if v == "currentcolor" {
return CurrentColor
}
if v == "inherit" {
return Inherit
}
// Empty value (unresolved var())
if v == "" {
return Invalid
}
// color-mix()
if v.has_prefix("color-mix(") {
match parse_color_mix(v) {
Some(result) => return result
None => ()
}
}
// Gradients and URLs are paint values, not solid colors.
if v.has_prefix("linear-gradient") ||
v.has_prefix("radial-gradient") ||
v.has_prefix("conic-gradient") ||
v.has_prefix("repeating-linear-gradient") ||
v.has_prefix("repeating-radial-gradient") ||
v.has_prefix("url(") {
return Invalid
}
// Hex color
if v.has_prefix("#") {
match parse_hex_color(v.to_string()) {
Some(c) => return Resolved(c)
None => ()
}
}
// rgb() / rgba()
if v.has_prefix("rgb") {
match parse_rgb_function(v.to_string()) {
Some(c) => return Resolved(c)
None => ()
}
}
// hsl() / hsla()
if v.has_prefix("hsl") {
match parse_hsl_function(v.to_string()) {
Some(c) => return Resolved(c)
None => ()
}
}
// Named color
match get_named_color(v.to_string()) {
Some(c) => return Resolved(c)
None => ()
}
Invalid
}
///|
fn char_at(s : String, i : Int) -> Char {
s[i].to_int().unsafe_to_char()
}
///|
fn hex_digit(c : Char) -> Int {
let code = c.to_int()
if code >= '0'.to_int() && code <= '9'.to_int() {
code - '0'.to_int()
} else if code >= 'a'.to_int() && code <= 'f'.to_int() {
code - 'a'.to_int() + 10
} else if code >= 'A'.to_int() && code <= 'F'.to_int() {
code - 'A'.to_int() + 10
} else {
-1
}
}
///|
/// Parse hex color (#rgb, #rrggbb, #rgba, #rrggbbaa)
fn parse_hex_color(value : String) -> @types.Color? {
if value.length() < 2 {
return None
}
// Skip the '#' prefix
let len = value.length() - 1
for i in 1.. Int {
let d = hex_digit(char_at(value, idx))
d * 16 + d
}
fn hex2(idx1 : Int, idx2 : Int) -> Int {
hex_digit(char_at(value, idx1)) * 16 + hex_digit(char_at(value, idx2))
}
match len {
3 => {
// #rgb
let r = hex1(1)
let g = hex1(2)
let b = hex1(3)
Some(@types.Color::rgb(r, g, b))
}
4 => {
// #rgba
let r = hex1(1)
let g = hex1(2)
let b = hex1(3)
let a = hex1(4).to_double() / 255.0
Some(@types.Color::rgba(r, g, b, a))
}
6 => {
// #rrggbb
let r = hex2(1, 2)
let g = hex2(3, 4)
let b = hex2(5, 6)
Some(@types.Color::rgb(r, g, b))
}
8 => {
// #rrggbbaa
let r = hex2(1, 2)
let g = hex2(3, 4)
let b = hex2(5, 6)
let a = hex2(7, 8).to_double() / 255.0
Some(@types.Color::rgba(r, g, b, a))
}
_ => None
}
}
///|
/// Find first index of character in string
fn find_char(s : String, c : Char) -> Int {
for i = 0; i < s.length(); i = i + 1 {
if char_at(s, i) == c {
return i
}
}
-1
}
///|
/// Find last index of character in string
fn find_char_last(s : String, c : Char) -> Int {
let mut last = -1
for i = 0; i < s.length(); i = i + 1 {
if char_at(s, i) == c {
last = i
}
}
last
}
///|
/// Extract substring
fn substr(s : String, start : Int, end : Int) -> String {
let sb = StringBuilder::new()
for i = start; i < end && i < s.length(); i = i + 1 {
sb.write_char(char_at(s, i))
}
sb.to_string()
}
///|
/// Parse rgb() or rgba() function
fn parse_rgb_function(value : String) -> @types.Color? {
// Extract content between parentheses
let start = find_char(value, '(')
let end = find_char_last(value, ')')
if start < 0 || end < 0 || end <= start {
return None
}
let content = substr(value, start + 1, end).trim()
// Split by comma or space
let parts = split_color_args(content.to_owned())
if parts.length() != 3 && parts.length() != 4 {
return None
}
let r = match parse_color_component(parts[0], false) {
Some(value) => value
None => return None
}
let g = match parse_color_component(parts[1], false) {
Some(value) => value
None => return None
}
let b = match parse_color_component(parts[2], false) {
Some(value) => value
None => return None
}
let a = if parts.length() >= 4 {
match parse_color_component(parts[3], true) {
Some(value) => value
None => return None
}
} else {
1.0
}
Some(
@types.Color::rgba(
color_clamp(r, 0.0, 255.0).to_int(),
color_clamp(g, 0.0, 255.0).to_int(),
color_clamp(b, 0.0, 255.0).to_int(),
color_clamp(a, 0.0, 1.0),
),
)
}
///|
/// Split color function arguments (handles comma and space separation)
fn split_color_args(s : String) -> Array[String] {
let result : Array[String] = []
let current = StringBuilder::new()
for c in s {
if c == ',' || c == '/' || c == ' ' {
let part = current.to_string().trim()
if part.length() > 0 {
result.push(part.to_owned())
}
current.reset()
} else {
current.write_char(c)
}
}
let part = current.to_string().trim()
if part.length() > 0 {
result.push(part.to_owned())
}
result
}
///|
/// Parse a color component (0-255 or 0%-100%, or 0-1 for alpha)
fn parse_color_component(s : String, is_alpha : Bool) -> Double? {
let v = s.trim().to_owned()
if v.has_suffix("%") {
let num_str = substr(v, 0, v.length() - 1)
let n = @string.parse_double(num_str) catch { _ => return None }
if is_alpha {
Some(n / 100.0)
} else {
Some(n * 255.0 / 100.0)
}
} else {
let n = @string.parse_double(v) catch { _ => return None }
Some(n)
}
}
///|
fn color_clamp(value : Double, minimum : Double, maximum : Double) -> Double {
if value < minimum {
minimum
} else if value > maximum {
maximum
} else {
value
}
}
///|
fn parse_hsl_percentage(value : String) -> Double? {
let v = value.trim().to_owned()
guard v.strip_suffix("%") is Some(number) else { return None }
let parsed = @string.parse_double(number.to_owned()) catch {
_ => return None
}
Some(color_clamp(parsed / 100.0, 0.0, 1.0))
}
///|
fn parse_hsl_function(value : String) -> @types.Color? {
let start = find_char(value, '(')
let end = find_char_last(value, ')')
if start < 0 || end != value.length() - 1 || end <= start {
return None
}
let parts = split_color_args(substr(value, start + 1, end).trim().to_owned())
if parts.length() != 3 && parts.length() != 4 {
return None
}
let raw_hue = @string.parse_double(parts[0]) catch { _ => return None }
let saturation = match parse_hsl_percentage(parts[1]) {
Some(value) => value
None => return None
}
let lightness = match parse_hsl_percentage(parts[2]) {
Some(value) => value
None => return None
}
let alpha = if parts.length() == 4 {
match parse_color_component(parts[3], true) {
Some(value) => color_clamp(value, 0.0, 1.0)
None => return None
}
} else {
1.0
}
let hue = raw_hue - (raw_hue / 360.0).floor() * 360.0
let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation
let segment = hue / 60.0
let segment_mod = segment - (segment / 2.0).floor() * 2.0
let x = chroma * (1.0 - (segment_mod - 1.0).abs())
let (r1, g1, b1) = if segment < 1.0 {
(chroma, x, 0.0)
} else if segment < 2.0 {
(x, chroma, 0.0)
} else if segment < 3.0 {
(0.0, chroma, x)
} else if segment < 4.0 {
(0.0, x, chroma)
} else if segment < 5.0 {
(x, 0.0, chroma)
} else {
(chroma, 0.0, x)
}
let m = lightness - chroma / 2.0
Some(
@types.Color::rgba(
((r1 + m) * 255.0).round().to_int(),
((g1 + m) * 255.0).round().to_int(),
((b1 + m) * 255.0).round().to_int(),
alpha,
),
)
}
///|
/// Get a named CSS color (148 colors)
fn get_named_color(name : String) -> @types.Color? {
// CSS Named Colors (Level 4) - all 148 colors
match name {
"aliceblue" => Some(@types.Color::rgb(240, 248, 255))
"antiquewhite" => Some(@types.Color::rgb(250, 235, 215))
"aqua" => Some(@types.Color::rgb(0, 255, 255))
"aquamarine" => Some(@types.Color::rgb(127, 255, 212))
"azure" => Some(@types.Color::rgb(240, 255, 255))
"beige" => Some(@types.Color::rgb(245, 245, 220))
"bisque" => Some(@types.Color::rgb(255, 228, 196))
"black" => Some(@types.Color::rgb(0, 0, 0))
"blanchedalmond" => Some(@types.Color::rgb(255, 235, 205))
"blue" => Some(@types.Color::rgb(0, 0, 255))
"blueviolet" => Some(@types.Color::rgb(138, 43, 226))
"brown" => Some(@types.Color::rgb(165, 42, 42))
"burlywood" => Some(@types.Color::rgb(222, 184, 135))
"cadetblue" => Some(@types.Color::rgb(95, 158, 160))
"chartreuse" => Some(@types.Color::rgb(127, 255, 0))
"chocolate" => Some(@types.Color::rgb(210, 105, 30))
"coral" => Some(@types.Color::rgb(255, 127, 80))
"cornflowerblue" => Some(@types.Color::rgb(100, 149, 237))
"cornsilk" => Some(@types.Color::rgb(255, 248, 220))
"crimson" => Some(@types.Color::rgb(220, 20, 60))
"cyan" => Some(@types.Color::rgb(0, 255, 255))
"darkblue" => Some(@types.Color::rgb(0, 0, 139))
"darkcyan" => Some(@types.Color::rgb(0, 139, 139))
"darkgoldenrod" => Some(@types.Color::rgb(184, 134, 11))
"darkgray" => Some(@types.Color::rgb(169, 169, 169))
"darkgreen" => Some(@types.Color::rgb(0, 100, 0))
"darkgrey" => Some(@types.Color::rgb(169, 169, 169))
"darkkhaki" => Some(@types.Color::rgb(189, 183, 107))
"darkmagenta" => Some(@types.Color::rgb(139, 0, 139))
"darkolivegreen" => Some(@types.Color::rgb(85, 107, 47))
"darkorange" => Some(@types.Color::rgb(255, 140, 0))
"darkorchid" => Some(@types.Color::rgb(153, 50, 204))
"darkred" => Some(@types.Color::rgb(139, 0, 0))
"darksalmon" => Some(@types.Color::rgb(233, 150, 122))
"darkseagreen" => Some(@types.Color::rgb(143, 188, 143))
"darkslateblue" => Some(@types.Color::rgb(72, 61, 139))
"darkslategray" => Some(@types.Color::rgb(47, 79, 79))
"darkslategrey" => Some(@types.Color::rgb(47, 79, 79))
"darkturquoise" => Some(@types.Color::rgb(0, 206, 209))
"darkviolet" => Some(@types.Color::rgb(148, 0, 211))
"deeppink" => Some(@types.Color::rgb(255, 20, 147))
"deepskyblue" => Some(@types.Color::rgb(0, 191, 255))
"dimgray" => Some(@types.Color::rgb(105, 105, 105))
"dimgrey" => Some(@types.Color::rgb(105, 105, 105))
"dodgerblue" => Some(@types.Color::rgb(30, 144, 255))
"firebrick" => Some(@types.Color::rgb(178, 34, 34))
"floralwhite" => Some(@types.Color::rgb(255, 250, 240))
"forestgreen" => Some(@types.Color::rgb(34, 139, 34))
"fuchsia" => Some(@types.Color::rgb(255, 0, 255))
"gainsboro" => Some(@types.Color::rgb(220, 220, 220))
"ghostwhite" => Some(@types.Color::rgb(248, 248, 255))
"gold" => Some(@types.Color::rgb(255, 215, 0))
"goldenrod" => Some(@types.Color::rgb(218, 165, 32))
"gray" => Some(@types.Color::rgb(128, 128, 128))
"green" => Some(@types.Color::rgb(0, 128, 0))
"greenyellow" => Some(@types.Color::rgb(173, 255, 47))
"grey" => Some(@types.Color::rgb(128, 128, 128))
"honeydew" => Some(@types.Color::rgb(240, 255, 240))
"hotpink" => Some(@types.Color::rgb(255, 105, 180))
"indianred" => Some(@types.Color::rgb(205, 92, 92))
"indigo" => Some(@types.Color::rgb(75, 0, 130))
"ivory" => Some(@types.Color::rgb(255, 255, 240))
"khaki" => Some(@types.Color::rgb(240, 230, 140))
"lavender" => Some(@types.Color::rgb(230, 230, 250))
"lavenderblush" => Some(@types.Color::rgb(255, 240, 245))
"lawngreen" => Some(@types.Color::rgb(124, 252, 0))
"lemonchiffon" => Some(@types.Color::rgb(255, 250, 205))
"lightblue" => Some(@types.Color::rgb(173, 216, 230))
"lightcoral" => Some(@types.Color::rgb(240, 128, 128))
"lightcyan" => Some(@types.Color::rgb(224, 255, 255))
"lightgoldenrodyellow" => Some(@types.Color::rgb(250, 250, 210))
"lightgray" => Some(@types.Color::rgb(211, 211, 211))
"lightgreen" => Some(@types.Color::rgb(144, 238, 144))
"lightgrey" => Some(@types.Color::rgb(211, 211, 211))
"lightpink" => Some(@types.Color::rgb(255, 182, 193))
"lightsalmon" => Some(@types.Color::rgb(255, 160, 122))
"lightseagreen" => Some(@types.Color::rgb(32, 178, 170))
"lightskyblue" => Some(@types.Color::rgb(135, 206, 250))
"lightslategray" => Some(@types.Color::rgb(119, 136, 153))
"lightslategrey" => Some(@types.Color::rgb(119, 136, 153))
"lightsteelblue" => Some(@types.Color::rgb(176, 196, 222))
"lightyellow" => Some(@types.Color::rgb(255, 255, 224))
"lime" => Some(@types.Color::rgb(0, 255, 0))
"limegreen" => Some(@types.Color::rgb(50, 205, 50))
"linen" => Some(@types.Color::rgb(250, 240, 230))
"magenta" => Some(@types.Color::rgb(255, 0, 255))
"maroon" => Some(@types.Color::rgb(128, 0, 0))
"mediumaquamarine" => Some(@types.Color::rgb(102, 205, 170))
"mediumblue" => Some(@types.Color::rgb(0, 0, 205))
"mediumorchid" => Some(@types.Color::rgb(186, 85, 211))
"mediumpurple" => Some(@types.Color::rgb(147, 112, 219))
"mediumseagreen" => Some(@types.Color::rgb(60, 179, 113))
"mediumslateblue" => Some(@types.Color::rgb(123, 104, 238))
"mediumspringgreen" => Some(@types.Color::rgb(0, 250, 154))
"mediumturquoise" => Some(@types.Color::rgb(72, 209, 204))
"mediumvioletred" => Some(@types.Color::rgb(199, 21, 133))
"midnightblue" => Some(@types.Color::rgb(25, 25, 112))
"mintcream" => Some(@types.Color::rgb(245, 255, 250))
"mistyrose" => Some(@types.Color::rgb(255, 228, 225))
"moccasin" => Some(@types.Color::rgb(255, 228, 181))
"navajowhite" => Some(@types.Color::rgb(255, 222, 173))
"navy" => Some(@types.Color::rgb(0, 0, 128))
"oldlace" => Some(@types.Color::rgb(253, 245, 230))
"olive" => Some(@types.Color::rgb(128, 128, 0))
"olivedrab" => Some(@types.Color::rgb(107, 142, 35))
"orange" => Some(@types.Color::rgb(255, 165, 0))
"orangered" => Some(@types.Color::rgb(255, 69, 0))
"orchid" => Some(@types.Color::rgb(218, 112, 214))
"palegoldenrod" => Some(@types.Color::rgb(238, 232, 170))
"palegreen" => Some(@types.Color::rgb(152, 251, 152))
"paleturquoise" => Some(@types.Color::rgb(175, 238, 238))
"palevioletred" => Some(@types.Color::rgb(219, 112, 147))
"papayawhip" => Some(@types.Color::rgb(255, 239, 213))
"peachpuff" => Some(@types.Color::rgb(255, 218, 185))
"peru" => Some(@types.Color::rgb(205, 133, 63))
"pink" => Some(@types.Color::rgb(255, 192, 203))
"plum" => Some(@types.Color::rgb(221, 160, 221))
"powderblue" => Some(@types.Color::rgb(176, 224, 230))
"purple" => Some(@types.Color::rgb(128, 0, 128))
"rebeccapurple" => Some(@types.Color::rgb(102, 51, 153))
"red" => Some(@types.Color::rgb(255, 0, 0))
"rosybrown" => Some(@types.Color::rgb(188, 143, 143))
"royalblue" => Some(@types.Color::rgb(65, 105, 225))
"saddlebrown" => Some(@types.Color::rgb(139, 69, 19))
"salmon" => Some(@types.Color::rgb(250, 128, 114))
"sandybrown" => Some(@types.Color::rgb(244, 164, 96))
"seagreen" => Some(@types.Color::rgb(46, 139, 87))
"seashell" => Some(@types.Color::rgb(255, 245, 238))
"sienna" => Some(@types.Color::rgb(160, 82, 45))
"silver" => Some(@types.Color::rgb(192, 192, 192))
"skyblue" => Some(@types.Color::rgb(135, 206, 235))
"slateblue" => Some(@types.Color::rgb(106, 90, 205))
"slategray" => Some(@types.Color::rgb(112, 128, 144))
"slategrey" => Some(@types.Color::rgb(112, 128, 144))
"snow" => Some(@types.Color::rgb(255, 250, 250))
"springgreen" => Some(@types.Color::rgb(0, 255, 127))
"steelblue" => Some(@types.Color::rgb(70, 130, 180))
"tan" => Some(@types.Color::rgb(210, 180, 140))
"teal" => Some(@types.Color::rgb(0, 128, 128))
"thistle" => Some(@types.Color::rgb(216, 191, 216))
"tomato" => Some(@types.Color::rgb(255, 99, 71))
"turquoise" => Some(@types.Color::rgb(64, 224, 208))
"violet" => Some(@types.Color::rgb(238, 130, 238))
"wheat" => Some(@types.Color::rgb(245, 222, 179))
"white" => Some(@types.Color::rgb(255, 255, 255))
"whitesmoke" => Some(@types.Color::rgb(245, 245, 245))
"yellow" => Some(@types.Color::rgb(255, 255, 0))
"yellowgreen" => Some(@types.Color::rgb(154, 205, 50))
_ => None
}
}
///|
/// Resolve currentColor using parent's color
pub fn resolve_current_color(
value : CssColorValue,
parent_color : @types.Color,
) -> @types.Color {
match value {
Resolved(c) => c
CurrentColor => parent_color
Inherit => parent_color
Invalid => parent_color
}
}
///|
/// Default color for text (black)
pub fn default_color() -> @types.Color {
@types.Color::black()
}
///|
/// Default color for background (transparent)
pub fn default_background_color() -> @types.Color {
@types.Color::transparent()
}
///|
/// Parse a linear-gradient() value into a LinearGradient
pub fn parse_linear_gradient(value : String) -> @types.BackgroundImage {
let v = value.trim().to_lower().to_owned()
// Must start with "linear-gradient("
if !v.has_prefix("linear-gradient(") {
return None
}
// Extract content between parens
let start = 16 // "linear-gradient(".length()
let end = v.length() - 1
if end <= start || v[end] != ')' {
return None
}
let inner = v.unsafe_substring(start~, end~).to_string()
// Split by commas, respecting parentheses
let parts = split_gradient_args(inner)
if parts.length() < 2 {
return None
}
// Parse direction (first argument, if it's not a color)
let mut angle = 180.0 // default: to bottom
let mut color_start = 0
let first = parts[0].trim().to_owned()
if first.has_prefix("to ") {
angle = parse_direction_keyword(first)
color_start = 1
} else if first.has_suffix("deg") {
let deg_str = first
.unsafe_substring(start=0, end=first.length() - 3)
.to_string()
let d = @string.parse_double(deg_str) catch { _ => -999.0 }
if d != -999.0 {
angle = d
color_start = 1
}
} else if first.has_suffix("turn") {
let turn_str = first
.unsafe_substring(start=0, end=first.length() - 4)
.to_string()
let t = @string.parse_double(turn_str) catch { _ => -999.0 }
if t != -999.0 {
angle = t * 360.0
color_start = 1
}
}
// Parse color stops
let stops : Array[@types.GradientStop] = []
let num_colors = parts.length() - color_start
for i = color_start; i < parts.length(); i = i + 1 {
let part = parts[i].trim().to_owned()
let (color, pos) = parse_gradient_stop(part, i - color_start, num_colors)
stops.push({ color, position: pos })
}
if stops.length() < 2 {
return None
}
Gradient({ angle_deg: angle, stops })
}
///|
fn parse_direction_keyword(dir : String) -> Double {
let d = dir.trim().to_lower()
if d == "to top" {
0.0
} else if d == "to right" {
90.0
} else if d == "to bottom" {
180.0
} else if d == "to left" {
270.0
} else if d == "to top right" || d == "to right top" {
45.0
} else if d == "to bottom right" || d == "to right bottom" {
135.0
} else if d == "to bottom left" || d == "to left bottom" {
225.0
} else if d == "to top left" || d == "to left top" {
315.0
} else {
180.0
}
}
///|
fn parse_gradient_stop(
part : String,
index : Int,
total : Int,
) -> (@types.Color, Double) {
// A stop can be "color" or "color position%"
// Try to find a percentage at the end
let trimmed = part.trim().to_owned()
// Check if last token is a percentage
let last_space = find_last_space(trimmed)
if last_space > 0 {
let color_part = trimmed
.unsafe_substring(start=0, end=last_space)
.trim()
.to_owned()
let pos_part = trimmed
.unsafe_substring(start=last_space + 1, end=trimmed.length())
.trim()
.to_owned()
if pos_part.has_suffix("%") {
let pct_str = pos_part
.unsafe_substring(start=0, end=pos_part.length() - 1)
.to_string()
let pct = @string.parse_double(pct_str) catch { _ => -999.0 }
if pct != -999.0 {
let color = resolve_gradient_color(color_part)
return (color, pct / 100.0)
}
}
}
// No explicit position - distribute evenly
let color = resolve_gradient_color(trimmed)
let pos = if total <= 1 {
0.0
} else {
index.to_double() / (total - 1).to_double()
}
(color, pos)
}
///|
fn resolve_gradient_color(s : String) -> @types.Color {
match parse_color_value(s) {
Resolved(c) => c
_ => @types.Color::transparent()
}
}
///|
fn find_last_space(s : String) -> Int {
let mut last = -1
let mut depth = 0
for i = 0; i < s.length(); i = i + 1 {
if s[i] == '(' {
depth = depth + 1
} else if s[i] == ')' {
depth = depth - 1
} else if s[i] == ' ' && depth == 0 {
last = i
}
}
last
}
///|
fn split_gradient_args(s : String) -> Array[String] {
let result : Array[String] = []
let mut depth = 0
let mut start = 0
for i = 0; i < s.length(); i = i + 1 {
if s[i] == '(' {
depth = depth + 1
} else if s[i] == ')' {
depth = depth - 1
} else if s[i] == ',' && depth == 0 {
result.push(s.unsafe_substring(start~, end=i).to_string())
start = i + 1
}
}
if start < s.length() {
result.push(s.unsafe_substring(start~, end=s.length()).to_string())
}
result
}