1
//! CSS properties for managing content overflow.
2

            
3
use alloc::string::{String, ToString};
4
use crate::corety::{AzString, OptionF32};
5

            
6
use 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)]
16
pub 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

            
31
impl 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
20
    #[must_use] pub const fn needs_scrollbar(&self, currently_overflowing: bool) -> bool {
41
20
        match self {
42
4
            Self::Scroll => true,
43
5
            Self::Auto => currently_overflowing,
44
11
            Self::Hidden | Self::Visible | Self::Clip => false,
45
        }
46
20
    }
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
65
    #[must_use] pub const fn is_clipped(&self) -> bool {
54
        // All overflow values except 'visible' clip their content
55
10
        matches!(
56
65
            self,
57
            Self::Hidden
58
                | Self::Clip
59
                | Self::Auto
60
                | Self::Scroll
61
        )
62
65
    }
63

            
64
    /// Returns `true` if the overflow type is `scroll`.
65
7
    #[must_use] pub const fn is_scroll(&self) -> bool {
66
7
        matches!(self, Self::Scroll)
67
7
    }
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
106
    #[must_use] pub fn is_overflow_visible(&self) -> bool {
90
106
        *self == Self::Visible
91
106
    }
92

            
93
    /// Returns `true` if the overflow type is `hidden`.
94
106
    #[must_use] pub fn is_overflow_hidden(&self) -> bool {
95
106
        *self == Self::Hidden
96
106
    }
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
1662042
    #[must_use] pub const fn resolve_computed(self, other_axis: Self) -> Self {
103
1662042
        let other_is_scrollable = !matches!(other_axis, Self::Visible | Self::Clip);
104
1662042
        if other_is_scrollable {
105
17412
            match self {
106
1300
                Self::Visible => Self::Auto,
107
78
                Self::Clip => Self::Hidden,
108
16034
                other => other,
109
            }
110
        } else {
111
1644630
            self
112
        }
113
1662042
    }
114
}
115

            
116
impl PrintAsCssValue for LayoutOverflow {
117
11
    fn print_as_css_value(&self) -> String {
118
11
        String::from(match self {
119
2
            Self::Scroll => "scroll",
120
3
            Self::Auto => "auto",
121
2
            Self::Hidden => "hidden",
122
2
            Self::Visible => "visible",
123
2
            Self::Clip => "clip",
124
        })
125
11
    }
126
}
127

            
128
// -- Parser
129

            
130
/// Error returned when parsing an `overflow` property fails.
131
#[derive(Clone, PartialEq, Eq)]
132
pub enum LayoutOverflowParseError<'a> {
133
    /// The provided value is not a valid `overflow` keyword.
134
    InvalidValue(&'a str),
135
}
136

            
137
impl_debug_as_display!(LayoutOverflowParseError<'a>);
138
impl_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)]
147
pub enum LayoutOverflowParseErrorOwned {
148
    InvalidValue(AzString),
149
}
150

            
151
impl LayoutOverflowParseError<'_> {
152
    /// Converts the borrowed error into an owned error.
153
15
    #[must_use] pub fn to_contained(&self) -> LayoutOverflowParseErrorOwned {
154
15
        match self {
155
15
            LayoutOverflowParseError::InvalidValue(s) => {
156
15
                LayoutOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
157
            }
158
        }
159
15
    }
160
}
161

            
162
impl LayoutOverflowParseErrorOwned {
163
    /// Converts the owned error back into a borrowed error.
164
15
    #[must_use] pub fn to_shared(&self) -> LayoutOverflowParseError<'_> {
165
15
        match self {
166
15
            Self::InvalidValue(s) => {
167
15
                LayoutOverflowParseError::InvalidValue(s.as_str())
168
            }
169
        }
170
15
    }
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.
178
4953
pub fn parse_layout_overflow(
179
4953
    input: &str,
180
4953
) -> Result<LayoutOverflow, LayoutOverflowParseError<'_>> {
181
4953
    let input_trimmed = input.trim();
182
4953
    match input_trimmed {
183
4953
        "scroll" => Ok(LayoutOverflow::Scroll),
184
2160
        "auto" | "overlay" => Ok(LayoutOverflow::Auto), // +spec:overflow:6120e6 - "overlay" is a legacy value alias of "auto"
185
1823
        "hidden" => Ok(LayoutOverflow::Hidden),
186
80
        "visible" => Ok(LayoutOverflow::Visible),
187
66
        "clip" => Ok(LayoutOverflow::Clip),
188
52
        _ => Err(LayoutOverflowParseError::InvalidValue(input)),
189
    }
190
4953
}
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)]
202
pub 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

            
212
impl PrintAsCssValue for StyleScrollbarGutter {
213
4
    fn print_as_css_value(&self) -> String {
214
4
        String::from(match self {
215
1
            Self::Auto => "auto",
216
1
            Self::Stable => "stable",
217
2
            Self::StableBothEdges => "stable both-edges",
218
        })
219
4
    }
220
}
221

            
222
// -- Parser for StyleScrollbarGutter
223

            
224
/// Error returned when parsing a `scrollbar-gutter` property fails.
225
#[derive(Clone, PartialEq, Eq)]
226
pub enum StyleScrollbarGutterParseError<'a> {
227
    /// The provided value is not a valid `scrollbar-gutter` keyword.
228
    InvalidValue(&'a str),
229
}
230

            
231
impl_debug_as_display!(StyleScrollbarGutterParseError<'a>);
232
impl_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)]
241
pub enum StyleScrollbarGutterParseErrorOwned {
242
    InvalidValue(AzString),
243
}
244

            
245
impl StyleScrollbarGutterParseError<'_> {
246
    /// Converts the borrowed error into an owned error.
247
15
    #[must_use] pub fn to_contained(&self) -> StyleScrollbarGutterParseErrorOwned {
248
15
        match self {
249
15
            StyleScrollbarGutterParseError::InvalidValue(s) => {
250
15
                StyleScrollbarGutterParseErrorOwned::InvalidValue((*s).to_string().into())
251
            }
252
        }
253
15
    }
254
}
255

            
256
impl StyleScrollbarGutterParseErrorOwned {
257
    /// Converts the owned error back into a borrowed error.
258
15
    #[must_use] pub fn to_shared(&self) -> StyleScrollbarGutterParseError<'_> {
259
15
        match self {
260
15
            Self::InvalidValue(s) => {
261
15
                StyleScrollbarGutterParseError::InvalidValue(s.as_str())
262
            }
263
        }
264
15
    }
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.
272
32
pub fn parse_style_scrollbar_gutter(
273
32
    input: &str,
274
32
) -> Result<StyleScrollbarGutter, StyleScrollbarGutterParseError<'_>> {
275
32
    let input_trimmed = input.trim();
276
32
    match input_trimmed {
277
32
        "auto" => Ok(StyleScrollbarGutter::Auto),
278
31
        "stable" => Ok(StyleScrollbarGutter::Stable),
279
30
        "stable both-edges" => Ok(StyleScrollbarGutter::StableBothEdges),
280
27
        _ => Err(StyleScrollbarGutterParseError::InvalidValue(input)),
281
    }
282
32
}
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)]
296
pub 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

            
304
impl PrintAsCssValue for StyleTextOverflow {
305
4
    fn print_as_css_value(&self) -> String {
306
4
        String::from(match self {
307
1
            Self::Clip => "clip",
308
3
            Self::Ellipsis => "ellipsis",
309
        })
310
4
    }
311
}
312

            
313
// -- Parser for StyleTextOverflow
314

            
315
/// Error returned when parsing a `text-overflow` property fails.
316
#[derive(Clone, PartialEq, Eq)]
317
pub enum StyleTextOverflowParseError<'a> {
318
    /// The provided value is not a valid `text-overflow` keyword.
