we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement CSS math functions: calc(), min(), max(), clamp()

Add parsing, evaluation, and layout-time resolution for CSS math
functions per CSS Values and Units Module Level 4.

- CSS crate: MathExpr AST with recursive descent parser supporting
operator precedence, nested functions, and mixed units
- Style crate: Two-phase evaluation — resolves non-percentage units
at style time, defers percentage-based expressions to layout via
new LengthOrAuto::Calc and ClampCalc variants
- Layout crate: Resolves deferred calc expressions against containing
block dimensions at layout time
- Handles calc(100% - 20px), min/max with vw/px, clamp with mixed
units, division by zero, and nested expressions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1292 -21
+561
crates/css/src/values.rs
··· 41 41 Zero, 42 42 /// A list of values (for multi-value properties like margin shorthand). 43 43 List(Vec<CssValue>), 44 + /// A CSS math function expression: calc(), min(), max(), clamp(). 45 + Math(Box<MathExpr>), 46 + } 47 + 48 + // --------------------------------------------------------------------------- 49 + // CSS math expression AST (calc, min, max, clamp) 50 + // --------------------------------------------------------------------------- 51 + 52 + /// A node in a CSS math expression tree. 53 + #[derive(Debug, Clone, PartialEq)] 54 + pub enum MathExpr { 55 + /// A literal length value. 56 + Length(f64, LengthUnit), 57 + /// A literal percentage value. 58 + Percentage(f64), 59 + /// A literal unitless number. 60 + Number(f64), 61 + /// Addition: `a + b`. 62 + Add(Box<MathExpr>, Box<MathExpr>), 63 + /// Subtraction: `a - b`. 64 + Sub(Box<MathExpr>, Box<MathExpr>), 65 + /// Multiplication: `a * b` (one operand should be a number). 66 + Mul(Box<MathExpr>, Box<MathExpr>), 67 + /// Division: `a / b` (divisor should be a number). 68 + Div(Box<MathExpr>, Box<MathExpr>), 69 + /// `min(a, b, ...)` — returns the smallest value. 70 + Min(Vec<MathExpr>), 71 + /// `max(a, b, ...)` — returns the largest value. 72 + Max(Vec<MathExpr>), 73 + /// `clamp(min, val, max)` — equivalent to `max(min, min(val, max))`. 74 + Clamp(Box<MathExpr>, Box<MathExpr>, Box<MathExpr>), 44 75 } 45 76 46 77 /// CSS length unit. ··· 239 270 match name.to_ascii_lowercase().as_str() { 240 271 "rgb" => parse_rgb(args, false), 241 272 "rgba" => parse_rgb(args, true), 273 + "calc" => parse_math_calc(args), 274 + "min" => parse_math_min(args), 275 + "max" => parse_math_max(args), 276 + "clamp" => parse_math_clamp(args), 242 277 _ => CssValue::Keyword(format!("{name}()")), 278 + } 279 + } 280 + 281 + // --------------------------------------------------------------------------- 282 + // CSS math function parsing (calc, min, max, clamp) 283 + // --------------------------------------------------------------------------- 284 + 285 + fn parse_math_calc(args: &[ComponentValue]) -> CssValue { 286 + let tokens = filter_math_tokens(args); 287 + match parse_sum(&tokens) { 288 + Some((expr, [])) => CssValue::Math(Box::new(expr)), 289 + _ => CssValue::Keyword("calc()".to_string()), 290 + } 291 + } 292 + 293 + fn parse_math_min(args: &[ComponentValue]) -> CssValue { 294 + let exprs = parse_comma_separated_exprs(args); 295 + if exprs.is_empty() { 296 + return CssValue::Keyword("min()".to_string()); 297 + } 298 + CssValue::Math(Box::new(MathExpr::Min(exprs))) 299 + } 300 + 301 + fn parse_math_max(args: &[ComponentValue]) -> CssValue { 302 + let exprs = parse_comma_separated_exprs(args); 303 + if exprs.is_empty() { 304 + return CssValue::Keyword("max()".to_string()); 305 + } 306 + CssValue::Math(Box::new(MathExpr::Max(exprs))) 307 + } 308 + 309 + fn parse_math_clamp(args: &[ComponentValue]) -> CssValue { 310 + let exprs = parse_comma_separated_exprs(args); 311 + if exprs.len() != 3 { 312 + return CssValue::Keyword("clamp()".to_string()); 313 + } 314 + let mut iter = exprs.into_iter(); 315 + let min = iter.next().unwrap(); 316 + let val = iter.next().unwrap(); 317 + let max = iter.next().unwrap(); 318 + CssValue::Math(Box::new(MathExpr::Clamp( 319 + Box::new(min), 320 + Box::new(val), 321 + Box::new(max), 322 + ))) 323 + } 324 + 325 + /// Intermediate token for math expression parsing. 326 + #[derive(Debug, Clone)] 327 + enum MathToken { 328 + Number(f64), 329 + Dimension(f64, LengthUnit), 330 + Percentage(f64), 331 + Plus, 332 + Minus, 333 + Star, 334 + Slash, 335 + /// A pre-parsed nested math expression (e.g., nested min/max/clamp/calc). 336 + Nested(MathExpr), 337 + } 338 + 339 + /// Convert ComponentValues into MathTokens, filtering whitespace but preserving 340 + /// it around +/- operators (whitespace is required around + and - per spec). 341 + fn filter_math_tokens(args: &[ComponentValue]) -> Vec<MathToken> { 342 + let mut tokens = Vec::new(); 343 + let mut i = 0; 344 + while i < args.len() { 345 + match &args[i] { 346 + ComponentValue::Number(n, _) => tokens.push(MathToken::Number(*n)), 347 + ComponentValue::Percentage(n) => tokens.push(MathToken::Percentage(*n)), 348 + ComponentValue::Dimension(n, _, unit) => { 349 + if let Some(lu) = parse_length_unit(unit) { 350 + tokens.push(MathToken::Dimension(*n, lu)); 351 + } 352 + } 353 + ComponentValue::Delim('+') => tokens.push(MathToken::Plus), 354 + ComponentValue::Delim('-') => tokens.push(MathToken::Minus), 355 + ComponentValue::Delim('*') => tokens.push(MathToken::Star), 356 + ComponentValue::Delim('/') => tokens.push(MathToken::Slash), 357 + ComponentValue::Function(name, inner_args) => { 358 + let lower = name.to_ascii_lowercase(); 359 + let nested = match lower.as_str() { 360 + "calc" => { 361 + let inner_tokens = filter_math_tokens(inner_args); 362 + parse_sum(&inner_tokens).and_then(|(e, r)| { 363 + if r.is_empty() { 364 + Some(e) 365 + } else { 366 + Option::None 367 + } 368 + }) 369 + } 370 + "min" => { 371 + let exprs = parse_comma_separated_exprs(inner_args); 372 + if exprs.is_empty() { 373 + Option::None 374 + } else { 375 + Some(MathExpr::Min(exprs)) 376 + } 377 + } 378 + "max" => { 379 + let exprs = parse_comma_separated_exprs(inner_args); 380 + if exprs.is_empty() { 381 + Option::None 382 + } else { 383 + Some(MathExpr::Max(exprs)) 384 + } 385 + } 386 + "clamp" => { 387 + let exprs = parse_comma_separated_exprs(inner_args); 388 + if exprs.len() == 3 { 389 + let mut it = exprs.into_iter(); 390 + Some(MathExpr::Clamp( 391 + Box::new(it.next().unwrap()), 392 + Box::new(it.next().unwrap()), 393 + Box::new(it.next().unwrap()), 394 + )) 395 + } else { 396 + Option::None 397 + } 398 + } 399 + _ => Option::None, 400 + }; 401 + if let Some(expr) = nested { 402 + tokens.push(MathToken::Nested(expr)); 403 + } 404 + } 405 + ComponentValue::Whitespace | ComponentValue::Comma => {} 406 + _ => {} 407 + } 408 + i += 1; 409 + } 410 + tokens 411 + } 412 + 413 + fn parse_length_unit(unit: &str) -> Option<LengthUnit> { 414 + match unit.to_ascii_lowercase().as_str() { 415 + "px" => Some(LengthUnit::Px), 416 + "pt" => Some(LengthUnit::Pt), 417 + "cm" => Some(LengthUnit::Cm), 418 + "mm" => Some(LengthUnit::Mm), 419 + "in" => Some(LengthUnit::In), 420 + "pc" => Some(LengthUnit::Pc), 421 + "em" => Some(LengthUnit::Em), 422 + "rem" => Some(LengthUnit::Rem), 423 + "vw" => Some(LengthUnit::Vw), 424 + "vh" => Some(LengthUnit::Vh), 425 + "vmin" => Some(LengthUnit::Vmin), 426 + "vmax" => Some(LengthUnit::Vmax), 427 + _ => Option::None, 428 + } 429 + } 430 + 431 + /// Parse comma-separated math expressions (for min, max, clamp arguments). 432 + fn parse_comma_separated_exprs(args: &[ComponentValue]) -> Vec<MathExpr> { 433 + // Split on commas at the top level, then parse each group as a sum expression. 434 + let mut groups: Vec<Vec<&ComponentValue>> = vec![vec![]]; 435 + for cv in args { 436 + if matches!(cv, ComponentValue::Comma) { 437 + groups.push(vec![]); 438 + } else { 439 + groups.last_mut().unwrap().push(cv); 440 + } 441 + } 442 + 443 + let mut results = Vec::new(); 444 + for group in groups { 445 + let owned: Vec<ComponentValue> = group.into_iter().cloned().collect(); 446 + let tokens = filter_math_tokens(&owned); 447 + if let Some((expr, rest)) = parse_sum(&tokens) { 448 + if rest.is_empty() { 449 + results.push(expr); 450 + } 451 + } 452 + } 453 + results 454 + } 455 + 456 + /// Parse an additive expression: `product (('+' | '-') product)*`. 457 + /// Per spec, whitespace is required around `+` and `-`. 458 + fn parse_sum(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { 459 + let (mut left, mut rest) = parse_product(tokens)?; 460 + while !rest.is_empty() { 461 + match rest.first() { 462 + Some(MathToken::Plus) => { 463 + let (right, r) = parse_product(&rest[1..])?; 464 + left = MathExpr::Add(Box::new(left), Box::new(right)); 465 + rest = r; 466 + } 467 + Some(MathToken::Minus) => { 468 + let (right, r) = parse_product(&rest[1..])?; 469 + left = MathExpr::Sub(Box::new(left), Box::new(right)); 470 + rest = r; 471 + } 472 + _ => break, 473 + } 474 + } 475 + Some((left, rest)) 476 + } 477 + 478 + /// Parse a multiplicative expression: `atom (('*' | '/') atom)*`. 479 + fn parse_product(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { 480 + let (mut left, mut rest) = parse_atom(tokens)?; 481 + while !rest.is_empty() { 482 + match rest.first() { 483 + Some(MathToken::Star) => { 484 + let (right, r) = parse_atom(&rest[1..])?; 485 + left = MathExpr::Mul(Box::new(left), Box::new(right)); 486 + rest = r; 487 + } 488 + Some(MathToken::Slash) => { 489 + let (right, r) = parse_atom(&rest[1..])?; 490 + left = MathExpr::Div(Box::new(left), Box::new(right)); 491 + rest = r; 492 + } 493 + _ => break, 494 + } 495 + } 496 + Some((left, rest)) 497 + } 498 + 499 + /// Parse an atom: a number, dimension, percentage, parenthesized expression, 500 + /// or nested math function. 501 + fn parse_atom(tokens: &[MathToken]) -> Option<(MathExpr, &[MathToken])> { 502 + let first = tokens.first()?; 503 + match first { 504 + MathToken::Number(n) => Some((MathExpr::Number(*n), &tokens[1..])), 505 + MathToken::Dimension(n, unit) => Some((MathExpr::Length(*n, *unit), &tokens[1..])), 506 + MathToken::Percentage(n) => Some((MathExpr::Percentage(*n), &tokens[1..])), 507 + MathToken::Nested(expr) => Some((expr.clone(), &tokens[1..])), 508 + // Handle unary minus: e.g. `-20px` tokenized as Minus followed by value. 509 + MathToken::Minus => { 510 + let (inner, rest) = parse_atom(&tokens[1..])?; 511 + let negated = MathExpr::Mul(Box::new(MathExpr::Number(-1.0)), Box::new(inner)); 512 + Some((negated, rest)) 513 + } 514 + _ => Option::None, 243 515 } 244 516 } 245 517 ··· 1343 1615 parse_single_value(&cv), 1344 1616 CssValue::String("hello".to_string()) 1345 1617 ); 1618 + } 1619 + 1620 + // -- Math function parsing tests ------------------------------------------ 1621 + 1622 + #[test] 1623 + fn test_calc_simple_px() { 1624 + let args = vec![ 1625 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1626 + ComponentValue::Whitespace, 1627 + ComponentValue::Delim('+'), 1628 + ComponentValue::Whitespace, 1629 + ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), 1630 + ]; 1631 + let cv = ComponentValue::Function("calc".to_string(), args); 1632 + let result = parse_single_value(&cv); 1633 + assert_eq!( 1634 + result, 1635 + CssValue::Math(Box::new(MathExpr::Add( 1636 + Box::new(MathExpr::Length(10.0, LengthUnit::Px)), 1637 + Box::new(MathExpr::Length(20.0, LengthUnit::Px)), 1638 + ))) 1639 + ); 1640 + } 1641 + 1642 + #[test] 1643 + fn test_calc_percentage_minus_px() { 1644 + let args = vec![ 1645 + ComponentValue::Percentage(100.0), 1646 + ComponentValue::Whitespace, 1647 + ComponentValue::Delim('-'), 1648 + ComponentValue::Whitespace, 1649 + ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), 1650 + ]; 1651 + let cv = ComponentValue::Function("calc".to_string(), args); 1652 + let result = parse_single_value(&cv); 1653 + assert_eq!( 1654 + result, 1655 + CssValue::Math(Box::new(MathExpr::Sub( 1656 + Box::new(MathExpr::Percentage(100.0)), 1657 + Box::new(MathExpr::Length(20.0, LengthUnit::Px)), 1658 + ))) 1659 + ); 1660 + } 1661 + 1662 + #[test] 1663 + fn test_calc_multiplication() { 1664 + // calc(2 * 10px) 1665 + let args = vec![ 1666 + ComponentValue::Number(2.0, NumericType::Integer), 1667 + ComponentValue::Whitespace, 1668 + ComponentValue::Delim('*'), 1669 + ComponentValue::Whitespace, 1670 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1671 + ]; 1672 + let cv = ComponentValue::Function("calc".to_string(), args); 1673 + let result = parse_single_value(&cv); 1674 + assert_eq!( 1675 + result, 1676 + CssValue::Math(Box::new(MathExpr::Mul( 1677 + Box::new(MathExpr::Number(2.0)), 1678 + Box::new(MathExpr::Length(10.0, LengthUnit::Px)), 1679 + ))) 1680 + ); 1681 + } 1682 + 1683 + #[test] 1684 + fn test_calc_division() { 1685 + // calc(100px / 2) 1686 + let args = vec![ 1687 + ComponentValue::Dimension(100.0, NumericType::Integer, "px".to_string()), 1688 + ComponentValue::Whitespace, 1689 + ComponentValue::Delim('/'), 1690 + ComponentValue::Whitespace, 1691 + ComponentValue::Number(2.0, NumericType::Integer), 1692 + ]; 1693 + let cv = ComponentValue::Function("calc".to_string(), args); 1694 + let result = parse_single_value(&cv); 1695 + assert_eq!( 1696 + result, 1697 + CssValue::Math(Box::new(MathExpr::Div( 1698 + Box::new(MathExpr::Length(100.0, LengthUnit::Px)), 1699 + Box::new(MathExpr::Number(2.0)), 1700 + ))) 1701 + ); 1702 + } 1703 + 1704 + #[test] 1705 + fn test_calc_operator_precedence() { 1706 + // calc(10px + 2 * 5px) should be Add(10px, Mul(2, 5px)) 1707 + let args = vec![ 1708 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1709 + ComponentValue::Whitespace, 1710 + ComponentValue::Delim('+'), 1711 + ComponentValue::Whitespace, 1712 + ComponentValue::Number(2.0, NumericType::Integer), 1713 + ComponentValue::Whitespace, 1714 + ComponentValue::Delim('*'), 1715 + ComponentValue::Whitespace, 1716 + ComponentValue::Dimension(5.0, NumericType::Integer, "px".to_string()), 1717 + ]; 1718 + let cv = ComponentValue::Function("calc".to_string(), args); 1719 + let result = parse_single_value(&cv); 1720 + assert_eq!( 1721 + result, 1722 + CssValue::Math(Box::new(MathExpr::Add( 1723 + Box::new(MathExpr::Length(10.0, LengthUnit::Px)), 1724 + Box::new(MathExpr::Mul( 1725 + Box::new(MathExpr::Number(2.0)), 1726 + Box::new(MathExpr::Length(5.0, LengthUnit::Px)), 1727 + )), 1728 + ))) 1729 + ); 1730 + } 1731 + 1732 + #[test] 1733 + fn test_min_two_args() { 1734 + let args = vec![ 1735 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1736 + ComponentValue::Comma, 1737 + ComponentValue::Whitespace, 1738 + ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), 1739 + ]; 1740 + let cv = ComponentValue::Function("min".to_string(), args); 1741 + let result = parse_single_value(&cv); 1742 + assert_eq!( 1743 + result, 1744 + CssValue::Math(Box::new(MathExpr::Min(vec![ 1745 + MathExpr::Length(10.0, LengthUnit::Px), 1746 + MathExpr::Length(5.0, LengthUnit::Vw), 1747 + ]))) 1748 + ); 1749 + } 1750 + 1751 + #[test] 1752 + fn test_max_two_args() { 1753 + let args = vec![ 1754 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1755 + ComponentValue::Comma, 1756 + ComponentValue::Whitespace, 1757 + ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), 1758 + ]; 1759 + let cv = ComponentValue::Function("max".to_string(), args); 1760 + let result = parse_single_value(&cv); 1761 + assert_eq!( 1762 + result, 1763 + CssValue::Math(Box::new(MathExpr::Max(vec![ 1764 + MathExpr::Length(10.0, LengthUnit::Px), 1765 + MathExpr::Length(5.0, LengthUnit::Vw), 1766 + ]))) 1767 + ); 1768 + } 1769 + 1770 + #[test] 1771 + fn test_clamp_three_args() { 1772 + let args = vec![ 1773 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1774 + ComponentValue::Comma, 1775 + ComponentValue::Whitespace, 1776 + ComponentValue::Percentage(50.0), 1777 + ComponentValue::Comma, 1778 + ComponentValue::Whitespace, 1779 + ComponentValue::Dimension(200.0, NumericType::Integer, "px".to_string()), 1780 + ]; 1781 + let cv = ComponentValue::Function("clamp".to_string(), args); 1782 + let result = parse_single_value(&cv); 1783 + assert_eq!( 1784 + result, 1785 + CssValue::Math(Box::new(MathExpr::Clamp( 1786 + Box::new(MathExpr::Length(10.0, LengthUnit::Px)), 1787 + Box::new(MathExpr::Percentage(50.0)), 1788 + Box::new(MathExpr::Length(200.0, LengthUnit::Px)), 1789 + ))) 1790 + ); 1791 + } 1792 + 1793 + #[test] 1794 + fn test_calc_nested_min() { 1795 + // calc(min(10px, 5vw) + 20px) 1796 + let args = vec![ 1797 + ComponentValue::Function( 1798 + "min".to_string(), 1799 + vec![ 1800 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1801 + ComponentValue::Comma, 1802 + ComponentValue::Whitespace, 1803 + ComponentValue::Dimension(5.0, NumericType::Integer, "vw".to_string()), 1804 + ], 1805 + ), 1806 + ComponentValue::Whitespace, 1807 + ComponentValue::Delim('+'), 1808 + ComponentValue::Whitespace, 1809 + ComponentValue::Dimension(20.0, NumericType::Integer, "px".to_string()), 1810 + ]; 1811 + let cv = ComponentValue::Function("calc".to_string(), args); 1812 + let result = parse_single_value(&cv); 1813 + assert_eq!( 1814 + result, 1815 + CssValue::Math(Box::new(MathExpr::Add( 1816 + Box::new(MathExpr::Min(vec![ 1817 + MathExpr::Length(10.0, LengthUnit::Px), 1818 + MathExpr::Length(5.0, LengthUnit::Vw), 1819 + ])), 1820 + Box::new(MathExpr::Length(20.0, LengthUnit::Px)), 1821 + ))) 1822 + ); 1823 + } 1824 + 1825 + #[test] 1826 + fn test_calc_mixed_units() { 1827 + // calc(50% - 2em) 1828 + let args = vec![ 1829 + ComponentValue::Percentage(50.0), 1830 + ComponentValue::Whitespace, 1831 + ComponentValue::Delim('-'), 1832 + ComponentValue::Whitespace, 1833 + ComponentValue::Dimension(2.0, NumericType::Number, "em".to_string()), 1834 + ]; 1835 + let cv = ComponentValue::Function("calc".to_string(), args); 1836 + let result = parse_single_value(&cv); 1837 + assert_eq!( 1838 + result, 1839 + CssValue::Math(Box::new(MathExpr::Sub( 1840 + Box::new(MathExpr::Percentage(50.0)), 1841 + Box::new(MathExpr::Length(2.0, LengthUnit::Em)), 1842 + ))) 1843 + ); 1844 + } 1845 + 1846 + #[test] 1847 + fn test_invalid_calc_empty() { 1848 + let args = vec![]; 1849 + let cv = ComponentValue::Function("calc".to_string(), args); 1850 + assert_eq!( 1851 + parse_single_value(&cv), 1852 + CssValue::Keyword("calc()".to_string()) 1853 + ); 1854 + } 1855 + 1856 + #[test] 1857 + fn test_invalid_clamp_too_few_args() { 1858 + let args = vec![ 1859 + ComponentValue::Dimension(10.0, NumericType::Integer, "px".to_string()), 1860 + ComponentValue::Comma, 1861 + ComponentValue::Dimension(50.0, NumericType::Integer, "px".to_string()), 1862 + ]; 1863 + let cv = ComponentValue::Function("clamp".to_string(), args); 1864 + assert_eq!( 1865 + parse_single_value(&cv), 1866 + CssValue::Keyword("clamp()".to_string()) 1867 + ); 1868 + } 1869 + 1870 + #[test] 1871 + fn test_calc_from_css_text() { 1872 + use crate::parser::Parser; 1873 + 1874 + let ss = Parser::parse("div { width: calc(100% - 20px); }"); 1875 + let rule = match &ss.rules[0] { 1876 + crate::parser::Rule::Style(r) => r, 1877 + _ => panic!("expected style rule"), 1878 + }; 1879 + let val = parse_value(&rule.declarations[0].value); 1880 + assert!(matches!(val, CssValue::Math(_))); 1881 + } 1882 + 1883 + #[test] 1884 + fn test_min_from_css_text() { 1885 + use crate::parser::Parser; 1886 + 1887 + let ss = Parser::parse("div { width: min(10px, 5vw); }"); 1888 + let rule = match &ss.rules[0] { 1889 + crate::parser::Rule::Style(r) => r, 1890 + _ => panic!("expected style rule"), 1891 + }; 1892 + let val = parse_value(&rule.declarations[0].value); 1893 + assert!(matches!(val, CssValue::Math(_))); 1894 + } 1895 + 1896 + #[test] 1897 + fn test_clamp_from_css_text() { 1898 + use crate::parser::Parser; 1899 + 1900 + let ss = Parser::parse("div { width: clamp(10px, 50%, 200px); }"); 1901 + let rule = match &ss.rules[0] { 1902 + crate::parser::Rule::Style(r) => r, 1903 + _ => panic!("expected style rule"), 1904 + }; 1905 + let val = parse_value(&rule.declarations[0].value); 1906 + assert!(matches!(val, CssValue::Math(_))); 1346 1907 } 1347 1908 }
+122 -10
crates/layout/src/lib.rs
··· 276 276 // Resolve LengthOrAuto to f32 277 277 // --------------------------------------------------------------------------- 278 278 279 + /// Check if a `LengthOrAuto` needs layout-time resolution (contains percentages). 280 + fn needs_reference_resolution(value: LengthOrAuto) -> bool { 281 + matches!( 282 + value, 283 + LengthOrAuto::Percentage(_) | LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } 284 + ) 285 + } 286 + 279 287 /// Resolve a `LengthOrAuto` to px. Percentages are resolved against 280 288 /// `reference` (typically the containing block width). Auto resolves to 0. 281 289 fn resolve_length_against(value: LengthOrAuto, reference: f32) -> f32 { 282 290 match value { 283 291 LengthOrAuto::Length(px) => px, 284 292 LengthOrAuto::Percentage(p) => p / 100.0 * reference, 293 + LengthOrAuto::Calc(px, pct) => px + pct / 100.0 * reference, 294 + LengthOrAuto::ClampCalc { 295 + min_px, 296 + px_offset, 297 + pct, 298 + max_px, 299 + } => (px_offset + pct / 100.0 * reference) 300 + .max(min_px) 301 + .min(max_px), 285 302 LengthOrAuto::Auto => 0.0, 286 303 } 287 304 } ··· 292 309 match left { 293 310 LengthOrAuto::Length(px) => px, 294 311 LengthOrAuto::Percentage(p) => p / 100.0 * cb_width, 312 + LengthOrAuto::Calc(px, pct) => px + pct / 100.0 * cb_width, 313 + LengthOrAuto::ClampCalc { 314 + min_px, 315 + px_offset, 316 + pct, 317 + max_px, 318 + } => (px_offset + pct / 100.0 * cb_width).max(min_px).min(max_px), 295 319 LengthOrAuto::Auto => match right { 296 320 LengthOrAuto::Length(px) => -px, 297 321 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_width), 322 + LengthOrAuto::Calc(px, pct) => -(px + pct / 100.0 * cb_width), 323 + LengthOrAuto::ClampCalc { 324 + min_px, 325 + px_offset, 326 + pct, 327 + max_px, 328 + } => -((px_offset + pct / 100.0 * cb_width).max(min_px).min(max_px)), 298 329 LengthOrAuto::Auto => 0.0, 299 330 }, 300 331 } ··· 306 337 match top { 307 338 LengthOrAuto::Length(px) => px, 308 339 LengthOrAuto::Percentage(p) => p / 100.0 * cb_height, 340 + LengthOrAuto::Calc(px, pct) => px + pct / 100.0 * cb_height, 341 + LengthOrAuto::ClampCalc { 342 + min_px, 343 + px_offset, 344 + pct, 345 + max_px, 346 + } => (px_offset + pct / 100.0 * cb_height) 347 + .max(min_px) 348 + .min(max_px), 309 349 LengthOrAuto::Auto => match bottom { 310 350 LengthOrAuto::Length(px) => -px, 311 351 LengthOrAuto::Percentage(p) => -(p / 100.0 * cb_height), 352 + LengthOrAuto::Calc(px, pct) => -(px + pct / 100.0 * cb_height), 353 + LengthOrAuto::ClampCalc { 354 + min_px, 355 + px_offset, 356 + pct, 357 + max_px, 358 + } => { 359 + -((px_offset + pct / 100.0 * cb_height) 360 + .max(min_px) 361 + .min(max_px)) 362 + } 312 363 LengthOrAuto::Auto => 0.0, 313 364 }, 314 365 } ··· 716 767 // Resolve percentage margins against containing block width. 717 768 // Only re-resolve percentages — absolute margins may have been modified 718 769 // by margin collapsing and must not be overwritten. 719 - if matches!(b.css_margin[0], LengthOrAuto::Percentage(_)) { 770 + if needs_reference_resolution(b.css_margin[0]) { 720 771 b.margin.top = resolve_length_against(b.css_margin[0], available_width); 721 772 } 722 - if matches!(b.css_margin[1], LengthOrAuto::Percentage(_)) { 773 + if needs_reference_resolution(b.css_margin[1]) { 723 774 b.margin.right = resolve_length_against(b.css_margin[1], available_width); 724 775 } 725 - if matches!(b.css_margin[2], LengthOrAuto::Percentage(_)) { 776 + if needs_reference_resolution(b.css_margin[2]) { 726 777 b.margin.bottom = resolve_length_against(b.css_margin[2], available_width); 727 778 } 728 - if matches!(b.css_margin[3], LengthOrAuto::Percentage(_)) { 779 + if needs_reference_resolution(b.css_margin[3]) { 729 780 b.margin.left = resolve_length_against(b.css_margin[3], available_width); 730 781 } 731 782 732 783 // Resolve percentage padding against containing block width. 733 - if matches!(b.css_padding[0], LengthOrAuto::Percentage(_)) { 784 + if needs_reference_resolution(b.css_padding[0]) { 734 785 b.padding.top = resolve_length_against(b.css_padding[0], available_width); 735 786 } 736 - if matches!(b.css_padding[1], LengthOrAuto::Percentage(_)) { 787 + if needs_reference_resolution(b.css_padding[1]) { 737 788 b.padding.right = resolve_length_against(b.css_padding[1], available_width); 738 789 } 739 - if matches!(b.css_padding[2], LengthOrAuto::Percentage(_)) { 790 + if needs_reference_resolution(b.css_padding[2]) { 740 791 b.padding.bottom = resolve_length_against(b.css_padding[2], available_width); 741 792 } 742 - if matches!(b.css_padding[3], LengthOrAuto::Percentage(_)) { 793 + if needs_reference_resolution(b.css_padding[3]) { 743 794 b.padding.left = resolve_length_against(b.css_padding[3], available_width); 744 795 } 745 796 ··· 756 807 }, 757 808 LengthOrAuto::Percentage(p) => { 758 809 let resolved = p / 100.0 * available_width; 810 + match b.box_sizing { 811 + BoxSizing::ContentBox => resolved.max(0.0), 812 + BoxSizing::BorderBox => (resolved - horizontal_extra).max(0.0), 813 + } 814 + } 815 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 816 + let resolved = resolve_length_against(b.css_width, available_width); 759 817 match b.box_sizing { 760 818 BoxSizing::ContentBox => resolved.max(0.0), 761 819 BoxSizing::BorderBox => (resolved - horizontal_extra).max(0.0), ··· 817 875 // For the root element, use viewport height. 818 876 let cb_height = viewport_height; 819 877 let resolved = p / 100.0 * cb_height; 878 + let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 879 + b.rect.height = match b.box_sizing { 880 + BoxSizing::ContentBox => resolved.max(0.0), 881 + BoxSizing::BorderBox => (resolved - vertical_extra).max(0.0), 882 + }; 883 + } 884 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 885 + let cb_height = viewport_height; 886 + let resolved = resolve_length_against(b.css_height, cb_height); 820 887 let vertical_extra = b.border.top + b.border.bottom + b.padding.top + b.padding.bottom; 821 888 b.rect.height = match b.box_sizing { 822 889 BoxSizing::ContentBox => resolved.max(0.0), ··· 889 956 match value { 890 957 LengthOrAuto::Length(px) => Some(px), 891 958 LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 959 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 960 + Some(resolve_length_against(value, reference)) 961 + } 892 962 LengthOrAuto::Auto => None, 893 963 } 894 964 } ··· 1008 1078 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 1009 1079 } 1010 1080 } 1081 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1082 + let resolved = resolve_length_against(child.css_width, cb.width); 1083 + match child.box_sizing { 1084 + BoxSizing::ContentBox => resolved.max(0.0), 1085 + BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 1086 + } 1087 + } 1011 1088 LengthOrAuto::Auto => { 1012 1089 // If both left and right are specified, stretch to fill. 1013 1090 if let (Some(l), Some(r)) = (left_offset, right_offset) { ··· 1100 1177 BoxSizing::BorderBox => (resolved - vert_extra).max(0.0), 1101 1178 }; 1102 1179 } 1180 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1181 + let resolved = resolve_length_against(child.css_height, cb.height); 1182 + child.rect.height = match child.box_sizing { 1183 + BoxSizing::ContentBox => resolved.max(0.0), 1184 + BoxSizing::BorderBox => (resolved - vert_extra).max(0.0), 1185 + }; 1186 + } 1103 1187 LengthOrAuto::Auto => { 1104 1188 // If both top and bottom are specified, stretch. 1105 1189 if let (Some(t), Some(b_val)) = (top_offset, bottom_offset) { ··· 1482 1566 BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 1483 1567 } 1484 1568 } 1569 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1570 + let resolved = resolve_length_against(child.css_width, container_width); 1571 + match child.box_sizing { 1572 + BoxSizing::ContentBox => resolved.max(0.0), 1573 + BoxSizing::BorderBox => (resolved - horiz_extra).max(0.0), 1574 + } 1575 + } 1485 1576 LengthOrAuto::Auto => { 1486 1577 // Shrink-to-fit: measure content. 1487 1578 let max_content = measure_float_content_width(child, font); ··· 1536 1627 } 1537 1628 LengthOrAuto::Percentage(p) => { 1538 1629 let resolved = p / 100.0 * viewport_height; 1630 + let vert_extra = 1631 + child.border.top + child.border.bottom + child.padding.top + child.padding.bottom; 1632 + child.rect.height = match child.box_sizing { 1633 + BoxSizing::ContentBox => resolved.max(0.0), 1634 + BoxSizing::BorderBox => (resolved - vert_extra).max(0.0), 1635 + }; 1636 + } 1637 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1638 + let resolved = resolve_length_against(child.css_height, viewport_height); 1539 1639 let vert_extra = 1540 1640 child.border.top + child.border.bottom + child.padding.top + child.padding.bottom; 1541 1641 child.rect.height = match child.box_sizing { ··· 1652 1752 match parent.css_height { 1653 1753 LengthOrAuto::Length(h) => h, 1654 1754 LengthOrAuto::Percentage(p) => p / 100.0 * viewport_height, 1755 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1756 + resolve_length_against(parent.css_height, viewport_height) 1757 + } 1655 1758 LengthOrAuto::Auto => viewport_height, // fallback 1656 1759 } 1657 1760 } ··· 1660 1763 FlexDirection::Row | FlexDirection::RowReverse => match parent.css_height { 1661 1764 LengthOrAuto::Length(h) => Some(h), 1662 1765 LengthOrAuto::Percentage(p) => Some(p / 100.0 * viewport_height), 1766 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1767 + Some(resolve_length_against(parent.css_height, viewport_height)) 1768 + } 1663 1769 LengthOrAuto::Auto => None, 1664 1770 }, 1665 1771 FlexDirection::Column | FlexDirection::ColumnReverse => Some(parent.rect.width), ··· 1725 1831 1726 1832 // Resolve percentage margins and padding against containing block width. 1727 1833 for i in 0..4 { 1728 - if matches!(child.css_margin[i], LengthOrAuto::Percentage(_)) { 1834 + if needs_reference_resolution(child.css_margin[i]) { 1729 1835 let resolved = resolve_length_against(child.css_margin[i], cb_width); 1730 1836 match i { 1731 1837 0 => child.margin.top = resolved, ··· 1735 1841 _ => {} 1736 1842 } 1737 1843 } 1738 - if matches!(child.css_padding[i], LengthOrAuto::Percentage(_)) { 1844 + if needs_reference_resolution(child.css_padding[i]) { 1739 1845 let resolved = resolve_length_against(child.css_padding[i], cb_width); 1740 1846 match i { 1741 1847 0 => child.padding.top = resolved, ··· 1779 1885 let base_size = match flex_basis { 1780 1886 LengthOrAuto::Length(px) => px, 1781 1887 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1888 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1889 + resolve_length_against(flex_basis, container_main_size) 1890 + } 1782 1891 LengthOrAuto::Auto => { 1783 1892 // Use specified main size if set, otherwise content size. 1784 1893 match specified_main { 1785 1894 LengthOrAuto::Length(px) => px, 1786 1895 LengthOrAuto::Percentage(p) => p / 100.0 * container_main_size, 1896 + LengthOrAuto::Calc(..) | LengthOrAuto::ClampCalc { .. } => { 1897 + resolve_length_against(specified_main, container_main_size) 1898 + } 1787 1899 LengthOrAuto::Auto => { 1788 1900 // Content-based sizing: use max-content size. 1789 1901 if is_row {
+11
crates/render/src/lib.rs
··· 326 326 match value { 327 327 LengthOrAuto::Length(v) => Some(v), 328 328 LengthOrAuto::Percentage(p) => Some(p / 100.0 * reference), 329 + LengthOrAuto::Calc(px, pct) => Some(px + pct / 100.0 * reference), 330 + LengthOrAuto::ClampCalc { 331 + min_px, 332 + px_offset, 333 + pct, 334 + max_px, 335 + } => Some( 336 + (px_offset + pct / 100.0 * reference) 337 + .max(min_px) 338 + .min(max_px), 339 + ), 329 340 LengthOrAuto::Auto => None, 330 341 } 331 342 }
+598 -11
crates/style/src/computed.rs
··· 7 7 use std::collections::HashMap; 8 8 9 9 use we_css::parser::{ComponentValue, Declaration, Stylesheet}; 10 - use we_css::values::{expand_shorthand, parse_value, Color, CssValue, LengthUnit}; 10 + use we_css::values::{expand_shorthand, parse_value, Color, CssValue, LengthUnit, MathExpr}; 11 11 use we_dom::{Document, NodeData, NodeId}; 12 12 13 13 use crate::matching::collect_matching_rules; ··· 263 263 Length(f32), 264 264 /// Percentage value (0.0–100.0), resolved during layout against the containing block. 265 265 Percentage(f32), 266 + /// Linear calc: `px_offset + (pct / 100) * reference`. 267 + /// Used for expressions like `calc(50% - 20px)`. 268 + Calc(f32, f32), 269 + /// Clamped calc: `clamp(min_px, px_offset + (pct / 100) * reference, max_px)`. 270 + /// Used for `clamp()`, and `min()`/`max()` with mixed percentage/length args. 271 + ClampCalc { 272 + min_px: f32, 273 + px_offset: f32, 274 + pct: f32, 275 + max_px: f32, 276 + }, 266 277 #[default] 267 278 Auto, 268 279 } ··· 604 615 } 605 616 CssValue::Zero => LengthOrAuto::Length(0.0), 606 617 CssValue::Number(n) if *n == 0.0 => LengthOrAuto::Length(0.0), 618 + CssValue::Math(expr) => resolve_math_expr_to_layout(expr, current_font_size, viewport), 607 619 _ => LengthOrAuto::Auto, 608 620 } 609 621 } 610 622 623 + // --------------------------------------------------------------------------- 624 + // CSS math expression evaluation 625 + // --------------------------------------------------------------------------- 626 + 627 + /// Result of partially evaluating a math expression. 628 + /// All non-percentage units are resolved to px; percentages are preserved. 629 + #[derive(Debug, Clone, Copy)] 630 + struct PartialCalc { 631 + px: f32, 632 + pct: f32, 633 + } 634 + 635 + impl PartialCalc { 636 + fn from_px(px: f32) -> Self { 637 + Self { px, pct: 0.0 } 638 + } 639 + fn from_pct(pct: f32) -> Self { 640 + Self { px: 0.0, pct } 641 + } 642 + fn is_pure_px(&self) -> bool { 643 + self.pct == 0.0 644 + } 645 + fn is_pure_pct(&self) -> bool { 646 + self.px == 0.0 647 + } 648 + } 649 + 650 + /// Evaluate a `MathExpr` into a `LengthOrAuto`, resolving all units except 651 + /// percentages (which depend on the containing block, available only at layout time). 652 + fn resolve_math_expr_to_layout( 653 + expr: &MathExpr, 654 + em_base: f32, 655 + viewport: (f32, f32), 656 + ) -> LengthOrAuto { 657 + match try_eval_partial(expr, em_base, viewport) { 658 + Some(pc) if pc.is_pure_px() => LengthOrAuto::Length(pc.px), 659 + Some(pc) if pc.is_pure_pct() => LengthOrAuto::Percentage(pc.pct), 660 + Some(pc) => LengthOrAuto::Calc(pc.px, pc.pct), 661 + None => { 662 + // For min/max/clamp that can't be reduced to a linear combination, 663 + // try to evaluate with clamp representation. 664 + resolve_complex_math(expr, em_base, viewport) 665 + } 666 + } 667 + } 668 + 669 + /// Try to reduce a math expression to a linear `px + pct%` combination. 670 + /// Returns `None` for min/max/clamp that mix percentages and lengths (non-linear). 671 + fn try_eval_partial(expr: &MathExpr, em_base: f32, viewport: (f32, f32)) -> Option<PartialCalc> { 672 + match expr { 673 + MathExpr::Length(n, unit) => Some(PartialCalc::from_px(resolve_length_unit( 674 + *n, *unit, em_base, viewport, 675 + ))), 676 + MathExpr::Percentage(p) => Some(PartialCalc::from_pct(*p as f32)), 677 + MathExpr::Number(n) => Some(PartialCalc::from_px(*n as f32)), 678 + MathExpr::Add(a, b) => { 679 + let a = try_eval_partial(a, em_base, viewport)?; 680 + let b = try_eval_partial(b, em_base, viewport)?; 681 + Some(PartialCalc { 682 + px: a.px + b.px, 683 + pct: a.pct + b.pct, 684 + }) 685 + } 686 + MathExpr::Sub(a, b) => { 687 + let a = try_eval_partial(a, em_base, viewport)?; 688 + let b = try_eval_partial(b, em_base, viewport)?; 689 + Some(PartialCalc { 690 + px: a.px - b.px, 691 + pct: a.pct - b.pct, 692 + }) 693 + } 694 + MathExpr::Mul(a, b) => { 695 + let a = try_eval_partial(a, em_base, viewport)?; 696 + let b = try_eval_partial(b, em_base, viewport)?; 697 + // Multiplication: one operand must be a pure number (no units). 698 + if a.is_pure_px() && a.pct == 0.0 { 699 + // a is unitless number 700 + Some(PartialCalc { 701 + px: a.px * b.px, 702 + pct: a.px * b.pct, 703 + }) 704 + } else if b.is_pure_px() && b.pct == 0.0 { 705 + Some(PartialCalc { 706 + px: b.px * a.px, 707 + pct: b.px * a.pct, 708 + }) 709 + } else { 710 + // Both have units — invalid per spec, treat as 0. 711 + Some(PartialCalc::from_px(0.0)) 712 + } 713 + } 714 + MathExpr::Div(a, b) => { 715 + let a = try_eval_partial(a, em_base, viewport)?; 716 + let b = try_eval_partial(b, em_base, viewport)?; 717 + // Divisor must be a pure number. 718 + if !b.is_pure_px() || b.pct != 0.0 { 719 + return Some(PartialCalc::from_px(0.0)); 720 + } 721 + if b.px == 0.0 { 722 + // Division by zero → invalid. 723 + return Some(PartialCalc::from_px(0.0)); 724 + } 725 + Some(PartialCalc { 726 + px: a.px / b.px, 727 + pct: a.pct / b.px, 728 + }) 729 + } 730 + MathExpr::Min(args) => { 731 + // Only works if all args reduce to pure px (no percentages). 732 + let vals: Vec<PartialCalc> = args 733 + .iter() 734 + .map(|a| try_eval_partial(a, em_base, viewport)) 735 + .collect::<Option<Vec<_>>>()?; 736 + if vals.iter().all(|v| v.is_pure_px()) { 737 + let min = vals.iter().map(|v| v.px).fold(f32::INFINITY, f32::min); 738 + Some(PartialCalc::from_px(min)) 739 + } else { 740 + Option::None // non-linear, needs complex handling 741 + } 742 + } 743 + MathExpr::Max(args) => { 744 + let vals: Vec<PartialCalc> = args 745 + .iter() 746 + .map(|a| try_eval_partial(a, em_base, viewport)) 747 + .collect::<Option<Vec<_>>>()?; 748 + if vals.iter().all(|v| v.is_pure_px()) { 749 + let max = vals.iter().map(|v| v.px).fold(f32::NEG_INFINITY, f32::max); 750 + Some(PartialCalc::from_px(max)) 751 + } else { 752 + Option::None 753 + } 754 + } 755 + MathExpr::Clamp(min_e, val_e, max_e) => { 756 + let min_v = try_eval_partial(min_e, em_base, viewport)?; 757 + let val_v = try_eval_partial(val_e, em_base, viewport)?; 758 + let max_v = try_eval_partial(max_e, em_base, viewport)?; 759 + if min_v.is_pure_px() && val_v.is_pure_px() && max_v.is_pure_px() { 760 + Some(PartialCalc::from_px(val_v.px.max(min_v.px).min(max_v.px))) 761 + } else { 762 + Option::None 763 + } 764 + } 765 + } 766 + } 767 + 768 + /// Handle min/max/clamp with mixed percentage/px operands. 769 + /// Produces `ClampCalc` when possible, falls back to `Auto`. 770 + fn resolve_complex_math(expr: &MathExpr, em_base: f32, viewport: (f32, f32)) -> LengthOrAuto { 771 + match expr { 772 + MathExpr::Min(args) if args.len() == 2 => { 773 + // min(a, b) = clamp(-inf, a, b) when b is px, or clamp(-inf, b, a) when a is px 774 + let a = try_eval_partial(&args[0], em_base, viewport); 775 + let b = try_eval_partial(&args[1], em_base, viewport); 776 + match (a, b) { 777 + (Some(pct_side), Some(px_side)) 778 + if !pct_side.is_pure_px() && px_side.is_pure_px() => 779 + { 780 + LengthOrAuto::ClampCalc { 781 + min_px: f32::NEG_INFINITY, 782 + px_offset: pct_side.px, 783 + pct: pct_side.pct, 784 + max_px: px_side.px, 785 + } 786 + } 787 + (Some(px_side), Some(pct_side)) 788 + if px_side.is_pure_px() && !pct_side.is_pure_px() => 789 + { 790 + LengthOrAuto::ClampCalc { 791 + min_px: f32::NEG_INFINITY, 792 + px_offset: pct_side.px, 793 + pct: pct_side.pct, 794 + max_px: px_side.px, 795 + } 796 + } 797 + _ => LengthOrAuto::Auto, 798 + } 799 + } 800 + MathExpr::Max(args) if args.len() == 2 => { 801 + let a = try_eval_partial(&args[0], em_base, viewport); 802 + let b = try_eval_partial(&args[1], em_base, viewport); 803 + match (a, b) { 804 + (Some(pct_side), Some(px_side)) 805 + if !pct_side.is_pure_px() && px_side.is_pure_px() => 806 + { 807 + LengthOrAuto::ClampCalc { 808 + min_px: px_side.px, 809 + px_offset: pct_side.px, 810 + pct: pct_side.pct, 811 + max_px: f32::INFINITY, 812 + } 813 + } 814 + (Some(px_side), Some(pct_side)) 815 + if px_side.is_pure_px() && !pct_side.is_pure_px() => 816 + { 817 + LengthOrAuto::ClampCalc { 818 + min_px: px_side.px, 819 + px_offset: pct_side.px, 820 + pct: pct_side.pct, 821 + max_px: f32::INFINITY, 822 + } 823 + } 824 + _ => LengthOrAuto::Auto, 825 + } 826 + } 827 + MathExpr::Clamp(min_e, val_e, max_e) => { 828 + let min_v = try_eval_partial(min_e, em_base, viewport); 829 + let val_v = try_eval_partial(val_e, em_base, viewport); 830 + let max_v = try_eval_partial(max_e, em_base, viewport); 831 + match (min_v, val_v, max_v) { 832 + (Some(mn), Some(val), Some(mx)) if mn.is_pure_px() && mx.is_pure_px() => { 833 + LengthOrAuto::ClampCalc { 834 + min_px: mn.px, 835 + px_offset: val.px, 836 + pct: val.pct, 837 + max_px: mx.px, 838 + } 839 + } 840 + _ => LengthOrAuto::Auto, 841 + } 842 + } 843 + _ => LengthOrAuto::Auto, 844 + } 845 + } 846 + 847 + /// Fully evaluate a math expression to px, resolving percentages against 848 + /// `pct_base`. Used for properties like font-size where the percentage 849 + /// reference is known at style time. 850 + fn eval_math_expr_full( 851 + expr: &MathExpr, 852 + em_base: f32, 853 + pct_base: f32, 854 + viewport: (f32, f32), 855 + ) -> Option<f32> { 856 + match expr { 857 + MathExpr::Length(n, unit) => Some(resolve_length_unit(*n, *unit, em_base, viewport)), 858 + MathExpr::Percentage(p) => Some((*p as f32 / 100.0) * pct_base), 859 + MathExpr::Number(n) => Some(*n as f32), 860 + MathExpr::Add(a, b) => { 861 + let a = eval_math_expr_full(a, em_base, pct_base, viewport)?; 862 + let b = eval_math_expr_full(b, em_base, pct_base, viewport)?; 863 + Some(a + b) 864 + } 865 + MathExpr::Sub(a, b) => { 866 + let a = eval_math_expr_full(a, em_base, pct_base, viewport)?; 867 + let b = eval_math_expr_full(b, em_base, pct_base, viewport)?; 868 + Some(a - b) 869 + } 870 + MathExpr::Mul(a, b) => { 871 + let a = eval_math_expr_full(a, em_base, pct_base, viewport)?; 872 + let b = eval_math_expr_full(b, em_base, pct_base, viewport)?; 873 + Some(a * b) 874 + } 875 + MathExpr::Div(a, b) => { 876 + let a = eval_math_expr_full(a, em_base, pct_base, viewport)?; 877 + let b = eval_math_expr_full(b, em_base, pct_base, viewport)?; 878 + if b == 0.0 { 879 + Some(0.0) 880 + } else { 881 + Some(a / b) 882 + } 883 + } 884 + MathExpr::Min(args) => { 885 + let mut result = f32::INFINITY; 886 + for arg in args { 887 + result = result.min(eval_math_expr_full(arg, em_base, pct_base, viewport)?); 888 + } 889 + Some(result) 890 + } 891 + MathExpr::Max(args) => { 892 + let mut result = f32::NEG_INFINITY; 893 + for arg in args { 894 + result = result.max(eval_math_expr_full(arg, em_base, pct_base, viewport)?); 895 + } 896 + Some(result) 897 + } 898 + MathExpr::Clamp(min_e, val_e, max_e) => { 899 + let mn = eval_math_expr_full(min_e, em_base, pct_base, viewport)?; 900 + let val = eval_math_expr_full(val_e, em_base, pct_base, viewport)?; 901 + let mx = eval_math_expr_full(max_e, em_base, pct_base, viewport)?; 902 + Some(val.max(mn).min(mx)) 903 + } 904 + } 905 + } 906 + 611 907 fn resolve_color(value: &CssValue, current_color: Color) -> Option<Color> { 612 908 match value { 613 909 CssValue::Color(c) => Some(*c), ··· 812 1108 _ => style.font_size, 813 1109 }; 814 1110 } 1111 + CssValue::Math(expr) => { 1112 + // For font-size, percentages resolve against parent font-size, 1113 + // so we can fully evaluate the expression now. 1114 + if let Some(px) = eval_math_expr_full(expr, parent_fs, parent_fs, viewport) { 1115 + style.font_size = px; 1116 + } 1117 + } 815 1118 _ => {} 816 1119 } 817 1120 // Update line-height when font-size changes ··· 894 1197 } 895 1198 CssValue::Percentage(p) => { 896 1199 style.line_height = (*p / 100.0) as f32 * style.font_size; 1200 + } 1201 + CssValue::Math(expr) => { 1202 + if let Some(px) = 1203 + eval_math_expr_full(expr, style.font_size, style.font_size, viewport) 1204 + { 1205 + style.line_height = px; 1206 + } 897 1207 } 898 1208 _ => {} 899 1209 }, ··· 1082 1392 _ => style.align_content, 1083 1393 }; 1084 1394 } 1085 - "row-gap" => { 1086 - if let CssValue::Length(n, unit) = value { 1395 + "row-gap" => match value { 1396 + CssValue::Length(n, unit) => { 1087 1397 style.row_gap = resolve_length_unit(*n, *unit, current_fs, viewport); 1088 - } else if let CssValue::Zero = value { 1089 - style.row_gap = 0.0; 1398 + } 1399 + CssValue::Zero => style.row_gap = 0.0, 1400 + CssValue::Math(expr) => { 1401 + if let Some(px) = eval_math_expr_full(expr, current_fs, 0.0, viewport) { 1402 + style.row_gap = px; 1403 + } 1090 1404 } 1091 - } 1092 - "column-gap" => { 1093 - if let CssValue::Length(n, unit) = value { 1405 + _ => {} 1406 + }, 1407 + "column-gap" => match value { 1408 + CssValue::Length(n, unit) => { 1094 1409 style.column_gap = resolve_length_unit(*n, *unit, current_fs, viewport); 1095 - } else if let CssValue::Zero = value { 1096 - style.column_gap = 0.0; 1410 + } 1411 + CssValue::Zero => style.column_gap = 0.0, 1412 + CssValue::Math(expr) => { 1413 + if let Some(px) = eval_math_expr_full(expr, current_fs, 0.0, viewport) { 1414 + style.column_gap = px; 1415 + } 1097 1416 } 1098 - } 1417 + _ => {} 1418 + }, 1099 1419 1100 1420 // Flex item properties 1101 1421 "flex-grow" => { ··· 1152 1472 "thick" => 5.0, 1153 1473 _ => 0.0, 1154 1474 }, 1475 + CssValue::Math(expr) => eval_math_expr_full(expr, em_base, 0.0, viewport).unwrap_or(0.0), 1155 1476 _ => 0.0, 1156 1477 } 1157 1478 } ··· 2990 3311 let div_node = &body_node.children[0]; 2991 3312 2992 3313 assert_eq!(div_node.style.color, Color::rgb(0, 128, 0)); 3314 + } 3315 + 3316 + // ----------------------------------------------------------------------- 3317 + // CSS math functions: calc(), min(), max(), clamp() 3318 + // ----------------------------------------------------------------------- 3319 + 3320 + #[test] 3321 + fn calc_simple_px_addition_in_margin() { 3322 + let (mut doc, _, _, body) = make_doc_with_body(); 3323 + let div = doc.create_element("div"); 3324 + let text = doc.create_text("test"); 3325 + doc.append_child(body, div); 3326 + doc.append_child(div, text); 3327 + 3328 + let ss = Parser::parse("div { margin-top: calc(10px + 20px); }"); 3329 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3330 + let body_node = &styled.children[0]; 3331 + let div_node = &body_node.children[0]; 3332 + 3333 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(30.0)); 3334 + } 3335 + 3336 + #[test] 3337 + fn calc_percentage_minus_px_in_width() { 3338 + let (mut doc, _, _, body) = make_doc_with_body(); 3339 + let div = doc.create_element("div"); 3340 + let text = doc.create_text("test"); 3341 + doc.append_child(body, div); 3342 + doc.append_child(div, text); 3343 + 3344 + let ss = Parser::parse("div { width: calc(100% - 20px); }"); 3345 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3346 + let body_node = &styled.children[0]; 3347 + let div_node = &body_node.children[0]; 3348 + 3349 + // 100% - 20px should produce Calc(-20.0, 100.0) 3350 + assert_eq!(div_node.style.width, LengthOrAuto::Calc(-20.0, 100.0)); 3351 + } 3352 + 3353 + #[test] 3354 + fn calc_em_plus_px() { 3355 + let (mut doc, _, _, body) = make_doc_with_body(); 3356 + let div = doc.create_element("div"); 3357 + let text = doc.create_text("test"); 3358 + doc.append_child(body, div); 3359 + doc.append_child(div, text); 3360 + 3361 + // Default font-size is 16px, so 2em = 32px, 32 + 10 = 42 3362 + let ss = Parser::parse("div { margin-top: calc(2em + 10px); }"); 3363 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3364 + let body_node = &styled.children[0]; 3365 + let div_node = &body_node.children[0]; 3366 + 3367 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(42.0)); 3368 + } 3369 + 3370 + #[test] 3371 + fn calc_multiplication() { 3372 + let (mut doc, _, _, body) = make_doc_with_body(); 3373 + let div = doc.create_element("div"); 3374 + let text = doc.create_text("test"); 3375 + doc.append_child(body, div); 3376 + doc.append_child(div, text); 3377 + 3378 + // 3 * 10px = 30px 3379 + let ss = Parser::parse("div { margin-top: calc(3 * 10px); }"); 3380 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3381 + let body_node = &styled.children[0]; 3382 + let div_node = &body_node.children[0]; 3383 + 3384 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(30.0)); 3385 + } 3386 + 3387 + #[test] 3388 + fn calc_division() { 3389 + let (mut doc, _, _, body) = make_doc_with_body(); 3390 + let div = doc.create_element("div"); 3391 + let text = doc.create_text("test"); 3392 + doc.append_child(body, div); 3393 + doc.append_child(div, text); 3394 + 3395 + // 100px / 4 = 25px 3396 + let ss = Parser::parse("div { margin-top: calc(100px / 4); }"); 3397 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3398 + let body_node = &styled.children[0]; 3399 + let div_node = &body_node.children[0]; 3400 + 3401 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(25.0)); 3402 + } 3403 + 3404 + #[test] 3405 + fn calc_division_by_zero() { 3406 + let (mut doc, _, _, body) = make_doc_with_body(); 3407 + let div = doc.create_element("div"); 3408 + let text = doc.create_text("test"); 3409 + doc.append_child(body, div); 3410 + doc.append_child(div, text); 3411 + 3412 + let ss = Parser::parse("div { margin-top: calc(100px / 0); }"); 3413 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3414 + let body_node = &styled.children[0]; 3415 + let div_node = &body_node.children[0]; 3416 + 3417 + // Division by zero produces 0 3418 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(0.0)); 3419 + } 3420 + 3421 + #[test] 3422 + fn min_px_values() { 3423 + let (mut doc, _, _, body) = make_doc_with_body(); 3424 + let div = doc.create_element("div"); 3425 + let text = doc.create_text("test"); 3426 + doc.append_child(body, div); 3427 + doc.append_child(div, text); 3428 + 3429 + // min(10px, 50px) = 10px 3430 + let ss = Parser::parse("div { margin-top: min(10px, 50px); }"); 3431 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3432 + let body_node = &styled.children[0]; 3433 + let div_node = &body_node.children[0]; 3434 + 3435 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(10.0)); 3436 + } 3437 + 3438 + #[test] 3439 + fn min_vw_and_px() { 3440 + let (mut doc, _, _, body) = make_doc_with_body(); 3441 + let div = doc.create_element("div"); 3442 + let text = doc.create_text("test"); 3443 + doc.append_child(body, div); 3444 + doc.append_child(div, text); 3445 + 3446 + // viewport width = 800, 5vw = 40px; min(100px, 5vw) = min(100, 40) = 40 3447 + let ss = Parser::parse("div { margin-top: min(100px, 5vw); }"); 3448 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3449 + let body_node = &styled.children[0]; 3450 + let div_node = &body_node.children[0]; 3451 + 3452 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(40.0)); 3453 + } 3454 + 3455 + #[test] 3456 + fn max_px_values() { 3457 + let (mut doc, _, _, body) = make_doc_with_body(); 3458 + let div = doc.create_element("div"); 3459 + let text = doc.create_text("test"); 3460 + doc.append_child(body, div); 3461 + doc.append_child(div, text); 3462 + 3463 + // max(10px, 50px) = 50px 3464 + let ss = Parser::parse("div { margin-top: max(10px, 50px); }"); 3465 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3466 + let body_node = &styled.children[0]; 3467 + let div_node = &body_node.children[0]; 3468 + 3469 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(50.0)); 3470 + } 3471 + 3472 + #[test] 3473 + fn max_vw_and_px() { 3474 + let (mut doc, _, _, body) = make_doc_with_body(); 3475 + let div = doc.create_element("div"); 3476 + let text = doc.create_text("test"); 3477 + doc.append_child(body, div); 3478 + doc.append_child(div, text); 3479 + 3480 + // viewport width = 800, 5vw = 40px; max(10px, 5vw) = max(10, 40) = 40 3481 + let ss = Parser::parse("div { margin-top: max(10px, 5vw); }"); 3482 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3483 + let body_node = &styled.children[0]; 3484 + let div_node = &body_node.children[0]; 3485 + 3486 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(40.0)); 3487 + } 3488 + 3489 + #[test] 3490 + fn clamp_px_values() { 3491 + let (mut doc, _, _, body) = make_doc_with_body(); 3492 + let div = doc.create_element("div"); 3493 + let text = doc.create_text("test"); 3494 + doc.append_child(body, div); 3495 + doc.append_child(div, text); 3496 + 3497 + // clamp(10px, 5px, 200px) = max(10, min(5, 200)) = 10 3498 + let ss = Parser::parse("div { margin-top: clamp(10px, 5px, 200px); }"); 3499 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3500 + let body_node = &styled.children[0]; 3501 + let div_node = &body_node.children[0]; 3502 + 3503 + assert_eq!(div_node.style.margin_top, LengthOrAuto::Length(10.0)); 3504 + } 3505 + 3506 + #[test] 3507 + fn clamp_with_percentage_middle() { 3508 + let (mut doc, _, _, body) = make_doc_with_body(); 3509 + let div = doc.create_element("div"); 3510 + let text = doc.create_text("test"); 3511 + doc.append_child(body, div); 3512 + doc.append_child(div, text); 3513 + 3514 + // clamp(10px, 50%, 200px) → deferred to layout 3515 + let ss = Parser::parse("div { width: clamp(10px, 50%, 200px); }"); 3516 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3517 + let body_node = &styled.children[0]; 3518 + let div_node = &body_node.children[0]; 3519 + 3520 + assert_eq!( 3521 + div_node.style.width, 3522 + LengthOrAuto::ClampCalc { 3523 + min_px: 10.0, 3524 + px_offset: 0.0, 3525 + pct: 50.0, 3526 + max_px: 200.0, 3527 + } 3528 + ); 3529 + } 3530 + 3531 + #[test] 3532 + fn calc_in_font_size() { 3533 + let (mut doc, _, _, body) = make_doc_with_body(); 3534 + let div = doc.create_element("div"); 3535 + let text = doc.create_text("test"); 3536 + doc.append_child(body, div); 3537 + doc.append_child(div, text); 3538 + 3539 + // calc(1em + 4px) with parent font-size 16px = 16 + 4 = 20 3540 + let ss = Parser::parse("div { font-size: calc(1em + 4px); }"); 3541 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3542 + let body_node = &styled.children[0]; 3543 + let div_node = &body_node.children[0]; 3544 + 3545 + assert_eq!(div_node.style.font_size, 20.0); 3546 + } 3547 + 3548 + #[test] 3549 + fn calc_percentage_in_font_size() { 3550 + let (mut doc, _, _, body) = make_doc_with_body(); 3551 + let div = doc.create_element("div"); 3552 + let text = doc.create_text("test"); 3553 + doc.append_child(body, div); 3554 + doc.append_child(div, text); 3555 + 3556 + // calc(50% + 8px) with parent font-size 16px = 8 + 8 = 16 3557 + let ss = Parser::parse("div { font-size: calc(50% + 8px); }"); 3558 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3559 + let body_node = &styled.children[0]; 3560 + let div_node = &body_node.children[0]; 3561 + 3562 + assert_eq!(div_node.style.font_size, 16.0); 3563 + } 3564 + 3565 + #[test] 3566 + fn calc_in_line_height() { 3567 + let (mut doc, _, _, body) = make_doc_with_body(); 3568 + let div = doc.create_element("div"); 3569 + let text = doc.create_text("test"); 3570 + doc.append_child(body, div); 3571 + doc.append_child(div, text); 3572 + 3573 + // calc(1em + 4px) with font-size 16px = 16 + 4 = 20 3574 + let ss = Parser::parse("div { line-height: calc(1em + 4px); }"); 3575 + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); 3576 + let body_node = &styled.children[0]; 3577 + let div_node = &body_node.children[0]; 3578 + 3579 + assert_eq!(div_node.style.line_height, 20.0); 2993 3580 } 2994 3581 }