blob: 64c97787ea3db495a64751d1464f6c98a361236b [file]
//! Script ↔ Rust value conversion.
//!
//! Every coercion lives here so the rules are defined once: a bare number is
//! pixels, a percent string is a relative length, a `#rrggbb` string is a color,
//! and colors are explicit hexadecimal values obtained from the theme API or
//! written as literals.
use crate::{
error::{Result as ShellResult, ShellError},
spec::{BackgroundKind, BackgroundSpec, ShadowSpec},
};
use gpui::{BoxShadow, Hsla, Pixels, point, px, rgba};
/// A script argument after bridging, stored in the spec arena.
///
/// Engines convert their own values into this; everything above `engine/` only
/// ever sees these cases. The four primitives are what almost every argument
/// is; the two structured cases exist for the paint styles alone — `bg` takes a
/// `Background` built by the script's `Background` builder, and `shadow` takes a
/// list of shadow records — and every other coercion refuses them by name.
#[derive(Clone, Debug, PartialEq)]
pub enum Bridged {
Nil,
Bool(bool),
Number(f64),
Str(String),
/// A value from the script's `Background` builder. Boxed so the common
/// primitive cases keep `Bridged` small in the spec arena.
Background(Box<BackgroundSpec>),
/// A `shadow([...])` list, already checked for shape; the colours are
/// checked when it is applied.
Shadows(Vec<ShadowSpec>),
}
impl Bridged {
pub fn as_f32(&self) -> ShellResult<f32> {
match self {
Bridged::Number(n) => Ok(*n as f32),
other => Err(ShellError::runtime(format!(
"expected a number, got {}",
other.describe()
))),
}
}
/// JavaScript truthiness after a value has crossed the bridge.
pub fn is_truthy(&self) -> bool {
match self {
Bridged::Nil => false,
Bridged::Bool(value) => *value,
Bridged::Number(value) => *value != 0.0 && !value.is_nan(),
Bridged::Str(value) => !value.is_empty(),
// Objects and arrays are truthy in JavaScript, empty or not.
Bridged::Background(_) | Bridged::Shadows(_) => true,
}
}
pub fn as_str(&self) -> ShellResult<&str> {
match self {
Bridged::Str(s) => Ok(s),
other => Err(ShellError::runtime(format!(
"expected a string, got {}",
other.describe()
))),
}
}
/// A number is pixels.
pub fn as_pixels(&self) -> ShellResult<Pixels> {
Ok(px(self.as_f32()?))
}
/// A `#rgb`, `#rrggbb`, or `#rrggbbaa` color value.
///
/// Semantic names are deliberately rejected. Script code must read theme
/// colors from `cx.theme().colors` so the dependency on the active theme is
/// explicit and remains reactive.
pub fn as_color(&self) -> ShellResult<Hsla> {
let text = self.as_str()?;
let Some(hex) = text.strip_prefix('#') else {
return Err(ShellError::runtime(format!(
"`{text}` is not a color value; pass a color from `cx.theme().colors` or a #rgb, #rrggbb, or #rrggbbaa literal"
)));
};
parse_hex(hex).ok_or_else(|| {
ShellError::runtime(format!(
"`{text}` is not a valid color literal (expected #rgb, #rrggbb or #rrggbbaa)"
))
})
}
/// A `Background` value, or a colour literal standing for a solid one.
///
/// The colour form is what `bg("#rrggbb")` always meant, so accepting it
/// here keeps one coercion for both spellings.
pub fn as_background(&self) -> ShellResult<gpui::Background> {
match self {
Bridged::Background(spec) => spec.resolve(),
Bridged::Str(_) => Ok(gpui::solid_background(self.as_color()?)),
other => Err(ShellError::runtime(format!(
"expected a color or a Background, got {}",
other.describe()
))),
}
}
/// A `shadow([...])` list as GPUI box shadows.
///
/// CSS paints the first shadow on top; GPUI paints its list in order, so
/// the last one lands on top. The list is reversed here so a stack copied
/// from a stylesheet layers the way it did there. Errors still name the
/// position the script wrote.
pub fn as_shadows(&self) -> ShellResult<Vec<BoxShadow>> {
match self {
Bridged::Shadows(shadows) => shadows
.iter()
.enumerate()
.rev()
.map(|(index, shadow)| {
shadow.resolve().map_err(|error| {
ShellError::runtime(format!("shadow {index}: {}", error.message()))
})
})
.collect(),
other => Err(ShellError::runtime(format!(
"expected an array of shadows, got {}",
other.describe()
))),
}
}
fn describe(&self) -> String {
match self {
Bridged::Nil => "nil".into(),
Bridged::Bool(b) => format!("boolean ({b})"),
Bridged::Number(n) => format!("number ({n})"),
Bridged::Str(s) => format!("string (\"{s}\")"),
Bridged::Background(_) => "a Background".into(),
Bridged::Shadows(_) => "a shadow list".into(),
}
}
}
fn color_literal(text: &str) -> ShellResult<Hsla> {
Bridged::Str(text.to_owned()).as_color()
}
impl BackgroundSpec {
/// The GPUI value this describes.
///
/// Shared by `window.paint_path` and the `bg` style, so a `Background`
/// means the same thing wherever a script hands one over.
pub fn resolve(&self) -> ShellResult<gpui::Background> {
let background = match &self.kind {
BackgroundKind::Solid { color } => gpui::solid_background(color_literal(color)?),
BackgroundKind::LinearGradient {
angle,
from,
to,
color_space,
} => gpui::linear_gradient(
*angle,
gpui::linear_color_stop(color_literal(&from.0)?, from.1),
gpui::linear_color_stop(color_literal(&to.0)?, to.1),
)
.color_space(match color_space.as_str() {
"oklab" => gpui::ColorSpace::Oklab,
_ => gpui::ColorSpace::Srgb,
}),
BackgroundKind::PatternSlash {
color,
width,
interval,
} => gpui::pattern_slash(color_literal(color)?, *width, *interval),
BackgroundKind::Checkerboard { color, size } => {
gpui::checkerboard(color_literal(color)?, *size)
}
};
Ok(background.opacity(self.opacity))
}
}
impl ShadowSpec {
/// The GPUI shadow this describes.
///
/// `blur` is written with CSS semantics, where the blur radius is twice the
/// gaussian's standard deviation; GPUI's shader takes the deviation itself,
/// so it is halved here — the same conversion `gpui-component`'s own
/// surface shadows make. Without it a shadow copied from a stylesheet
/// spreads over twice the distance.
pub fn resolve(&self) -> ShellResult<BoxShadow> {
Ok(BoxShadow {
color: color_literal(&self.color)?,
offset: point(px(self.x), px(self.y)),
blur_radius: px(self.blur / 2.),
spread_radius: px(self.spread),
inset: self.inset,
})
}
}
fn parse_hex(hex: &str) -> Option<Hsla> {
let expand = |c: char| {
let d = c.to_digit(16)?;
Some(d * 17)
};
let rgba_value = match hex.len() {
3 => {
let mut chars = hex.chars();
let r = expand(chars.next()?)?;
let g = expand(chars.next()?)?;
let b = expand(chars.next()?)?;
(r << 24) | (g << 16) | (b << 8) | 0xff
}
6 => (u32::from_str_radix(hex, 16).ok()? << 8) | 0xff,
8 => u32::from_str_radix(hex, 16).ok()?,
_ => return None,
};
Some(rgba(rgba_value).into())
}
/// Reads the positional arguments of a bound method.
pub fn arg(args: &[Bridged], index: usize, method: &str) -> ShellResult<Bridged> {
args.get(index).cloned().ok_or_else(|| {
ShellError::runtime(format!(
"`{method}` expects at least {} argument(s), got {}",
index + 1,
args.len()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_colors_parse_in_every_supported_length() {
assert!(parse_hex("f00").is_some());
assert!(parse_hex("ff0000").is_some());
assert!(parse_hex("ff000080").is_some());
assert!(parse_hex("ff00").is_none());
assert!(parse_hex("zzzzzz").is_none());
}
#[test]
fn bridge_truthiness_matches_javascript_primitives() {
for falsy in [
Bridged::Nil,
Bridged::Bool(false),
Bridged::Number(0.0),
Bridged::Number(-0.0),
Bridged::Number(f64::NAN),
Bridged::Str(String::new()),
] {
assert!(!falsy.is_truthy(), "{falsy:?}");
}
for truthy in [
Bridged::Bool(true),
Bridged::Number(1.0),
Bridged::Number(-1.0),
Bridged::Str("false".to_owned()),
] {
assert!(truthy.is_truthy(), "{truthy:?}");
}
}
#[test]
fn number_arguments_are_pixels() {
assert_eq!(Bridged::Number(12.0).as_pixels().unwrap(), px(12.));
}
#[test]
fn semantic_color_names_are_rejected() {
let error = Bridged::Str("border".into()).as_color().unwrap_err();
assert!(error.to_string().contains("cx.theme().colors"));
}
}