319
    InvalidValue(&'a str),
320
}
321

            
322
impl_debug_as_display!(StyleTextOverflowParseError<'a>);
323
impl_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)]
332
pub enum StyleTextOverflowParseErrorOwned {
333
    InvalidValue(AzString),
334
}
335

            
336
impl StyleTextOverflowParseError<'_> {
337
    /// Converts the borrowed error into an owned error.
338
1
    #[must_use] pub fn to_contained(&self) -> StyleTextOverflowParseErrorOwned {
339
1
        match self {
340
1
            StyleTextOverflowParseError::InvalidValue(s) => {
341
1
                StyleTextOverflowParseErrorOwned::InvalidValue((*s).to_string().into())
342
            }
343
        }
344
1
    }
345
}
346

            
347
impl StyleTextOverflowParseErrorOwned {
348
    /// Converts the owned error back into a borrowed error.
349
1
    #[must_use] pub fn to_shared(&self) -> StyleTextOverflowParseError<'_> {
350
1
        match self {
351
1
            Self::InvalidValue(s) => {
352
1
                StyleTextOverflowParseError::InvalidValue(s.as_str())
353
            }
354
        }
355
1
    }
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.
363
39
pub fn parse_style_text_overflow(
364
39
    input: &str,
365
39
) -> Result<StyleTextOverflow, StyleTextOverflowParseError<'_>> {
366
39
    match input.trim() {
367
39
        "clip" => Ok(StyleTextOverflow::Clip),
368
37
        "ellipsis" => Ok(StyleTextOverflow::Ellipsis),
369
5
        other => Err(StyleTextOverflowParseError::InvalidValue(other)),
370
    }
371
39
}
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)]
382
pub 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

            
392
impl PrintAsCssValue for VisualBox {
393
23
    fn print_as_css_value(&self) -> String {
394
23
        String::from(match self {
395
8
            Self::ContentBox => "content-box",
396
9
            Self::PaddingBox => "padding-box",
397
6
            Self::BorderBox => "border-box",
398
        })
399
23
    }
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)]
412
pub 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

            
419
impl PrintAsCssValue for StyleOverflowClipMargin {
420
20
    fn print_as_css_value(&self) -> String {
421
20
        let edge = self.clip_edge.print_as_css_value();
422
20
        let len = self.inner.print_as_css_value();
423
        #[allow(clippy::float_cmp)] // exact zero check: value is default-initialized, not computed
424
20
        if self.inner.number.get() == 0.0 {
425
4
            edge
426
16
        } else if self.clip_edge == VisualBox::PaddingBox {
427
6
            len
428
        } else {
429
10
            format!("{edge} {len}")
430
        }
431
20
    }
432
}
433

            
434
/// Error returned when parsing an `overflow-clip-margin` property fails.
435
#[derive(Clone, PartialEq, Eq)]
436
pub enum StyleOverflowClipMarginParseError<'a> {
437
    /// The provided value is not a valid `overflow-clip-margin` value.
438
    InvalidValue(&'a str),
439
}
440

            
441
impl_debug_as_display!(StyleOverflowClipMarginParseError<'a>);
442
impl_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)]
449
pub enum StyleOverflowClipMarginParseErrorOwned {
450
    InvalidValue(AzString),
451
}
452

            
453
impl StyleOverflowClipMarginParseError<'_> {
454
    /// Converts the borrowed error into an owned error.
455
15
    #[must_use] pub fn to_contained(&self) -> StyleOverflowClipMarginParseErrorOwned {
456
15
        match self {
457
15
            StyleOverflowClipMarginParseError::InvalidValue(s) => {
458
15
                StyleOverflowClipMarginParseErrorOwned::InvalidValue((*s).to_string().into())
459
            }
460
        }
461
15
    }
462
}
463

            
464
impl StyleOverflowClipMarginParseErrorOwned {
465
    /// Converts the owned error back into a borrowed error.
466
15
    #[must_use] pub fn to_shared(&self) -> StyleOverflowClipMarginParseError<'_> {
467
15
        match self {
468
15
            Self::InvalidValue(s) => {
469
15
                StyleOverflowClipMarginParseError::InvalidValue(s.as_str())
470
            }
471
        }
472
15
    }
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.
484
57
pub fn parse_style_overflow_clip_margin(
485
57
    input: &str,
486
57
) -> Result<StyleOverflowClipMargin, StyleOverflowClipMarginParseError<'_>> {
487
    use crate::props::basic::pixel::parse_pixel_value;
488

            
489
57
    let input_trimmed = input.trim();
490
57
    let mut clip_edge = None;
491
57
    let mut length = None;
492

            
493
76
    for token in input_trimmed.split_whitespace() {
494
52
        match token {
495
76
            "content-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::ContentBox),
496
61
            "padding-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::PaddingBox),
497
58
            "border-box" if clip_edge.is_none() => clip_edge = Some(VisualBox::BorderBox),
498
52
            _ if length.is_none() => {
499
48
                match parse_pixel_value(token) {
500
31
                    Ok(pv) => length = Some(pv),
501
17
                    Err(_) => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
502
                }
503
            }
504
4
            _ => return Err(StyleOverflowClipMarginParseError::InvalidValue(input)),
505
        }
506
    }
507

            
508
36
    if clip_edge.is_none() && length.is_none() {
509
3
        return Err(StyleOverflowClipMarginParseError::InvalidValue(input));
510
33
    }
511

            
512
33
    Ok(StyleOverflowClipMargin {
513
33
        clip_edge: clip_edge.unwrap_or_default(),
514
33
        inner: length.unwrap_or_default(),
515
33
    })
516
57
}
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)]
532
pub 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

            
543
impl 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
12
    #[must_use] pub fn resolve(
549
12
        &self,
550
12
        used_width: f32,
551
12
        used_height: f32,
552
12
        padding_left: f32,
553
12
        padding_right: f32,
554
12
        padding_top: f32,
555
12
        padding_bottom: f32,
556
12
        border_left: f32,
557
12
        border_right: f32,
558
12
        border_top: f32,
559
12
        border_bottom: f32,
560
12
    ) -> (f32, f32, f32, f32) {
561
12
        let top = self.top.into_option().unwrap_or(0.0);
562
12
        let left = self.left.into_option().unwrap_or(0.0);
563
12
        let bottom = self
564
12
            .bottom
565
12
            .into_option()
566
12
            .unwrap_or(used_height + padding_top + padding_bottom + border_top + border_bottom);
567
12
        let right = self
568
12
            .right
569
12
            .into_option()
570
12
            .unwrap_or(used_width + padding_left + padding_right + border_left + border_right);
571
12
        (top, right, bottom, left)
572
12
    }
573
}
574

            
575
impl PrintAsCssValue for StyleClipRect {
576
5
    fn print_as_css_value(&self) -> String {
577
20
        fn fmt_edge(o: OptionF32) -> String {
578
20
            o.into_option()
579
20
                .map_or_else(|| String::from("auto"), |v| format!("{v}px"))
580
20
        }
581
5
        format!(
582
5
            "rect({}, {}, {}, {})",
583
5
            fmt_edge(self.top),
584
5
            fmt_edge(self.right),
585
5
            fmt_edge(self.bottom),
586
5
            fmt_edge(self.left)
587
        )
588
5
    }
589
}
590

            
591
// -- Parser for StyleClipRect
592

            
593
/// Error returned when parsing a CSS `clip` property value fails.
594
#[derive(Clone, PartialEq, Eq)]
595
pub enum StyleClipRectParseError<'a> {
596
    /// The provided value is not a valid `clip` value.
597
    InvalidValue(&'a str),
598
}
599

            
600
impl_debug_as_display!(StyleClipRectParseError<'a>);
601
impl_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)]
610
pub enum StyleClipRectParseErrorOwned {
611
    InvalidValue(AzString),
612
}
613

            
614
impl StyleClipRectParseError<'_> {
615
    /// Converts the borrowed error into an owned error.
616
15
    #[must_use] pub fn to_contained(&self) -> StyleClipRectParseErrorOwned {
