///|
/// A structured authored value. Nodes are private; consumers can inspect its
/// stable identity and variable uses without manufacturing untyped CSS.
pub struct Expression {
node : ValueNode
category : String
} derive(Eq, Debug)
///|
enum ValueNode {
Literal(String)
Reference(VariableUse)
Group(String, Array[ValueNode], String, String)
Constrained(String, ValueNode)
} derive(Eq, Debug)
///|
pub struct MathExpr[T] {
expression : Expression
marker : T?
} derive(Eq, Debug)
///|
pub trait LengthMathDimension {}
///|
pub impl LengthMathDimension for Length
///|
pub impl LengthMathDimension for LengthPercentage
///|
pub trait LengthContractExpression {
fn contract_expression(Self) -> String
}
///|
#warnings("-unused_trait_bound")
pub impl[T : LengthMathDimension] LengthContractExpression for MathExpr[T] with fn contract_expression(
self,
) {
self.expression.identity()
}
///|
pub extend ValueNode with Eq::{equal, not_equal}
///|
pub extend ValueNode with @debug.Debug::{to_repr}
///|
pub(all) struct ExpressionVariable {
variable : VariableUse
constraints : Array[String]
} derive(Eq, Debug)
///|
fn literal_expression(css : String, category : String) -> Expression {
{ node: Literal(css), category, }
}
///|
fn reference_expression(variable : VariableUse) -> Expression {
{ node: Reference(variable), category: variable.definition.category.name(), }
}
///|
fn group_expression(
category : String,
open : String,
values : Array[Expression],
separator : String,
close : String,
) -> Expression {
{
node: Group(open, values.map(value => value.node), separator, close),
category,
}
}
///|
fn Expression::constrain(self : Expression, constraint : String) -> Expression {
{ ..self, node: Constrained(constraint, self.node), }
}
///|
fn ValueNode::css(self : ValueNode, in_math~ : Bool = false) -> String {
match self {
Literal(css) => css
Reference(value) => "var(" + value.definition.name + ")"
Group(open, values, separator, close) => {
let in_math = in_math ||
["calc(", "min(", "max(", "clamp("].contains(open)
open + values.map(value => value.css(in_math~)).join(separator) + close
}
Constrained(constraint, value) => {
let css = value.css(in_math~)
// A bare zero is valid as a length declaration, but CSS math interprets
// it as a number. Keep the dimension of typed length operands.
if in_math &&
css == "0" &&
(constraint == "math:length" || constraint == "math:length-percentage") {
"0px"
} else {
css
}
}
}
}
///|
fn ValueNode::identity(self : ValueNode) -> Json {
match self {
Literal(css) => ["literal", css]
Reference(value) =>
[
if value.role == TokenRole {
"token"
} else {
"slot"
},
value.definition.name,
value.definition.category.name(),
]
Group(open, values, separator, close) =>
[
"group",
open,
Json::array(values.map(value => value.identity())),
separator,
close,
]
Constrained(constraint, value) =>
["constraint", constraint, value.identity()]
}
}
///|
pub fn Expression::to_css(self : Expression) -> String {
self.node.css()
}
///|
pub fn Expression::identity(self : Expression) -> String {
let value : Json = [self.category, self.node.identity()]
value.stringify()
}
///|
pub fn Expression::value_category(self : Expression) -> String {
self.category
}
///|
fn ValueNode::collect_variables(
self : ValueNode,
constraints : Array[String],
output : Array[ExpressionVariable],
) -> Unit {
match self {
Literal(_) => ()
Reference(variable) =>
output.push({ variable, constraints: constraints.copy(), })
Group(_, values, _, _) =>
for value in values {
value.collect_variables(constraints, output)
}
Constrained(constraint, value) => {
let next = constraints.copy()
if !next.contains(constraint) {
next.push(constraint)
}
value.collect_variables(next, output)
}
}
}
///|
pub fn Expression::variables(self : Expression) -> Array[ExpressionVariable] {
let output = []
self.node.collect_variables([], output)
output
}
///|
pub fn Expression::is_dynamic(self : Expression) -> Bool {
self.variables().any(use_ => use_.variable.role == DynamicSlotRole)
}
///|
fn numeric_part(css : String, category : String) -> Double? {
let units = match category {
"length" => length_suffixes()
"length-percentage" => [..length_suffixes(), "%"]
"percentage" => ["%"]
"angle" => ["deg", "turn", "rad", "grad"]
"time" => ["ms", "s"]
"resolution" => ["dppx", "dpi", "dpcm"]
"number" => [""]
_ => []
}
for unit in units {
if css.has_suffix(unit) {
let number = @string.parse_double(css[:css.length() - unit.length()]) catch {
_ => continue
}
if !number.is_nan() && !number.is_pos_inf() && !number.is_neg_inf() {
return Some(number)
}
}
}
// Unitless zero is an existing Length representation.
if css == "0" && (category == "length" || category == "length-percentage") {
Some(0.0)
} else {
None
}
}
///|
/// The same leaf constraints are used for literals, defaults, theme overrides,
/// and runtime arguments. All errors are returned before any bindings are made.
pub fn expression_value_issues(
constraints : Array[String],
css : String,
) -> Array[ValueIssue] {
let issues = value_issues(css)
if !issues.is_empty() {
return issues
}
for constraint in constraints {
if constraint.has_prefix("math:") {
let category = constraint[5:].to_owned()
if numeric_part(css, category) is None {
issues.push({
code: "STYLE_MATH_VALUE",
label: "Expected a finite numeric " + category + " operand",
})
}
} else if constraint.has_prefix("property:") {
for issue in property_value_issues(constraint[9:].to_owned(), css) {
issues.push(issue)
}
} else if constraint == "nonnegative" {
if css.has_prefix("-") {
issues.push({
code: "STYLE_VALUE_OUT_OF_RANGE",
label: "Expected a nonnegative value",
})
}
} else if constraint == "mix-percentage" {
if numeric_part(css, "percentage") is Some(value) &&
value >= 0.0 &&
value <= 100.0 {
()
} else {
issues.push({
code: "STYLE_COLOR_MIX_WEIGHT",
label: "Mix percentages must be between zero and 100",
})
}
} else if constraint == "fontWidth" {
for issue in property_value_issues("fontWidth", css) {
issues.push(issue)
}
} else if constraint == "no-percentage" && css.has_suffix("%") {
issues.push({
code: "STYLE_MATH_DIMENSION",
label: "This property does not accept percentages",
})
}
}
let unique : Array[ValueIssue] = []
for issue in issues {
if !unique.any(previous => previous.code == issue.code) {
unique.push(issue)
}
}
unique
}
///|
fn ValueNode::issues(
self : ValueNode,
constraints : Array[String],
output : Array[ValueIssue],
) -> Unit {
match self {
Literal(css) =>
for issue in expression_value_issues(constraints, css) {
output.push(issue)
}
Reference(variable) =>
for
issue in expression_value_issues(
constraints,
variable.definition.default_css,
) {
output.push(issue)
}
Group(_, values, _, _) =>
for value in values {
value.issues(constraints, output)
}
Constrained(constraint, value) => {
let next = constraints.copy()
if !next.contains(constraint) {
next.push(constraint)
}
value.issues(next, output)
}
}
}
///|
pub fn Expression::issues(
self : Expression,
property : String,
) -> Array[ValueIssue] {
let output = []
let constraints = self.property_constraints(property)
self.node.issues(constraints, output)
if self.category == "grid-template-track-list" &&
["gridAutoColumns", "gridAutoRows", "grid-auto-columns", "grid-auto-rows"].contains(
property,
) {
output.push({
code: "STYLE_GRID_AUTO_TRACK",
label: "Auto tracks cannot contain named lines, repeat, or subgrid",
})
}
output
}
///|
pub fn Expression::property_constraints(
self : Expression,
property : String,
) -> Array[String] {
let output = []
if common_value_property(property) {
match self.node {
Group(_, _, _, _) => ()
_ => output.push("property:" + property)
}
}
if property == "fontWidth" || property == "font-width" {
output.push("fontWidth")
}
if (self.category == "length" || self.category == "length-percentage") &&
[
"perspective", "borderSpacing", "border-spacing", "outlineOffset", "outline-offset",
"columnWidth", "column-width", "overflowClipMargin", "overflow-clip-margin",
].contains(property) {
output.push("no-percentage")
}
output
}
///|
pub fn[T] MathExpr::expression(self : MathExpr[T]) -> Expression {
self.expression
}
///|
pub fn[T] MathExpr::to_css(self : MathExpr[T]) -> String {
self.expression.to_css()
}
///|
fn[T] math_expression(expression : Expression) -> MathExpr[T] {
{ expression, marker: None, }
}
///|
pub fn[T] MathExpr::add(self : MathExpr[T], other : MathExpr[T]) -> MathExpr[T] {
math_expression(
group_expression(
self.expression.category,
"calc(",
[self.expression, other.expression],
" + ",
")",
),
)
}
///|
pub fn[T] MathExpr::subtract(
self : MathExpr[T],
other : MathExpr[T],
) -> MathExpr[T] {
math_expression(
group_expression(
self.expression.category,
"calc(",
[self.expression, other.expression],
" - ",
")",
),
)
}
///|
pub fn[T] MathExpr::scale(
self : MathExpr[T],
factor : MathExpr[Number],
) -> MathExpr[T] {
math_expression(
group_expression(
self.expression.category,
"calc(",
[self.expression, factor.expression],
" * ",
")",
),
)
}
///|
/// Division is deliberately limited to a finite, nonzero static denominator.
pub fn[T] MathExpr::divide(self : MathExpr[T], divisor : Double) -> MathExpr[T] {
let css = if divisor == 0.0 ||
divisor.is_nan() ||
divisor.is_pos_inf() ||
divisor.is_neg_inf() {
issue_css("STYLE_MATH_DIVISOR", "Division requires a finite nonzero number")
} else {
divisor.to_string()
}
math_expression(
group_expression(
self.expression.category,
"calc(",
[self.expression, literal_expression(css, "number")],
" / ",
")",
),
)
}
///|
fn[T] math_comparison(
name : String,
values : Array[MathExpr[T]],
) -> MathExpr[T] {
if values.is_empty() {
return math_expression(
literal_expression(
issue_css("STYLE_MATH_EMPTY", name + " requires operands"),
"invalid",
),
)
}
math_expression(
group_expression(
values[0].expression.category,
name + "(",
values.map(value => value.expression),
", ",
")",
),
)
}
///|
pub fn[T] css_min(values : Array[MathExpr[T]]) -> MathExpr[T] {
math_comparison("min", values)
}
///|
pub fn[T] css_max(values : Array[MathExpr[T]]) -> MathExpr[T] {
math_comparison("max", values)
}
///|
pub fn[T] css_clamp(
minimum : MathExpr[T],
preferred : MathExpr[T],
maximum : MathExpr[T],
) -> MathExpr[T] {
math_comparison("clamp", [minimum, preferred, maximum])
}
///|
pub fn MathExpr::as_length_percentage(
self : MathExpr[Length],
) -> MathExpr[LengthPercentage] {
math_expression({ ..self.expression, category: "length-percentage", })
}
///|
pub fn percentage_as_length(
value : MathExpr[Percentage],
) -> MathExpr[LengthPercentage] {
math_expression({ ..value.expression, category: "length-percentage", })
}
///|
fn valid_identity_node(node : Json) -> Bool {
match node {
["literal", String(_)] => true
["token" | "slot", String(name), String(category)] =>
name.has_prefix("--") &&
[
"length", "length-percentage", "percentage", "number", "integer", "color",
"angle", "time", "resolution", "url",
].contains(category)
["constraint", String(_), value] => valid_identity_node(value)
["group", String(_), Array(values), String(_), String(_)] =>
values.every(valid_identity_node)
_ => false
}
}
///|
pub fn valid_expression_identity(identity : String) -> Bool {
if !value_issues(identity).is_empty() {
return false
}
let parsed = @json.parse(identity) catch { _ => return false }
if parsed.stringify() != identity {
return false
}
match parsed {
[String(_), value] => valid_identity_node(value)
_ => false
}
}