Skip to main content

azul_css/props/layout/
overflow.rs

1//! CSS properties for managing content overflow.
2
3use alloc::string::{String, ToString};
4use crate::corety::{AzString, OptionF32};
5
6use crate::props::formatter::PrintAsCssValue;
7
8// +spec:overflow:647a7b - overflow property (visible/hidden/clip/scroll/auto), overflow-clip-margin, text-overflow defined in CSS Overflow 3
9/// Represents an `overflow-x` or `overflow-y` property.
10///
11/// Determines what to do when content overflows an element's box.
12// +spec:overflow:3526f7 - overflow property with scroll/clip/hidden/visible/auto values
13// +spec:overflow:36c4f6 - overflow-x/overflow-y properties with clip value
14#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
15#[repr(C)]
16pub enum LayoutOverflow {
17    /// Always shows a scroll bar, overflows on scroll.
18    Scroll,
19    /// Shows a scroll bar only when content overflows.
20    Auto,
21    /// Clips overflowing content. The rest of the content will be invisible.
22    Hidden,
23    /// Content is not clipped and renders outside the element's box. This is the CSS default.
24    // +spec:overflow:236100 - initial value of 'overflow' is 'visible'
25    #[default]
26    Visible,
27    /// Similar to `hidden`, clips the content at the box's edge.
28    Clip,
29}
30
31impl LayoutOverflow {
32    /// Returns whether this overflow value requires a scrollbar to be displayed.
33    ///
34    /// - `overflow: scroll` always shows the scrollbar.
35    /// - `overflow: auto` only shows the scrollbar if the content is currently overflowing.
36    /// - `overflow: hidden`, `overflow: visible`, and `overflow: clip` do not show any scrollbars.
37    // +spec:overflow:2bf182 - overflow:scroll always shows scrollbar whether or not content is clipped
38    // +spec:overflow:84cd40 - scroll value always displays scrollbar for accessing clipped content
39    // +spec:overflow:8fcdd8 - auto causes scrolling mechanism for overflowing boxes (table exception is UA-level)
40    #[must_use] pub const fn needs_scrollbar(&self, currently_overflowing: bool) -> bool {
41        match self {
42            Self::Scroll => true,
43            Self::Auto => currently_overflowing,
44            Self::Hidden | Self::Visible | Self::Clip => false,
45        }
46    }
47
48    // +spec:overflow:145749 - overflow:hidden clips content to containing element box
49    // +spec:overflow:3dc18e - overflow:hidden clips content with no scrolling UI
50    // +spec:overflow:81e306 - clipping region clips all aspects outside it; clipped content does not cause overflow
51    // +spec:overflow:fd38ce - overflow properties specify whether a box's content is clipped / scroll container
52    /// Returns `true` if this overflow value clips content (everything except `visible`).
53    #[must_use] pub const fn is_clipped(&self) -> bool {
54        // All overflow values except 'visible' clip their content
55        matches!(
56            self,
57            Self::Hidden
58                | Self::Clip
59                | Self::Auto
60                | Self::Scroll
61        )
62    }
63
64    /// Returns `true` if the overflow type is `scroll`.
65    #[must_use] pub const fn is_scroll(&self) -> bool {
66        matches!(self, Self::Scroll)
67    }
68
69    // +spec:overflow:3be57c - overflow:hidden disables user scrolling but programmatic scrolling still works
70    /// Does this value establish a SCROLL CONTAINER (css-overflow-3 §3.1)?
71    ///
72    /// `hidden`, `scroll` and `auto` all do — an `overflow: hidden` box is
73    /// programmatically scrollable (scrollIntoView, scroll offsets set from
74    /// callbacks) even though its user-facing scrolling UI is disabled.
75    /// `visible` and `clip` do not scroll at all.
76    #[must_use] pub const fn is_scroll_container(&self) -> bool {
77        matches!(self, Self::Hidden | Self::Scroll | Self::Auto)
78    }
79
80    /// Does this value allow scrolling DIRECTLY TRIGGERED BY THE USER
81    /// (wheel, trackpad, scrollbar drag, keyboard)? `hidden` does not —
82    /// only programmatic scrolling reaches it.
83    #[must_use] pub const fn allows_user_scrolling(&self) -> bool {
84        matches!(self, Self::Scroll | Self::Auto)
85    }
86
87    /// Returns `true` if the overflow type is `visible`, which is the only
88    /// overflow type that doesn't clip its children.
89    #[must_use] pub fn is_overflow_visible(&self) -> bool {
90        *self == Self::Visible
91    }
92
93    /// Returns `true` if the overflow type is `hidden`.
94    #[must_use] pub fn is_overflow_hidden(&self) -> bool {
95        *self == Self::Hidden
96    }
97
98    // +spec:overflow:833078 - visible/clip compute to auto/hidden if other axis is scrollable
99    /// Resolves the computed value per CSS Overflow 3 § 3.1:
100    /// visible/clip values compute to auto/hidden (respectively)
101    /// if the other axis is neither visible nor clip.
102    #[must_use] pub const fn resolve_computed(self, other_axis: Self) -> Self {
103        let other_is_scrollable = !matches!(other_axis, Self::Visible | Self::Clip);
104        if other_is_scrollable {
105            match self {
106                Self::Visible => Self::Auto,
107                Self::Clip => Self::Hidden,
108                other => other,
109            }
110        } else {
111            self
112        }
113    }
114}
115
116impl PrintAsCssValue for LayoutOverflow {
117    fn print_as_css_value(&self) -> String {
118        String::from(match self {
119            Self::Scroll => "scroll",
120            Self::Auto => "auto",
121            Self::Hidden => "hidden",
122            Self::Visible => "visible",
123            Self::Clip => "clip",
124        })
125    }
126}
127
128// -- Parser
129
130/// Error returned when parsing an `overflow` property fails.
131#[derive(Clone, PartialEq, Eq)]
132pub enum LayoutOverflowParseError<'a> {
133    /// The provided value is not a valid `overflow` keyword.
134    InvalidValue(&'a str),
135}
136
137impl_debug_as_display!(LayoutOverflowParseError<'a>);
138impl_display! { LayoutOverflowParseError<'a>, {
139    InvalidValue(val) => format!(
140        "Invalid overflow value: \"{}\". Expected 'scroll', 'auto', 'hidden', 'visible', or 'clip'.", val
141    ),
142}}
143
144/// An owned version of `LayoutOverflowParseError`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146#[repr(C, u8)]
147pub enum LayoutOverflowParseErrorOwned {
148    InvalidValue(AzString),
149}
150
151impl LayoutOverflowParseError<'_> {
152    /// Converts the borrowed error into an owned error.
153    #[must_use] pub fn to_contained(&self) -> LayoutOverflowParseErrorOwned {
154        match self {
155            LayoutOverflowParseError::InvalidValue(s) => {
156                LayoutOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
157            }
158        }
159    }
160}
161
162impl LayoutOverflowParseErrorOwned {
163    /// Converts the owned error back into a borrowed error.
164    #[must_use] pub fn to_shared(&self) -> LayoutOverflowParseError<'_> {
165        match self {
166            Self::InvalidValue(s) => {
167                LayoutOverflowParseError::InvalidValue(s.as_str())
168            }
169        }
170    }
171}
172
173#[cfg(feature = "parser")]
174/// Parses a `LayoutOverflow` from a string slice.
175/// # Errors
176///
177/// Returns an error if `input` is not a valid CSS `overflow` value.
178pub fn parse_layout_overflow(
179    input: &str,
180) -> Result<LayoutOverflow, LayoutOverflowParseError<'_>> {
181    let input_trimmed = input.trim();
182    match input_trimmed {
183        "scroll" => Ok(LayoutOverflow::Scroll),
184        "auto" | "overlay" => Ok(LayoutOverflow::Auto), // +spec:overflow:6120e6 - "overlay" is a legacy value alias of "auto"
185        "hidden" => Ok(LayoutOverflow::Hidden),
186        "visible" => Ok(LayoutOverflow::Visible),
187        "clip" => Ok(LayoutOverflow::Clip),
188        _ => Err(LayoutOverflowParseError::InvalidValue(input)),
189    }
190}
191
192// -- StyleScrollbarGutter --
193// +spec:box-model:e98b7c - scrollbar gutter: space between inner border edge and outer padding edge
194
195/// Represents the `scrollbar-gutter` CSS property.
196///
197/// Controls whether space is reserved for the scrollbar, preventing
198/// layout shifts when content overflows.
199// +spec:overflow:da4bbc - scrollbar-gutter affects gutter presence, not scrollbar visibility
200#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
201#[repr(C)]
202pub enum StyleScrollbarGutter {
203    /// No scrollbar gutter is reserved.
204    #[default]
205    Auto,
206    /// Space is reserved for the scrollbar on one edge.
207    Stable,
208    /// Space is reserved for the scrollbar on both edges.
209    StableBothEdges,
210}
211
212impl PrintAsCssValue for StyleScrollbarGutter {
213    fn print_as_css_value(&self) -> String {
214        String::from(match self {
215            Self::Auto => "auto",
216            Self::Stable => "stable",
217            Self::StableBothEdges => "stable both-edges",
218        })
219    }
220}
221
222// -- Parser for StyleScrollbarGutter
223
224/// Error returned when parsing a `scrollbar-gutter` property fails.
225#[derive(Clone, PartialEq, Eq)]
226pub enum StyleScrollbarGutterParseError<'a> {
227    /// The provided value is not a valid `scrollbar-gutter` keyword.
228    InvalidValue(&'a str),
229}
230
231impl_debug_as_display!(StyleScrollbarGutterParseError<'a>);
232impl_display! { StyleScrollbarGutterParseError<'a>, {
233    InvalidValue(val) => format!(
234        "Invalid scrollbar-gutter value: \"{}\". Expected 'auto', 'stable', or 'stable both-edges'.", val
235    ),
236}}
237
238/// An owned version of `StyleScrollbarGutterParseError`.
239#[derive(Debug, Clone, PartialEq, Eq)]
240#[repr(C, u8)]
241pub enum StyleScrollbarGutterParseErrorOwned {
242    InvalidValue(AzString),
243}
244
245impl StyleScrollbarGutterParseError<'_> {
246    /// Converts the borrowed error into an owned error.
247    #[must_use] pub fn to_contained(&self) -> StyleScrollbarGutterParseErrorOwned {
248        match self {
249            StyleScrollbarGutterParseError::InvalidValue(s) => {
250                StyleScrollbarGutterParseErrorOwned::InvalidValue((*s).to_string().into())
251            }
252        }
253    }
254}
255
256impl StyleScrollbarGutterParseErrorOwned {
257    /// Converts the owned error back into a borrowed error.
258    #[must_use] pub fn to_shared(&self) -> StyleScrollbarGutterParseError<'_> {
259        match self {
260            Self::InvalidValue(s) => {
261                StyleScrollbarGutterParseError::InvalidValue(s.as_str())
262            }
263        }
264    }
265}
266
267#[cfg(feature = "parser")]
268/// Parses a `StyleScrollbarGutter` from a string slice.
269/// # Errors
270///
271/// Returns an error if `input` is not a valid CSS `scrollbar-gutter` value.
272pub fn parse_style_scrollbar_gutter(
273    input: &str,
274) -> Result<StyleScrollbarGutter, StyleScrollbarGutterParseError<'_>> {
275    let input_trimmed = input.trim();
276    match input_trimmed {
277        "auto" => Ok(StyleScrollbarGutter::Auto),
278        "stable" => Ok(StyleScrollbarGutter::Stable),
279        "stable both-edges" => Ok(StyleScrollbarGutter::StableBothEdges),
280        _ => Err(StyleScrollbarGutterParseError::InvalidValue(input)),
281    }
282}
283
284// -- StyleTextOverflow --
285// +spec:overflow:647a7b - text-overflow property defined in CSS Overflow 3
286
287/// Represents the `text-overflow` CSS property.
288///
289/// Determines how inline content that is clipped (because the block container
290/// has `overflow` other than `visible`) is signaled to the user at the end of
291/// the line box.
292///
293/// CSS Overflow Module Level 3 §5: <https://www.w3.org/TR/css-overflow-3/#text-overflow>
294#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
295#[repr(C)]
296pub enum StyleTextOverflow {
297    /// Clip the inline content at the edge of its line box. This is the initial value.
298    #[default]
299    Clip,
300    /// Render an ellipsis (`…`, U+2026) to represent clipped inline content.
301    Ellipsis,
302}
303
304impl PrintAsCssValue for StyleTextOverflow {
305    fn print_as_css_value(&self) -> String {
306        String::from(match self {
307            Self::Clip => "clip",
308            Self::Ellipsis => "ellipsis",
309        })
310    }
311}
312
313// -- Parser for StyleTextOverflow
314
315/// Error returned when parsing a `text-overflow` property fails.
316#[derive(Clone, PartialEq, Eq)]
317pub enum StyleTextOverflowParseError<'a> {
318    /// The provided value is not a valid `text-overflow` keyword.
319    InvalidValue(&'a str),
320}
321
322impl_debug_as_display!(StyleTextOverflowParseError<'a>);
323impl_display! { StyleTextOverflowParseError<'a>, {
324    InvalidValue(val) => format!(
325        "Invalid text-overflow value: \"{}\". Expected 'clip' or 'ellipsis'.", val
326    ),
327}}
328
329/// An owned version of `StyleTextOverflowParseError`.
330#[derive(Debug, Clone, PartialEq, Eq)]
331#[repr(C, u8)]
332pub enum StyleTextOverflowParseErrorOwned {
333    InvalidValue(AzString),
334}
335
336impl StyleTextOverflowParseError<'_> {
337    /// Converts the borrowed error into an owned error.
338    #[must_use] pub fn to_contained(&self) -> StyleTextOverflowParseErrorOwned {
339        match self {
340            StyleTextOverflowParseError::InvalidValue(s) => {
341                StyleTextOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
342            }
343        }
344    }
345}
346
347impl StyleTextOverflowParseErrorOwned {
348    /// Converts the owned error back into a borrowed error.
349    #[must_use] pub fn to_shared(&self) -> StyleTextOverflowParseError<'_> {
350        match self {
351            Self::InvalidValue(s) => {
352                StyleTextOverflowParseError::InvalidValue(s.as_str())
353            }
354        }
355    }
356}
357
358#[cfg(feature = "parser")]
359/// Parses a `StyleTextOverflow` from a string slice.
360/// # Errors
361///
362/// Returns an error if `input` is not a valid CSS `text-overflow` value.
363pub fn parse_style_text_overflow(
364    input: &str,
365) -> Result<StyleTextOverflow, StyleTextOverflowParseError<'_>> {
366    match input.trim() {
367        "clip" => Ok(StyleTextOverflow::Clip),
368        "ellipsis" => Ok(StyleTextOverflow::Ellipsis),
369        other => Err(StyleTextOverflowParseError::InvalidValue(other)),
370    }
371}
372
373// -- VisualBox --
374
375// +spec:overflow:f6955f - box edge origin for overflow-clip-margin
376/// Represents the `<visual-box>` value used as the overflow clip edge origin.
377///
378/// Specifies which box edge to use as the starting point for the clip region.
379/// Defaults to `padding-box` per CSS Overflow Module Level 3.
380#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
381#[repr(C)]
382pub enum VisualBox {
383    /// Clip edge starts at the content box edge.
384    ContentBox,
385    /// Clip edge starts at the padding box edge (default).
386    #[default]
387    PaddingBox,
388    /// Clip edge starts at the border box edge.
389    BorderBox,
390}
391
392impl PrintAsCssValue for VisualBox {
393    fn print_as_css_value(&self) -> String {
394        String::from(match self {
395            Self::ContentBox => "content-box",
396            Self::PaddingBox => "padding-box",
397            Self::BorderBox => "border-box",
398        })
399    }
400}
401
402// -- StyleOverflowClipMargin --
403
404/// Represents the `overflow-clip-margin` CSS property.
405///
406/// Determines how far outside the element's box the content may paint
407/// before being clipped when `overflow: clip` is used.
408/// Syntax: `<visual-box> || <length [0,∞]>`
409// +spec:overflow:455786 - overflow-clip-margin has no effect on hidden/scroll, only on clip
410#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
411#[repr(C)]
412pub struct StyleOverflowClipMargin {
413    /// The box edge to use as the clip origin (content-box, padding-box, or border-box).
414    pub clip_edge: VisualBox,
415    /// The clip margin distance beyond the clip edge.
416    pub inner: crate::props::basic::pixel::PixelValue,
417}
418
419impl PrintAsCssValue for StyleOverflowClipMargin {
420    fn print_as_css_value(&self) -> String {
421        let edge = self.clip_edge.print_as_css_value();
422        let len = self.inner.print_as_css_value();
423        #[allow(clippy::float_cmp)] // exact zero check: value is default-initialized, not computed
424        if self.inner.number.get() == 0.0 {
425            edge
426        } else if self.clip_edge == VisualBox::PaddingBox {
427            len
428        } else {
429            format!("{edge} {len}")
430        }
431    }
432}
433
434/// Error returned when parsing an `overflow-clip-margin` property fails.
435#[derive(Clone, PartialEq, Eq)]
436pub enum StyleOverflowClipMarginParseError<'a> {
437    /// The provided value is not a valid `overflow-clip-margin` value.
438    InvalidValue(&'a str),
439}
440
441impl_debug_as_display!(StyleOverflowClipMarginParseError<'a>);
442impl_display! { StyleOverflowClipMarginParseError<'a>, {
443    InvalidValue(val) => format!("Invalid overflow-clip-margin value: \"{}\"", val),
444}}
445
446/// An owned version of `StyleOverflowClipMarginParseError`.
447#[derive(Debug, Clone, PartialEq, Eq)]
448#[repr(C, u8)]
449pub enum StyleOverflowClipMarginParseErrorOwned {
450    InvalidValue(AzString),
451}
452
453impl StyleOverflowClipMarginParseError<'_> {
454    /// Converts the borrowed error into an owned error.
455    #[must_use] pub fn to_contained(&self) -> StyleOverflowClipMarginParseErrorOwned {
456        match self {
457            StyleOverflowClipMarginParseError::InvalidValue(s) => {
458                StyleOverflowClipMarginParseErrorOwned::InvalidValue((*s).to_string().into())
459            }
460        }
461    }
462}
463
464impl StyleOverflowClipMarginParseErrorOwned {
465    /// Converts the owned error back into a borrowed error.
466    #[must_use] pub fn to_shared(&self) -> StyleOverflowClipMarginParseError<'_> {
467        match self {
468            Self::InvalidValue(s) => {
469                StyleOverflowClipMarginParseError::InvalidValue(s.as_str())
470            }
471        }
472    }
473}
474
475#[cfg(feature = "parser")]
476/// Parses a `StyleOverflowClipMargin` from a string slice.
477///
478/// Syntax: `<visual-box> || <length [0,∞]>`
479/// The `<visual-box>` defaults to `padding-box` if omitted.
480/// The `<length>` defaults to `0px` if omitted.
481/// # Errors
482///
483/// Returns an error if `input` is not a valid CSS `overflow-clip-margin` value.
484pub fn parse_style_overflow_clip_margin(
485    input: &str,
486) -> Result<StyleOverflowClipMargin, StyleOverflowClipMarginParseError<'_>> {
487    use crate::props::basic::pixel::parse_pixel_value;
488
489    let input_trimmed = input.trim();
490    let mut clip_edge = None;
491    let mut length = None;
492
493    for token in input_trimmed.split_whitespace() {
494        match token {
495            "content-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::ContentBox),
496            "padding-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::PaddingBox),
497            "border-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::BorderBox),
498            _ if length.is_none() => {
499                match parse_pixel_value(token) {
500                    Ok(pv) => length = Some(pv),
501                    Err(_) => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
502                }
503            }
504            _ => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
505        }
506    }
507
508    if clip_edge.is_none() && length.is_none() {
509        return Err(StyleOverflowClipMarginParseError::InvalidValue(input));
510    }
511
512    Ok(StyleOverflowClipMargin {
513        clip_edge: clip_edge.unwrap_or_default(),
514        inner: length.unwrap_or_default(),
515    })
516}
517
518// -- StyleClipRect --
519
520/// Represents the deprecated CSS `clip` property value `rect(top, right, bottom, left)`.
521///
522/// Each edge can be a length or `auto`. When `auto`, the edge matches the
523/// element's generated border box edge:
524/// - `auto` for top/left = 0
525/// - `auto` for bottom = used height + vertical padding + vertical border
526/// - `auto` for right = used width + horizontal padding + horizontal border
527///
528/// Negative lengths are permitted.
529// +spec:overflow:297dc3 - clip rect() auto values resolve to border box edges
530#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
531#[repr(C)]
532pub struct StyleClipRect {
533    /// Top edge offset in pixels. `None` means `auto` (= 0).
534    pub top: OptionF32,
535    /// Right edge offset in pixels. `None` means `auto` (= used width + horiz padding + horiz border).
536    pub right: OptionF32,
537    /// Bottom edge offset in pixels. `None` means `auto` (= used height + vert padding + vert border).
538    pub bottom: OptionF32,
539    /// Left edge offset in pixels. `None` means `auto` (= 0).
540    pub left: OptionF32,
541}
542
543impl StyleClipRect {
544    /// Resolves `auto` values to border box edges given the element's
545    /// used width/height and padding/border sizes.
546    ///
547    /// Returns `(top, right, bottom, left)` in pixels.
548    #[must_use] pub fn resolve(
549        &self,
550        used_width: f32,
551        used_height: f32,
552        padding_left: f32,
553        padding_right: f32,
554        padding_top: f32,
555        padding_bottom: f32,
556        border_left: f32,
557        border_right: f32,
558        border_top: f32,
559        border_bottom: f32,
560    ) -> (f32, f32, f32, f32) {
561        let top = self.top.into_option().unwrap_or(0.0);
562        let left = self.left.into_option().unwrap_or(0.0);
563        let bottom = self
564            .bottom
565            .into_option()
566            .unwrap_or(used_height + padding_top + padding_bottom + border_top + border_bottom);
567        let right = self
568            .right
569            .into_option()
570            .unwrap_or(used_width + padding_left + padding_right + border_left + border_right);
571        (top, right, bottom, left)
572    }
573}
574
575impl PrintAsCssValue for StyleClipRect {
576    fn print_as_css_value(&self) -> String {
577        fn fmt_edge(o: OptionF32) -> String {
578            o.into_option()
579                .map_or_else(|| String::from("auto"), |v| format!("{v}px"))
580        }
581        format!(
582            "rect({}, {}, {}, {})",
583            fmt_edge(self.top),
584            fmt_edge(self.right),
585            fmt_edge(self.bottom),
586            fmt_edge(self.left)
587        )
588    }
589}
590
591// -- Parser for StyleClipRect
592
593/// Error returned when parsing a CSS `clip` property value fails.
594#[derive(Clone, PartialEq, Eq)]
595pub enum StyleClipRectParseError<'a> {
596    /// The provided value is not a valid `clip` value.
597    InvalidValue(&'a str),
598}
599
600impl_debug_as_display!(StyleClipRectParseError<'a>);
601impl_display! { StyleClipRectParseError<'a>, {
602    InvalidValue(val) => format!(
603        "Invalid clip value: \"{}\". Expected 'auto' or 'rect(<top>, <right>, <bottom>, <left>)'.", val
604    ),
605}}
606
607/// An owned version of `StyleClipRectParseError`.
608#[derive(Debug, Clone, PartialEq, Eq)]
609#[repr(C, u8)]
610pub enum StyleClipRectParseErrorOwned {
611    InvalidValue(AzString),
612}
613
614impl StyleClipRectParseError<'_> {
615    /// Converts the borrowed error into an owned error.
616    #[must_use] pub fn to_contained(&self) -> StyleClipRectParseErrorOwned {
617        match self {
618            StyleClipRectParseError::InvalidValue(s) => {
619                StyleClipRectParseErrorOwned::InvalidValue((*s).to_string().into())
620            }
621        }
622    }
623}
624
625impl StyleClipRectParseErrorOwned {
626    /// Converts the owned error back into a borrowed error.
627    #[must_use] pub fn to_shared(&self) -> StyleClipRectParseError<'_> {
628        match self {
629            Self::InvalidValue(s) => {
630                StyleClipRectParseError::InvalidValue(s.as_str())
631            }
632        }
633    }
634}
635
636#[cfg(feature = "parser")]
637fn parse_clip_edge(token: &str) -> Result<OptionF32, StyleClipRectParseError<'_>> {
638    use crate::props::basic::pixel::parse_pixel_value;
639
640    let token = token.trim();
641    if token.eq_ignore_ascii_case("auto") {
642        return Ok(OptionF32::None);
643    }
644    let pv = parse_pixel_value(token)
645        .map_err(|_| StyleClipRectParseError::InvalidValue(token))?;
646    Ok(OptionF32::Some(pv.number.get()))
647}
648
649#[cfg(feature = "parser")]
650/// Parses a `StyleClipRect` from a string slice.
651///
652/// Accepts:
653/// - `auto` — equivalent to `rect(auto, auto, auto, auto)`.
654/// - `rect(<top>, <right>, <bottom>, <left>)` — comma-separated form.
655/// - `rect(<top> <right> <bottom> <left>)` — legacy space-separated form.
656///
657/// Each edge is either `auto` or a `<length>`. Negative lengths are permitted.
658/// # Errors
659///
660/// Returns an error if `input` is not a valid CSS `clip-rect` value.
661pub fn parse_clip_rect(input: &str) -> Result<StyleClipRect, StyleClipRectParseError<'_>> {
662    let trimmed = input.trim();
663
664    if trimmed.eq_ignore_ascii_case("auto") {
665        return Ok(StyleClipRect::default());
666    }
667
668    let inner = trimmed
669        .strip_prefix("rect(")
670        .or_else(|| trimmed.strip_prefix("RECT("))
671        .and_then(|s| s.strip_suffix(')'))
672        .ok_or(StyleClipRectParseError::InvalidValue(input))?;
673
674    let inner = inner.trim();
675    let parts: Vec<&str> = if inner.contains(',') {
676        inner.split(',').map(str::trim).collect()
677    } else {
678        inner.split_whitespace().collect()
679    };
680
681    if parts.len() != 4 {
682        return Err(StyleClipRectParseError::InvalidValue(input));
683    }
684
685    Ok(StyleClipRect {
686        top: parse_clip_edge(parts[0])?,
687        right: parse_clip_edge(parts[1])?,
688        bottom: parse_clip_edge(parts[2])?,
689        left: parse_clip_edge(parts[3])?,
690    })
691}
692
693#[cfg(all(test, feature = "parser"))]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn test_parse_layout_overflow_valid() {
699        assert_eq!(
700            parse_layout_overflow("visible").unwrap(),
701            LayoutOverflow::Visible
702        );
703        assert_eq!(
704            parse_layout_overflow("hidden").unwrap(),
705            LayoutOverflow::Hidden
706        );
707        assert_eq!(parse_layout_overflow("clip").unwrap(), LayoutOverflow::Clip);
708        assert_eq!(
709            parse_layout_overflow("scroll").unwrap(),
710            LayoutOverflow::Scroll
711        );
712        assert_eq!(parse_layout_overflow("auto").unwrap(), LayoutOverflow::Auto);
713    }
714
715    #[test]
716    fn test_parse_style_text_overflow_valid() {
717        assert_eq!(
718            parse_style_text_overflow("clip").unwrap(),
719            StyleTextOverflow::Clip
720        );
721        assert_eq!(
722            parse_style_text_overflow("ellipsis").unwrap(),
723            StyleTextOverflow::Ellipsis
724        );
725        // whitespace is tolerated
726        assert_eq!(
727            parse_style_text_overflow("  ellipsis  ").unwrap(),
728            StyleTextOverflow::Ellipsis
729        );
730        // initial value is `clip`
731        assert_eq!(StyleTextOverflow::default(), StyleTextOverflow::Clip);
732    }
733
734    #[test]
735    fn test_parse_style_text_overflow_invalid() {
736        assert!(parse_style_text_overflow("none").is_err());
737        assert!(parse_style_text_overflow("").is_err());
738        assert!(parse_style_text_overflow("fade").is_err());
739        // error message names the property and quotes the value
740        let msg = format!(
741            "{}",
742            StyleTextOverflowParseError::InvalidValue("fade")
743        );
744        assert!(msg.contains("text-overflow") && msg.contains("fade"), "{msg}");
745        // owned <-> shared round-trips
746        let e = parse_style_text_overflow("fade").unwrap_err();
747        assert_eq!(e.to_contained().to_shared(), e);
748    }
749
750    #[test]
751    fn test_style_text_overflow_print_round_trip() {
752        for v in [StyleTextOverflow::Clip, StyleTextOverflow::Ellipsis] {
753            let printed = v.print_as_css_value();
754            assert_eq!(parse_style_text_overflow(&printed).unwrap(), v);
755        }
756    }
757
758    #[test]
759    fn test_parse_layout_overflow_whitespace() {
760        assert_eq!(
761            parse_layout_overflow("  scroll  ").unwrap(),
762            LayoutOverflow::Scroll
763        );
764    }
765
766    #[test]
767    fn test_parse_layout_overflow_invalid() {
768        assert!(parse_layout_overflow("none").is_err());
769        assert!(parse_layout_overflow("").is_err());
770        assert!(parse_layout_overflow("auto scroll").is_err());
771        assert!(parse_layout_overflow("hidden-x").is_err());
772    }
773
774    #[test]
775    fn test_needs_scrollbar() {
776        assert!(LayoutOverflow::Scroll.needs_scrollbar(false));
777        assert!(LayoutOverflow::Scroll.needs_scrollbar(true));
778        assert!(LayoutOverflow::Auto.needs_scrollbar(true));
779        assert!(!LayoutOverflow::Auto.needs_scrollbar(false));
780        assert!(!LayoutOverflow::Hidden.needs_scrollbar(true));
781        assert!(!LayoutOverflow::Visible.needs_scrollbar(true));
782        assert!(!LayoutOverflow::Clip.needs_scrollbar(true));
783    }
784
785    #[test]
786    fn test_parse_clip_rect_auto_keyword() {
787        let r = parse_clip_rect("auto").unwrap();
788        assert_eq!(r.top, OptionF32::None);
789        assert_eq!(r.right, OptionF32::None);
790        assert_eq!(r.bottom, OptionF32::None);
791        assert_eq!(r.left, OptionF32::None);
792    }
793
794    #[test]
795    fn test_parse_clip_rect_all_auto_in_rect() {
796        let r = parse_clip_rect("rect(auto, auto, auto, auto)").unwrap();
797        assert_eq!(r.top, OptionF32::None);
798        assert_eq!(r.right, OptionF32::None);
799        assert_eq!(r.bottom, OptionF32::None);
800        assert_eq!(r.left, OptionF32::None);
801    }
802
803    #[test]
804    fn test_parse_clip_rect_mixed_auto_and_lengths() {
805        let r = parse_clip_rect("rect(10px, auto, 30px, auto)").unwrap();
806        assert_eq!(r.top, OptionF32::Some(10.0));
807        assert_eq!(r.right, OptionF32::None);
808        assert_eq!(r.bottom, OptionF32::Some(30.0));
809        assert_eq!(r.left, OptionF32::None);
810    }
811
812    #[test]
813    fn test_parse_clip_rect_negative_lengths() {
814        let r = parse_clip_rect("rect(-5px, 0px, -10px, 0px)").unwrap();
815        assert_eq!(r.top, OptionF32::Some(-5.0));
816        assert_eq!(r.right, OptionF32::Some(0.0));
817        assert_eq!(r.bottom, OptionF32::Some(-10.0));
818        assert_eq!(r.left, OptionF32::Some(0.0));
819    }
820
821    #[test]
822    fn test_parse_clip_rect_legacy_space_separated() {
823        // Legacy CSS 2.1 syntax used spaces instead of commas.
824        let r = parse_clip_rect("rect(1px 2px 3px 4px)").unwrap();
825        assert_eq!(r.top, OptionF32::Some(1.0));
826        assert_eq!(r.right, OptionF32::Some(2.0));
827        assert_eq!(r.bottom, OptionF32::Some(3.0));
828        assert_eq!(r.left, OptionF32::Some(4.0));
829    }
830
831    #[test]
832    fn test_parse_clip_rect_malformed() {
833        assert!(parse_clip_rect("").is_err());
834        assert!(parse_clip_rect("none").is_err());
835        // Wrong number of edges.
836        assert!(parse_clip_rect("rect(10px, 20px, 30px)").is_err());
837        // Missing closing paren.
838        assert!(parse_clip_rect("rect(10px, 20px, 30px, 40px").is_err());
839        // Garbage edge.
840        assert!(parse_clip_rect("rect(10px, abc, 30px, 40px)").is_err());
841    }
842}
843
844#[cfg(all(test, feature = "parser"))]
845mod autotest_generated {
846    use crate::props::basic::pixel::PixelValue;
847    use crate::props::basic::length::SizeMetric;
848
849    use super::*;
850
851    // ---------------------------------------------------------------------
852    // Variant tables. Each is kept honest by an exhaustive `match` below:
853    // adding a variant to the enum stops the index fn from compiling.
854    // ---------------------------------------------------------------------
855
856    const ALL_OVERFLOW: [LayoutOverflow; 5] = [
857        LayoutOverflow::Scroll,
858        LayoutOverflow::Auto,
859        LayoutOverflow::Hidden,
860        LayoutOverflow::Visible,
861        LayoutOverflow::Clip,
862    ];
863
864    const fn overflow_variant_index(o: LayoutOverflow) -> usize {
865        match o {
866            LayoutOverflow::Scroll => 0,
867            LayoutOverflow::Auto => 1,
868            LayoutOverflow::Hidden => 2,
869            LayoutOverflow::Visible => 3,
870            LayoutOverflow::Clip => 4,
871        }
872    }
873
874    const ALL_GUTTER: [StyleScrollbarGutter; 3] = [
875        StyleScrollbarGutter::Auto,
876        StyleScrollbarGutter::Stable,
877        StyleScrollbarGutter::StableBothEdges,
878    ];
879
880    const fn gutter_variant_index(g: StyleScrollbarGutter) -> usize {
881        match g {
882            StyleScrollbarGutter::Auto => 0,
883            StyleScrollbarGutter::Stable => 1,
884            StyleScrollbarGutter::StableBothEdges => 2,
885        }
886    }
887
888    const ALL_VISUAL_BOX: [VisualBox; 3] = [
889        VisualBox::ContentBox,
890        VisualBox::PaddingBox,
891        VisualBox::BorderBox,
892    ];
893
894    const fn visual_box_variant_index(v: VisualBox) -> usize {
895        match v {
896            VisualBox::ContentBox => 0,
897            VisualBox::PaddingBox => 1,
898            VisualBox::BorderBox => 2,
899        }
900    }
901
902    /// A value is "scrollable" (per CSS Overflow 3 § 3.1) when it is neither
903    /// `visible` nor `clip` — i.e. it establishes a scroll container.
904    const fn is_scrollable(o: LayoutOverflow) -> bool {
905        !matches!(o, LayoutOverflow::Visible | LayoutOverflow::Clip)
906    }
907
908    #[test]
909    fn variant_tables_cover_every_variant_exactly_once() {
910        for (i, o) in ALL_OVERFLOW.iter().enumerate() {
911            assert_eq!(overflow_variant_index(*o), i);
912        }
913        for (i, g) in ALL_GUTTER.iter().enumerate() {
914            assert_eq!(gutter_variant_index(*g), i);
915        }
916        for (i, v) in ALL_VISUAL_BOX.iter().enumerate() {
917            assert_eq!(visual_box_variant_index(*v), i);
918        }
919    }
920
921    // ---------------------------------------------------------------------
922    // LayoutOverflow — predicates & invariants
923    // ---------------------------------------------------------------------
924
925    #[test]
926    fn needs_scrollbar_truth_table_is_monotone_in_currently_overflowing() {
927        for o in ALL_OVERFLOW {
928            let idle = o.needs_scrollbar(false);
929            let overflowing = o.needs_scrollbar(true);
930
931            // A scrollbar that is shown while *not* overflowing must also be
932            // shown while overflowing — the flag can only ever add scrollbars.
933            assert!(
934                !idle || overflowing,
935                "{o:?} shows a scrollbar when idle but hides it when overflowing"
936            );
937
938            // Only `scroll` shows a scrollbar unconditionally; only `auto`
939            // reacts to the flag; nothing else ever shows one.
940            let (expect_idle, expect_overflowing) = match o {
941                LayoutOverflow::Scroll => (true, true),
942                LayoutOverflow::Auto => (false, true),
943                LayoutOverflow::Hidden | LayoutOverflow::Visible | LayoutOverflow::Clip => {
944                    (false, false)
945                }
946            };
947            assert_eq!(idle, expect_idle, "needs_scrollbar(false) wrong for {o:?}");
948            assert_eq!(
949                overflowing, expect_overflowing,
950                "needs_scrollbar(true) wrong for {o:?}"
951            );
952
953            // Anything that can show a scrollbar must also clip.
954            assert!(!overflowing || o.is_clipped());
955        }
956    }
957
958    #[test]
959    fn is_clipped_is_exactly_the_negation_of_is_overflow_visible() {
960        for o in ALL_OVERFLOW {
961            assert_eq!(
962                o.is_clipped(),
963                !o.is_overflow_visible(),
964                "is_clipped/is_overflow_visible disagree for {o:?}"
965            );
966            // Deterministic: repeated calls on the same value never differ.
967            assert_eq!(o.is_clipped(), o.is_clipped());
968        }
969        assert!(!LayoutOverflow::Visible.is_clipped());
970        assert!(LayoutOverflow::Hidden.is_clipped());
971    }
972
973    #[test]
974    fn is_scroll_and_is_overflow_hidden_match_exactly_one_variant_each() {
975        let scrolls: Vec<LayoutOverflow> =
976            ALL_OVERFLOW.into_iter().filter(LayoutOverflow::is_scroll).collect();
977        assert_eq!(scrolls, vec![LayoutOverflow::Scroll]);
978
979        let hiddens: Vec<LayoutOverflow> = ALL_OVERFLOW
980            .into_iter()
981            .filter(LayoutOverflow::is_overflow_hidden)
982            .collect();
983        assert_eq!(hiddens, vec![LayoutOverflow::Hidden]);
984
985        // `auto` is not `scroll`, even though both can produce a scrollbar.
986        assert!(!LayoutOverflow::Auto.is_scroll());
987        assert!(LayoutOverflow::Auto.needs_scrollbar(true));
988    }
989
990    #[test]
991    fn default_overflow_is_visible_and_neither_clips_nor_scrolls() {
992        let d = LayoutOverflow::default();
993        assert_eq!(d, LayoutOverflow::Visible);
994        assert!(d.is_overflow_visible());
995        assert!(!d.is_clipped());
996        assert!(!d.is_scroll());
997        assert!(!d.is_overflow_hidden());
998        assert!(!d.needs_scrollbar(false));
999        assert!(!d.needs_scrollbar(true));
1000    }
1001
1002    // ---------------------------------------------------------------------
1003    // LayoutOverflow::resolve_computed — CSS Overflow 3 § 3.1
1004    // ---------------------------------------------------------------------
1005
1006    #[test]
1007    fn resolve_computed_is_identity_when_the_other_axis_is_not_scrollable() {
1008        for other in [LayoutOverflow::Visible, LayoutOverflow::Clip] {
1009            for o in ALL_OVERFLOW {
1010                assert_eq!(
1011                    o.resolve_computed(other),
1012                    o,
1013                    "{o:?} must be untouched when the other axis is {other:?}"
1014                );
1015            }
1016        }
1017    }
1018
1019    #[test]
1020    fn resolve_computed_promotes_visible_to_auto_and_clip_to_hidden() {
1021        for other in [
1022            LayoutOverflow::Scroll,
1023            LayoutOverflow::Auto,
1024            LayoutOverflow::Hidden,
1025        ] {
1026            assert_eq!(
1027                LayoutOverflow::Visible.resolve_computed(other),
1028                LayoutOverflow::Auto
1029            );
1030            assert_eq!(
1031                LayoutOverflow::Clip.resolve_computed(other),
1032                LayoutOverflow::Hidden
1033            );
1034            // Already-scrollable values are left alone.
1035            for o in [
1036                LayoutOverflow::Scroll,
1037                LayoutOverflow::Auto,
1038                LayoutOverflow::Hidden,
1039            ] {
1040                assert_eq!(o.resolve_computed(other), o);
1041            }
1042        }
1043    }
1044
1045    #[test]
1046    fn resolve_computed_is_idempotent_and_never_removes_clipping() {
1047        for o in ALL_OVERFLOW {
1048            for other in ALL_OVERFLOW {
1049                let once = o.resolve_computed(other);
1050                assert_eq!(
1051                    once.resolve_computed(other),
1052                    once,
1053                    "resolve_computed not idempotent for ({o:?}, {other:?})"
1054                );
1055                // Resolution only ever adds clipping, never takes it away.
1056                assert!(
1057                    !o.is_clipped() || once.is_clipped(),
1058                    "({o:?}, {other:?}) lost clipping"
1059                );
1060                // ...and never turns a scroll container back into a non-scroller.
1061                assert!(!is_scrollable(o) || is_scrollable(once));
1062            }
1063        }
1064    }
1065
1066    #[test]
1067    fn resolve_computed_leaves_both_axes_consistently_scrollable() {
1068        // The whole point of the rule: after resolving *both* axes against each
1069        // other you can never end up with one scrollable axis and one that is
1070        // still visible/clip (which would be unrenderable).
1071        for x in ALL_OVERFLOW {
1072            for y in ALL_OVERFLOW {
1073                let rx = x.resolve_computed(y);
1074                let ry = y.resolve_computed(x);
1075                assert_eq!(
1076                    is_scrollable(rx),
1077                    is_scrollable(ry),
1078                    "({x:?}, {y:?}) resolved to the mismatched pair ({rx:?}, {ry:?})"
1079                );
1080            }
1081        }
1082
1083        // Spot-check the documented pairs.
1084        assert_eq!(
1085            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Scroll),
1086            LayoutOverflow::Auto
1087        );
1088        assert_eq!(
1089            LayoutOverflow::Scroll.resolve_computed(LayoutOverflow::Visible),
1090            LayoutOverflow::Scroll
1091        );
1092        // visible + clip is a legal pair and must survive untouched.
1093        assert_eq!(
1094            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Clip),
1095            LayoutOverflow::Visible
1096        );
1097        assert_eq!(
1098            LayoutOverflow::Clip.resolve_computed(LayoutOverflow::Visible),
1099            LayoutOverflow::Clip
1100        );
1101    }
1102
1103    // ---------------------------------------------------------------------
1104    // parse_layout_overflow
1105    // ---------------------------------------------------------------------
1106
1107    #[test]
1108    fn layout_overflow_round_trips_through_print_as_css_value() {
1109        for o in ALL_OVERFLOW {
1110            let printed = o.print_as_css_value();
1111            assert_eq!(
1112                parse_layout_overflow(&printed).unwrap(),
1113                o,
1114                "{o:?} printed as {printed:?} did not round-trip"
1115            );
1116            // The printed form is a bare keyword: no whitespace, all lowercase.
1117            assert!(!printed.is_empty());
1118            assert!(!printed.contains(char::is_whitespace));
1119            assert_eq!(printed, printed.to_lowercase());
1120        }
1121    }
1122
1123    #[test]
1124    fn parse_layout_overflow_treats_overlay_as_a_one_way_alias_of_auto() {
1125        // "overlay" is a legacy alias that parses to Auto but is never printed,
1126        // so the round-trip is stable only after the first normalisation.
1127        assert_eq!(parse_layout_overflow("overlay").unwrap(), LayoutOverflow::Auto);
1128        let normalised = parse_layout_overflow("overlay").unwrap().print_as_css_value();
1129        assert_eq!(normalised, "auto");
1130        assert_eq!(
1131            parse_layout_overflow(&normalised).unwrap(),
1132            LayoutOverflow::Auto
1133        );
1134        for o in ALL_OVERFLOW {
1135            assert_ne!(o.print_as_css_value(), "overlay");
1136        }
1137    }
1138
1139    #[test]
1140    fn parse_layout_overflow_rejects_empty_and_whitespace_only_input() {
1141        for input in ["", " ", "   ", "\t", "\n", "\r\n", "\t \n \r", "\u{00A0}"] {
1142            assert!(
1143                parse_layout_overflow(input).is_err(),
1144                "{input:?} must not parse"
1145            );
1146        }
1147    }
1148
1149    #[test]
1150    fn parse_layout_overflow_error_carries_the_untrimmed_input() {
1151        // The parser trims for matching but reports the *original* slice.
1152        let err = parse_layout_overflow("  bogus  ").unwrap_err();
1153        assert_eq!(err, LayoutOverflowParseError::InvalidValue("  bogus  "));
1154        let msg = format!("{err}");
1155        assert!(msg.contains("bogus"), "{msg}");
1156        assert!(msg.contains("scroll"), "error should list the valid keywords: {msg}");
1157    }
1158
1159    #[test]
1160    fn parse_layout_overflow_is_ascii_case_sensitive() {
1161        // NOTE: CSS keywords are ASCII case-insensitive, but this parser only
1162        // accepts the lowercase spelling (property *names* are lowercased
1163        // upstream, values are not). Characterised here so a future fix has to
1164        // update the test deliberately.
1165        for input in ["SCROLL", "Scroll", "sCrOlL", "AUTO", "Hidden", "VISIBLE", "Clip"] {
1166            assert!(
1167                parse_layout_overflow(input).is_err(),
1168                "{input:?} unexpectedly parsed"
1169            );
1170        }
1171        assert_eq!(parse_layout_overflow("scroll").unwrap(), LayoutOverflow::Scroll);
1172    }
1173
1174    #[test]
1175    fn parse_layout_overflow_trims_unicode_whitespace_but_not_zero_width_chars() {
1176        // `str::trim` uses the Unicode White_Space property, which is wider than
1177        // CSS whitespace: NBSP and the ideographic space are stripped too.
1178        assert_eq!(
1179            parse_layout_overflow("\u{00A0}scroll\u{00A0}").unwrap(),
1180            LayoutOverflow::Scroll
1181        );
1182        assert_eq!(
1183            parse_layout_overflow("\u{3000}auto").unwrap(),
1184            LayoutOverflow::Auto
1185        );
1186        // ...but a zero-width space is not whitespace, so it stays and rejects.
1187        assert!(parse_layout_overflow("\u{200B}scroll").is_err());
1188        assert!(parse_layout_overflow("scroll\u{FEFF}").is_err());
1189    }
1190
1191    #[test]
1192    fn parse_layout_overflow_rejects_garbage_unicode_and_boundary_numbers() {
1193        for input in [
1194            "none",
1195            "hidden-x",
1196            "auto scroll",
1197            "scroll;",
1198            "scroll garbage",
1199            "visible !important",
1200            "\0",
1201            "scroll\0",
1202            "!@#$%^&*()",
1203            "\u{1F600}",
1204            "scroll\u{1F600}",
1205            "e\u{0301}",
1206            "scroll",
1207            "скролл",
1208            "0",
1209            "-0",
1210            "0.0",
1211            "NaN",
1212            "nan",
1213            "inf",
1214            "-inf",
1215            "infinity",
1216            "9223372036854775807",
1217            "-9223372036854775808",
1218            "1e400",
1219            "1e-400",
1220        ] {
1221            assert!(
1222                parse_layout_overflow(input).is_err(),
1223                "{input:?} unexpectedly parsed"
1224            );
1225        }
1226    }
1227
1228    #[test]
1229    fn parse_layout_overflow_survives_extremely_long_and_deeply_nested_input() {
1230        let long = "scroll".repeat(200_000);
1231        assert!(parse_layout_overflow(&long).is_err());
1232
1233        let junk = "a".repeat(1_000_000);
1234        assert!(parse_layout_overflow(&junk).is_err());
1235
1236        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1237        assert!(parse_layout_overflow(&nested).is_err());
1238
1239        // A valid keyword buried in megabytes of padding is still just padding.
1240        let padded = format!("{}scroll{}", " ".repeat(500_000), " ".repeat(500_000));
1241        assert_eq!(parse_layout_overflow(&padded).unwrap(), LayoutOverflow::Scroll);
1242    }
1243
1244    // ---------------------------------------------------------------------
1245    // parse_style_scrollbar_gutter
1246    // ---------------------------------------------------------------------
1247
1248    #[test]
1249    fn scrollbar_gutter_round_trips_through_print_as_css_value() {
1250        for g in ALL_GUTTER {
1251            let printed = g.print_as_css_value();
1252            assert_eq!(
1253                parse_style_scrollbar_gutter(&printed).unwrap(),
1254                g,
1255                "{g:?} printed as {printed:?} did not round-trip"
1256            );
1257        }
1258        assert_eq!(
1259            StyleScrollbarGutter::StableBothEdges.print_as_css_value(),
1260            "stable both-edges"
1261        );
1262        assert_eq!(StyleScrollbarGutter::default(), StyleScrollbarGutter::Auto);
1263    }
1264
1265    #[test]
1266    fn parse_style_scrollbar_gutter_matches_the_keyword_string_verbatim() {
1267        // The parser compares the whole trimmed string, so it accepts exactly
1268        // one ASCII space between `stable` and `both-edges`. Per the grammar
1269        // (`auto | stable && both-edges?`) the reversed order and collapsed
1270        // runs of whitespace should also be legal — characterising the gap.
1271        assert_eq!(
1272            parse_style_scrollbar_gutter("stable both-edges").unwrap(),
1273            StyleScrollbarGutter::StableBothEdges
1274        );
1275        for rejected in [
1276            "stable  both-edges", // two spaces
1277            "stable\tboth-edges",
1278            "stable\nboth-edges",
1279            "both-edges stable", // `&&` allows either order
1280            "both-edges",
1281            "STABLE",
1282            "Stable Both-Edges",
1283            "stable both-edges stable",
1284        ] {
1285            assert!(
1286                parse_style_scrollbar_gutter(rejected).is_err(),
1287                "{rejected:?} unexpectedly parsed"
1288            );
1289        }
1290        // Outer whitespace *is* trimmed.
1291        assert_eq!(
1292            parse_style_scrollbar_gutter("  stable both-edges \n").unwrap(),
1293            StyleScrollbarGutter::StableBothEdges
1294        );
1295    }
1296
1297    #[test]
1298    fn parse_style_scrollbar_gutter_rejects_empty_garbage_unicode_and_numbers() {
1299        for input in [
1300            "", " ", "\t\n", "none", "auto stable", "auto;", "stable;", "0", "-0", "NaN", "inf",
1301            "9223372036854775807", "\u{1F600}", "stable", "stable\0",
1302        ] {
1303            assert!(
1304                parse_style_scrollbar_gutter(input).is_err(),
1305                "{input:?} unexpectedly parsed"
1306            );
1307        }
1308        let err = parse_style_scrollbar_gutter("  nope  ").unwrap_err();
1309        assert_eq!(
1310            err,
1311            StyleScrollbarGutterParseError::InvalidValue("  nope  ")
1312        );
1313        assert!(format!("{err}").contains("scrollbar-gutter"));
1314    }
1315
1316    #[test]
1317    fn parse_style_scrollbar_gutter_survives_long_and_nested_input() {
1318        let long = "stable ".repeat(200_000);
1319        assert!(parse_style_scrollbar_gutter(&long).is_err());
1320        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1321        assert!(parse_style_scrollbar_gutter(&nested).is_err());
1322    }
1323
1324    // ---------------------------------------------------------------------
1325    // parse_style_overflow_clip_margin
1326    // ---------------------------------------------------------------------
1327
1328    #[test]
1329    fn parse_style_overflow_clip_margin_accepts_either_component_in_either_order() {
1330        // <visual-box> only — length defaults to 0.
1331        let only_box = parse_style_overflow_clip_margin("content-box").unwrap();
1332        assert_eq!(only_box.clip_edge, VisualBox::ContentBox);
1333        assert_eq!(only_box.inner, PixelValue::default());
1334
1335        // <length> only — box defaults to padding-box.
1336        let only_len = parse_style_overflow_clip_margin("20px").unwrap();
1337        assert_eq!(only_len.clip_edge, VisualBox::PaddingBox);
1338        assert_eq!(only_len.inner, PixelValue::const_px(20));
1339
1340        // `||` means either order is valid.
1341        let a = parse_style_overflow_clip_margin("border-box 10px").unwrap();
1342        let b = parse_style_overflow_clip_margin("10px border-box").unwrap();
1343        assert_eq!(a, b);
1344        assert_eq!(a.clip_edge, VisualBox::BorderBox);
1345        assert_eq!(a.inner, PixelValue::const_px(10));
1346
1347        // Interior whitespace is collapsed by split_whitespace.
1348        let c = parse_style_overflow_clip_margin("  border-box \t\n  10px  ").unwrap();
1349        assert_eq!(c, a);
1350
1351        assert_eq!(VisualBox::default(), VisualBox::PaddingBox);
1352    }
1353
1354    #[test]
1355    fn parse_style_overflow_clip_margin_rejects_empty_duplicates_and_garbage() {
1356        for input in [
1357            "",
1358            "   ",
1359            "\t\n",
1360            "content-box content-box", // duplicate box
1361            "10px 20px",               // duplicate length
1362            "content-box 10px 20px",
1363            "content-box padding-box",
1364            "content-box 10px border-box",
1365            "none",
1366            "auto",
1367            "margin-box",
1368            "10px;",
1369            "10 px extra",
1370            "px",
1371            "\u{1F600}",
1372            "10\u{1F600}",
1373            "content-box",
1374            "content_box",
1375            "CONTENT-BOX",
1376        ] {
1377            assert!(
1378                parse_style_overflow_clip_margin(input).is_err(),
1379                "{input:?} unexpectedly parsed"
1380            );
1381        }
1382        let err = parse_style_overflow_clip_margin("  nope  ").unwrap_err();
1383        assert_eq!(
1384            err,
1385            StyleOverflowClipMarginParseError::InvalidValue("  nope  ")
1386        );
1387        assert!(format!("{err}").contains("overflow-clip-margin"));
1388    }
1389
1390    #[test]
1391    fn parse_style_overflow_clip_margin_accepts_out_of_range_lengths() {
1392        // The declared syntax is `<visual-box> || <length [0,∞]>`: negatives and
1393        // percentages are invalid CSS. The parser delegates to parse_pixel_value
1394        // and clamps nothing, so both are accepted. Characterised, not endorsed.
1395        let neg = parse_style_overflow_clip_margin("-5px").unwrap();
1396        assert!(neg.inner.number.get() < 0.0);
1397
1398        let pct = parse_style_overflow_clip_margin("50%").unwrap();
1399        assert_eq!(pct.inner.metric, SizeMetric::Percent);
1400        assert_eq!(pct.inner.number.get(), 50.0);
1401
1402        // Unitless non-zero numbers are also let through (CSS requires a unit).
1403        let unitless = parse_style_overflow_clip_margin("7").unwrap();
1404        assert_eq!(unitless.inner.metric, SizeMetric::Px);
1405        assert_eq!(unitless.inner.number.get(), 7.0);
1406    }
1407
1408    #[test]
1409    fn parse_style_overflow_clip_margin_saturates_nan_and_infinity() {
1410        // Rust's f32 parser accepts "NaN"/"inf", so these reach PixelValue.
1411        // FloatValue stores milli-units in an isize: NaN saturates to 0 and the
1412        // infinities to the isize bounds — no non-finite value can escape into
1413        // layout, which is the property that actually matters.
1414        let nan = parse_style_overflow_clip_margin("NaN").unwrap();
1415        assert!(!nan.inner.number.get().is_nan());
1416        assert_eq!(nan.inner.number.get(), 0.0);
1417
1418        let pos_inf = parse_style_overflow_clip_margin("inf").unwrap();
1419        assert!(pos_inf.inner.number.get().is_finite());
1420        assert!(pos_inf.inner.number.get() > 0.0);
1421
1422        let neg_inf = parse_style_overflow_clip_margin("-inf").unwrap();
1423        assert!(neg_inf.inner.number.get().is_finite());
1424        assert!(neg_inf.inner.number.get() < 0.0);
1425
1426        // A number far beyond f32 range overflows to inf during parsing and
1427        // then saturates the same way.
1428        let huge = format!("{}px", "9".repeat(4096));
1429        let huge = parse_style_overflow_clip_margin(&huge).unwrap();
1430        assert!(huge.inner.number.get().is_finite());
1431
1432        // Sub-milli precision is quantised away rather than rounded up.
1433        let tiny = parse_style_overflow_clip_margin("0.0001px").unwrap();
1434        assert_eq!(tiny.inner.number.get(), 0.0);
1435    }
1436
1437    #[test]
1438    fn parse_style_overflow_clip_margin_survives_long_and_nested_input() {
1439        let long_token = format!("{}px", "a".repeat(1_000_000));
1440        assert!(parse_style_overflow_clip_margin(&long_token).is_err());
1441
1442        let many_tokens = "content-box ".repeat(100_000);
1443        assert!(parse_style_overflow_clip_margin(&many_tokens).is_err());
1444
1445        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
1446        assert!(parse_style_overflow_clip_margin(&nested).is_err());
1447    }
1448
1449    #[test]
1450    fn overflow_clip_margin_round_trips_through_print_as_css_value() {
1451        let lengths = [
1452            PixelValue::const_px(12),
1453            PixelValue::px(1.5),
1454            PixelValue::const_em(2),
1455            PixelValue::const_percent(50),
1456            PixelValue::px(-3.25),
1457        ];
1458        for edge in ALL_VISUAL_BOX {
1459            for inner in lengths {
1460                let original = StyleOverflowClipMargin {
1461                    clip_edge: edge,
1462                    inner,
1463                };
1464                let printed = original.print_as_css_value();
1465                let reparsed = parse_style_overflow_clip_margin(&printed).unwrap_or_else(|e| {
1466                    panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
1467                });
1468                assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
1469            }
1470        }
1471    }
1472
1473    #[test]
1474    fn overflow_clip_margin_zero_length_prints_only_the_box_and_forgets_the_unit() {
1475        // A zero length is elided from the printed form, so its unit is lost on
1476        // the way back (0em == 0px semantically, so this is benign — but the
1477        // struct is *not* preserved bit-for-bit, which a naive round-trip
1478        // assertion would trip over).
1479        let zero_em = StyleOverflowClipMargin {
1480            clip_edge: VisualBox::ContentBox,
1481            inner: PixelValue::const_em(0),
1482        };
1483        assert_eq!(zero_em.print_as_css_value(), "content-box");
1484        let back = parse_style_overflow_clip_margin(&zero_em.print_as_css_value()).unwrap();
1485        assert_eq!(back.clip_edge, VisualBox::ContentBox);
1486        assert_eq!(back.inner.number.get(), 0.0);
1487        assert_eq!(back.inner.metric, SizeMetric::Px);
1488        assert_ne!(back, zero_em);
1489
1490        // The all-default value prints as the bare default box.
1491        let default = StyleOverflowClipMargin::default();
1492        assert_eq!(default.print_as_css_value(), "padding-box");
1493        assert_eq!(
1494            parse_style_overflow_clip_margin(&default.print_as_css_value()).unwrap(),
1495            default
1496        );
1497
1498        // padding-box + non-zero length prints only the length.
1499        let padding_len = StyleOverflowClipMargin {
1500            clip_edge: VisualBox::PaddingBox,
1501            inner: PixelValue::const_px(4),
1502        };
1503        assert_eq!(padding_len.print_as_css_value(), "4px");
1504    }
1505
1506    #[test]
1507    fn visual_box_round_trips_through_the_clip_margin_parser() {
1508        for v in ALL_VISUAL_BOX {
1509            let printed = v.print_as_css_value();
1510            let parsed = parse_style_overflow_clip_margin(&printed).unwrap();
1511            assert_eq!(parsed.clip_edge, v, "{printed:?} did not round-trip");
1512        }
1513    }
1514
1515    // ---------------------------------------------------------------------
1516    // parse_clip_edge (private)
1517    // ---------------------------------------------------------------------
1518
1519    #[test]
1520    fn parse_clip_edge_auto_is_ascii_case_insensitive_and_trimmed() {
1521        for input in ["auto", "AUTO", "Auto", "aUtO", "  auto  ", "\tauto\n"] {
1522            assert_eq!(
1523                parse_clip_edge(input).unwrap(),
1524                OptionF32::None,
1525                "{input:?} should be auto"
1526            );
1527        }
1528        // ...but only the whole token: `auto` glued to anything else is invalid.
1529        assert!(parse_clip_edge("auto5").is_err());
1530        assert!(parse_clip_edge("autopx").is_err());
1531        assert!(parse_clip_edge("auto auto").is_err());
1532    }
1533
1534    #[test]
1535    fn parse_clip_edge_silently_discards_the_unit() {
1536        // BUG (characterised): the edge keeps only `PixelValue::number`, so the
1537        // metric is thrown away — `rect(5em, ...)` is treated as 5 *pixels*, and
1538        // percentages (invalid for `clip`) are accepted as raw numbers.
1539        for input in ["5px", "5em", "5rem", "5pt", "5in", "5cm", "5mm", "5vw", "5vh", "5%"] {
1540            assert_eq!(
1541                parse_clip_edge(input).unwrap(),
1542                OptionF32::Some(5.0),
1543                "{input:?} did not collapse to a bare 5.0"
1544            );
1545        }
1546        // A unitless number is accepted as well (CSS requires a unit here).
1547        assert_eq!(parse_clip_edge("5").unwrap(), OptionF32::Some(5.0));
1548        // And whitespace between number and unit is tolerated by the pixel parser.
1549        assert_eq!(parse_clip_edge("5 px").unwrap(), OptionF32::Some(5.0));
1550    }
1551
1552    #[test]
1553    fn parse_clip_edge_quantises_to_thousandths_and_normalises_negative_zero() {
1554        // FloatValue is a fixed-point isize in milli-units: anything below 1/1000
1555        // truncates toward zero rather than rounding.
1556        assert_eq!(parse_clip_edge("0.001px").unwrap(), OptionF32::Some(0.001));
1557        assert_eq!(parse_clip_edge("0.0001px").unwrap(), OptionF32::Some(0.0));
1558        assert_eq!(parse_clip_edge("-0.0009px").unwrap(), OptionF32::Some(0.0));
1559        assert_eq!(parse_clip_edge("1.9999px").unwrap(), OptionF32::Some(1.999));
1560
1561        // -0 loses its sign, so it can never poison downstream sign checks.
1562        let minus_zero = parse_clip_edge("-0px").unwrap().into_option().unwrap();
1563        assert_eq!(minus_zero, 0.0);
1564        assert!(minus_zero.is_sign_positive());
1565
1566        // Negative lengths are explicitly legal for `clip`.
1567        assert_eq!(parse_clip_edge("-10px").unwrap(), OptionF32::Some(-10.0));
1568    }
1569
1570    #[test]
1571    fn parse_clip_edge_saturates_nan_and_infinity_to_finite_values() {
1572        let nan = parse_clip_edge("NaN").unwrap().into_option().unwrap();
1573        assert!(!nan.is_nan(), "NaN must not survive into a clip edge");
1574        assert_eq!(nan, 0.0);
1575
1576        let pos_inf = parse_clip_edge("inf").unwrap().into_option().unwrap();
1577        assert!(pos_inf.is_finite());
1578        assert!(pos_inf > 0.0);
1579
1580        let neg_inf = parse_clip_edge("-infinity").unwrap().into_option().unwrap();
1581        assert!(neg_inf.is_finite());
1582        assert!(neg_inf < 0.0);
1583
1584        let huge = format!("{}px", "9".repeat(4096));
1585        let huge = parse_clip_edge(&huge).unwrap().into_option().unwrap();
1586        assert!(huge.is_finite());
1587    }
1588
1589    #[test]
1590    fn parse_clip_edge_rejects_empty_bare_units_and_garbage() {
1591        for input in [
1592            "",
1593            "   ",
1594            "\t\n",
1595            "px",
1596            "em",
1597            "%",
1598            "abc",
1599            "10px;",
1600            "10px 20px",
1601            "(10px)",
1602            "\0",
1603            "\u{1F600}",
1604            "1px",
1605            "1px\u{0301}",
1606            "0x10",
1607        ] {
1608            assert!(parse_clip_edge(input).is_err(), "{input:?} unexpectedly parsed");
1609        }
1610        // The error carries the *trimmed token*, not the surrounding input.
1611        assert_eq!(
1612            parse_clip_edge("  abc  ").unwrap_err(),
1613            StyleClipRectParseError::InvalidValue("abc")
1614        );
1615    }
1616
1617    // ---------------------------------------------------------------------
1618    // parse_clip_rect
1619    // ---------------------------------------------------------------------
1620
1621    #[test]
1622    fn clip_rect_round_trips_through_print_as_css_value() {
1623        let rects = [
1624            StyleClipRect::default(),
1625            StyleClipRect {
1626                top: OptionF32::Some(0.0),
1627                right: OptionF32::Some(-2.25),
1628                bottom: OptionF32::Some(1.5),
1629                left: OptionF32::None,
1630            },
1631            StyleClipRect {
1632                top: OptionF32::Some(10.0),
1633                right: OptionF32::Some(20.0),
1634                bottom: OptionF32::Some(30.0),
1635                left: OptionF32::Some(40.0),
1636            },
1637            StyleClipRect {
1638                top: OptionF32::None,
1639                right: OptionF32::Some(-1.0),
1640                bottom: OptionF32::None,
1641                left: OptionF32::Some(-1.0),
1642            },
1643        ];
1644        for original in rects {
1645            let printed = original.print_as_css_value();
1646            let reparsed = parse_clip_rect(&printed).unwrap_or_else(|e| {
1647                panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
1648            });
1649            assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
1650        }
1651        assert_eq!(
1652            StyleClipRect::default().print_as_css_value(),
1653            "rect(auto, auto, auto, auto)"
1654        );
1655    }
1656
1657    #[test]
1658    fn parse_clip_rect_accepts_the_auto_comma_and_legacy_space_forms() {
1659        let all_auto = StyleClipRect::default();
1660        for input in [
1661            "auto",
1662            "AUTO",
1663            "  auto  ",
1664            "\u{00A0}auto", // NBSP is Unicode whitespace, so `trim` eats it
1665            "rect(auto, auto, auto, auto)",
1666            "rect(auto auto auto auto)",
1667            "RECT(auto, auto, auto, auto)",
1668            "  rect( auto , auto , auto , auto )  ",
1669        ] {
1670            assert_eq!(
1671                parse_clip_rect(input).unwrap(),
1672                all_auto,
1673                "{input:?} should be all-auto"
1674            );
1675        }
1676
1677        let mixed = parse_clip_rect("rect(1px, auto, -3px, 4px)").unwrap();
1678        assert_eq!(mixed.top, OptionF32::Some(1.0));
1679        assert_eq!(mixed.right, OptionF32::None);
1680        assert_eq!(mixed.bottom, OptionF32::Some(-3.0));
1681        assert_eq!(mixed.left, OptionF32::Some(4.0));
1682
1683        // No space after the commas is fine too.
1684        assert_eq!(
1685            parse_clip_rect("rect(1px,2px,3px,4px)").unwrap(),
1686            StyleClipRect {
1687                top: OptionF32::Some(1.0),
1688                right: OptionF32::Some(2.0),
1689                bottom: OptionF32::Some(3.0),
1690                left: OptionF32::Some(4.0),
1691            }
1692        );
1693    }
1694
1695    #[test]
1696    fn parse_clip_rect_rejects_wrong_arity_mixed_separators_and_trailing_junk() {
1697        for input in [
1698            "rect()",
1699            "rect(,,,)",
1700            "rect(1px)",
1701            "rect(1px, 2px, 3px)",
1702            "rect(1px, 2px, 3px, 4px, 5px)",
1703            "rect(1px, 2px, 3px, 4px,)",
1704            "rect(1px 2px, 3px 4px)", // half comma-separated, half not
1705            "rect(1px 2px 3px)",
1706            "rect(1px 2px 3px 4px 5px)",
1707            "rect(1px, 2px, 3px, 4px",  // no closing paren
1708            "rect 1px, 2px, 3px, 4px)", // no opening paren
1709            "rect (1px, 2px, 3px, 4px)", // space before the paren
1710            "rect(1px, 2px, 3px, 4px) trailing",
1711            "rect(1px, 2px, 3px, 4px);",
1712            "junk rect(1px, 2px, 3px, 4px)",
1713            "rect(auto, auto, auto, abc)",
1714            "",
1715            "   ",
1716            "none",
1717            "inherit",
1718            "0",
1719        ] {
1720            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1721        }
1722    }
1723
1724    #[test]
1725    fn parse_clip_rect_function_name_accepts_only_all_lower_or_all_upper_case() {
1726        // `rect(` and `RECT(` are special-cased; every mixed casing is rejected,
1727        // even though CSS function names are ASCII case-insensitive.
1728        assert!(parse_clip_rect("rect(auto, auto, auto, auto)").is_ok());
1729        assert!(parse_clip_rect("RECT(auto, auto, auto, auto)").is_ok());
1730        for input in [
1731            "Rect(auto, auto, auto, auto)",
1732            "rECT(auto, auto, auto, auto)",
1733            "ReCt(auto, auto, auto, auto)",
1734        ] {
1735            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1736        }
1737    }
1738
1739    #[test]
1740    fn parse_clip_rect_errors_point_at_the_offending_token() {
1741        // A bad *edge* reports just the token...
1742        let err = parse_clip_rect("rect(1px, abc, 3px, 4px)").unwrap_err();
1743        assert_eq!(err, StyleClipRectParseError::InvalidValue("abc"));
1744        let msg = format!("{err}");
1745        assert!(msg.contains("abc"), "{msg}");
1746        // (the message's own "Expected rect(...)" hint aside, none of the *input*
1747        // apart from the bad token is echoed back)
1748        assert!(!msg.contains("1px"), "message leaked the whole input: {msg}");
1749
1750        // ...while a structural error reports the untrimmed input.
1751        let err = parse_clip_rect("  rect(1px)  ").unwrap_err();
1752        assert_eq!(err, StyleClipRectParseError::InvalidValue("  rect(1px)  "));
1753    }
1754
1755    #[test]
1756    fn parse_clip_rect_survives_deep_nesting_and_huge_input() {
1757        // Not a recursive-descent parser, so nesting cannot blow the stack.
1758        let nested = format!("{}{}", "rect(".repeat(10_000), ")".repeat(10_000));
1759        assert!(parse_clip_rect(&nested).is_err());
1760
1761        let parens = format!("{}{}", "(".repeat(100_000), ")".repeat(100_000));
1762        assert!(parse_clip_rect(&parens).is_err());
1763
1764        // 50k edges: rejected on arity, not by hanging.
1765        let wide = format!("rect({})", "1px,".repeat(50_000));
1766        assert!(parse_clip_rect(&wide).is_err());
1767
1768        let long_token = format!("rect({}, auto, auto, auto)", "a".repeat(1_000_000));
1769        assert!(parse_clip_rect(&long_token).is_err());
1770
1771        // A legitimately huge magnitude parses and saturates instead of overflowing.
1772        let huge = format!("rect({}px, auto, auto, auto)", "9".repeat(4096));
1773        let huge = parse_clip_rect(&huge).unwrap();
1774        let top = huge.top.into_option().unwrap();
1775        assert!(top.is_finite());
1776        assert!(top > 0.0);
1777    }
1778
1779    #[test]
1780    fn parse_clip_rect_does_not_panic_on_multibyte_input() {
1781        for input in [
1782            "rect(\u{1F600}, \u{1F600}, \u{1F600}, \u{1F600})",
1783            "rect(1px\u{0301}, auto, auto, auto)",
1784            "réct(1px, 2px, 3px, 4px)",
1785            "rect(1px, auto, auto, auto)", // fullwidth digit
1786            "rect(1px, auto, auto, auto\u{200B})",
1787            "\u{1F600}",
1788            "автo",
1789            "rect(٣px, auto, auto, auto)", // arabic-indic digit
1790        ] {
1791            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
1792        }
1793    }
1794
1795    // ---------------------------------------------------------------------
1796    // StyleClipRect::resolve
1797    // ---------------------------------------------------------------------
1798
1799    #[test]
1800    fn clip_rect_default_is_all_auto() {
1801        let d = StyleClipRect::default();
1802        assert_eq!(d.top, OptionF32::None);
1803        assert_eq!(d.right, OptionF32::None);
1804        assert_eq!(d.bottom, OptionF32::None);
1805        assert_eq!(d.left, OptionF32::None);
1806    }
1807
1808    #[test]
1809    fn clip_rect_resolve_expands_auto_edges_to_the_border_box() {
1810        // auto: top/left = 0, bottom/right = the border-box extent.
1811        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1812            100.0, 50.0, // used width / height
1813            1.0, 2.0, 3.0, 4.0, // padding l / r / t / b
1814            5.0, 6.0, 7.0, 8.0, // border  l / r / t / b
1815        );
1816        assert_eq!(top, 0.0);
1817        assert_eq!(left, 0.0);
1818        assert_eq!(right, 100.0 + 1.0 + 2.0 + 5.0 + 6.0);
1819        assert_eq!(bottom, 50.0 + 3.0 + 4.0 + 7.0 + 8.0);
1820    }
1821
1822    #[test]
1823    fn clip_rect_resolve_at_zero_and_with_negative_geometry() {
1824        let all_zero = StyleClipRect::default().resolve(
1825            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1826        );
1827        assert_eq!(all_zero, (0.0, 0.0, 0.0, 0.0));
1828
1829        // Negative geometry is summed as-is (no clamping): deterministic, finite.
1830        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1831            -10.0, -20.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
1832        );
1833        assert_eq!(top, 0.0);
1834        assert_eq!(left, 0.0);
1835        assert_eq!(right, -14.0);
1836        assert_eq!(bottom, -24.0);
1837    }
1838
1839    #[test]
1840    fn clip_rect_resolve_ignores_the_geometry_for_explicit_edges() {
1841        let explicit = StyleClipRect {
1842            top: OptionF32::Some(1.0),
1843            right: OptionF32::Some(2.0),
1844            bottom: OptionF32::Some(3.0),
1845            left: OptionF32::Some(4.0),
1846        };
1847        // Even with hostile geometry the explicit edges come back untouched.
1848        for geometry in [
1849            f32::NAN,
1850            f32::INFINITY,
1851            f32::NEG_INFINITY,
1852            f32::MAX,
1853            f32::MIN,
1854            f32::MIN_POSITIVE,
1855        ] {
1856            let resolved = explicit.resolve(
1857                geometry, geometry, geometry, geometry, geometry, geometry, geometry, geometry,
1858                geometry, geometry,
1859            );
1860            assert_eq!(
1861                resolved,
1862                (1.0, 2.0, 3.0, 4.0),
1863                "explicit edges were perturbed by geometry {geometry:?}"
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    fn clip_rect_resolve_saturates_at_f32_max_and_keeps_nan_contained() {
1870        // f32::MAX + f32::MAX overflows to +inf rather than panicking.
1871        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1872            f32::MAX,
1873            f32::MAX,
1874            f32::MAX,
1875            f32::MAX,
1876            f32::MAX,
1877            f32::MAX,
1878            f32::MAX,
1879            f32::MAX,
1880            f32::MAX,
1881            f32::MAX,
1882        );
1883        assert_eq!(top, 0.0);
1884        assert_eq!(left, 0.0);
1885        assert!(right.is_infinite() && right.is_sign_positive());
1886        assert!(bottom.is_infinite() && bottom.is_sign_positive());
1887
1888        // NaN geometry propagates into the auto edges only (documented result:
1889        // NaN in, NaN out — no panic, and the fixed edges stay clean).
1890        let (top, right, bottom, left) = StyleClipRect::default().resolve(
1891            f32::NAN,
1892            f32::NAN,
1893            0.0,
1894            0.0,
1895            0.0,
1896            0.0,
1897            0.0,
1898            0.0,
1899            0.0,
1900            0.0,
1901        );
1902        assert_eq!(top, 0.0);
1903        assert_eq!(left, 0.0);
1904        assert!(right.is_nan());
1905        assert!(bottom.is_nan());
1906
1907        // +inf added to -inf is NaN — still no panic.
1908        let (_, right, bottom, _) = StyleClipRect::default().resolve(
1909            f32::INFINITY,
1910            f32::INFINITY,
1911            f32::NEG_INFINITY,
1912            0.0,
1913            f32::NEG_INFINITY,
1914            0.0,
1915            0.0,
1916            0.0,
1917            0.0,
1918            0.0,
1919        );
1920        assert!(right.is_nan());
1921        assert!(bottom.is_nan());
1922    }
1923
1924    // ---------------------------------------------------------------------
1925    // Error types: to_contained / to_shared
1926    // ---------------------------------------------------------------------
1927
1928    /// Payloads that an error may have to carry: empty, whitespace, multibyte,
1929    /// combining marks, an embedded NUL, and a large string.
1930    fn error_payloads() -> Vec<String> {
1931        vec![
1932            String::new(),
1933            String::from(" "),
1934            String::from("bogus"),
1935            String::from("\u{1F600}\u{0301}"),
1936            String::from("a\0b"),
1937            String::from("rect(1px, 2px, 3px, 4px)"),
1938            "x".repeat(100_000),
1939        ]
1940    }
1941
1942    macro_rules! assert_error_round_trips {
1943        ($borrowed:ident) => {{
1944            for payload in error_payloads() {
1945                let borrowed = $borrowed::InvalidValue(payload.as_str());
1946                let owned = borrowed.to_contained();
1947                let back = owned.to_shared();
1948                assert_eq!(
1949                    back, borrowed,
1950                    "{}::InvalidValue({payload:?}) lost data on to_contained/to_shared",
1951                    stringify!($borrowed)
1952                );
1953                // ...and the owned form is stable under a second lap.
1954                assert_eq!(owned.to_shared().to_contained(), owned);
1955            }
1956        }};
1957    }
1958
1959    #[test]
1960    fn parse_errors_round_trip_between_borrowed_and_owned_forms() {
1961        assert_error_round_trips!(LayoutOverflowParseError);
1962        assert_error_round_trips!(StyleScrollbarGutterParseError);
1963        assert_error_round_trips!(StyleOverflowClipMarginParseError);
1964        assert_error_round_trips!(StyleClipRectParseError);
1965    }
1966
1967    #[test]
1968    fn parse_errors_produced_by_the_parsers_round_trip_too() {
1969        let e = parse_layout_overflow("nope").unwrap_err();
1970        assert_eq!(e.to_contained().to_shared(), e);
1971
1972        let e = parse_style_scrollbar_gutter("nope").unwrap_err();
1973        assert_eq!(e.to_contained().to_shared(), e);
1974
1975        let e = parse_style_overflow_clip_margin("nope nope").unwrap_err();
1976        assert_eq!(e.to_contained().to_shared(), e);
1977
1978        let e = parse_clip_rect("rect(nope)").unwrap_err();
1979        assert_eq!(e.to_contained().to_shared(), e);
1980    }
1981
1982    #[test]
1983    fn parse_error_messages_name_the_property_and_quote_the_value() {
1984        let msg = format!("{}", LayoutOverflowParseError::InvalidValue("zzz"));
1985        assert!(msg.contains("overflow") && msg.contains("zzz"), "{msg}");
1986
1987        let msg = format!("{}", StyleScrollbarGutterParseError::InvalidValue("zzz"));
1988        assert!(msg.contains("scrollbar-gutter") && msg.contains("zzz"), "{msg}");
1989
1990        let msg = format!("{}", StyleOverflowClipMarginParseError::InvalidValue("zzz"));
1991        assert!(
1992            msg.contains("overflow-clip-margin") && msg.contains("zzz"),
1993            "{msg}"
1994        );
1995
1996        let msg = format!("{}", StyleClipRectParseError::InvalidValue("zzz"));
1997        assert!(msg.contains("clip") && msg.contains("zzz"), "{msg}");
1998
1999        // Debug is wired to Display: it must not panic on hostile payloads.
2000        let weird = StyleClipRectParseError::InvalidValue("\u{1F600}\0\u{0301}");
2001        assert!(!format!("{weird:?}").is_empty());
2002    }
2003}