617
15
        match self {
618
15
            StyleClipRectParseError::InvalidValue(s) => {
619
15
                StyleClipRectParseErrorOwned::InvalidValue((*s).to_string().into())
620
            }
621
        }
622
15
    }
623
}
624

            
625
impl StyleClipRectParseErrorOwned {
626
    /// Converts the owned error back into a borrowed error.
627
15
    #[must_use] pub fn to_shared(&self) -> StyleClipRectParseError<'_> {
628
15
        match self {
629
15
            Self::InvalidValue(s) => {
630
15
                StyleClipRectParseError::InvalidValue(s.as_str())
631
            }
632
        }
633
15
    }
634
}
635

            
636
#[cfg(feature = "parser")]
637
133
fn parse_clip_edge(token: &str) -> Result<OptionF32, StyleClipRectParseError<'_>> {
638
    use crate::props::basic::pixel::parse_pixel_value;
639

            
640
133
    let token = token.trim();
641
133
    if token.eq_ignore_ascii_case("auto") {
642
52
        return Ok(OptionF32::None);
643
81
    }
644
81
    let pv = parse_pixel_value(token)
645
81
        .map_err(|_| StyleClipRectParseError::InvalidValue(token))?;
646
52
    Ok(OptionF32::Some(pv.number.get()))
647
133
}
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.
661
66
pub fn parse_clip_rect(input: &str) -> Result<StyleClipRect, StyleClipRectParseError<'_>> {
662
66
    let trimmed = input.trim();
663

            
664
66
    if trimmed.eq_ignore_ascii_case("auto") {
665
5
        return Ok(StyleClipRect::default());
666
61
    }
667

            
668
61
    let inner = trimmed
669
61
        .strip_prefix("rect(")
670
61
        .or_else(|| trimmed.strip_prefix("RECT("))
671
61
        .and_then(|s| s.strip_suffix(')'))
672
61
        .ok_or(StyleClipRectParseError::InvalidValue(input))?;
673

            
674
40
    let inner = inner.trim();
675
40
    let parts: Vec<&str> = if inner.contains(',') {
676
31
        inner.split(',').map(str::trim).collect()
677
    } else {
678
9
        inner.split_whitespace().collect()
679
    };
680

            
681
40
    if parts.len() != 4 {
682
13
        return Err(StyleClipRectParseError::InvalidValue(input));
683
27
    }
684

            
685
    Ok(StyleClipRect {
686
27
        top: parse_clip_edge(parts[0])?,
687
21
        right: parse_clip_edge(parts[1])?,
688
19
        bottom: parse_clip_edge(parts[2])?,
689
19
        left: parse_clip_edge(parts[3])?,
690
    })
