Skip to main content

azul_css/props/layout/
dimensions.rs

1//! CSS properties related to dimensions and sizing.
2//!
3//! Key types: [`LayoutWidth`] / [`LayoutHeight`] (support `auto`, pixel values,
4//! `min-content`, `max-content`, `fit-content()`, and `calc()` expressions),
5//! [`LayoutMinWidth`], [`LayoutMinHeight`], [`LayoutMaxWidth`], [`LayoutMaxHeight`]
6//! (simple pixel-value constraints), and [`LayoutBoxSizing`].
7//!
8//! `calc()` expressions use a flat stack-machine representation via [`CalcAstItem`]
9//! — see its documentation for the encoding scheme. The layout solver in
10//! `layout/src/solver3/calc.rs` evaluates these at resolve time.
11
12use alloc::{
13    string::{String, ToString},
14    vec::Vec,
15};
16
17use crate::{
18    impl_option, impl_option_inner, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_eq,
19    impl_vec_hash, impl_vec_mut, impl_vec_ord, impl_vec_partialeq, impl_vec_partialord,
20    props::{
21        basic::pixel::{CssPixelValueParseError, CssPixelValueParseErrorOwned, PixelValue},
22        formatter::PrintAsCssValue,
23        macros::PixelValueTaker,
24    },
25};
26
27// -- Calc AST --
28
29/// A single item in a `calc()` expression, stored as a flat stack-machine representation.
30///
31/// The expression `calc(33.333% - 10px)` is stored as:
32/// ```text
33/// [Value(33.333%), Sub, Value(10px)]
34/// ```
35///
36/// For nested expressions like `calc(100% - (20px + 5%))`:
37/// ```text
38/// [Value(100%), Sub, BraceOpen, Value(20px), Add, Value(5%), BraceClose]
39/// ```
40///
41/// **Resolution**: Walk left to right. When `BraceClose` is hit, resolve everything
42/// back to the matching `BraceOpen`, replace that span with a single `Value`, and continue.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44#[repr(C, u8)]
45pub enum CalcAstItem {
46    /// A literal value (e.g. `10px`, `33.333%`, `2em`)
47    Value(PixelValue),
48    /// `+` operator
49    Add,
50    /// `-` operator
51    Sub,
52    /// `*` operator
53    Mul,
54    /// `/` operator
55    Div,
56    /// `(` — opens a sub-expression
57    BraceOpen,
58    /// `)` — closes a sub-expression; triggers resolution of the inner span
59    BraceClose,
60}
61
62/// C-compatible `Vec<CalcAstItem>` for FFI interop.
63impl_vec!(
64    CalcAstItem,
65    CalcAstItemVec,
66    CalcAstItemVecDestructor,
67    CalcAstItemVecDestructorType,
68    CalcAstItemVecSlice,
69    OptionCalcAstItem
70);
71impl_vec_clone!(CalcAstItem, CalcAstItemVec, CalcAstItemVecDestructor);
72impl_vec_debug!(CalcAstItem, CalcAstItemVec);
73impl_vec_partialeq!(CalcAstItem, CalcAstItemVec);
74impl_vec_eq!(CalcAstItem, CalcAstItemVec);
75impl_vec_partialord!(CalcAstItem, CalcAstItemVec);
76impl_vec_ord!(CalcAstItem, CalcAstItemVec);
77impl_vec_hash!(CalcAstItem, CalcAstItemVec);
78impl_vec_mut!(CalcAstItem, CalcAstItemVec);
79
80impl_option!(
81    CalcAstItem,
82    OptionCalcAstItem,
83    copy = false,
84    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
85);
86
87/// Parse a calc() inner expression (the part between the parentheses) into
88/// a flat `CalcAstItemVec` suitable for stack-machine evaluation.
89///
90/// Examples:
91/// - `"100% - 20px"` → `[Value(100%), Sub, Value(20px)]`
92/// - `"(100% - 20px) / 3"` → `[BraceOpen, Value(100%), Sub, Value(20px), BraceClose, Div, Value(3)]`
93///
94/// **Tokenisation rules**:
95///  - Whitespace is skipped between tokens.
96///  - `+`, `-`, `*`, `/` are operators (but `-` at the start of a number is
97///    part of the number literal, e.g. `-10px`).
98///  - `(` / `)` produce `BraceOpen` / `BraceClose`.
99///  - Anything else is parsed as a `PixelValue` via `parse_pixel_value`.
100#[cfg(feature = "parser")]
101fn parse_calc_expression(input: &str) -> Result<CalcAstItemVec, ()> {
102    use crate::props::basic::pixel::parse_pixel_value;
103
104    let mut items: Vec<CalcAstItem> = Vec::new();
105    let input = input.trim();
106    let bytes = input.as_bytes();
107    let mut i = 0;
108
109    while i < bytes.len() {
110        // Skip whitespace
111        if bytes[i].is_ascii_whitespace() {
112            i += 1;
113            continue;
114        }
115
116        match bytes[i] {
117            b'+' => { items.push(CalcAstItem::Add); i += 1; }
118            b'*' => { items.push(CalcAstItem::Mul); i += 1; }
119            b'/' => { items.push(CalcAstItem::Div); i += 1; }
120            b'(' => { items.push(CalcAstItem::BraceOpen); i += 1; }
121            b')' => { items.push(CalcAstItem::BraceClose); i += 1; }
122            b'-' => {
123                // Decide: is this a subtraction operator or a negative number?
124                // It's a negative number if:
125                //   - it's the first token, OR
126                //   - the previous token is an operator or BraceOpen
127                let is_negative_number = items.is_empty()
128                    || matches!(
129                        items.last(),
130                        Some(CalcAstItem::Add)
131                            | Some(CalcAstItem::Sub)
132                            | Some(CalcAstItem::Mul)
133                            | Some(CalcAstItem::Div)
134                            | Some(CalcAstItem::BraceOpen)
135                    );
136
137                if is_negative_number {
138                    // Parse as negative number value
139                    let rest = &input[i..];
140                    let end = find_value_end(rest);
141                    if end == 0 { return Err(()); }
142                    let val_str = &rest[..end];
143                    let pv = parse_pixel_value(val_str).map_err(|_| ())?;
144                    items.push(CalcAstItem::Value(pv));
145                    i += end;
146                } else {
147                    items.push(CalcAstItem::Sub);
148                    i += 1;
149                }
150            }
151            _ => {
152                // Must be a numeric value (e.g. 100%, 20px, 3, 1.5em)
153                let rest = &input[i..];
154                let end = find_value_end(rest);
155                if end == 0 { return Err(()); }
156                let val_str = &rest[..end];
157                let pv = parse_pixel_value(val_str).map_err(|_| ())?;
158                items.push(CalcAstItem::Value(pv));
159                i += end;
160            }
161        }
162    }
163
164    if items.is_empty() {
165        return Err(());
166    }
167
168    Ok(CalcAstItemVec::from(items))
169}
170
171/// Find the end of a numeric value token in a calc() expression.
172/// Returns the byte offset where the value ends.
173#[cfg(feature = "parser")]
174fn find_value_end(s: &str) -> usize {
175    let bytes = s.as_bytes();
176    let mut i = 0;
177
178    // Optional leading sign
179    if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
180        i += 1;
181    }
182
183    // Digits and decimal point
184    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
185        i += 1;
186    }
187
188    // Unit suffix (alphabetic characters like px, %, em, rem, vw, vh, etc.)
189    while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'%') {
190        i += 1;
191    }
192
193    i
194}
195
196/// Format a `CalcAstItemVec` as a CSS `calc(...)` string.
197fn calc_ast_to_css_string(items: &CalcAstItemVec) -> String {
198    let inner: Vec<String> = items.iter().map(|i| match i {
199        CalcAstItem::Value(v) => v.to_string(),
200        CalcAstItem::Add => "+".to_string(),
201        CalcAstItem::Sub => "-".to_string(),
202        CalcAstItem::Mul => "*".to_string(),
203        CalcAstItem::Div => "/".to_string(),
204        CalcAstItem::BraceOpen => "(".to_string(),
205        CalcAstItem::BraceClose => ")".to_string(),
206    }).collect();
207    alloc::format!("calc({})", inner.join(" "))
208}
209
210// -- Type Definitions --
211
212macro_rules! define_dimension_property {
213    ($struct_name:ident, $default_fn:expr) => {
214        #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
215        #[repr(C)]
216        pub struct $struct_name {
217            pub inner: PixelValue,
218        }
219
220        impl Default for $struct_name {
221            fn default() -> Self {
222                $default_fn()
223            }
224        }
225
226        impl PixelValueTaker for $struct_name {
227            fn from_pixel_value(inner: PixelValue) -> Self {
228                Self { inner }
229            }
230        }
231
232        impl_pixel_value!($struct_name);
233
234        impl PrintAsCssValue for $struct_name {
235            fn print_as_css_value(&self) -> String {
236                self.inner.to_string()
237            }
238        }
239    };
240}
241
242macro_rules! define_sizing_enum {
243    ($name:ident) => {
244        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
245        #[repr(C, u8)]
246        #[derive(Default)]
247        pub enum $name {
248            #[default]
249            Auto,
250            Px(PixelValue),
251            MinContent,
252            MaxContent,
253            /// `fit-content(<length-percentage>)` = `min(max-content, max(min-content, <length-percentage>))`
254            FitContent(PixelValue),
255            /// `calc()` expression stored as a flat stack-machine AST
256            Calc(CalcAstItemVec),
257        }
258
259        impl PixelValueTaker for $name {
260            fn from_pixel_value(inner: PixelValue) -> Self {
261                $name::Px(inner)
262            }
263        }
264
265        impl PrintAsCssValue for $name {
266            fn print_as_css_value(&self) -> String {
267                match self {
268                    $name::Auto => "auto".to_string(),
269                    $name::Px(v) => v.to_string(),
270                    $name::MinContent => "min-content".to_string(),
271                    $name::MaxContent => "max-content".to_string(),
272                    $name::FitContent(v) => alloc::format!("fit-content({})", v),
273                    $name::Calc(items) => calc_ast_to_css_string(items),
274                }
275            }
276        }
277
278        impl $name {
279            pub fn px(value: f32) -> Self {
280                $name::Px(PixelValue::px(value))
281            }
282
283            pub const fn const_px(value: isize) -> Self {
284                $name::Px(PixelValue::const_px(value))
285            }
286
287            pub fn interpolate(&self, other: &Self, t: f32) -> Self {
288                match (self, other) {
289                    ($name::Px(a), $name::Px(b)) => $name::Px(a.interpolate(b, t)),
290                    ($name::FitContent(a), $name::FitContent(b)) => $name::FitContent(a.interpolate(b, t)),
291                    (_, $name::Px(b)) if t >= 0.5 => $name::Px(*b),
292                    ($name::Px(a), _) if t < 0.5 => $name::Px(*a),
293                    ($name::Auto, $name::Auto) => $name::Auto,
294                    (a, _) if t < 0.5 => a.clone(),
295                    (_, b) => b.clone(),
296                }
297            }
298        }
299    };
300}
301
302define_sizing_enum!(LayoutWidth);
303define_sizing_enum!(LayoutHeight);
304
305/// CSS `min-width` property. Defaults to `0px`.
306define_dimension_property!(LayoutMinWidth, || Self {
307    inner: PixelValue::zero()
308});
309/// CSS `min-height` property. Defaults to `0px`.
310define_dimension_property!(LayoutMinHeight, || Self {
311    inner: PixelValue::zero()
312});
313/// CSS `max-width` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
314///
315/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
316/// padding/margin to this sentinel would overflow to infinity.
317define_dimension_property!(LayoutMaxWidth, || Self {
318    inner: PixelValue::px(core::f32::MAX)
319});
320/// CSS `max-height` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
321///
322/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
323/// padding/margin to this sentinel would overflow to infinity.
324define_dimension_property!(LayoutMaxHeight, || Self {
325    inner: PixelValue::px(core::f32::MAX)
326});
327
328/// Represents a `box-sizing` attribute
329#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
330#[repr(C)]
331#[derive(Default)]
332pub enum LayoutBoxSizing {
333    #[default]
334    ContentBox,
335    BorderBox,
336}
337
338
339impl PrintAsCssValue for LayoutBoxSizing {
340    fn print_as_css_value(&self) -> String {
341        String::from(match self {
342            LayoutBoxSizing::ContentBox => "content-box",
343            LayoutBoxSizing::BorderBox => "border-box",
344        })
345    }
346}
347
348// -- Parser --
349
350#[cfg(feature = "parser")]
351pub mod parser {
352
353    use alloc::string::ToString;
354    use crate::corety::AzString;
355
356    use super::*;
357    use crate::props::basic::pixel::parse_pixel_value;
358
359    macro_rules! define_pixel_dimension_parser {
360        ($fn_name:ident, $struct_name:ident, $error_name:ident, $error_owned_name:ident) => {
361            #[derive(Clone, PartialEq)]
362            pub enum $error_name<'a> {
363                PixelValue(CssPixelValueParseError<'a>),
364            }
365
366            impl_debug_as_display!($error_name<'a>);
367            impl_display! { $error_name<'a>, {
368                PixelValue(e) => format!("{}", e),
369            }}
370
371            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
372
373            #[derive(Debug, Clone, PartialEq)]
374            #[repr(C, u8)]
375            pub enum $error_owned_name {
376                PixelValue(CssPixelValueParseErrorOwned),
377            }
378
379            impl<'a> $error_name<'a> {
380                pub fn to_contained(&self) -> $error_owned_name {
381                    match self {
382                        $error_name::PixelValue(e) => {
383                            $error_owned_name::PixelValue(e.to_contained())
384                        }
385                    }
386                }
387            }
388
389            impl $error_owned_name {
390                pub fn to_shared<'a>(&'a self) -> $error_name<'a> {
391                    match self {
392                        $error_owned_name::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
393                    }
394                }
395            }
396
397            pub fn $fn_name<'a>(input: &'a str) -> Result<$struct_name, $error_name<'a>> {
398                parse_pixel_value(input)
399                    .map(|v| $struct_name { inner: v })
400                    .map_err($error_name::PixelValue)
401            }
402        };
403    }
404
405    macro_rules! define_sizing_parser {
406        ($fn_name:ident, $enum_name:ident, $error_name:ident, $error_owned_name:ident, $keyword_label:expr) => {
407            #[derive(Clone, PartialEq)]
408            pub enum $error_name<'a> {
409                PixelValue(CssPixelValueParseError<'a>),
410                InvalidKeyword(&'a str),
411            }
412
413            impl_debug_as_display!($error_name<'a>);
414            impl_display! { $error_name<'a>, {
415                PixelValue(e) => format!("{}", e),
416                InvalidKeyword(k) => format!("Invalid {} keyword: \"{}\"", $keyword_label, k),
417            }}
418
419            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
420
421            #[derive(Debug, Clone, PartialEq)]
422            #[repr(C, u8)]
423            pub enum $error_owned_name {
424                PixelValue(CssPixelValueParseErrorOwned),
425                InvalidKeyword(AzString),
426            }
427
428            impl<'a> $error_name<'a> {
429                pub fn to_contained(&self) -> $error_owned_name {
430                    match self {
431                        $error_name::PixelValue(e) => {
432                            $error_owned_name::PixelValue(e.to_contained())
433                        }
434                        $error_name::InvalidKeyword(k) => {
435                            $error_owned_name::InvalidKeyword(k.to_string().into())
436                        }
437                    }
438                }
439            }
440
441            impl $error_owned_name {
442                pub fn to_shared<'a>(&'a self) -> $error_name<'a> {
443                    match self {
444                        $error_owned_name::PixelValue(e) => {
445                            $error_name::PixelValue(e.to_shared())
446                        }
447                        $error_owned_name::InvalidKeyword(k) => {
448                            $error_name::InvalidKeyword(k)
449                        }
450                    }
451                }
452            }
453
454            pub fn $fn_name<'a>(
455                input: &'a str,
456            ) -> Result<$enum_name, $error_name<'a>> {
457                let trimmed = input.trim();
458                match trimmed {
459                    "auto" => Ok($enum_name::Auto),
460                    "min-content" => Ok($enum_name::MinContent),
461                    "max-content" => Ok($enum_name::MaxContent),
462                    s if s.starts_with("fit-content(") && s.ends_with(')') => {
463                        let inner = &s[12..s.len() - 1].trim();
464                        parse_pixel_value(inner)
465                            .map(|pv| {
466                                if pv.number.get() < 0.0 {
467                                    $enum_name::FitContent(PixelValue::zero())
468                                } else {
469                                    $enum_name::FitContent(pv)
470                                }
471                            })
472                            .map_err($error_name::PixelValue)
473                    }
474                    s if s.starts_with("calc(") && s.ends_with(')') => {
475                        let inner = &s[5..s.len() - 1];
476                        parse_calc_expression(inner)
477                            .map($enum_name::Calc)
478                            .map_err(|_| $error_name::InvalidKeyword(input))
479                    }
480                    _ => parse_pixel_value(trimmed)
481                        .map($enum_name::Px)
482                        .map_err($error_name::PixelValue),
483                }
484            }
485        };
486    }
487
488    define_sizing_parser!(parse_layout_width, LayoutWidth, LayoutWidthParseError, LayoutWidthParseErrorOwned, "width");
489    define_sizing_parser!(parse_layout_height, LayoutHeight, LayoutHeightParseError, LayoutHeightParseErrorOwned, "height");
490    define_pixel_dimension_parser!(
491        parse_layout_min_width,
492        LayoutMinWidth,
493        LayoutMinWidthParseError,
494        LayoutMinWidthParseErrorOwned
495    );
496    define_pixel_dimension_parser!(
497        parse_layout_min_height,
498        LayoutMinHeight,
499        LayoutMinHeightParseError,
500        LayoutMinHeightParseErrorOwned
501    );
502    define_pixel_dimension_parser!(
503        parse_layout_max_width,
504        LayoutMaxWidth,
505        LayoutMaxWidthParseError,
506        LayoutMaxWidthParseErrorOwned
507    );
508    define_pixel_dimension_parser!(
509        parse_layout_max_height,
510        LayoutMaxHeight,
511        LayoutMaxHeightParseError,
512        LayoutMaxHeightParseErrorOwned
513    );
514
515    // -- Box Sizing Parser --
516
517    #[derive(Clone, PartialEq)]
518    pub enum LayoutBoxSizingParseError<'a> {
519        InvalidValue(&'a str),
520    }
521
522    impl_debug_as_display!(LayoutBoxSizingParseError<'a>);
523    impl_display! { LayoutBoxSizingParseError<'a>, {
524        InvalidValue(v) => format!("Invalid box-sizing value: \"{}\"", v),
525    }}
526
527    #[derive(Debug, Clone, PartialEq)]
528    #[repr(C, u8)]
529    pub enum LayoutBoxSizingParseErrorOwned {
530        InvalidValue(AzString),
531    }
532
533    impl<'a> LayoutBoxSizingParseError<'a> {
534        pub fn to_contained(&self) -> LayoutBoxSizingParseErrorOwned {
535            match self {
536                LayoutBoxSizingParseError::InvalidValue(s) => {
537                    LayoutBoxSizingParseErrorOwned::InvalidValue(s.to_string().into())
538                }
539            }
540        }
541    }
542
543    impl LayoutBoxSizingParseErrorOwned {
544        pub fn to_shared<'a>(&'a self) -> LayoutBoxSizingParseError<'a> {
545            match self {
546                LayoutBoxSizingParseErrorOwned::InvalidValue(s) => {
547                    LayoutBoxSizingParseError::InvalidValue(s)
548                }
549            }
550        }
551    }
552
553    pub fn parse_layout_box_sizing<'a>(
554        input: &'a str,
555    ) -> Result<LayoutBoxSizing, LayoutBoxSizingParseError<'a>> {
556        match input.trim() {
557            "content-box" => Ok(LayoutBoxSizing::ContentBox),
558            "border-box" => Ok(LayoutBoxSizing::BorderBox),
559            other => Err(LayoutBoxSizingParseError::InvalidValue(other)),
560        }
561    }
562}
563
564#[cfg(feature = "parser")]
565pub use self::parser::*;
566
567#[cfg(all(test, feature = "parser"))]
568mod tests {
569    use super::*;
570    use crate::props::basic::pixel::PixelValue;
571
572    #[test]
573    fn test_parse_layout_width() {
574        assert_eq!(
575            parse_layout_width("150px").unwrap(),
576            LayoutWidth::Px(PixelValue::px(150.0))
577        );
578        assert_eq!(
579            parse_layout_width("2.5em").unwrap(),
580            LayoutWidth::Px(PixelValue::em(2.5))
581        );
582        assert_eq!(
583            parse_layout_width("75%").unwrap(),
584            LayoutWidth::Px(PixelValue::percent(75.0))
585        );
586        assert_eq!(
587            parse_layout_width("0").unwrap(),
588            LayoutWidth::Px(PixelValue::px(0.0))
589        );
590        assert_eq!(
591            parse_layout_width("  100pt  ").unwrap(),
592            LayoutWidth::Px(PixelValue::pt(100.0))
593        );
594        assert_eq!(
595            parse_layout_width("min-content").unwrap(),
596            LayoutWidth::MinContent
597        );
598        assert_eq!(
599            parse_layout_width("max-content").unwrap(),
600            LayoutWidth::MaxContent
601        );
602    }
603
604    #[test]
605    fn test_parse_layout_height_invalid() {
606        // "auto" is now a valid value for height (CSS spec)
607        assert!(parse_layout_height("auto").is_ok());
608        // Liberal parsing accepts whitespace between number and unit
609        assert!(parse_layout_height("150 px").is_ok());
610        assert!(parse_layout_height("px").is_err());
611        assert!(parse_layout_height("invalid").is_err());
612    }
613
614    #[test]
615    fn test_parse_layout_box_sizing() {
616        assert_eq!(
617            parse_layout_box_sizing("content-box").unwrap(),
618            LayoutBoxSizing::ContentBox
619        );
620        assert_eq!(
621            parse_layout_box_sizing("border-box").unwrap(),
622            LayoutBoxSizing::BorderBox
623        );
624        assert_eq!(
625            parse_layout_box_sizing("  border-box  ").unwrap(),
626            LayoutBoxSizing::BorderBox
627        );
628    }
629
630    #[test]
631    fn test_parse_layout_box_sizing_invalid() {
632        assert!(parse_layout_box_sizing("padding-box").is_err());
633        assert!(parse_layout_box_sizing("borderbox").is_err());
634        assert!(parse_layout_box_sizing("").is_err());
635    }
636}