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#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
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, Copy, 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 | CalcAstItem::Sub | CalcAstItem::Mul | CalcAstItem::Div
131| CalcAstItem::BraceOpen)
132                    );
133
134                if is_negative_number {
135                    // Parse as negative number value
136                    let rest = &input[i..];
137                    let end = find_value_end(rest);
138                    if end == 0 { return Err(()); }
139                    let val_str = &rest[..end];
140                    let pv = parse_pixel_value(val_str).map_err(|_| ())?;
141                    items.push(CalcAstItem::Value(pv));
142                    i += end;
143                } else {
144                    items.push(CalcAstItem::Sub);
145                    i += 1;
146                }
147            }
148            _ => {
149                // Must be a numeric value (e.g. 100%, 20px, 3, 1.5em)
150                let rest = &input[i..];
151                let end = find_value_end(rest);
152                if end == 0 { return Err(()); }
153                let val_str = &rest[..end];
154                let pv = parse_pixel_value(val_str).map_err(|_| ())?;
155                items.push(CalcAstItem::Value(pv));
156                i += end;
157            }
158        }
159    }
160
161    if items.is_empty() {
162        return Err(());
163    }
164
165    Ok(CalcAstItemVec::from(items))
166}
167
168/// Find the end of a numeric value token in a `calc()` expression.
169/// Returns the byte offset where the value ends.
170#[cfg(feature = "parser")]
171fn find_value_end(s: &str) -> usize {
172    let bytes = s.as_bytes();
173    let mut i = 0;
174
175    // Optional leading sign
176    if i < bytes.len() && (bytes[i] == b'-' || bytes[i] == b'+') {
177        i += 1;
178    }
179
180    // Digits and decimal point
181    while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
182        i += 1;
183    }
184
185    // Unit suffix (alphabetic characters like px, %, em, rem, vw, vh, etc.)
186    while i < bytes.len() && (bytes[i].is_ascii_alphabetic() || bytes[i] == b'%') {
187        i += 1;
188    }
189
190    i
191}
192
193/// Format a `CalcAstItemVec` as a CSS `calc(...)` string.
194fn calc_ast_to_css_string(items: &CalcAstItemVec) -> String {
195    let inner: Vec<String> = items.iter().map(|i| match i {
196        CalcAstItem::Value(v) => v.to_string(),
197        CalcAstItem::Add => "+".to_string(),
198        CalcAstItem::Sub => "-".to_string(),
199        CalcAstItem::Mul => "*".to_string(),
200        CalcAstItem::Div => "/".to_string(),
201        CalcAstItem::BraceOpen => "(".to_string(),
202        CalcAstItem::BraceClose => ")".to_string(),
203    }).collect();
204    alloc::format!("calc({})", inner.join(" "))
205}
206
207// -- Type Definitions --
208
209macro_rules! define_dimension_property {
210    ($struct_name:ident, $default_fn:expr) => {
211        #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
212        #[repr(C)]
213        pub struct $struct_name {
214            pub inner: PixelValue,
215        }
216
217        impl Default for $struct_name {
218            fn default() -> Self {
219                $default_fn()
220            }
221        }
222
223        impl PixelValueTaker for $struct_name {
224            fn from_pixel_value(inner: PixelValue) -> Self {
225                Self { inner }
226            }
227        }
228
229        impl_pixel_value!($struct_name);
230
231        impl PrintAsCssValue for $struct_name {
232            fn print_as_css_value(&self) -> String {
233                self.inner.to_string()
234            }
235        }
236    };
237}
238
239macro_rules! define_sizing_enum {
240    ($name:ident) => {
241        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
242        #[repr(C, u8)]
243        #[derive(Default)]
244        pub enum $name {
245            #[default]
246            Auto,
247            Px(PixelValue),
248            MinContent,
249            MaxContent,
250            /// `fit-content(<length-percentage>)` = `min(max-content, max(min-content, <length-percentage>))`
251            FitContent(PixelValue),
252            /// `calc()` expression stored as a flat stack-machine AST
253            Calc(CalcAstItemVec),
254        }
255
256        impl PixelValueTaker for $name {
257            fn from_pixel_value(inner: PixelValue) -> Self {
258                $name::Px(inner)
259            }
260        }
261
262        impl PrintAsCssValue for $name {
263            fn print_as_css_value(&self) -> String {
264                match self {
265                    $name::Auto => "auto".to_string(),
266                    $name::Px(v) => v.to_string(),
267                    $name::MinContent => "min-content".to_string(),
268                    $name::MaxContent => "max-content".to_string(),
269                    $name::FitContent(v) => alloc::format!("fit-content({})", v),
270                    $name::Calc(items) => calc_ast_to_css_string(items),
271                }
272            }
273        }
274
275        impl $name {
276            #[must_use] pub fn px(value: f32) -> Self {
277                $name::Px(PixelValue::px(value))
278            }
279
280            #[must_use] pub const fn const_px(value: isize) -> Self {
281                $name::Px(PixelValue::const_px(value))
282            }
283
284            #[must_use] pub fn interpolate(&self, other: &Self, t: f32) -> Self {
285                match (self, other) {
286                    ($name::Px(a), $name::Px(b)) => $name::Px(a.interpolate(b, t)),
287                    ($name::FitContent(a), $name::FitContent(b)) => $name::FitContent(a.interpolate(b, t)),
288                    (_, $name::Px(b)) if t >= 0.5 => $name::Px(*b),
289                    ($name::Px(a), _) if t < 0.5 => $name::Px(*a),
290                    ($name::Auto, $name::Auto) => $name::Auto,
291                    (a, _) if t < 0.5 => a.clone(),
292                    (_, b) => b.clone(),
293                }
294            }
295        }
296    };
297}
298
299define_sizing_enum!(LayoutWidth);
300define_sizing_enum!(LayoutHeight);
301
302/// CSS `min-width` property. Defaults to `0px`.
303define_dimension_property!(LayoutMinWidth, || Self {
304    inner: PixelValue::zero()
305});
306/// CSS `min-height` property. Defaults to `0px`.
307define_dimension_property!(LayoutMinHeight, || Self {
308    inner: PixelValue::zero()
309});
310/// CSS `max-width` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
311///
312/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
313/// padding/margin to this sentinel would overflow to infinity.
314define_dimension_property!(LayoutMaxWidth, || Self {
315    inner: PixelValue::px(core::f32::MAX)
316});
317/// CSS `max-height` property. Defaults to `f32::MAX` pixels (i.e. unconstrained).
318///
319/// NOTE: The layout solver must handle `f32::MAX` gracefully — adding
320/// padding/margin to this sentinel would overflow to infinity.
321define_dimension_property!(LayoutMaxHeight, || Self {
322    inner: PixelValue::px(core::f32::MAX)
323});
324
325/// Represents a `box-sizing` attribute
326#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
327#[repr(C)]
328#[derive(Default)]
329pub enum LayoutBoxSizing {
330    #[default]
331    ContentBox,
332    BorderBox,
333}
334
335
336impl PrintAsCssValue for LayoutBoxSizing {
337    fn print_as_css_value(&self) -> String {
338        String::from(match self {
339            Self::ContentBox => "content-box",
340            Self::BorderBox => "border-box",
341        })
342    }
343}
344
345// -- Parser --
346
347#[cfg(feature = "parser")]
348pub mod parser {
349
350    use alloc::string::ToString;
351    use crate::corety::AzString;
352
353    #[allow(clippy::wildcard_imports)] // parser submodule reuses the parent module's value types
354    use super::*;
355    use crate::props::basic::pixel::parse_pixel_value;
356
357    macro_rules! define_pixel_dimension_parser {
358        ($fn_name:ident, $struct_name:ident, $error_name:ident, $error_owned_name:ident) => {
359            #[derive(Clone, PartialEq, Eq)]
360            pub enum $error_name<'a> {
361                PixelValue(CssPixelValueParseError<'a>),
362            }
363
364            impl_debug_as_display!($error_name<'a>);
365            impl_display! { $error_name<'a>, {
366                PixelValue(e) => format!("{}", e),
367            }}
368
369            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
370
371            #[derive(Debug, Clone, PartialEq, Eq)]
372            #[repr(C, u8)]
373            pub enum $error_owned_name {
374                PixelValue(CssPixelValueParseErrorOwned),
375            }
376
377            impl $error_name<'_> {
378                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
379                    match self {
380                        $error_name::PixelValue(e) => {
381                            $error_owned_name::PixelValue(e.to_contained())
382                        }
383                    }
384                }
385            }
386
387            impl $error_owned_name {
388                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
389                    match self {
390                        $error_owned_name::PixelValue(e) => $error_name::PixelValue(e.to_shared()),
391                    }
392                }
393            }
394
395            /// # Errors
396            ///
397            /// Returns an error if `input` is not a valid CSS value for this property.
398            pub fn $fn_name(input: &str) -> Result<$struct_name, $error_name<'_>> {
399                parse_pixel_value(input)
400                    .map(|v| $struct_name { inner: v })
401                    .map_err($error_name::PixelValue)
402            }
403        };
404    }
405
406    macro_rules! define_sizing_parser {
407        ($fn_name:ident, $enum_name:ident, $error_name:ident, $error_owned_name:ident, $keyword_label:expr) => {
408            #[derive(Clone, PartialEq, Eq)]
409            pub enum $error_name<'a> {
410                PixelValue(CssPixelValueParseError<'a>),
411                InvalidKeyword(&'a str),
412            }
413
414            impl_debug_as_display!($error_name<'a>);
415            impl_display! { $error_name<'a>, {
416                PixelValue(e) => format!("{}", e),
417                InvalidKeyword(k) => format!("Invalid {} keyword: \"{}\"", $keyword_label, k),
418            }}
419
420            impl_from! { CssPixelValueParseError<'a>, $error_name::PixelValue }
421
422            #[derive(Debug, Clone, PartialEq, Eq)]
423            #[repr(C, u8)]
424            pub enum $error_owned_name {
425                PixelValue(CssPixelValueParseErrorOwned),
426                InvalidKeyword(AzString),
427            }
428
429            impl $error_name<'_> {
430                #[must_use] pub fn to_contained(&self) -> $error_owned_name {
431                    match self {
432                        $error_name::PixelValue(e) => {
433                            $error_owned_name::PixelValue(e.to_contained())
434                        }
435                        $error_name::InvalidKeyword(k) => {
436                            $error_owned_name::InvalidKeyword(k.to_string().into())
437                        }
438                    }
439                }
440            }
441
442            impl $error_owned_name {
443                #[must_use] pub fn to_shared(&self) -> $error_name<'_> {
444                    match self {
445                        $error_owned_name::PixelValue(e) => {
446                            $error_name::PixelValue(e.to_shared())
447                        }
448                        $error_owned_name::InvalidKeyword(k) => {
449                            $error_name::InvalidKeyword(k)
450                        }
451                    }
452                }
453            }
454
455            /// # Errors
456            ///
457            /// Returns an error if `input` is not a valid CSS value for this property.
458            pub fn $fn_name(
459                input: &str,
460            ) -> Result<$enum_name, $error_name<'_>> {
461                let trimmed = input.trim();
462                match trimmed {
463                    "auto" => Ok($enum_name::Auto),
464                    "min-content" => Ok($enum_name::MinContent),
465                    "max-content" => Ok($enum_name::MaxContent),
466                    s if s.starts_with("fit-content(") && s.ends_with(')') => {
467                        let inner = &s[12..s.len() - 1].trim();
468                        parse_pixel_value(inner)
469                            .map(|pv| {
470                                if pv.number.get() < 0.0 {
471                                    $enum_name::FitContent(PixelValue::zero())
472                                } else {
473                                    $enum_name::FitContent(pv)
474                                }
475                            })
476                            .map_err($error_name::PixelValue)
477                    }
478                    s if s.starts_with("calc(") && s.ends_with(')') => {
479                        let inner = &s[5..s.len() - 1];
480                        parse_calc_expression(inner)
481                            .map($enum_name::Calc)
482                            .map_err(|_| $error_name::InvalidKeyword(input))
483                    }
484                    _ => parse_pixel_value(trimmed)
485                        .map($enum_name::Px)
486                        .map_err($error_name::PixelValue),
487                }
488            }
489        };
490    }
491
492    define_sizing_parser!(parse_layout_width, LayoutWidth, LayoutWidthParseError, LayoutWidthParseErrorOwned, "width");
493    define_sizing_parser!(parse_layout_height, LayoutHeight, LayoutHeightParseError, LayoutHeightParseErrorOwned, "height");
494    define_pixel_dimension_parser!(
495        parse_layout_min_width,
496        LayoutMinWidth,
497        LayoutMinWidthParseError,
498        LayoutMinWidthParseErrorOwned
499    );
500    define_pixel_dimension_parser!(
501        parse_layout_min_height,
502        LayoutMinHeight,
503        LayoutMinHeightParseError,
504        LayoutMinHeightParseErrorOwned
505    );
506    define_pixel_dimension_parser!(
507        parse_layout_max_width,
508        LayoutMaxWidth,
509        LayoutMaxWidthParseError,
510        LayoutMaxWidthParseErrorOwned
511    );
512    define_pixel_dimension_parser!(
513        parse_layout_max_height,
514        LayoutMaxHeight,
515        LayoutMaxHeightParseError,
516        LayoutMaxHeightParseErrorOwned
517    );
518
519    // -- Box Sizing Parser --
520
521    #[derive(Clone, PartialEq, Eq)]
522    pub enum LayoutBoxSizingParseError<'a> {
523        InvalidValue(&'a str),
524    }
525
526    impl_debug_as_display!(LayoutBoxSizingParseError<'a>);
527    impl_display! { LayoutBoxSizingParseError<'a>, {
528        InvalidValue(v) => format!("Invalid box-sizing value: \"{}\"", v),
529    }}
530
531    #[derive(Debug, Clone, PartialEq, Eq)]
532    #[repr(C, u8)]
533    pub enum LayoutBoxSizingParseErrorOwned {
534        InvalidValue(AzString),
535    }
536
537    impl LayoutBoxSizingParseError<'_> {
538        #[must_use] pub fn to_contained(&self) -> LayoutBoxSizingParseErrorOwned {
539            match self {
540                LayoutBoxSizingParseError::InvalidValue(s) => {
541                    LayoutBoxSizingParseErrorOwned::InvalidValue((*s).to_string().into())
542                }
543            }
544        }
545    }
546
547    impl LayoutBoxSizingParseErrorOwned {
548        #[must_use] pub fn to_shared(&self) -> LayoutBoxSizingParseError<'_> {
549            match self {
550                Self::InvalidValue(s) => {
551                    LayoutBoxSizingParseError::InvalidValue(s)
552                }
553            }
554        }
555    }
556
557    /// # Errors
558    ///
559    /// Returns an error if `input` is not a valid CSS `box-sizing` value.
560    pub fn parse_layout_box_sizing(
561        input: &str,
562    ) -> Result<LayoutBoxSizing, LayoutBoxSizingParseError<'_>> {
563        match input.trim() {
564            "content-box" => Ok(LayoutBoxSizing::ContentBox),
565            "border-box" => Ok(LayoutBoxSizing::BorderBox),
566            other => Err(LayoutBoxSizingParseError::InvalidValue(other)),
567        }
568    }
569}
570
571#[cfg(feature = "parser")]
572pub use self::parser::*;
573
574#[cfg(all(test, feature = "parser"))]
575mod tests {
576    use super::*;
577    use crate::props::basic::pixel::PixelValue;
578
579    #[test]
580    fn test_parse_layout_width() {
581        assert_eq!(
582            parse_layout_width("150px").unwrap(),
583            LayoutWidth::Px(PixelValue::px(150.0))
584        );
585        assert_eq!(
586            parse_layout_width("2.5em").unwrap(),
587            LayoutWidth::Px(PixelValue::em(2.5))
588        );
589        assert_eq!(
590            parse_layout_width("75%").unwrap(),
591            LayoutWidth::Px(PixelValue::percent(75.0))
592        );
593        assert_eq!(
594            parse_layout_width("0").unwrap(),
595            LayoutWidth::Px(PixelValue::px(0.0))
596        );
597        assert_eq!(
598            parse_layout_width("  100pt  ").unwrap(),
599            LayoutWidth::Px(PixelValue::pt(100.0))
600        );
601        assert_eq!(
602            parse_layout_width("min-content").unwrap(),
603            LayoutWidth::MinContent
604        );
605        assert_eq!(
606            parse_layout_width("max-content").unwrap(),
607            LayoutWidth::MaxContent
608        );
609    }
610
611    #[test]
612    fn test_parse_layout_height_invalid() {
613        // "auto" is now a valid value for height (CSS spec)
614        assert!(parse_layout_height("auto").is_ok());
615        // Liberal parsing accepts whitespace between number and unit
616        assert!(parse_layout_height("150 px").is_ok());
617        assert!(parse_layout_height("px").is_err());
618        assert!(parse_layout_height("invalid").is_err());
619    }
620
621    #[test]
622    fn test_parse_layout_box_sizing() {
623        assert_eq!(
624            parse_layout_box_sizing("content-box").unwrap(),
625            LayoutBoxSizing::ContentBox
626        );
627        assert_eq!(
628            parse_layout_box_sizing("border-box").unwrap(),
629            LayoutBoxSizing::BorderBox
630        );
631        assert_eq!(
632            parse_layout_box_sizing("  border-box  ").unwrap(),
633            LayoutBoxSizing::BorderBox
634        );
635    }
636
637    #[test]
638    fn test_parse_layout_box_sizing_invalid() {
639        assert!(parse_layout_box_sizing("padding-box").is_err());
640        assert!(parse_layout_box_sizing("borderbox").is_err());
641        assert!(parse_layout_box_sizing("").is_err());
642    }
643}