691
66
}
692

            
693
#[cfg(all(test, feature = "parser"))]
694
mod tests {
695
    use super::*;
696

            
697
    #[test]
698
1
    fn test_parse_layout_overflow_valid() {
699
1
        assert_eq!(
700
1
            parse_layout_overflow("visible").unwrap(),
701
            LayoutOverflow::Visible
702
        );
703
1
        assert_eq!(
704
1
            parse_layout_overflow("hidden").unwrap(),
705
            LayoutOverflow::Hidden
706
        );
707
1
        assert_eq!(parse_layout_overflow("clip").unwrap(), LayoutOverflow::Clip);
708
1
        assert_eq!(
709
1
            parse_layout_overflow("scroll").unwrap(),
710
            LayoutOverflow::Scroll
711
        );
712
1
        assert_eq!(parse_layout_overflow("auto").unwrap(), LayoutOverflow::Auto);
713
1
    }
714

            
715
    #[test]
716
1
    fn test_parse_style_text_overflow_valid() {
717
1
        assert_eq!(
718
1
            parse_style_text_overflow("clip").unwrap(),
719
            StyleTextOverflow::Clip
720
        );
721
1
        assert_eq!(
722
1
            parse_style_text_overflow("ellipsis").unwrap(),
723
            StyleTextOverflow::Ellipsis
724
        );
725
        // whitespace is tolerated
726
1
        assert_eq!(
727
1
            parse_style_text_overflow("  ellipsis  ").unwrap(),
728
            StyleTextOverflow::Ellipsis
729
        );
730
        // initial value is `clip`
731
1
        assert_eq!(StyleTextOverflow::default(), StyleTextOverflow::Clip);
732
1
    }
733

            
734
    #[test]
735
1
    fn test_parse_style_text_overflow_invalid() {
736
1
        assert!(parse_style_text_overflow("none").is_err());
737
1
        assert!(parse_style_text_overflow("").is_err());
738
1
        assert!(parse_style_text_overflow("fade").is_err());
739
        // error message names the property and quotes the value
740
1
        let msg = format!(
741
1
            "{}",
742
            StyleTextOverflowParseError::InvalidValue("fade")
743
        );
744
1
        assert!(msg.contains("text-overflow") && msg.contains("fade"), "{msg}");
745
        // owned <-> shared round-trips
746
1
        let e = parse_style_text_overflow("fade").unwrap_err();
747
1
        assert_eq!(e.to_contained().to_shared(), e);
748
1
    }
749

            
750
    #[test]
751
1
    fn test_style_text_overflow_print_round_trip() {
752
2
        for v in [StyleTextOverflow::Clip, StyleTextOverflow::Ellipsis] {
753
2
            let printed = v.print_as_css_value();
754
2
            assert_eq!(parse_style_text_overflow(&printed).unwrap(), v);
755
        }
756
1
    }
757

            
758
    #[test]
759
1
    fn test_parse_layout_overflow_whitespace() {
760
1
        assert_eq!(
761
1
            parse_layout_overflow("  scroll  ").unwrap(),
762
            LayoutOverflow::Scroll
763
        );
764
1
    }
765

            
766
    #[test]
767
1
    fn test_parse_layout_overflow_invalid() {
768
1
        assert!(parse_layout_overflow("none").is_err());
769
1
        assert!(parse_layout_overflow("").is_err());
770
1
        assert!(parse_layout_overflow("auto scroll").is_err());
771
1
        assert!(parse_layout_overflow("hidden-x").is_err());
772
1
    }
773

            
774
    #[test]
775
1
    fn test_needs_scrollbar() {
776
1
        assert!(LayoutOverflow::Scroll.needs_scrollbar(false));
777
1
        assert!(LayoutOverflow::Scroll.needs_scrollbar(true));
778
1
        assert!(LayoutOverflow::Auto.needs_scrollbar(true));
779
1
        assert!(!LayoutOverflow::Auto.needs_scrollbar(false));
780
1
        assert!(!LayoutOverflow::Hidden.needs_scrollbar(true));
781
1
        assert!(!LayoutOverflow::Visible.needs_scrollbar(true));
782
1
        assert!(!LayoutOverflow::Clip.needs_scrollbar(true));
783
1
    }
784

            
785
    #[test]
786
1
    fn test_parse_clip_rect_auto_keyword() {
787
1
        let r = parse_clip_rect("auto").unwrap();
788
1
        assert_eq!(r.top, OptionF32::None);
789
1
        assert_eq!(r.right, OptionF32::None);
790
1
        assert_eq!(r.bottom, OptionF32::None);
791
1
        assert_eq!(r.left, OptionF32::None);
792
1
    }
793

            
794
    #[test]
795
1
    fn test_parse_clip_rect_all_auto_in_rect() {
796
1
        let r = parse_clip_rect("rect(auto, auto, auto, auto)").unwrap();
797
1
        assert_eq!(r.top, OptionF32::None);
798
1
        assert_eq!(r.right, OptionF32::None);
799
1
        assert_eq!(r.bottom, OptionF32::None);
800
1
        assert_eq!(r.left, OptionF32::None);
801
1
    }
802

            
803
    #[test]
804
1
    fn test_parse_clip_rect_mixed_auto_and_lengths() {
805
1
        let r = parse_clip_rect("rect(10px, auto, 30px, auto)").unwrap();
806
1
        assert_eq!(r.top, OptionF32::Some(10.0));
807
1
        assert_eq!(r.right, OptionF32::None);
808
1
        assert_eq!(r.bottom, OptionF32::Some(30.0));
809
1
        assert_eq!(r.left, OptionF32::None);
810
1
    }
811

            
812
    #[test]
813
1
    fn test_parse_clip_rect_negative_lengths() {
814
1
        let r = parse_clip_rect("rect(-5px, 0px, -10px, 0px)").unwrap();
815
1
        assert_eq!(r.top, OptionF32::Some(-5.0));
816
1
        assert_eq!(r.right, OptionF32::Some(0.0));
817
1
        assert_eq!(r.bottom, OptionF32::Some(-10.0));
818
1
        assert_eq!(r.left, OptionF32::Some(0.0));
819
1
    }
820

            
821
    #[test]
822
1
    fn test_parse_clip_rect_legacy_space_separated() {
823
        // Legacy CSS 2.1 syntax used spaces instead of commas.
824
1
        let r = parse_clip_rect("rect(1px 2px 3px 4px)").unwrap();
825
1
        assert_eq!(r.top, OptionF32::Some(1.0));
826
1
        assert_eq!(r.right, OptionF32::Some(2.0));
827
1
        assert_eq!(r.bottom, OptionF32::Some(3.0));
828
1
        assert_eq!(r.left, OptionF32::Some(4.0));
829
1
    }
830

            
831
    #[test]
832
1
    fn test_parse_clip_rect_malformed() {
833
1
        assert!(parse_clip_rect("").is_err());
834
1
        assert!(parse_clip_rect("none").is_err());
835
        // Wrong number of edges.
836
1
        assert!(parse_clip_rect("rect(10px, 20px, 30px)").is_err());
837
        // Missing closing paren.
838
1
        assert!(parse_clip_rect("rect(10px, 20px, 30px, 40px").is_err());
839
        // Garbage edge.
840
1
        assert!(parse_clip_rect("rect(10px, abc, 30px, 40px)").is_err());
841
1
    }
842
}
843

            
844
#[cfg(all(test, feature = "parser"))]
845
mod 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));
    }
    // ---------------------------------------------------------------------
    // LayoutOverflow::resolve_computed — CSS Overflow 3 § 3.1
    // ---------------------------------------------------------------------
    #[test]
    fn resolve_computed_is_identity_when_the_other_axis_is_not_scrollable() {
        for other in [LayoutOverflow::Visible, LayoutOverflow::Clip] {
            for o in ALL_OVERFLOW {
                assert_eq!(
                    o.resolve_computed(other),
                    o,
                    "{o:?} must be untouched when the other axis is {other:?}"
                );
            }
        }
    }
    #[test]
    fn resolve_computed_promotes_visible_to_auto_and_clip_to_hidden() {
        for other in [
            LayoutOverflow::Scroll,
            LayoutOverflow::Auto,
            LayoutOverflow::Hidden,
        ] {
            assert_eq!(
                LayoutOverflow::Visible.resolve_computed(other),
                LayoutOverflow::Auto
            );
            assert_eq!(
                LayoutOverflow::Clip.resolve_computed(other),
                LayoutOverflow::Hidden
            );
            // Already-scrollable values are left alone.
            for o in [
                LayoutOverflow::Scroll,
                LayoutOverflow::Auto,
                LayoutOverflow::Hidden,
            ] {
                assert_eq!(o.resolve_computed(other), o);
            }
        }
    }
    #[test]
    fn resolve_computed_is_idempotent_and_never_removes_clipping() {
        for o in ALL_OVERFLOW {
            for other in ALL_OVERFLOW {
                let once = o.resolve_computed(other);
                assert_eq!(
                    once.resolve_computed(other),
                    once,
                    "resolve_computed not idempotent for ({o:?}, {other:?})"
                );
                // Resolution only ever adds clipping, never takes it away.
                assert!(
                    !o.is_clipped() || once.is_clipped(),
                    "({o:?}, {other:?}) lost clipping"
                );
                // ...and never turns a scroll container back into a non-scroller.
                assert!(!is_scrollable(o) || is_scrollable(once));
            }
        }
    }
    #[test]
    fn resolve_computed_leaves_both_axes_consistently_scrollable() {
        // The whole point of the rule: after resolving *both* axes against each
        // other you can never end up with one scrollable axis and one that is
        // still visible/clip (which would be unrenderable).
        for x in ALL_OVERFLOW {
            for y in ALL_OVERFLOW {
                let rx = x.resolve_computed(y);
                let ry = y.resolve_computed(x);
                assert_eq!(
                    is_scrollable(rx),
                    is_scrollable(ry),
                    "({x:?}, {y:?}) resolved to the mismatched pair ({rx:?}, {ry:?})"
                );
            }
        }
        // Spot-check the documented pairs.
        assert_eq!(
            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Scroll),
            LayoutOverflow::Auto
        );
        assert_eq!(
            LayoutOverflow::Scroll.resolve_computed(LayoutOverflow::Visible),
            LayoutOverflow::Scroll
        );
        // visible + clip is a legal pair and must survive untouched.
        assert_eq!(
            LayoutOverflow::Visible.resolve_computed(LayoutOverflow::Clip),
            LayoutOverflow::Visible
        );
        assert_eq!(
            LayoutOverflow::Clip.resolve_computed(LayoutOverflow::Visible),
            LayoutOverflow::Clip
        );
    }
    // ---------------------------------------------------------------------
    // parse_layout_overflow
    // ---------------------------------------------------------------------
    #[test]
    fn layout_overflow_round_trips_through_print_as_css_value() {
        for o in ALL_OVERFLOW {
            let printed = o.print_as_css_value();
            assert_eq!(
                parse_layout_overflow(&printed).unwrap(),
                o,
                "{o:?} printed as {printed:?} did not round-trip"
            );
            // The printed form is a bare keyword: no whitespace, all lowercase.
            assert!(!printed.is_empty());
            assert!(!printed.contains(char::is_whitespace));
            assert_eq!(printed, printed.to_lowercase());
        }
    }
    #[test]
    fn parse_layout_overflow_treats_overlay_as_a_one_way_alias_of_auto() {
        // "overlay" is a legacy alias that parses to Auto but is never printed,
        // so the round-trip is stable only after the first normalisation.
        assert_eq!(parse_layout_overflow("overlay").unwrap(), LayoutOverflow::Auto);
        let normalised = parse_layout_overflow("overlay").unwrap().print_as_css_value();
        assert_eq!(normalised, "auto");
        assert_eq!(
            parse_layout_overflow(&normalised).unwrap(),
            LayoutOverflow::Auto
        );
        for o in ALL_OVERFLOW {
            assert_ne!(o.print_as_css_value(), "overlay");
        }
    }
    #[test]
    fn parse_layout_overflow_rejects_empty_and_whitespace_only_input() {
        for input in ["", " ", "   ", "\t", "\n", "\r\n", "\t \n \r", "\u{00A0}"] {
            assert!(
                parse_layout_overflow(input).is_err(),
                "{input:?} must not parse"
            );
        }
    }
    #[test]
    fn parse_layout_overflow_error_carries_the_untrimmed_input() {
        // The parser trims for matching but reports the *original* slice.
        let err = parse_layout_overflow("  bogus  ").unwrap_err();
        assert_eq!(err, LayoutOverflowParseError::InvalidValue("  bogus  "));
        let msg = format!("{err}");
        assert!(msg.contains("bogus"), "{msg}");
        assert!(msg.contains("scroll"), "error should list the valid keywords: {msg}");
    }
    #[test]
    fn parse_layout_overflow_is_ascii_case_sensitive() {
        // NOTE: CSS keywords are ASCII case-insensitive, but this parser only
        // accepts the lowercase spelling (property *names* are lowercased
        // upstream, values are not). Characterised here so a future fix has to
        // update the test deliberately.
        for input in ["SCROLL", "Scroll", "sCrOlL", "AUTO", "Hidden", "VISIBLE", "Clip"] {
            assert!(
                parse_layout_overflow(input).is_err(),
                "{input:?} unexpectedly parsed"
            );
        }
        assert_eq!(parse_layout_overflow("scroll").unwrap(), LayoutOverflow::Scroll);
    }
    #[test]
    fn parse_layout_overflow_trims_unicode_whitespace_but_not_zero_width_chars() {
        // `str::trim` uses the Unicode White_Space property, which is wider than
        // CSS whitespace: NBSP and the ideographic space are stripped too.
        assert_eq!(
            parse_layout_overflow("\u{00A0}scroll\u{00A0}").unwrap(),
            LayoutOverflow::Scroll
        );
        assert_eq!(
            parse_layout_overflow("\u{3000}auto").unwrap(),
            LayoutOverflow::Auto
        );
        // ...but a zero-width space is not whitespace, so it stays and rejects.
        assert!(parse_layout_overflow("\u{200B}scroll").is_err());
        assert!(parse_layout_overflow("scroll\u{FEFF}").is_err());
    }
    #[test]
    fn parse_layout_overflow_rejects_garbage_unicode_and_boundary_numbers() {
        for input in [
            "none",
            "hidden-x",
            "auto scroll",
            "scroll;",
            "scroll garbage",
            "visible !important",
            "\0",
            "scroll\0",
            "!@#$%^&*()",
            "\u{1F600}",
            "scroll\u{1F600}",
            "e\u{0301}",
            "scroll",
            "скролл",
            "0",
            "-0",
            "0.0",
            "NaN",
            "nan",
            "inf",
            "-inf",
            "infinity",
            "9223372036854775807",
            "-9223372036854775808",
            "1e400",
            "1e-400",
        ] {
            assert!(
                parse_layout_overflow(input).is_err(),
                "{input:?} unexpectedly parsed"
            );
        }
    }
    #[test]
    fn parse_layout_overflow_survives_extremely_long_and_deeply_nested_input() {
        let long = "scroll".repeat(200_000);
        assert!(parse_layout_overflow(&long).is_err());
        let junk = "a".repeat(1_000_000);
        assert!(parse_layout_overflow(&junk).is_err());
        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_layout_overflow(&nested).is_err());
        // A valid keyword buried in megabytes of padding is still just padding.
        let padded = format!("{}scroll{}", " ".repeat(500_000), " ".repeat(500_000));
        assert_eq!(parse_layout_overflow(&padded).unwrap(), LayoutOverflow::Scroll);
    }
    // ---------------------------------------------------------------------
    // parse_style_scrollbar_gutter
    // ---------------------------------------------------------------------
    #[test]
    fn scrollbar_gutter_round_trips_through_print_as_css_value() {
        for g in ALL_GUTTER {
            let printed = g.print_as_css_value();
            assert_eq!(
                parse_style_scrollbar_gutter(&printed).unwrap(),
                g,
                "{g:?} printed as {printed:?} did not round-trip"
            );
        }
        assert_eq!(
            StyleScrollbarGutter::StableBothEdges.print_as_css_value(),
            "stable both-edges"
        );
        assert_eq!(StyleScrollbarGutter::default(), StyleScrollbarGutter::Auto);
    }
    #[test]
    fn parse_style_scrollbar_gutter_matches_the_keyword_string_verbatim() {
        // The parser compares the whole trimmed string, so it accepts exactly
        // one ASCII space between `stable` and `both-edges`. Per the grammar
        // (`auto | stable && both-edges?`) the reversed order and collapsed
        // runs of whitespace should also be legal — characterising the gap.
        assert_eq!(
            parse_style_scrollbar_gutter("stable both-edges").unwrap(),
            StyleScrollbarGutter::StableBothEdges
        );
        for rejected in [
            "stable  both-edges", // two spaces
            "stable\tboth-edges",
            "stable\nboth-edges",
            "both-edges stable", // `&&` allows either order
            "both-edges",
            "STABLE",
            "Stable Both-Edges",
            "stable both-edges stable",
        ] {
            assert!(
                parse_style_scrollbar_gutter(rejected).is_err(),
                "{rejected:?} unexpectedly parsed"
            );
        }
        // Outer whitespace *is* trimmed.
        assert_eq!(
            parse_style_scrollbar_gutter("  stable both-edges \n").unwrap(),
            StyleScrollbarGutter::StableBothEdges
        );
    }
    #[test]
    fn parse_style_scrollbar_gutter_rejects_empty_garbage_unicode_and_numbers() {
        for input in [
            "", " ", "\t\n", "none", "auto stable", "auto;", "stable;", "0", "-0", "NaN", "inf",
            "9223372036854775807", "\u{1F600}", "stable", "stable\0",
        ] {
            assert!(
                parse_style_scrollbar_gutter(input).is_err(),
                "{input:?} unexpectedly parsed"
            );
        }
        let err = parse_style_scrollbar_gutter("  nope  ").unwrap_err();
        assert_eq!(
            err,
            StyleScrollbarGutterParseError::InvalidValue("  nope  ")
        );
        assert!(format!("{err}").contains("scrollbar-gutter"));
    }
    #[test]
    fn parse_style_scrollbar_gutter_survives_long_and_nested_input() {
        let long = "stable ".repeat(200_000);
        assert!(parse_style_scrollbar_gutter(&long).is_err());
        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_style_scrollbar_gutter(&nested).is_err());
    }
    // ---------------------------------------------------------------------
    // parse_style_overflow_clip_margin
    // ---------------------------------------------------------------------
    #[test]
    fn parse_style_overflow_clip_margin_accepts_either_component_in_either_order() {
        // <visual-box> only — length defaults to 0.
        let only_box = parse_style_overflow_clip_margin("content-box").unwrap();
        assert_eq!(only_box.clip_edge, VisualBox::ContentBox);
        assert_eq!(only_box.inner, PixelValue::default());
        // <length> only — box defaults to padding-box.
        let only_len = parse_style_overflow_clip_margin("20px").unwrap();
        assert_eq!(only_len.clip_edge, VisualBox::PaddingBox);
        assert_eq!(only_len.inner, PixelValue::const_px(20));
        // `||` means either order is valid.
        let a = parse_style_overflow_clip_margin("border-box 10px").unwrap();
        let b = parse_style_overflow_clip_margin("10px border-box").unwrap();
        assert_eq!(a, b);
        assert_eq!(a.clip_edge, VisualBox::BorderBox);
        assert_eq!(a.inner, PixelValue::const_px(10));
        // Interior whitespace is collapsed by split_whitespace.
        let c = parse_style_overflow_clip_margin("  border-box \t\n  10px  ").unwrap();
        assert_eq!(c, a);
        assert_eq!(VisualBox::default(), VisualBox::PaddingBox);
    }
    #[test]
    fn parse_style_overflow_clip_margin_rejects_empty_duplicates_and_garbage() {
        for input in [
            "",
            "   ",
            "\t\n",
            "content-box content-box", // duplicate box
            "10px 20px",               // duplicate length
            "content-box 10px 20px",
            "content-box padding-box",
            "content-box 10px border-box",
            "none",
            "auto",
            "margin-box",
            "10px;",
            "10 px extra",
            "px",
            "\u{1F600}",
            "10\u{1F600}",
            "content-box",
            "content_box",
            "CONTENT-BOX",
        ] {
            assert!(
                parse_style_overflow_clip_margin(input).is_err(),
                "{input:?} unexpectedly parsed"
            );
        }
        let err = parse_style_overflow_clip_margin("  nope  ").unwrap_err();
        assert_eq!(
            err,
            StyleOverflowClipMarginParseError::InvalidValue("  nope  ")
        );
        assert!(format!("{err}").contains("overflow-clip-margin"));
    }
    #[test]
    fn parse_style_overflow_clip_margin_accepts_out_of_range_lengths() {
        // The declared syntax is `<visual-box> || <length [0,∞]>`: negatives and
        // percentages are invalid CSS. The parser delegates to parse_pixel_value
        // and clamps nothing, so both are accepted. Characterised, not endorsed.
        let neg = parse_style_overflow_clip_margin("-5px").unwrap();
        assert!(neg.inner.number.get() < 0.0);
        let pct = parse_style_overflow_clip_margin("50%").unwrap();
        assert_eq!(pct.inner.metric, SizeMetric::Percent);
        assert_eq!(pct.inner.number.get(), 50.0);
        // Unitless non-zero numbers are also let through (CSS requires a unit).
        let unitless = parse_style_overflow_clip_margin("7").unwrap();
        assert_eq!(unitless.inner.metric, SizeMetric::Px);
        assert_eq!(unitless.inner.number.get(), 7.0);
    }
    #[test]
    fn parse_style_overflow_clip_margin_saturates_nan_and_infinity() {
        // Rust's f32 parser accepts "NaN"/"inf", so these reach PixelValue.
        // FloatValue stores milli-units in an isize: NaN saturates to 0 and the
        // infinities to the isize bounds — no non-finite value can escape into
        // layout, which is the property that actually matters.
        let nan = parse_style_overflow_clip_margin("NaN").unwrap();
        assert!(!nan.inner.number.get().is_nan());
        assert_eq!(nan.inner.number.get(), 0.0);
        let pos_inf = parse_style_overflow_clip_margin("inf").unwrap();
        assert!(pos_inf.inner.number.get().is_finite());
        assert!(pos_inf.inner.number.get() > 0.0);
        let neg_inf = parse_style_overflow_clip_margin("-inf").unwrap();
        assert!(neg_inf.inner.number.get().is_finite());
        assert!(neg_inf.inner.number.get() < 0.0);
        // A number far beyond f32 range overflows to inf during parsing and
        // then saturates the same way.
        let huge = format!("{}px", "9".repeat(4096));
        let huge = parse_style_overflow_clip_margin(&huge).unwrap();
        assert!(huge.inner.number.get().is_finite());
        // Sub-milli precision is quantised away rather than rounded up.
        let tiny = parse_style_overflow_clip_margin("0.0001px").unwrap();
        assert_eq!(tiny.inner.number.get(), 0.0);
    }
    #[test]
    fn parse_style_overflow_clip_margin_survives_long_and_nested_input() {
        let long_token = format!("{}px", "a".repeat(1_000_000));
        assert!(parse_style_overflow_clip_margin(&long_token).is_err());
        let many_tokens = "content-box ".repeat(100_000);
        assert!(parse_style_overflow_clip_margin(&many_tokens).is_err());
        let nested = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_style_overflow_clip_margin(&nested).is_err());
    }
    #[test]
    fn overflow_clip_margin_round_trips_through_print_as_css_value() {
        let lengths = [
            PixelValue::const_px(12),
            PixelValue::px(1.5),
            PixelValue::const_em(2),
            PixelValue::const_percent(50),
            PixelValue::px(-3.25),
        ];
        for edge in ALL_VISUAL_BOX {
            for inner in lengths {
                let original = StyleOverflowClipMargin {
                    clip_edge: edge,
                    inner,
                };
                let printed = original.print_as_css_value();
                let reparsed = parse_style_overflow_clip_margin(&printed).unwrap_or_else(|e| {
                    panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
                });
                assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
            }
        }
    }
    #[test]
    fn overflow_clip_margin_zero_length_prints_only_the_box_and_forgets_the_unit() {
        // A zero length is elided from the printed form, so its unit is lost on
        // the way back (0em == 0px semantically, so this is benign — but the
        // struct is *not* preserved bit-for-bit, which a naive round-trip
        // assertion would trip over).
        let zero_em = StyleOverflowClipMargin {
            clip_edge: VisualBox::ContentBox,
            inner: PixelValue::const_em(0),
        };
        assert_eq!(zero_em.print_as_css_value(), "content-box");
        let back = parse_style_overflow_clip_margin(&zero_em.print_as_css_value()).unwrap();
        assert_eq!(back.clip_edge, VisualBox::ContentBox);
        assert_eq!(back.inner.number.get(), 0.0);
        assert_eq!(back.inner.metric, SizeMetric::Px);
        assert_ne!(back, zero_em);
        // The all-default value prints as the bare default box.
        let default = StyleOverflowClipMargin::default();
        assert_eq!(default.print_as_css_value(), "padding-box");
        assert_eq!(
            parse_style_overflow_clip_margin(&default.print_as_css_value()).unwrap(),
            default
        );
        // padding-box + non-zero length prints only the length.
        let padding_len = StyleOverflowClipMargin {
            clip_edge: VisualBox::PaddingBox,
            inner: PixelValue::const_px(4),
        };
        assert_eq!(padding_len.print_as_css_value(), "4px");
    }
    #[test]
    fn visual_box_round_trips_through_the_clip_margin_parser() {
        for v in ALL_VISUAL_BOX {
            let printed = v.print_as_css_value();
            let parsed = parse_style_overflow_clip_margin(&printed).unwrap();
            assert_eq!(parsed.clip_edge, v, "{printed:?} did not round-trip");
        }
    }
    // ---------------------------------------------------------------------
    // parse_clip_edge (private)
    // ---------------------------------------------------------------------
    #[test]
    fn parse_clip_edge_auto_is_ascii_case_insensitive_and_trimmed() {
        for input in ["auto", "AUTO", "Auto", "aUtO", "  auto  ", "\tauto\n"] {
            assert_eq!(
                parse_clip_edge(input).unwrap(),
                OptionF32::None,
                "{input:?} should be auto"
            );
        }
        // ...but only the whole token: `auto` glued to anything else is invalid.
        assert!(parse_clip_edge("auto5").is_err());
        assert!(parse_clip_edge("autopx").is_err());
        assert!(parse_clip_edge("auto auto").is_err());
    }
    #[test]
    fn parse_clip_edge_silently_discards_the_unit() {
        // BUG (characterised): the edge keeps only `PixelValue::number`, so the
        // metric is thrown away — `rect(5em, ...)` is treated as 5 *pixels*, and
        // percentages (invalid for `clip`) are accepted as raw numbers.
        for input in ["5px", "5em", "5rem", "5pt", "5in", "5cm", "5mm", "5vw", "5vh", "5%"] {
            assert_eq!(
                parse_clip_edge(input).unwrap(),
                OptionF32::Some(5.0),
                "{input:?} did not collapse to a bare 5.0"
            );
        }
        // A unitless number is accepted as well (CSS requires a unit here).
        assert_eq!(parse_clip_edge("5").unwrap(), OptionF32::Some(5.0));
        // And whitespace between number and unit is tolerated by the pixel parser.
        assert_eq!(parse_clip_edge("5 px").unwrap(), OptionF32::Some(5.0));
    }
    #[test]
    fn parse_clip_edge_quantises_to_thousandths_and_normalises_negative_zero() {
        // FloatValue is a fixed-point isize in milli-units: anything below 1/1000
        // truncates toward zero rather than rounding.
        assert_eq!(parse_clip_edge("0.001px").unwrap(), OptionF32::Some(0.001));
        assert_eq!(parse_clip_edge("0.0001px").unwrap(), OptionF32::Some(0.0));
        assert_eq!(parse_clip_edge("-0.0009px").unwrap(), OptionF32::Some(0.0));
        assert_eq!(parse_clip_edge("1.9999px").unwrap(), OptionF32::Some(1.999));
        // -0 loses its sign, so it can never poison downstream sign checks.
        let minus_zero = parse_clip_edge("-0px").unwrap().into_option().unwrap();
        assert_eq!(minus_zero, 0.0);
        assert!(minus_zero.is_sign_positive());
        // Negative lengths are explicitly legal for `clip`.
        assert_eq!(parse_clip_edge("-10px").unwrap(), OptionF32::Some(-10.0));
    }
    #[test]
    fn parse_clip_edge_saturates_nan_and_infinity_to_finite_values() {
        let nan = parse_clip_edge("NaN").unwrap().into_option().unwrap();
        assert!(!nan.is_nan(), "NaN must not survive into a clip edge");
        assert_eq!(nan, 0.0);
        let pos_inf = parse_clip_edge("inf").unwrap().into_option().unwrap();
        assert!(pos_inf.is_finite());
        assert!(pos_inf > 0.0);
        let neg_inf = parse_clip_edge("-infinity").unwrap().into_option().unwrap();
        assert!(neg_inf.is_finite());
        assert!(neg_inf < 0.0);
        let huge = format!("{}px", "9".repeat(4096));
        let huge = parse_clip_edge(&huge).unwrap().into_option().unwrap();
        assert!(huge.is_finite());
    }
    #[test]
    fn parse_clip_edge_rejects_empty_bare_units_and_garbage() {
        for input in [
            "",
            "   ",
            "\t\n",
            "px",
            "em",
            "%",
            "abc",
            "10px;",
            "10px 20px",
            "(10px)",
            "\0",
            "\u{1F600}",
            "1px",
            "1px\u{0301}",
            "0x10",
        ] {
            assert!(parse_clip_edge(input).is_err(), "{input:?} unexpectedly parsed");
        }
        // The error carries the *trimmed token*, not the surrounding input.
        assert_eq!(
            parse_clip_edge("  abc  ").unwrap_err(),
            StyleClipRectParseError::InvalidValue("abc")
        );
    }
    // ---------------------------------------------------------------------
    // parse_clip_rect
    // ---------------------------------------------------------------------
    #[test]
    fn clip_rect_round_trips_through_print_as_css_value() {
        let rects = [
            StyleClipRect::default(),
            StyleClipRect {
                top: OptionF32::Some(0.0),
                right: OptionF32::Some(-2.25),
                bottom: OptionF32::Some(1.5),
                left: OptionF32::None,
            },
            StyleClipRect {
                top: OptionF32::Some(10.0),
                right: OptionF32::Some(20.0),
                bottom: OptionF32::Some(30.0),
                left: OptionF32::Some(40.0),
            },
            StyleClipRect {
                top: OptionF32::None,
                right: OptionF32::Some(-1.0),
                bottom: OptionF32::None,
                left: OptionF32::Some(-1.0),
            },
        ];
        for original in rects {
            let printed = original.print_as_css_value();
            let reparsed = parse_clip_rect(&printed).unwrap_or_else(|e| {
                panic!("{original:?} printed as {printed:?} but failed to reparse: {e}")
            });
            assert_eq!(reparsed, original, "round-trip broke via {printed:?}");
        }
        assert_eq!(
            StyleClipRect::default().print_as_css_value(),
            "rect(auto, auto, auto, auto)"
        );
    }
    #[test]
    fn parse_clip_rect_accepts_the_auto_comma_and_legacy_space_forms() {
        let all_auto = StyleClipRect::default();
        for input in [
            "auto",
            "AUTO",
            "  auto  ",
            "\u{00A0}auto", // NBSP is Unicode whitespace, so `trim` eats it
            "rect(auto, auto, auto, auto)",
            "rect(auto auto auto auto)",
            "RECT(auto, auto, auto, auto)",
            "  rect( auto , auto , auto , auto )  ",
        ] {
            assert_eq!(
                parse_clip_rect(input).unwrap(),
                all_auto,
                "{input:?} should be all-auto"
            );
        }
        let mixed = parse_clip_rect("rect(1px, auto, -3px, 4px)").unwrap();
        assert_eq!(mixed.top, OptionF32::Some(1.0));
        assert_eq!(mixed.right, OptionF32::None);
        assert_eq!(mixed.bottom, OptionF32::Some(-3.0));
        assert_eq!(mixed.left, OptionF32::Some(4.0));
        // No space after the commas is fine too.
        assert_eq!(
            parse_clip_rect("rect(1px,2px,3px,4px)").unwrap(),
            StyleClipRect {
                top: OptionF32::Some(1.0),
                right: OptionF32::Some(2.0),
                bottom: OptionF32::Some(3.0),
                left: OptionF32::Some(4.0),
            }
        );
    }
    #[test]
    fn parse_clip_rect_rejects_wrong_arity_mixed_separators_and_trailing_junk() {
        for input in [
            "rect()",
            "rect(,,,)",
            "rect(1px)",
            "rect(1px, 2px, 3px)",
            "rect(1px, 2px, 3px, 4px, 5px)",
            "rect(1px, 2px, 3px, 4px,)",
            "rect(1px 2px, 3px 4px)", // half comma-separated, half not
            "rect(1px 2px 3px)",
            "rect(1px 2px 3px 4px 5px)",
            "rect(1px, 2px, 3px, 4px",  // no closing paren
            "rect 1px, 2px, 3px, 4px)", // no opening paren
            "rect (1px, 2px, 3px, 4px)", // space before the paren
            "rect(1px, 2px, 3px, 4px) trailing",
            "rect(1px, 2px, 3px, 4px);",
            "junk rect(1px, 2px, 3px, 4px)",
            "rect(auto, auto, auto, abc)",
            "",
            "   ",
            "none",
            "inherit",
            "0",
        ] {
            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
        }
    }
    #[test]
    fn parse_clip_rect_function_name_accepts_only_all_lower_or_all_upper_case() {
        // `rect(` and `RECT(` are special-cased; every mixed casing is rejected,
        // even though CSS function names are ASCII case-insensitive.
        assert!(parse_clip_rect("rect(auto, auto, auto, auto)").is_ok());
        assert!(parse_clip_rect("RECT(auto, auto, auto, auto)").is_ok());
        for input in [
            "Rect(auto, auto, auto, auto)",
            "rECT(auto, auto, auto, auto)",
            "ReCt(auto, auto, auto, auto)",
        ] {
            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
        }
    }
    #[test]
    fn parse_clip_rect_errors_point_at_the_offending_token() {
        // A bad *edge* reports just the token...
        let err = parse_clip_rect("rect(1px, abc, 3px, 4px)").unwrap_err();
        assert_eq!(err, StyleClipRectParseError::InvalidValue("abc"));
        let msg = format!("{err}");
        assert!(msg.contains("abc"), "{msg}");
        // (the message's own "Expected rect(...)" hint aside, none of the *input*
        // apart from the bad token is echoed back)
        assert!(!msg.contains("1px"), "message leaked the whole input: {msg}");
        // ...while a structural error reports the untrimmed input.
        let err = parse_clip_rect("  rect(1px)  ").unwrap_err();
        assert_eq!(err, StyleClipRectParseError::InvalidValue("  rect(1px)  "));
    }
    #[test]
    fn parse_clip_rect_survives_deep_nesting_and_huge_input() {
        // Not a recursive-descent parser, so nesting cannot blow the stack.
        let nested = format!("{}{}", "rect(".repeat(10_000), ")".repeat(10_000));
        assert!(parse_clip_rect(&nested).is_err());
        let parens = format!("{}{}", "(".repeat(100_000), ")".repeat(100_000));
        assert!(parse_clip_rect(&parens).is_err());
        // 50k edges: rejected on arity, not by hanging.
        let wide = format!("rect({})", "1px,".repeat(50_000));
        assert!(parse_clip_rect(&wide).is_err());
        let long_token = format!("rect({}, auto, auto, auto)", "a".repeat(1_000_000));
        assert!(parse_clip_rect(&long_token).is_err());
        // A legitimately huge magnitude parses and saturates instead of overflowing.
        let huge = format!("rect({}px, auto, auto, auto)", "9".repeat(4096));
        let huge = parse_clip_rect(&huge).unwrap();
        let top = huge.top.into_option().unwrap();
        assert!(top.is_finite());
        assert!(top > 0.0);
    }
    #[test]
    fn parse_clip_rect_does_not_panic_on_multibyte_input() {
        for input in [
            "rect(\u{1F600}, \u{1F600}, \u{1F600}, \u{1F600})",
            "rect(1px\u{0301}, auto, auto, auto)",
            "réct(1px, 2px, 3px, 4px)",
            "rect(1px, auto, auto, auto)", // fullwidth digit
            "rect(1px, auto, auto, auto\u{200B})",
            "\u{1F600}",
            "автo",
            "rect(٣px, auto, auto, auto)", // arabic-indic digit
        ] {
            assert!(parse_clip_rect(input).is_err(), "{input:?} unexpectedly parsed");
        }
    }
    // ---------------------------------------------------------------------
    // StyleClipRect::resolve
    // ---------------------------------------------------------------------
    #[test]
    fn clip_rect_default_is_all_auto() {
        let d = StyleClipRect::default();
        assert_eq!(d.top, OptionF32::None);
        assert_eq!(d.right, OptionF32::None);
        assert_eq!(d.bottom, OptionF32::None);
        assert_eq!(d.left, OptionF32::None);
    }
    #[test]
    fn clip_rect_resolve_expands_auto_edges_to_the_border_box() {
        // auto: top/left = 0, bottom/right = the border-box extent.
        let (top, right, bottom, left) = StyleClipRect::default().resolve(
            100.0, 50.0, // used width / height
            1.0, 2.0, 3.0, 4.0, // padding l / r / t / b
            5.0, 6.0, 7.0, 8.0, // border  l / r / t / b
        );
        assert_eq!(top, 0.0);
        assert_eq!(left, 0.0);
        assert_eq!(right, 100.0 + 1.0 + 2.0 + 5.0 + 6.0);
        assert_eq!(bottom, 50.0 + 3.0 + 4.0 + 7.0 + 8.0);
    }
    #[test]
    fn clip_rect_resolve_at_zero_and_with_negative_geometry() {
        let all_zero = StyleClipRect::default().resolve(
            0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
        );
        assert_eq!(all_zero, (0.0, 0.0, 0.0, 0.0));
        // Negative geometry is summed as-is (no clamping): deterministic, finite.
        let (top, right, bottom, left) = StyleClipRect::default().resolve(
            -10.0, -20.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
        );
        assert_eq!(top, 0.0);
        assert_eq!(left, 0.0);
        assert_eq!(right, -14.0);
        assert_eq!(bottom, -24.0);
    }
    #[test]
    fn clip_rect_resolve_ignores_the_geometry_for_explicit_edges() {
        let explicit = StyleClipRect {
            top: OptionF32::Some(1.0),
            right: OptionF32::Some(2.0),
            bottom: OptionF32::Some(3.0),
            left: OptionF32::Some(4.0),
        };
        // Even with hostile geometry the explicit edges come back untouched.
        for geometry in [
            f32::NAN,
            f32::INFINITY,
            f32::NEG_INFINITY,
            f32::MAX,
            f32::MIN,
            f32::MIN_POSITIVE,
        ] {
            let resolved = explicit.resolve(
                geometry, geometry, geometry, geometry, geometry, geometry, geometry, geometry,
                geometry, geometry,
            );
            assert_eq!(
                resolved,
                (1.0, 2.0, 3.0, 4.0),
                "explicit edges were perturbed by geometry {geometry:?}"
            );
        }
    }
    #[test]
    fn clip_rect_resolve_saturates_at_f32_max_and_keeps_nan_contained() {
        // f32::MAX + f32::MAX overflows to +inf rather than panicking.
        let (top, right, bottom, left) = StyleClipRect::default().resolve(
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
            f32::MAX,
        );
        assert_eq!(top, 0.0);
        assert_eq!(left, 0.0);
        assert!(right.is_infinite() && right.is_sign_positive());
        assert!(bottom.is_infinite() && bottom.is_sign_positive());
        // NaN geometry propagates into the auto edges only (documented result:
        // NaN in, NaN out — no panic, and the fixed edges stay clean).
        let (top, right, bottom, left) = StyleClipRect::default().resolve(
            f32::NAN,
            f32::NAN,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
        );
        assert_eq!(top, 0.0);
        assert_eq!(left, 0.0);
        assert!(right.is_nan());
        assert!(bottom.is_nan());
        // +inf added to -inf is NaN — still no panic.
        let (_, right, bottom, _) = StyleClipRect::default().resolve(
            f32::INFINITY,
            f32::INFINITY,
            f32::NEG_INFINITY,
            0.0,
            f32::NEG_INFINITY,
            0.0,
            0.0,
            0.0,
            0.0,
            0.0,
        );
        assert!(right.is_nan());
        assert!(bottom.is_nan());
    }
    // ---------------------------------------------------------------------
    // Error types: to_contained / to_shared
    // ---------------------------------------------------------------------
    /// Payloads that an error may have to carry: empty, whitespace, multibyte,
    /// combining marks, an embedded NUL, and a large string.
    fn error_payloads() -> Vec<String> {
        vec![
            String::new(),
            String::from(" "),
            String::from("bogus"),
            String::from("\u{1F600}\u{0301}"),
            String::from("a\0b"),
            String::from("rect(1px, 2px, 3px, 4px)"),
            "x".repeat(100_000),
        ]
    }
    macro_rules! assert_error_round_trips {
        ($borrowed:ident) => {{
            for payload in error_payloads() {
                let borrowed = $borrowed::InvalidValue(payload.as_str());
                let owned = borrowed.to_contained();
                let back = owned.to_shared();
                assert_eq!(
                    back, borrowed,
                    "{}::InvalidValue({payload:?}) lost data on to_contained/to_shared",
                    stringify!($borrowed)
                );
                // ...and the owned form is stable under a second lap.
                assert_eq!(owned.to_shared().to_contained(), owned);
            }
        }};
    }
    #[test]
    fn parse_errors_round_trip_between_borrowed_and_owned_forms() {
        assert_error_round_trips!(LayoutOverflowParseError);
        assert_error_round_trips!(StyleScrollbarGutterParseError);
        assert_error_round_trips!(StyleOverflowClipMarginParseError);
        assert_error_round_trips!(StyleClipRectParseError);
    }
    #[test]
    fn parse_errors_produced_by_the_parsers_round_trip_too() {
        let e = parse_layout_overflow("nope").unwrap_err();
        assert_eq!(e.to_contained().to_shared(), e);
        let e = parse_style_scrollbar_gutter("nope").unwrap_err();
        assert_eq!(e.to_contained().to_shared(), e);
        let e = parse_style_overflow_clip_margin("nope nope").unwrap_err();
        assert_eq!(e.to_contained().to_shared(), e);
        let e = parse_clip_rect("rect(nope)").unwrap_err();
        assert_eq!(e.to_contained().to_shared(), e);
    }
    #[test]
    fn parse_error_messages_name_the_property_and_quote_the_value() {
        let msg = format!("{}", LayoutOverflowParseError::InvalidValue("zzz"));
        assert!(msg.contains("overflow") && msg.contains("zzz"), "{msg}");
        let msg = format!("{}", StyleScrollbarGutterParseError::InvalidValue("zzz"));
        assert!(msg.contains("scrollbar-gutter") && msg.contains("zzz"), "{msg}");
        let msg = format!("{}", StyleOverflowClipMarginParseError::InvalidValue("zzz"));
        assert!(
            msg.contains("overflow-clip-margin") && msg.contains("zzz"),
            "{msg}"
        );
        let msg = format!("{}", StyleClipRectParseError::InvalidValue("zzz"));
        assert!(msg.contains("clip") && msg.contains("zzz"), "{msg}");
        // Debug is wired to Display: it must not panic on hostile payloads.
        let weird = StyleClipRectParseError::InvalidValue("\u{1F600}\0\u{0301}");
        assert!(!format!("{weird:?}").is_empty());
    }
}